diff --git a/CHANGELOG.md b/CHANGELOG.md index e62edded..7cb1a186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,89 @@ +# v0.5.55 (2026-08-14) + +## Features +- **Auth**: native SAML 2.0 SSO alongside OIDC — AuthnRequest generation, ACS + assertion handling, SP metadata export, admin config test, replay-protected + via a `saml_state` cookie matched against `InResponseTo` +- **Providers**: add Alibaba Token Plan (`token-plan.ap-southeast-1`) — the + fourth Alibaba key type, Singapore-only and OpenAI-compatible transport only +- **Providers**: add `glm-5.3` to GLM Coding and GLM (China) +- **Providers**: Kimchi accepts API keys as well as OAuth (dual auth), with a + working Test Connection for both modes +- **Antigravity**: add Gemini 3.7 Flash and its tiered high/medium/low variants + (also in the Gemini registry) with pricing and quota tracking +- **TTS**: add Fish Audio — model id travels in an HTTP `model` header, voice + is a `reference_id` (preset or cloned voice model) +- **OpenCode-Go**: route by request format via declared transports instead of + forcing every client into `/messages` — Codex/OpenAI clients no longer pay a + lossy Responses→OpenAI→Claude double translation. Per-model `supportedFormats` + guard; the bespoke executor is gone (its shared `_lastModel` cache could cross + auth headers between concurrent requests) +- **Usage**: dedup + cache Claude quota calls (120s TTL keyed by access token, + in-flight promise dedup, last-good read on soft failure) to stop multiple + tabs tripping 429; manual refresh (↻) sends `force=1` to bypass the cache + +## Fixes +- **Docker**: ship `sql.js` in the image so the pure-JS DB fallback can start — + file tracing carried the package's JS without `dist/sql-wasm.wasm`, so a + container with no native driver aborted with ENOENT and never got a database + (#3248) +- **Usage**: read Gemini `usageMetadata` out of the antigravity `{ response }` + envelope — every non-streaming antigravity request logged `IN 0 | OUT 0` + (#3260) +- **Claude**: re-anchor passthrough cache breakpoints — the client's own + `cache_control` markers point at pre-normalization offsets, so the tail was + re-cached every request. Last system block and last tool pinned at 1h TTL, + last assistant turn at 5m, mid-conversation system messages folded into the + neighbouring user turn instead of hoisted into `body.system` +- **Combos**: detect images from Hermes and attachment payloads (`images[]`, + `experimental_attachments`, message-level `image_url`/`audio_url`, inline + `data:` URIs) so the Vision Adapter auto-switch fires for Hermes/Ollama/ + Vercel AI SDK shapes +- **Kiro**: intercept chat via `x-amz-target` — Kiro IDE 1.0.228+ moved + `GenerateAssistantResponse` to `POST /` + header, bypassing MITM. Also emit + the now-mandatory initial-response frame and map the `auto` model slot +- **Kiro**: report real output tokens and stop discarding usable turns +- **Qoder**: detect billing blocks at stream start and return a synthetic 403 + so combo/account fallback triggers instead of leaking the error into chat +- **Antigravity**: strip competitive system prompts (Zed IDE's Claude-agent + prompt) that Antigravity flags with a 429 Quota Exhausted +- **OpenCode**: send the official client fingerprint on free-tier requests so + the Console stops classifying traffic as unidentified and rate-limiting it; + session id resolves conversation-stable to preserve prompt caching +- **Responses**: don't close the message on an empty `tool_calls` array — some + providers attach one to every chunk, and the truthy check ended the message + on the first content token (#3234) +- **Translator**: preserve `prompt_cache_key` when converting chat to responses +- **Models**: expose snake_case token limits on `/v1/models` +- **Combos**: strip `stream_options` from the Fusion panel fan-out to avoid a + DeepSeek 400 (#3024); raise the dashboard model-test probe budget to 1024 and + soft-pass reasoning-only responses (#3010) +- **Headroom**: the toggle reflects the `headroomEnabled` setting even when the + proxy is down — it previously showed OFF while the engine kept calling + `/v1/compress`; proxy status stays visible via the status chip +- **Hermes**: add the `api_key` parameter to the model block in YAML config +- **Providers**: add llm7 to provider test support + +## Docs +- **i18n**: add Spanish, French, and Brazilian Portuguese README translations + +## Security +- **Real IP**: `x-9r-real-ip` and the Host fallback were trusted from + client-controlled headers whenever `custom-server.js` was not in the request + path (`npm run start`, `start:bun`), letting a remote caller pose as local to + skip API key auth and reach `LOCAL_ONLY_PATHS` (`/api/mcp/*`, + `/api/tunnel/enable`, `/api/auth/reset-password`). The server now stamps a + per-process `x-9r-peer-token` on every request it sanitizes and only trusts + `x-9r-real-ip` behind it — falling back to Host in development and failing + closed in production (GHSA-pjm4-8fpg-f9p6). Also fixes IPv6 loopback + detection (`::1`, `::ffff:127.0.0.1`) and routes `npm run start` / + `start:bun` through `custom-server.js` +- **Search**: `resolveBaseUrl()` rejects client-supplied non-public baseUrls + (SSRF guard on `/v1/search`) +- **Login**: fresh-install remote login with the default password returns 403 + without issuing a JWT +- **Usage**: `/api/usage/request-details` redacts request/response payloads + # v0.5.50 (2026-08-05) ## Features diff --git a/Dockerfile b/Dockerfile index 5abe1f24..6e11dfe3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,9 @@ COPY --from=builder /app/src/mitm ./src/mitm 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 +# sql.js loads dist/sql-wasm.wasm by path at runtime; tracing only follows JS imports, +# so the last-resort DB driver would abort with ENOENT on the missing binary. +COPY --from=builder /app/node_modules/sql.js ./node_modules/sql.js RUN mkdir -p /app/data && chown -R node:node /app && \ mkdir -p /app/data-home && chown node:node /app/data-home && \ diff --git a/README.md b/README.md index a89c7dca..2b81116b 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ [🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com) -[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) • [🇮🇩 Indonesia](./i18n/README.id-ID.md) +[🇧🇷 Português (Brasil)](./i18n/README.pt-BR.md) • [🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) • [🇮🇩 Indonesia](./i18n/README.id-ID.md) • [🇪🇸 Español](./i18n/README.es.md) • [🇫🇷 Français](./i18n/README.fr.md) diff --git a/cli/package.json b/cli/package.json index 98ad8cdc..2fe55c9c 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.5.50", + "version": "0.5.55", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/cli/scripts/build-cli.js b/cli/scripts/build-cli.js index d4e4d279..2c23d1e7 100644 --- a/cli/scripts/build-cli.js +++ b/cli/scripts/build-cli.js @@ -216,7 +216,9 @@ function buildCliPackage() { 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"); + console.error("❌ custom-server.js not found — without it no request can be proven local,"); + console.error(" so the packaged CLI would demand an API key for its own dashboard and /v1."); + process.exit(1); } // Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules. diff --git a/cli/src/cli/menus/providers.js b/cli/src/cli/menus/providers.js index 7e28ec64..92d10a04 100644 --- a/cli/src/cli/menus/providers.js +++ b/cli/src/cli/menus/providers.js @@ -53,6 +53,9 @@ const PROVIDER_MODELS = { { id: "glm-4.7" }, ], ag: [ + { id: "gemini-3.7-flash-high" }, + { id: "gemini-3.7-flash-medium" }, + { id: "gemini-3.7-flash-low" }, { id: "gemini-3.6-flash-high" }, { id: "gemini-3.6-flash-medium" }, { id: "gemini-3.6-flash-low" }, diff --git a/custom-server.js b/custom-server.js index cf824ba4..ba092136 100644 --- a/custom-server.js +++ b/custom-server.js @@ -1,9 +1,18 @@ const http = require("http"); const path = require("path"); +const fs = require("fs"); +const crypto = require("crypto"); const { pathToFileURL } = require("url"); const origCreate = http.createServer.bind(http); +// Per-process secret proving x-9r-real-ip was stamped below rather than sent by the client. +// A bare `next start` / `next dev` never loads this file, so it cannot produce a matching +// header even though the env var is inherited by child processes. Named like x-9r-cli-token +// so the request-detail header sanitizer redacts it too. +const PEER_TOKEN = crypto.randomBytes(24).toString("hex"); +process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN; + let backgroundRefreshStarted = false; function startBackgroundTokenRefreshFromCustomServer() { @@ -57,7 +66,9 @@ http.createServer = (...args) => { delete req.headers["x-9r-real-ip"]; delete req.headers["x-forwarded-for"]; delete req.headers["x-9r-via-proxy"]; + delete req.headers["x-9r-peer-token"]; req.headers["x-9r-real-ip"] = ip; + req.headers["x-9r-peer-token"] = PEER_TOKEN; if (viaProxy) req.headers["x-9r-via-proxy"] = "1"; return handler(req, res); }; @@ -114,4 +125,15 @@ http.createServer = (...args) => { return server; }; -if (require.main === module) require("./server.js"); +if (require.main === module) { + const standalone = path.join(__dirname, "server.js"); + if (fs.existsSync(standalone)) { + require(standalone); + } else { + // Repo checkout has no standalone build next to us. `next start` builds its HTTP + // server in-process, so the wrapper above still sanitizes every request. + const nextBin = require.resolve("next/dist/bin/next"); + process.argv = [process.argv[0], nextBin, "start", ...process.argv.slice(2)]; + require(nextBin); + } +} diff --git a/docs/images/saml-admin-dashboard.png b/docs/images/saml-admin-dashboard.png new file mode 100644 index 00000000..53d0466e Binary files /dev/null and b/docs/images/saml-admin-dashboard.png differ diff --git a/docs/images/saml-login-screen.png b/docs/images/saml-login-screen.png new file mode 100644 index 00000000..b1995af1 Binary files /dev/null and b/docs/images/saml-login-screen.png differ diff --git a/i18n/README.es.md b/i18n/README.es.md new file mode 100644 index 00000000..5469fcb7 --- /dev/null +++ b/i18n/README.es.md @@ -0,0 +1,1445 @@ +
+ Panel de control de 9Router + + # 9Router - Enrutador de IA GRATUITO y ahorrador de tokens + + **Nunca dejes de programar. Ahorra entre 20-40% de tokens con RTK + reserva automática hacia modelos de IA GRATUITOS y económicos.** + + **Conecta todas tus herramientas de código con IA (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) a más de 40 proveedores de IA y más de 100 modelos.** + + [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) + [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) + [![Docker Pulls](https://img.shields.io/docker/pulls/decolua/9router.svg?logo=docker&label=Docker%20pulls)](https://hub.docker.com/r/decolua/9router) + [![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router) + [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + +decolua%2F9router | Trendshift + +[🚀 Inicio rápido](#-inicio-rápido) • [💡 Características](#-características-principales) • [📖 Configuración](#-guía-de-instalación) • [🌐 Sitio web](https://9router.com) + +[🇻🇳 Tiếng Việt](./README.vi.md) • [🇨🇳 中文](./README.zh-CN.md) • [🇯🇵 日本語](./README.ja-JP.md) • [🇷🇺 Русский](./README.ru.md) • [🇹🇭 ไทย](./README.th.md) • [🇮🇷 فارسی](./README.fa_IR.md) • [🇮🇩 Indonesia](./README.id-ID.md) • [🇪🇸 Español](./README.es.md) • [🇫🇷 Français](./README.fr.md) + +
+ +--- + +## 🤔 ¿Por qué 9Router? + +**Deja de perder dinero, tokens y de chocar contra los límites:** + +- ❌ La cuota de la suscripción caduca sin usar cada mes +- ❌ Los límites de velocidad te interrumpen a mitad de la programación +- ❌ Las salidas de las herramientas (git diff, grep, ls...) consumen tokens rápidamente +- ❌ APIs caras ($20-50/mes por proveedor) +- ❌ Cambio manual entre proveedores + +**9Router resuelve esto:** + +- ✅ **Ahorrador de tokens RTK** - Comprime automáticamente el contenido de tool_result y ahorra entre 20-40% de tokens por solicitud +- ✅ **Maximiza las suscripciones** - Realiza el seguimiento de la cuota y usa cada bit antes del restablecimiento +- ✅ **Reserva automática** - Suscripción → Económico → Gratuito, sin tiempos de inactividad +- ✅ **Multi-cuenta** - Round-robin entre cuentas de cada proveedor +- ✅ **Universal** - Funciona con Claude Code, Codex, Cursor, Cline y cualquier herramienta CLI + +--- + +## 🔄 Cómo funciona + +``` +┌─────────────┐ +│ Your CLI │ (Claude Code, Codex, OpenClaw, Cursor, Cline...) +│ Tool │ +└──────┬──────┘ + │ http://localhost:20128/v1 + ↓ +┌─────────────────────────────────────────────┐ +│ 9Router (Smart Router) │ +│ • RTK Token Saver (cut tool_result tokens) │ +│ • Format translation (OpenAI ↔ Claude) │ +│ • Quota tracking │ +│ • Auto token refresh │ +└──────┬──────────────────────────────────────┘ + │ + ├─→ [Tier 1: SUBSCRIPTION] Claude Code, Codex, GitHub Copilot + │ ↓ quota exhausted + ├─→ [Tier 2: CHEAP] GLM ($0.6/1M), MiniMax ($0.2/1M) + │ ↓ budget limit + └─→ [Tier 3: FREE] Kiro, OpenCode Free, Vertex ($300 credits) + +Result: Never stop coding, minimal cost + 20-40% token savings via RTK +``` + +--- + +## ⚡ Inicio rápido + +**1. Instálalo globalmente:** + +```bash +npm install -g 9router +9router +``` + +🎉 El panel de control se abre en `http://localhost:20128` + +**2. Conecta un proveedor GRATUITO (no requiere registro):** + +Panel de control → Providers → Conecta **Kiro AI** (Claude gratuito e ilimitado) o **OpenCode Free** (sin autenticación) → ¡Listo! + +**3. Úsalo en tu herramienta CLI:** + +``` +Ajustes de Claude Code/Codex/OpenClaw/Cursor/Cline: + Endpoint: http://localhost:20128/v1 + API Key: [copia desde el panel de control] + Model: kr/claude-sonnet-4.5 +``` + +**¡Eso es todo!** Empieza a programar con modelos de IA GRATUITOS. + +**Alternativa: ejecutar desde el código fuente (este repositorio):** + +El paquete de este repositorio es privado (`9router-app`), por lo que ejecutar desde el código fuente/Docker es la ruta de desarrollo local prevista. + +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev +``` + +Modo de producción: + +```bash +npm run build +PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start +``` + +URLs por defecto: + +- Panel de control: `http://localhost:20128/dashboard` +- API compatible con OpenAI: `http://localhost:20128/v1` + +--- + +## 🎥 Guías en video + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Tiết kiệm chi phí LLM với 9Router +
+ 🇻🇳 Tiếng Việt
+ Tiết kiệm chi phí LLM cho OpenClaw với 9Router
by Mì AI
+
+ + 9Router + Claude Code FREE Unlimited Setup +
+ 🇵🇰 اردو / हिन्दी
+ 9Router + Claude Code FREE Unlimited Setup
by Build AI With Hamid
+
+ + 9Router Setup Tutorial +
+ 🇺🇸 English
+ 9Router + Claude Code FREE Setup
by Build AI With Hamid
+
+ + 9Router Setup Tutorial +
+ 🇺🇸 English
+ 9Router + Claude Code FREE Setup
by Build AI With Hamid
+
+ + Claude Code FREE Forever +
+ 🇺🇸 English
+ Claude Code FREE Forever — Unlimited Models
by Build AI With Hamid
+
+ + Claude CLI Free Setup +
+ 🇺🇸 English
+ Claude CLI Free Setup with 9Router 🚀
by CodeVerse Soban
+
+ + Cài đặt OpenClaw Free A-Z +
+ 🇻🇳 Tiếng Việt
+ Cài Đặt OpenClaw Free Từ A-Z + 9Router
by Mai Gia
+
+ + FREE OpenClaw with Claude Opus +
+ 🇺🇸 English
+ FREE OpenClaw + Claude Opus 4.6
by Build AI With Hamid
+
+ + Claude CLI Free Setup +
+ 🇮🇩 Indonesia
+ Koding 24 Jam Anti Rate Limit! Hemat Token AI 65% | Tutorial Quick Setup 9Router 🚀
by Krisswuh
+
+ + Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB +
+ 🇮🇩 Indonesia
+ Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB
by Krisswuh
+
+ +
+ +> 🎬 **¿Has hecho un video sobre 9Router?** Envía una [Pull Request](https://github.com/decolua/9router/pulls) añadiendo tu video a esta sección — ¡lo fusionaremos! + +--- + +## 🛠️ Herramientas CLI compatibles + +9Router funciona a la perfección con todas las principales herramientas de código con IA: + +
+ + + + + + + + + + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ OpenClaw
+ OpenClaw +
+ Codex
+ Codex +
+ OpenCode
+ OpenCode +
+ Cursor
+ Cursor +
+ Antigravity
+ Antigravity +
+ Cline
+ Cline +
+ Continue
+ Continue +
+ Droid
+ Droid +
+ Roo
+ Roo +
+ Copilot
+ Copilot +
+ Kilo Code
+ Kilo Code +
+
+ +--- + +## 🌐 Proveedores compatibles + +### 🔐 Proveedores OAuth + +
+ + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ Antigravity
+ Antigravity +
+ Codex
+ Codex +
+ GitHub
+ GitHub +
+ Cursor
+ Cursor +
+ Kimchi
+ Kimchi +
+
+ +### 🆓 Proveedores gratuitos + +
+ + + + + + +
+ Kiro
+ Kiro AI
+ Claude 4.5 + GLM-5 + MiniMax
GRATUITO e ilimitado
+
+ OpenCode Free
+ OpenCode Free
+ Sin autenticación • Modelos automáticos
GRATUITO e ilimitado
+
+ Vertex AI
+ Vertex AI
+ Gemini 3 Pro + GLM-5 + DeepSeek
$300 de crédito gratuito
+
+
+ +> **Nota:** Los niveles gratuitos de iFlow, Qwen y Gemini CLI se suspendieron en 2026. Usa Kiro / OpenCode Free / Vertex en su lugar. + +### 🔑 Proveedores con clave API (más de 40) + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ OpenRouter
+ OpenRouter +
+ GLM
+ GLM +
+ Kimi
+ Kimi +
+ MiniMax
+ MiniMax +
+ OpenAI
+ OpenAI +
+ Anthropic
+ Anthropic +
+ Gemini
+ Gemini +
+ DeepSeek
+ DeepSeek +
+ Groq
+ Groq +
+ xAI
+ xAI +
+ Mistral
+ Mistral +
+ Perplexity
+ Perplexity +
+ Together
+ Together AI +
+ Fireworks
+ Fireworks +
+ Cerebras
+ Cerebras +
+ Cohere
+ Cohere +
+ NVIDIA
+ NVIDIA +
+ SiliconFlow
+ SiliconFlow +
+

...y más de 20 proveedores adicionales, incluyendo Nebius, Chutes, Hyperbolic y endpoints personalizados compatibles con OpenAI/Anthropic

+
+ +--- + +## 💡 Características principales + +| Característica | Qué hace | Por qué importa | +| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| 🚀 **Ahorrador de tokens RTK** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | Comprime las salidas de las herramientas (`git diff`, `grep`, `ls`, `tree`...) antes de enviarlas al LLM | Ahorra **20-40% de tokens de entrada** por solicitud | +| 🧠 **Ahorrador de tokens Headroom** ([Headroom](https://github.com/chopratejas/headroom)) | Proxy externo opcional `/v1/compress` antes del enrutamiento al proveedor | Ahorra más tokens de contexto sin cambiar los clientes | +| 🪨 **Modo cavernícola** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | Inyecta un prompt de lenguaje cavernícola → el LLM responde de forma concisa, se conserva el contenido técnico | Ahorra **hasta un 65% de tokens de salida** | +| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | Inyecta un prompt de "dev sénior perezoso" → el LLM escribe código mínimo, primero YAGNI (Lite/Full/Ultra) | **Menos tokens de salida, menos refactorización** | +| 🎯 **Reserva inteligente de 3 niveles** | Enrutado automático: Suscripción → Económico → Gratuito | Nunca dejes de programar, cero tiempos de inactividad | +| 📊 **Seguimiento de cuota en tiempo real** | Recuento de tokens en vivo + cuenta atrás de restablecimiento | Maximiza el valor de tu suscripción | +| 🔄 **Traducción de formatos** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | Funciona con cualquier herramienta CLI | +| 👥 **Compatibilidad con varias cuentas** | Varias cuentas por proveedor | Balance de carga + redundancia | +| 🔄 **Renovación automática de tokens** | Los tokens OAuth se renuevan automáticamente | Sin necesidad de volver a iniciar sesión | +| 🎨 **Combos personalizados** | Crea combinaciones de modelos ilimitadas | Adapta la reserva a tus necesidades | +| 📝 **Registro de solicitudes** | Modo de depuración con registros completos de solicitudes/respuestas | Soluciona problemas fácilmente | +| 💾 **Sincronización en la nube** | Sincroniza la configuración entre dispositivos | La misma configuración en todas partes | +| 📊 **Analítica de uso** | Realiza el seguimiento de tokens, costes y tendencias a lo largo del tiempo | Optimiza el gasto | +| 🌐 **Implementa en cualquier lugar** | Localhost, VPS, Docker, Cloudflare Workers | Opciones de implementación flexibles | + +
+📖 Detalles de las características + +### 🚀 Ahorrador de tokens RTK + +Las salidas de las herramientas (`git diff`, `grep`, `find`, `ls`, `tree`, volcados de registros...) suelen consumir entre 30-50% del presupuesto de tu prompt. RTK las detecta y aplica una compresión inteligente y sin pérdidas **antes** de que la solicitud llegue al LLM: + +- **Filtros:** `git-diff`, `git-status`, `grep`, `find`, `ls`, `tree`, `dedup-log`, `smart-truncate`, `read-numbered`, `search-list` +- **Detección automática:** No requiere configuración — RTK inspecciona el primer 1KB de cada `tool_result` y elige el filtro correcto. +- **Seguro por diseño:** Si un filtro falla, lanza un error o hace la salida más grande, RTK conserva silenciosamente el texto original. Los errores nunca interrumpen tu solicitud. +- **Universal:** Funciona en todos los formatos (OpenAI, Claude, Gemini, Cursor, Kiro, OpenAI Responses) porque se ejecuta **antes** de cualquier traducción de formato. +- **Activado por defecto:** Actívalo o desactívalo en cualquier momento en Panel de control → Ajustes de Endpoint. + +``` +Sin RTK: se envían 47K tokens al LLM +Con RTK: se envían 28K tokens al LLM (40% ahorrado · mismo contexto · misma respuesta) +``` + +### 🧠 Ahorrador de tokens Headroom + +Headroom es opcional y se ejecuta por separado. 9Router llama al endpoint local `/v1/compress` de Headroom y luego mantiene el enrutamiento, la reserva, la autenticación y el seguimiento de uso normales: + +``` +Client → 9Router → Headroom /v1/compress → 9Router → provider +``` + +Configuración local: + +```bash +pip install "headroom-ai[proxy]" +headroom proxy --port 8787 +``` + +Actívalo en Panel de control → Endpoint → Token Saver → Headroom. URL por defecto: `http://localhost:8787`. + +Ejemplos con Docker: + +```bash +# Servicio de Headroom en la misma red de Docker +http://headroom:8787 + +# Headroom ejecutándose en la máquina host +http://host.docker.internal:8787 +``` + +Si Headroom está caído o devuelve un error, 9Router falla de forma segura y envía la solicitud original. + +### 🐴 Ponytail (Dev sénior perezoso) + +Ponytail inyecta un prompt de sistema de _"dev sénior perezoso"_ en cada solicitud, orientando al LLM hacia un código mínimo y primero YAGNI — eliminación antes que adición, librería estándar antes que nuevas dependencias, una línea antes que abstracciones. Adaptado de [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail). + +- **Lite** — Construye lo que se pide, nombra la alternativa más perezosa. +- **Full** — Jerarquía YAGNI aplicada: stdlib → nativo → dependencias existentes → una línea → código mínimo. +- **Ultra** — Extremista del YAGNI: eliminación primero, entrega la solución de una línea, cuestiona el resto del requisito en la misma respuesta. + +``` +Sin Ponytail: código verboso, abstracciones extra, andamiaje "por si acaso" +Con Ponytail: diff funcional más corto, sin abstracciones no solicitadas, menos tokens +``` + +Nunca sacrifica: validación de entrada, manejo de errores que evita pérdida de datos, seguridad, accesibilidad ni nada solicitado explícitamente. Actívalo en Panel de control → Endpoint → Ponytail. Se combina con Caveman (concisión de salida) y RTK (compresión de entrada). + +### 🎯 Reserva inteligente de 3 niveles + +Crea combos con reserva automática: + +``` +Combo: "my-coding-stack" + 1. cc/claude-opus-4-6 (tu suscripción) + 2. glm/glm-4.7 (respaldo económico, $0.6/1M) + 3. if/kimi-k2-thinking (reserva gratuita) + +→ Cambia automáticamente cuando la cuota se agota o se producen errores +``` + +### 📊 Seguimiento de cuota en tiempo real + +- Consumo de tokens por proveedor +- Cuenta atrás de restablecimiento (5 horas, diaria, semanal) +- Estimación de costes para niveles de pago +- Informes de gasto mensual + +### 🔄 Traducción de formatos + +Traducción fluida entre formatos: + +- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **Cursor** ↔ **Kiro** ↔ **Vertex** ↔ **Antigravity** ↔ **Ollama** ↔ **OpenAI Responses** +- Tu herramienta CLI envía el formato OpenAI → 9Router lo traduce → El proveedor recibe el formato nativo +- Funciona con cualquier herramienta que admita endpoints OpenAI personalizados + +### 👥 Compatibilidad con varias cuentas + +- Añade varias cuentas por proveedor +- Enrutado automático round-robin o basado en prioridades +- Reserva a la siguiente cuenta cuando una alcanza su cuota + +### 🔄 Renovación automática de tokens + +- Los tokens OAuth se renuevan automáticamente antes de caducar +- Sin necesidad de reautenticación manual +- Experiencia fluida en todos los proveedores + +### 🎨 Combos personalizados + +- Crea combinaciones de modelos ilimitadas +- Mezcla niveles de suscripción, económicos y gratuitos +- Pon nombre a tus combos para acceder fácilmente +- Comparte combos entre dispositivos con Sincronización en la nube + +### 📝 Registro de solicitudes + +- Activa el modo de depuración para registros completos de solicitudes/respuestas +- Realiza el seguimiento de llamadas API, cabeceras y cargas útiles +- Soluciona problemas de integración +- Exporta los registros para su análisis + +### 💾 Sincronización en la nube + +- Sincroniza proveedores, combos y ajustes entre dispositivos +- Sincronización automática en segundo plano +- Almacenamiento cifrado y seguro +- Accede a tu configuración desde cualquier lugar + +#### Notas del runtime en la nube + +- Prefiere las variables de nube del lado del servidor en producción: + - `BASE_URL` (URL de retorno interno usada por el planificador de sincronización) + - `CLOUD_URL` (base del endpoint de sincronización en la nube) +- `NEXT_PUBLIC_BASE_URL` y `NEXT_PUBLIC_CLOUD_URL` siguen siendo compatibles para compatibilidad/UI, pero el runtime del servidor ahora prioriza `BASE_URL`/`CLOUD_URL`. +- Las solicitudes de sincronización en la nube ahora usan tiempo de espera + comportamiento de fallo rápido para evitar que la UI se cuelgue cuando el DNS/la red de la nube no está disponible. + +### 📊 Analítica de uso + +- Realiza el seguimiento del uso de tokens por proveedor y modelo +- Estimación de costes y tendencias de gasto +- Informes e información mensual +- Optimiza tu gasto en IA + +> **💡 IMPORTANTE - Comprender los costes del panel de control:** +> +> El "coste" que se muestra en Analítica de uso es **solo para seguimiento y comparación**. +> 9Router por sí mismo **nunca te cobra** nada. Solo pagas a los proveedores directamente (si usas servicios de pago). +> +> **Ejemplo:** Si tu panel muestra "coste total de $290" mientras usas modelos iFlow, esto representa +> lo que habrías pagado usando APIs de pago directamente. Tu coste real = **$0** (iFlow es gratis e ilimitado). +> +> ¡Piénsalo como un "rastreador de ahorros" que muestra cuánto estás ahorrando al usar modelos gratuitos o +> enrutar a través de 9Router! + +### 🌐 Implementa en cualquier lugar + +- 💻 **Localhost** - Por defecto, funciona sin conexión +- ☁️ **VPS/Nube** - Comparte entre dispositivos +- 🐳 **Docker** - Implementación con un solo comando +- 🚀 **Cloudflare Workers** - Red perimetral global + +
+ +--- + +## 💰 Precios de un vistazo + +| Nivel | Proveedor | Coste | Restablecimiento de cuota | Mejor para | +| --------------------- | ---------------------- | -------------- | ------------------------- | ------------------------------------------- | +| **🚀 AHORRO DE TOKENS** | **RTK (integrado)** | **GRATUITO** | Siempre activo | **Ahorra 20-40% de tokens en CADA solicitud** | +| **💳 SUSCRIPCIÓN** | Claude Code (Pro/Max) | $20-200/mes | 5 h + semanal | Ya suscrito | +| | Codex (Plus/Pro) | $20-200/mes | 5 h + semanal | Usuarios de OpenAI | +| | GitHub Copilot | $10-19/mes | Mensual | Usuarios de GitHub | +| | Cursor IDE | $20/mes | Mensual | Usuarios de Cursor | +| **💰 ECONÓMICO** | GLM-5.1 / GLM-4.7 | $0.6/1M | Diario 10:00 | Respaldo económico | +| | MiniMax M2.7 | $0.2/1M | Ventana de 5 horas | La opción más barata | +| | Kimi K2.5 | $9/mes fijos | 10M tokens/mes | Coste predecible | +| **🆓 GRATUITO** | Kiro AI | $0 | Ilimitado | Claude 4.5 + GLM-5 + MiniMax gratis | +| | OpenCode Free | $0 | Ilimitado | Sin autenticación, modelos automáticos | +| | Vertex AI | $300 de crédito | Cuentas GCP nuevas | Gemini 3 Pro + DeepSeek + GLM-5 | + +**💡 Consejo profesional:** Combo RTK + Kiro AI + OpenCode Free = **$0 de coste + 20-40% de ahorro de tokens**! + +--- + +### 📊 Cómo entender los costes y la facturación de 9Router + +**Realidad de la facturación de 9Router:** + +✅ **El software de 9Router = GRATUITO para siempre** (código abierto, nunca cobra) +✅ **Los "costes" del panel = Solo visualización/seguimiento** (no son facturas reales) +✅ **Pagas a los proveedores directamente** (suscripciones o tarifas de API) +✅ **Los proveedores GRATUITOS siguen siendo GRATUITOS** (iFlow, Kiro, Qwen = $0 ilimitado) +❌ **9Router nunca envía facturas** ni carga tu tarjeta + +**Cómo funciona la visualización de costes:** + +El panel muestra **costes estimados** como si estuvieras usando APIs de pago directamente. Esto **no es facturación** — es una herramienta de comparación para mostrar tus ahorros. + +**Escenario de ejemplo:** + +``` +Visualización del panel: +• Total de solicitudes: 1,662 +• Tokens totales: 47M +• Coste mostrado: $290 + +Comprobación de la realidad: +• Proveedor: iFlow (GRATUITO e ilimitado) +• Pago real: $0.00 +• Qué significa $290: ¡La cantidad que AHORRASTE usando modelos gratuitos! +``` + +**Reglas de pago:** + +- **Proveedores de suscripción** (Claude Code, Codex): Págales directamente a través de sus sitios web +- **Proveedores económicos** (GLM, MiniMax): Págales directamente, 9Router solo enruta +- **Proveedores GRATUITOS** (iFlow, Kiro, Qwen): Realmente gratuitos para siempre, sin cargos ocultos +- **9Router**: Nunca cobra nada, jamás + +--- + +## 🎯 Casos de uso + +### Caso 1: "Tengo una suscripción a Claude Pro" + +**Problema:** La cuota caduca sin usar, límites de velocidad durante programación intensa + +**Solución:** + +``` +Combo: "maximize-claude" + 1. cc/claude-opus-4-7 (usa la suscripción al máximo) + 2. glm/glm-5.1 (respaldo económico cuando se agota la cuota) + 3. kr/claude-sonnet-4.5 (reserva gratuita de emergencia) + +Coste mensual: $20 (suscripción) + ~$5 (respaldo) = $25 en total +vs. $20 + chocar contra límites = frustración +``` + +### Caso 2: "Quiero coste cero" + +**Problema:** No puedo permitirme suscripciones, necesito programación con IA fiable + +**Solución:** + +``` +Combo: "free-forever" + 1. kr/claude-sonnet-4.5 (Claude 4.5 gratuito e ilimitado) + 2. kr/glm-5 (GLM-5 gratis a través de Kiro) + 3. oc/ (OpenCode Free, sin autenticación) + +Coste mensual: $0 +Calidad: Modelos listos para producción + RTK ahorra 20-40% de tokens +``` + +### Caso 3: "Necesito programar 24/7, sin interrupciones" + +**Problema:** Plazos de entrega, no puedo permitirme tiempos de inactividad + +**Solución:** + +``` +Combo: "always-on" + 1. cc/claude-opus-4-7 (la mejor calidad) + 2. cx/gpt-5.5 (segunda suscripción) + 3. glm/glm-5.1 (económico, se restablece diariamente) + 4. minimax/MiniMax-M2.7 (el más barato, restablecimiento cada 5 h) + 5. kr/claude-sonnet-4.5 (gratuito e ilimitado) + +Resultado: 5 capas de reserva = cero tiempos de inactividad +Coste mensual: $20-200 (suscripciones) + $10-20 (respaldo) +``` + +### Caso 4: "Quiero IA GRATUITA en OpenClaw" + +**Problema:** Necesito un asistente de IA en aplicaciones de mensajería (WhatsApp, Telegram, Slack...), completamente gratis + +**Solución:** + +``` +Combo: "openclaw-free" + 1. kr/claude-sonnet-4.5 (Claude 4.5 gratuito) + 2. kr/glm-5 (GLM-5 gratuito) + 3. kr/MiniMax-M2.5 (MiniMax gratuito) + +Coste mensual: $0 +Acceso mediante: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... +``` + +--- + +## ❓ Preguntas frecuentes + +
+📊 ¿Por qué mi panel muestra costes elevados? + +El panel realiza el seguimiento de tu uso de tokens y muestra **costes estimados** como si estuvieras usando APIs de pago directamente. Esto **no es facturación real** — es una referencia para mostrar cuánto estás ahorrando al usar modelos gratuitos o suscripciones existentes a través de 9Router. + +**Ejemplo:** + +- **El panel muestra:** "$290 de coste total" +- **La realidad:** Estás usando iFlow (GRATUITO e ilimitado) +- **Tu coste real:** **$0.00** +- **Qué significa $290:** ¡La cantidad que **ahorraste** al usar modelos gratuitos en lugar de APIs de pago! + +La visualización de costes es un "rastreador de ahorros" que te ayuda a comprender tus patrones de uso y las oportunidades de optimización. + +
+ +
+💳 ¿9Router me cobrará algo? + +**No.** 9Router es un software gratuito y de código abierto que se ejecuta en tu propio ordenador. Nunca te cobra nada. + +**Solo pagas:** + +- ✅ **Proveedores de suscripción** (Claude Code $20/mes, Codex $20-200/mes) → Págales directamente en sus sitios web +- ✅ **Proveedores económicos** (GLM, MiniMax) → Págales directamente, 9Router solo enruta tus solicitudes +- ❌ **El propio 9Router** → **Nunca cobra nada, jamás** + +9Router es un proxy/enrutador local. No tiene tu tarjeta de crédito, no puede enviar facturas y no tiene sistema de facturación. Es software totalmente gratuito. + +
+ +
+🆓 ¿Los proveedores GRATUITOS son realmente ilimitados? + +**¡Sí!** Los proveedores GRATUITOS actuales (Kiro, OpenCode Free, Vertex) son realmente gratuitos y **no tienen cargos ocultos**. + +Estos son servicios gratuitos ofrecidos por esas respectivas empresas: + +- **Kiro AI**: Claude 4.5 + GLM-5 + MiniMax gratuitos e ilimitados mediante AWS Builder ID / Google / GitHub OAuth +- **OpenCode Free**: Proxy de paso sin autenticación, modelos obtenidos automáticamente de `opencode.ai/zen/v1/models` +- **Vertex AI**: $300 de crédito gratuito para cuentas nuevas de Google Cloud (90 días) + +9Router solo enruta tus solicitudes hacia ellos — no hay "trampa" ni facturación futura. Son servicios realmente gratuitos y 9Router los hace fáciles de usar con soporte de reserva. + +**Niveles gratuitos suspendidos (ya no recomendados):** + +- ❌ **iFlow**: Era gratuito e ilimitado, ahora es de pago (2026) +- ❌ **Qwen Code**: El nivel gratuito de OAuth fue suspendido por Alibaba el 15-04-2026 +- ❌ **Gemini CLI**: Sigue funcionando, pero usarlo con herramientas que no son CLI (Claude, Codex, Cursor...) puede resultar en baneo de cuentas — úsalo solo si te quedas con el propio Gemini CLI + +
+ +
+💰 ¿Cómo minimizo mis costes reales de IA? + +**Estrategia gratis-primero:** + +1. **Empieza con un combo 100% gratuito:** + + ``` + 1. gc/gemini-3-flash (180K/mes gratis de Google) + 2. if/kimi-k2-thinking (gratuito e ilimitado de iFlow) + 3. qw/qwen3-coder-plus (gratuito e ilimitado de Qwen) + ``` + + **Coste: $0/mes** + +2. **Añade un respaldo económico** solo si lo necesitas: + + ``` + 4. glm/glm-4.7 ($0.6/1M de tokens) + ``` + + **Coste adicional: Solo pagas por lo que realmente usas** + +3. **Usa los proveedores de suscripción al final:** + - Solo si ya los tienes + - 9Router te ayuda a maximizar su valor mediante el seguimiento de cuota + +**Resultado:** ¡La mayoría de los usuarios pueden operar con $0/mes usando solo niveles gratuitos! + +
+ +
+📈 ¿Qué pasa si mi uso se dispara de repente? + +La reserva inteligente de 9Router evita cargos sorpresa: + +**Escenario:** Estás en un sprint de programación y agotas tus cuotas + +**Sin 9Router:** + +- ❌ Chocas contra el límite de velocidad → El trabajo se detiene → Frustración +- ❌ O: Acumulas accidentalmente facturas enormes de API + +**Con 9Router:** + +- ✅ La suscripción alcanza su límite → Reserva automática al nivel económico +- ✅ El nivel económico se vuelve caro → Reserva automática al nivel gratuito +- ✅ Nunca dejas de programar → Costes predecibles + +**Tú tienes el control:** Establece límites de gasto por proveedor en el panel y 9Router los respeta. + +
+ +--- + +## 📖 Guía de instalación + +
+🔐 Proveedores de suscripción (maximiza el valor) + +### Claude Code (Pro/Max) + +```bash +Panel de control → Providers → Conecta Claude Code +→ Inicio de sesión OAuth → Renovación automática de tokens +→ Seguimiento de cuota de 5 horas + semanal + +Modelos: + cc/claude-opus-4-7 + cc/claude-opus-4-6 + cc/claude-sonnet-4-6 + cc/claude-haiku-4-5-20251001 +``` + +**Consejo profesional:** Usa Opus para tareas complejas y Sonnet para velocidad. ¡9Router realiza el seguimiento de la cuota por modelo! + +### OpenAI Codex (Plus/Pro) + +```bash +Panel de control → Providers → Conecta Codex +→ Inicio de sesión OAuth (puerto 1455) +→ Restablecimiento de 5 horas + semanal + +Modelos: + cx/gpt-5.5 + cx/gpt-5.4 + cx/gpt-5.3-codex + cx/gpt-5.2-codex +``` + +### GitHub Copilot + +```bash +Panel de control → Providers → Conecta GitHub +→ OAuth a través de GitHub +→ Restablecimiento mensual (día 1 de cada mes) + +Modelos: + gh/gpt-5.4 + gh/claude-opus-4.7 + gh/claude-sonnet-4.6 + gh/gemini-3.1-pro-preview + gh/grok-code-fast-1 +``` + +### Cursor IDE + +```bash +Panel de control → Providers → Conecta Cursor +→ Inicio de sesión OAuth +→ Suscripción mensual + +Modelos: + cu/claude-4.6-opus-max + cu/claude-4.5-sonnet-thinking + cu/gpt-5.3-codex +``` + +
+ +
+💰 Proveedores económicos (respaldo) + +### GLM-5.1 / GLM-4.7 (restablecimiento diario, $0.6/1M) + +1. Regístrate: [Zhipu AI](https://open.bigmodel.cn/) +2. Obtén la clave API del Coding Plan +3. Panel de control → Add API Key: + - Proveedor: `glm` + - API Key: `tu-clave` + +**Uso:** `glm/glm-5.1`, `glm/glm-5`, `glm/glm-4.7` + +**Consejo profesional:** ¡El Coding Plan ofrece cuota 3× por 1/7 del coste! Restablecimiento diario a las 10:00. + +### MiniMax M2.7 (restablecimiento cada 5 h, $0.20/1M) + +1. Regístrate: [MiniMax](https://www.minimax.io/) +2. Obtén la clave API +3. Panel de control → Add API Key + +**Uso:** `minimax/MiniMax-M2.7`, `minimax/MiniMax-M2.5` + +**Consejo profesional:** ¡La opción más barata para contexto largo (1M de tokens)! + +### Kimi K2.5 ($9/mes fijos) + +1. Suscríbete: [Moonshot AI](https://platform.moonshot.ai/) +2. Obtén la clave API +3. Panel de control → Add API Key + +**Uso:** `kimi/kimi-k2.5`, `kimi/kimi-k2.5-thinking` + +**Consejo profesional:** ¡$9/mes fijos por 10M de tokens = coste efectivo de $0.90/1M! + +
+ +
+🆓 Proveedores GRATUITOS (recomendados) + +### Kiro AI (Claude 4.5 + GLM-5 + MiniMax GRATIS) + +```bash +Panel de control → Conecta Kiro +→ AWS Builder ID, AWS IAM Identity Center, Google o GitHub +→ Uso ilimitado + +Modelos: + kr/claude-sonnet-4.5 + kr/claude-haiku-4.5 + kr/glm-5 + kr/MiniMax-M2.5 + kr/qwen3-coder-next + kr/deepseek-3.2 +``` + +**Consejo profesional:** La mejor opción gratuita para Claude. Sin clave API, sin pago, totalmente ilimitado. + +### OpenCode Free (sin autenticación, modelos automáticos) + +```bash +Panel de control → Conecta OpenCode Free +→ No requiere inicio de sesión (proxy de paso) +→ Modelos obtenidos automáticamente de opencode.ai/zen/v1/models +``` + +**Consejo profesional:** La configuración más rápida. Solo conéctate y empieza a programar. + +### Vertex AI ($300 de crédito gratuito para cuentas GCP nuevas) + +```bash +Panel de control → Conecta Vertex AI +→ Sube el JSON de la cuenta de servicio de Google Cloud +→ Habilita la API de Vertex AI en tu proyecto de GCP + +Modelos: + vertex/gemini-3.1-pro-preview + vertex/gemini-3-flash-preview + vertex/gemini-2.5-flash + +Vertex Partner (Anthropic / DeepSeek / GLM / Qwen a través de Vertex): + vertex-partner/glm-5-maas + vertex-partner/deepseek-v3.2-maas + vertex-partner/qwen3-next-80b-a3b-thinking-maas +``` + +**Consejo profesional:** Las cuentas nuevas de Google Cloud obtienen $300 de crédito gratis durante 90 días. Más que suficiente para programar a diario. + +
+ +
+🎨 Crea combos + +### Ejemplo 1: Maximiza la suscripción → Respaldo económico + +``` +Panel de control → Combos → Create New + +Nombre: premium-coding +Modelos: + 1. cc/claude-opus-4-7 (Suscripción principal) + 2. glm/glm-5.1 (Respaldo económico, $0.6/1M) + 3. minimax/MiniMax-M2.7 (Reserva más barata, $0.20/1M) + +Uso en CLI: premium-coding + +Ejemplo de coste mensual (100M de tokens): + 80M vía Claude (suscripción): $0 extra + 15M vía GLM: $9 + 5M vía MiniMax: $1 + Total: $10 + tu suscripción +``` + +### Ejemplo 2: Solo gratis (coste cero) + +``` +Nombre: free-combo +Modelos: + 1. kr/claude-sonnet-4.5 (Claude 4.5 gratuito e ilimitado) + 2. kr/glm-5 (GLM-5 gratis a través de Kiro) + 3. vertex/gemini-3.1-pro-preview ($300 de crédito gratuito) + +Coste: $0 para siempre (+ 20-40% de ahorro de tokens vía RTK)! +``` + +
+ +
+🔧 Integración con CLI + +### Cursor IDE + +``` +Settings → Models → Advanced: + OpenAI API Base URL: http://localhost:20128/v1 + OpenAI API Key: [desde el panel de 9router] + Model: cc/claude-opus-4-7 +``` + +O usa el combo: `premium-coding` + +### Claude Code + +Edita `~/.claude/config.json`: + +```json +{ + "anthropic_api_base": "http://localhost:20128/v1", + "anthropic_api_key": "tu-clave-api-de-9router" +} +``` + +### Codex CLI + +```bash +export OPENAI_BASE_URL="http://localhost:20128" +export OPENAI_API_KEY="tu-clave-api-de-9router" + +codex "tu prompt" +``` + +### OpenClaw + +**Opción 1 — Panel de control (recomendada):** + +``` +Panel de control → CLI Tools → OpenClaw → Selecciona el modelo → Aplicar +``` + +**Opción 2 — Manual:** Edita `~/.openclaw/openclaw.json`: + +```json +{ + "agents": { + "defaults": { + "model": { + "primary": "9router/kr/claude-sonnet-4.5" + } + } + }, + "models": { + "providers": { + "9router": { + "baseUrl": "http://127.0.0.1:20128/v1", + "apiKey": "sk_9router", + "api": "openai-completions", + "models": [ + { + "id": "kr/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5 (Kiro Free)" + } + ] + } + } + } +} +``` + +> **Nota:** OpenClaw solo funciona con 9Router local. Usa `127.0.0.1` en lugar de `localhost` para evitar problemas de resolución IPv6. + +### Cline / Continue / RooCode + +``` +Provider: OpenAI Compatible +Base URL: http://localhost:20128/v1 +API Key: [desde el panel] +Model: cc/claude-opus-4-7 +``` + +
+ +
+🚀 Implementación + +### Implementación en VPS + +```bash +# Clona e instala +git clone https://github.com/decolua/9router.git +cd 9router +npm install +npm run build + +# Configura +export JWT_SECRET="tu-secreto-seguro-cámbialo" +export INITIAL_PASSWORD="tu-contraseña" +export DATA_DIR="/var/lib/9router" +export PORT="20128" +export HOSTNAME="0.0.0.0" +export NODE_ENV="production" +export NEXT_PUBLIC_BASE_URL="http://localhost:20128" +export NEXT_PUBLIC_CLOUD_URL="https://9router.com" +export API_KEY_SECRET="endpoint-proxy-api-key-secret" +export MACHINE_ID_SALT="endpoint-proxy-salt" + +# Inicia +npm run start + +# O usa PM2 +npm install -g pm2 +pm2 start npm --name 9router -- start +pm2 save +pm2 startup +``` + +### Docker + +Imágenes publicadas (multi-plataforma `linux/amd64` + `linux/arm64`): + +- Docker Hub: [`decolua/9router`](https://hub.docker.com/r/decolua/9router) +- GHCR: [`ghcr.io/decolua/9router`](https://github.com/decolua/9router/pkgs/container/9router) + +**Inicio rápido (usa la imagen publicada):** + +```bash +docker run -d \ + --name 9router \ + -p 20128:20128 \ + -v "$HOME/.9router:/app/data" \ + -e DATA_DIR=/app/data \ + decolua/9router:latest +``` + +→ Abre http://localhost:20128 + +**Compilar desde el código fuente (dev):** + +```bash +git clone https://github.com/decolua/9router.git +cd 9router/app +docker build -t 9router . +docker run -d --name 9router -p 20128:20128 \ + -v "$HOME/.9router:/app/data" -e DATA_DIR=/app/data 9router +``` + +**Valores por defecto del contenedor:** + +- `PORT=20128` +- `HOSTNAME=0.0.0.0` + +**Comandos útiles:** + +```bash +docker logs -f 9router +docker restart 9router +docker stop 9router && docker rm 9router +docker pull decolua/9router:latest # actualiza a la última versión +``` + +**Persistencia de datos:** `$HOME/.9router/db/data.sqlite` en el host ↔ `/app/data/db/data.sqlite` en el contenedor. + +### Variables de entorno + +| Variable | Por defecto | Descripción | +| ---------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------ | +| `JWT_SECRET` | Generado automáticamente (`~/.9router/jwt-secret`) | Secreto de firma JWT para la cookie de autenticación del panel (sobreescríbelo para compartir entre instancias) | +| `INITIAL_PASSWORD` | `123456` | Contraseña del primer inicio de sesión cuando no existe un hash guardado | +| `DATA_DIR` | `~/.9router` | Ubicación principal de datos de la app (SQLite en `$DATA_DIR/db/data.sqlite`) | +| `PORT` | por defecto del framework | Puerto del servicio (`20128` en los ejemplos) | +| `HOSTNAME` | por defecto del framework | Host de enlace (Docker usa `0.0.0.0` por defecto) | +| `NODE_ENV` | por defecto del runtime | Establece `production` para el despliegue | +| `BASE_URL` | `http://localhost:20128` | URL base interna del lado del servidor usada por los trabajos de sincronización en la nube | +| `CLOUD_URL` | `https://9router.com` | URL base del endpoint de sincronización en la nube del lado del servidor | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | URL base pública/compatible con versiones anteriores (prefiere `BASE_URL` para el runtime del servidor) | +| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | URL de nube pública/compatible (prefiere `CLOUD_URL` para el runtime del servidor) | +| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | Secreto HMAC para las claves API generadas | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | Sal para el hash estable del ID de máquina | +| `ENABLE_REQUEST_LOGS` | `false` | Habilita los registros de solicitudes/respuestas en `logs/` | +| `AUTH_COOKIE_SECURE` | `false` | Fuerza la cookie `Secure` de autenticación (establece `true` detrás de un proxy HTTPS inverso) | +| `REQUIRE_API_KEY` | `false` | Aplica clave API Bearer en las rutas `/v1/*` (recomendado para despliegues expuestos a Internet) | +| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | vacío | Proxy saliente opcional para las llamadas a proveedores externos | +| `SEARXNG_URL` | `http://localhost:8888/search` | Endpoint del proveedor de búsqueda web SearXNG integrado sin autenticación | + +Notas: + +- También se admiten variables de proxy en minúsculas: `http_proxy`, `https_proxy`, `all_proxy`, `no_proxy`. +- `.env` no se incluye en la imagen de Docker (`.dockerignore`); inyecta la configuración del runtime con `--env-file` o `-e`. +- En Windows, se puede usar `APPDATA` para resolver la ruta de almacenamiento local. +- `INSTANCE_NAME` aparece en documentación/plantillas de entorno antiguas, pero actualmente no se usa en el runtime. + +### Archivos en tiempo de ejecución y almacenamiento + +- Estado principal de la app: `${DATA_DIR}/db/data.sqlite` (SQLite — proveedores, combos, alias, claves, ajustes, historial de uso) +- Copias de seguridad automáticas: `${DATA_DIR}/db/backups/` +- Registros opcionales de solicitudes/translator: `/logs/...` cuando `ENABLE_REQUEST_LOGS=true` +- Tanto `${DATA_DIR}` como `~/.9router` resuelven a la misma ubicación en un contenedor de Docker — el enlace simbólico `/root/.9router -> /app/data` se crea en tiempo de compilación. + +
+ +--- + +## 📊 Modelos disponibles + +
+Ver todos los modelos disponibles + +**Claude Code (`cc/`)** - Pro/Max: + +- `cc/claude-opus-4-7` +- `cc/claude-opus-4-6` +- `cc/claude-sonnet-4-6` +- `cc/claude-sonnet-4-5-20250929` +- `cc/claude-haiku-4-5-20251001` + +**Codex (`cx/`)** - Plus/Pro: + +- `cx/gpt-5.5` +- `cx/gpt-5.4` +- `cx/gpt-5.3-codex` +- `cx/gpt-5.2-codex` +- `cx/gpt-5.1-codex-max` + +**GitHub Copilot (`gh/`)**: + +- `gh/gpt-5.4` +- `gh/claude-opus-4.7` +- `gh/claude-sonnet-4.6` +- `gh/gemini-3.1-pro-preview` +- `gh/grok-code-fast-1` + +**Cursor (`cu/`)** - Suscripción: + +- `cu/claude-4.6-opus-max` +- `cu/claude-4.5-sonnet-thinking` +- `cu/gpt-5.3-codex` +- `cu/kimi-k2.5` + +**GLM (`glm/`)** - $0.6/1M: + +- `glm/glm-5.1` +- `glm/glm-5` +- `glm/glm-4.7` + +**MiniMax (`minimax/`)** - $0.2/1M: + +- `minimax/MiniMax-M2.7` +- `minimax/MiniMax-M2.5` + +**Kimi (`kimi/`)** - $9/mes fijos: + +- `kimi/kimi-k2.5` +- `kimi/kimi-k2.5-thinking` + +**Kiro (`kr/`)** - GRATUITO e ilimitado: + +- `kr/claude-sonnet-4.5` +- `kr/claude-haiku-4.5` +- `kr/glm-5` +- `kr/MiniMax-M2.5` +- `kr/qwen3-coder-next` +- `kr/deepseek-3.2` + +**OpenCode Free (`oc/`)** - GRATUITO sin autenticación: + +- Obtenidos automáticamente de `opencode.ai/zen/v1/models` + +**Vertex AI (`vertex/`)** - $300 de crédito gratuito: + +- `vertex/gemini-3.1-pro-preview` +- `vertex/gemini-3-flash-preview` +- `vertex/gemini-2.5-flash` +- `vertex-partner/glm-5-maas` +- `vertex-partner/deepseek-v3.2-maas` + +
+ +--- + +## 🐛 Solución de problemas + +**"Language model did not provide messages"** + +- Cuota del proveedor agotada → Revisa el rastreador de cuota del panel +- Solución: usa la reserva del combo o cambia a un nivel más barato + +**Límites de velocidad** + +- Cuota de suscripción agotada → Reserva a GLM/MiniMax +- Añade el combo: `cc/claude-opus-4-7 → glm/glm-5.1 → kr/claude-sonnet-4.5` + +**Token OAuth caducado** + +- 9Router lo renueva automáticamente +- Si los problemas persisten: Panel de control → Provider → Reconectar + +**Costes elevados** + +- Habilita RTK en Panel de control → Ajustes de Endpoint (activado por defecto, ahorra 20-40% de tokens) +- Revisa las estadísticas de uso en el panel +- Cambia el modelo principal a GLM/MiniMax +- Usa el nivel gratuito (Kiro, OpenCode Free, Vertex) para tareas no críticas + +**El panel se abre en el puerto equivocado** + +- Establece `PORT=20128` y `NEXT_PUBLIC_BASE_URL=http://localhost:20128` + +**El primer inicio de sesión no funciona** + +- Revisa `INITIAL_PASSWORD` en `.env` +- Si no está configurada, la contraseña de respaldo es `123456` + +**No hay registros de solicitudes en `logs/`** + +- Establece `ENABLE_REQUEST_LOGS=true` + +--- + +## 🛠️ Pila tecnológica + +- **Runtime**: Node.js 20+ +- **Framework**: Next.js 16 +- **UI**: React 19 + Tailwind CSS 4 +- **Base de datos**: SQLite (better-sqlite3 / node:sqlite / sql.js como respaldo) +- **Streaming**: Server-Sent Events (SSE) +- **Autenticación**: OAuth 2.0 (PKCE) + JWT + claves API + +--- + +## 📝 Referencia de la API + +### Chat Completions + +```bash +POST http://localhost:20128/v1/chat/completions +Authorization: Bearer tu-clave-api +Content-Type: application/json + +{ + "model": "cc/claude-opus-4-6", + "messages": [ + {"role": "user", "content": "Escribe una función para..."} + ], + "stream": true +} +``` + +### Listar modelos + +```bash +GET http://localhost:20128/v1/models +Authorization: Bearer tu-clave-api + +→ Devuelve todos los modelos y combos en formato OpenAI +``` + +## 📧 Soporte + +- **Sitio web**: [9router.com](https://9router.com) +- **GitHub**: [github.com/decolua/9router](https://github.com/decolua/9router) +- **Problemas**: [github.com/decolua/9router/issues](https://github.com/decolua/9router/issues) + +--- + +## 👥 Colaboradores + +¡Gracias a todos los colaboradores que ayudaron a mejorar 9Router! + +[![Contributors](https://contrib.rocks/image?repo=decolua/9router&max=150&columns=15&anon=1&v=20260309)](https://github.com/decolua/9router/graphs/contributors) + +--- + +## 📊 Gráfico de estrellas + +[![Star Chart](https://starchart.cc/decolua/9router.svg?variant=adaptive)](https://starchart.cc/decolua/9router) + +## 🔀 Forks + +**[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** — Un fork de 9Router en TypeScript con todas las funciones. Añade más de 36 proveedores, reserva automática de 4 niveles, APIs multimodales (imágenes, embeddings, audio, TTS), interruptor de circuitos, caché semántica, evaluaciones de LLM y un panel pulido. Más de 368 pruebas unitarias. Disponible a través de npm y Docker. + +--- + +## 🙏 Agradecimientos + +Construido sobre los hombros de gigantes: + +- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — implementación original en Go que inspiró este port a JavaScript. +- **[RTK](https://github.com/rtk-ai/rtk)** ![Stars](https://img.shields.io/github/stars/rtk-ai/rtk?style=flat&color=yellow) — ahorrador de tokens en Rust. 9Router porta su pipeline de compresión a JS → **−20-40% de tokens de entrada** en cada solicitud. +- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) por **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral _"why use many token when few token do trick"_. 9Router adapta su prompt → **−65% de tokens de salida**. +- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Stars](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) por **[@DietrichGebert](https://github.com/DietrichGebert)** — habilidad del _"dev sénior perezoso"_. 9Router inyecta su jerarquía primero-YAGNI → **menos tokens, menos código, diffs más cortos**. + +Un enorme agradecimiento a estos autores — sin su trabajo, las funciones de ahorro de tokens de 9Router no existirían. ¡Dales una ⭐ en GitHub! + +--- + +## 📄 Licencia + +Licencia MIT - consulta [LICENSE](LICENSE) para más detalles. + +--- + +
+ Hecho con ❤️ para desarrolladores que programan 24/7 +
+ diff --git a/i18n/README.fr.md b/i18n/README.fr.md new file mode 100644 index 00000000..d93df014 --- /dev/null +++ b/i18n/README.fr.md @@ -0,0 +1,1445 @@ +
+ Tableau de bord 9Router + + # 9Router - Routeur IA GRATUIT et économiseur de tokens + + **N'arrêtez jamais de coder. Économisez 20 à 40 % de tokens avec RTK + bascule automatique vers des modèles d'IA GRATUITS et bon marché.** + + **Connectez tous vos outils de codage IA (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) à plus de 40 fournisseurs d'IA et plus de 100 modèles.** + + [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) + [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) + [![Docker Pulls](https://img.shields.io/docker/pulls/decolua/9router.svg?logo=docker&label=Docker%20pulls)](https://hub.docker.com/r/decolua/9router) + [![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router) + [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + +decolua%2F9router | Trendshift + +[🚀 Démarrage rapide](#-démarrage-rapide) • [💡 Fonctionnalités](#-fonctionnalités-clés) • [📖 Configuration](#-guide-dinstallation) • [🌐 Site web](https://9router.com) + +[🇻🇳 Tiếng Việt](./README.vi.md) • [🇨🇳 中文](./README.zh-CN.md) • [🇯🇵 日本語](./README.ja-JP.md) • [🇷🇺 Русский](./README.ru.md) • [🇹🇭 ไทย](./README.th.md) • [🇮🇷 فارسی](./README.fa_IR.md) • [🇮🇩 Indonesia](./README.id-ID.md) • [🇪🇸 Español](./README.es.md) • [🇫🇷 Français](./README.fr.md) + +
+ +--- + +## 🤔 Pourquoi 9Router ? + +**Arrêtez de gaspiller de l'argent, des tokens et de buter contre les limites :** + +- ❌ Le quota de votre abonnement expire inutilisé chaque mois +- ❌ Les limites de débit vous interrompent en plein codage +- ❌ Les sorties des outils (git diff, grep, ls...) consomment vos tokens rapidement +- ❌ Des API coûteuses (20 à 50 $/mois par fournisseur) +- ❌ Changement manuel entre les fournisseurs + +**9Router résout tout cela :** + +- ✅ **Économiseur de tokens RTK** - Compresse automatiquement le contenu de tool_result et économise 20 à 40 % de tokens par requête +- ✅ **Maximise les abonnements** - Suit le quota et utilise chaque bit avant sa réinitialisation +- ✅ **Bascule automatique** - Abonnement → Bon marché → Gratuit, zéro temps d'arrêt +- ✅ **Multi-comptes** - Round-robin entre les comptes de chaque fournisseur +- ✅ **Universel** - Fonctionne avec Claude Code, Codex, Cursor, Cline et tout outil CLI + +--- + +## 🔄 Comment ça marche + +``` +┌─────────────┐ +│ Your CLI │ (Claude Code, Codex, OpenClaw, Cursor, Cline...) +│ Tool │ +└──────┬──────┘ + │ http://localhost:20128/v1 + ↓ +┌─────────────────────────────────────────────┐ +│ 9Router (Smart Router) │ +│ • RTK Token Saver (cut tool_result tokens) │ +│ • Format translation (OpenAI ↔ Claude) │ +│ • Quota tracking │ +│ • Auto token refresh │ +└──────┬──────────────────────────────────────┘ + │ + ├─→ [Tier 1: SUBSCRIPTION] Claude Code, Codex, GitHub Copilot + │ ↓ quota exhausted + ├─→ [Tier 2: CHEAP] GLM ($0.6/1M), MiniMax ($0.2/1M) + │ ↓ budget limit + └─→ [Tier 3: FREE] Kiro, OpenCode Free, Vertex ($300 credits) + +Result: Never stop coding, minimal cost + 20-40% token savings via RTK +``` + +--- + +## ⚡ Démarrage rapide + +**1. Installez globalement :** + +```bash +npm install -g 9router +9router +``` + +🎉 Le tableau de bord s'ouvre sur `http://localhost:20128` + +**2. Connectez un fournisseur GRATUIT (aucune inscription requise) :** + +Tableau de bord → Providers → Connectez **Kiro AI** (Claude gratuit et illimité) ou **OpenCode Free** (sans authentification) → C'est tout ! + +**3. Utilisez-le dans votre outil CLI :** + +``` +Paramètres de Claude Code/Codex/OpenClaw/Cursor/Cline : + Endpoint : http://localhost:20128/v1 + API Key : [copiez depuis le tableau de bord] + Model : kr/claude-sonnet-4.5 +``` + +**Et voilà !** Commencez à coder avec des modèles d'IA GRATUITS. + +**Alternative : exécuter depuis le code source (ce dépôt) :** + +Le paquet de ce dépôt est privé (`9router-app`), donc l'exécution depuis le code source/Docker est le chemin de développement local attendu. + +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev +``` + +Mode production : + +```bash +npm run build +PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start +``` + +URL par défaut : + +- Tableau de bord : `http://localhost:20128/dashboard` +- API compatible OpenAI : `http://localhost:20128/v1` + +--- + +## 🎥 Guides vidéo + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Tiết kiệm chi phí LLM với 9Router +
+ 🇻🇳 Tiếng Việt
+ Tiết kiệm chi phí LLM cho OpenClaw với 9Router
by Mì AI
+
+ + 9Router + Claude Code FREE Unlimited Setup +
+ 🇵🇰 اردو / हिन्दी
+ 9Router + Claude Code FREE Unlimited Setup
by Build AI With Hamid
+
+ + 9Router Setup Tutorial +
+ 🇺🇸 English
+ 9Router + Claude Code FREE Setup
by Build AI With Hamid
+
+ + 9Router Setup Tutorial +
+ 🇺🇸 English
+ 9Router + Claude Code FREE Setup
by Build AI With Hamid
+
+ + Claude Code FREE Forever +
+ 🇺🇸 English
+ Claude Code FREE Forever — Unlimited Models
by Build AI With Hamid
+
+ + Claude CLI Free Setup +
+ 🇺🇸 English
+ Claude CLI Free Setup with 9Router 🚀
by CodeVerse Soban
+
+ + Cài đặt OpenClaw Free A-Z +
+ 🇻🇳 Tiếng Việt
+ Cài Đặt OpenClaw Free Từ A-Z + 9Router
by Mai Gia
+
+ + FREE OpenClaw with Claude Opus +
+ 🇺🇸 English
+ FREE OpenClaw + Claude Opus 4.6
by Build AI With Hamid
+
+ + Claude CLI Free Setup +
+ 🇮🇩 Indonesia
+ Koding 24 Jam Anti Rate Limit! Hemat Token AI 65% | Tutorial Quick Setup 9Router 🚀
by Krisswuh
+
+ + Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB +
+ 🇮🇩 Indonesia
+ Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB
by Krisswuh
+
+ +
+ +> 🎬 **Vous avez réalisé une vidéo sur 9Router ?** Envoyez une [Pull Request](https://github.com/decolua/9router/pulls) en ajoutant votre vidéo à cette section — nous la fusionnerons ! + +--- + +## 🛠️ Outils CLI pris en charge + +9Router fonctionne parfaitement avec tous les principaux outils de codage IA : + +
+ + + + + + + + + + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ OpenClaw
+ OpenClaw +
+ Codex
+ Codex +
+ OpenCode
+ OpenCode +
+ Cursor
+ Cursor +
+ Antigravity
+ Antigravity +
+ Cline
+ Cline +
+ Continue
+ Continue +
+ Droid
+ Droid +
+ Roo
+ Roo +
+ Copilot
+ Copilot +
+ Kilo Code
+ Kilo Code +
+
+ +--- + +## 🌐 Fournisseurs pris en charge + +### 🔐 Fournisseurs OAuth + +
+ + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ Antigravity
+ Antigravity +
+ Codex
+ Codex +
+ GitHub
+ GitHub +
+ Cursor
+ Cursor +
+ Kimchi
+ Kimchi +
+
+ +### 🆓 Fournisseurs gratuits + +
+ + + + + + +
+ Kiro
+ Kiro AI
+ Claude 4.5 + GLM-5 + MiniMax
GRATUIT illimité
+
+ OpenCode Free
+ OpenCode Free
+ Sans authentification • Modèles automatiques
GRATUIT illimité
+
+ Vertex AI
+ Vertex AI
+ Gemini 3 Pro + GLM-5 + DeepSeek
300 $ de crédit gratuit
+
+
+ +> **Remarque :** Les niveaux gratuits d'iFlow, Qwen et Gemini CLI ont été interrompus en 2026. Utilisez plutôt Kiro / OpenCode Free / Vertex. + +### 🔑 Fournisseurs avec clé API (40+) + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ OpenRouter
+ OpenRouter +
+ GLM
+ GLM +
+ Kimi
+ Kimi +
+ MiniMax
+ MiniMax +
+ OpenAI
+ OpenAI +
+ Anthropic
+ Anthropic +
+ Gemini
+ Gemini +
+ DeepSeek
+ DeepSeek +
+ Groq
+ Groq +
+ xAI
+ xAI +
+ Mistral
+ Mistral +
+ Perplexity
+ Perplexity +
+ Together
+ Together AI +
+ Fireworks
+ Fireworks +
+ Cerebras
+ Cerebras +
+ Cohere
+ Cohere +
+ NVIDIA
+ NVIDIA +
+ SiliconFlow
+ SiliconFlow +
+

...et plus de 20 autres fournisseurs, dont Nebius, Chutes, Hyperbolic et des endpoints personnalisés compatibles OpenAI/Anthropic

+
+ +--- + +## 💡 Fonctionnalités clés + +| Fonctionnalité | Ce qu'elle fait | Pourquoi c'est important | +| -------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| 🚀 **Économiseur de tokens RTK** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | Compresse les sorties des outils (`git diff`, `grep`, `ls`, `tree`...) avant de les envoyer au LLM | Économise **20 à 40 % de tokens d'entrée** par requête | +| 🧠 **Économiseur de tokens Headroom** ([Headroom](https://github.com/chopratejas/headroom)) | Proxy externe optionnel `/v1/compress` avant le routage vers le fournisseur | Économise plus de tokens de contexte sans changer les clients | +| 🪨 **Mode cavernicole** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | Injecte un prompt en langage cavernicole → le LLM répond de manière concise, le contenu technique est conservé | Économise **jusqu'à 65 % de tokens de sortie** | +| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | Injecte un prompt de "dev sénior fainéant" → le LLM écrit un code minimal, YAGNI d'abord (Lite/Full/Ultra) | **Moins de tokens de sortie, moins de refactorisation** | +| 🎯 **Repli intelligent à 3 niveaux** | Routage automatique : Abonnement → Bon marché → Gratuit | N'arrêtez jamais de coder, zéro temps d'arrêt | +| 📊 **Suivi de quota en temps réel** | Compteur de tokens en direct + compte à rebours de réinitialisation | Maximisez la valeur de votre abonnement | +| 🔄 **Traduction de formats** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | Fonctionne avec n'importe quel outil CLI | +| 👥 **Prise en charge multi-comptes** | Plusieurs comptes par fournisseur | Équilibrage de charge + redondance | +| 🔄 **Renouvellement automatique des tokens** | Les tokens OAuth se renouvellent automatiquement | Plus besoin de se reconnecter manuellement | +| 🎨 **Combos personnalisés** | Créez des combinaisons de modèles illimitées | Adaptez le repli à vos besoins | +| 📝 **Journalisation des requêtes** | Mode débogage avec journaux complets de requêtes/réponses | Résolvez facilement les problèmes | +| 💾 **Synchronisation cloud** | Synchronisez la configuration entre vos appareils | La même configuration partout | +| 📊 **Analytique d'utilisation** | Suivez tokens, coûts et tendances au fil du temps | Optimisez vos dépenses | +| 🌐 **Déployez partout** | Localhost, VPS, Docker, Cloudflare Workers | Options de déploiement flexibles | + +
+📖 Détails des fonctionnalités + +### 🚀 Économiseur de tokens RTK + +Les sorties des outils (`git diff`, `grep`, `find`, `ls`, `tree`, vidages de journaux...) consomment souvent 30 à 50 % de votre budget de prompt. RTK les détecte et applique une compression intelligente et sans perte **avant** que la requête n'atteigne le LLM : + +- **Filtres :** `git-diff`, `git-status`, `grep`, `find`, `ls`, `tree`, `dedup-log`, `smart-truncate`, `read-numbered`, `search-list` +- **Détection automatique :** Aucune configuration requise — RTK inspecte le premier 1 Ko de chaque `tool_result` et choisit le bon filtre. +- **Sûr par conception :** Si un filtre échoue, génère une erreur ou rend la sortie plus volumineuse, RTK conserve silencieusement le texte original. Les erreurs ne cassent jamais votre requête. +- **Universel :** Fonctionne dans tous les formats (OpenAI, Claude, Gemini, Cursor, Kiro, OpenAI Responses) car il s'exécute **avant** toute traduction de format. +- **Activé par défaut :** Activez-le ou désactivez-le à tout moment dans Tableau de bord → Paramètres d'Endpoint. + +``` +Sans RTK : 47K tokens envoyés au LLM +Avec RTK : 28K tokens envoyés au LLM (40 % économisés · même contexte · même réponse) +``` + +### 🧠 Économiseur de tokens Headroom + +Headroom est optionnel et s'exécute séparément. 9Router appelle l'endpoint local `/v1/compress` de Headroom, puis conserve le routage, le repli, l'authentification et le suivi d'utilisation normaux : + +``` +Client → 9Router → Headroom /v1/compress → 9Router → fournisseur +``` + +Configuration locale : + +```bash +pip install "headroom-ai[proxy]" +headroom proxy --port 8787 +``` + +Activez-le dans Tableau de bord → Endpoint → Token Saver → Headroom. URL par défaut : `http://localhost:8787`. + +Exemples Docker : + +```bash +# Service Headroom dans le même réseau Docker +http://headroom:8787 + +# Headroom exécuté sur la machine hôte +http://host.docker.internal:8787 +``` + +Si Headroom est indisponible ou renvoie une erreur, 9Router bascule en mode dégradé et envoie la requête originale. + +### 🐴 Ponytail (Dev sénior fainéant) + +Ponytail injecte un prompt système de _"dev sénior fainéant"_ dans chaque requête, orientant le LLM vers un code minimal et YAGNI d'abord — la suppression plutôt que l'ajout, la bibliothèque standard plutôt que de nouvelles dépendances, une ligne plutôt que des abstractions. Adapté de [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail). + +- **Lite** — Construisez ce qui est demandé, nommez l'alternative plus fainéante. +- **Full** — Hiérarchie YAGNI appliquée : stdlib → natif → dépendances existantes → une ligne → code minimal. +- **Ultra** — Extrémiste du YAGNI : suppression d'abord, livrez la solution d'une ligne, remettez en question le reste de l'exigence dans la même réponse. + +``` +Sans Ponytail : code verbeux, abstractions superflues, échafaudage "au cas où" +Avec Ponytail : diff fonctionnel le plus court, aucune abstraction non demandée, moins de tokens +``` + +Ne sacrifie jamais : la validation des entrées, la gestion des erreurs qui évite la perte de données, la sécurité, l'accessibilité ni rien d'explicitement demandé. Activez-le dans Tableau de bord → Endpoint → Ponytail. Se cumule avec Caveman (concision de sortie) et RTK (compression d'entrée). + +### 🎯 Repli intelligent à 3 niveaux + +Créez des combos avec repli automatique : + +``` +Combo : "my-coding-stack" + 1. cc/claude-opus-4-6 (votre abonnement) + 2. glm/glm-4.7 (sauvegarde économique, $0.6/1M) + 3. if/kimi-k2-thinking (repli gratuit) + +→ Bascule automatiquement quand le quota est épuisé ou en cas d'erreur +``` + +### 📊 Suivi de quota en temps réel + +- Consommation de tokens par fournisseur +- Compte à rebours de réinitialisation (5 heures, quotidien, hebdomadaire) +- Estimation des coûts pour les niveaux payants +- Rapports de dépenses mensuels + +### 🔄 Traduction de formats + +Traduction transparente entre les formats : + +- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **Cursor** ↔ **Kiro** ↔ **Vertex** ↔ **Antigravity** ↔ **Ollama** ↔ **OpenAI Responses** +- Votre outil CLI envoie le format OpenAI → 9Router le traduit → Le fournisseur reçoit le format natif +- Fonctionne avec tout outil prenant en charge les endpoints OpenAI personnalisés + +### 👥 Prise en charge multi-comptes + +- Ajoutez plusieurs comptes par fournisseur +- Routage automatique round-robin ou basé sur la priorité +- Repli vers le compte suivant quand un compte atteint son quota + +### 🔄 Renouvellement automatique des tokens + +- Les tokens OAuth se renouvellent automatiquement avant expiration +- Plus besoin de réauthentification manuelle +- Expérience transparente avec tous les fournisseurs + +### 🎨 Combos personnalisés + +- Créez des combinaisons de modèles illimitées +- Mélangez niveaux d'abonnement, économiques et gratuits +- Nommez vos combos pour un accès facile +- Partagez vos combos entre appareils grâce à la Synchronisation cloud + +### 📝 Journalisation des requêtes + +- Activez le mode débogage pour des journaux complets de requêtes/réponses +- Suivez les appels API, les en-têtes et les charges utiles +- Résolvez les problèmes d'intégration +- Exportez les journaux pour analyse + +### 💾 Synchronisation cloud + +- Synchronisez fournisseurs, combos et paramètres entre appareils +- Synchronisation automatique en arrière-plan +- Stockage chiffré et sécurisé +- Accédez à votre configuration depuis n'importe où + +#### Remarques sur le runtime cloud + +- Privilégiez les variables cloud côté serveur en production : + - `BASE_URL` (URL de rappel interne utilisée par le planificateur de synchronisation) + - `CLOUD_URL` (base de l'endpoint de synchronisation cloud) +- `NEXT_PUBLIC_BASE_URL` et `NEXT_PUBLIC_CLOUD_URL` restent prises en charge pour la compatibilité/UI, mais le runtime serveur privilégie désormais `BASE_URL`/`CLOUD_URL`. +- Les requêtes de synchronisation cloud utilisent désormais un délai d'expiration + un comportement de défaillance rapide pour éviter que l'UI ne se bloque quand le DNS/réseau cloud est indisponible. + +### 📊 Analytique d'utilisation + +- Suivez l'utilisation des tokens par fournisseur et modèle +- Estimation des coûts et tendances de dépenses +- Rapports et informations mensuels +- Optimisez vos dépenses IA + +> **💡 IMPORTANT - Comprendre les coûts du tableau de bord :** +> +> Le "coût" affiché dans l'Analytique d'utilisation est **uniquement à des fins de suivi et de comparaison**. +> 9Router lui-même **ne vous facture jamais** quoi que ce soit. Vous ne payez que les fournisseurs directement (si vous utilisez des services payants). +> +> **Exemple :** Si votre tableau de bord affiche "coût total de 290 $" alors que vous utilisez les modèles iFlow, cela représente +> ce que vous auriez payé en utilisant des API payantes directement. Votre coût réel = **0 $** (iFlow est gratuit et illimité). +> +> Considérez-le comme un "suivi d'économies" qui montre combien vous économisez en utilisant des modèles gratuits ou +> en routant via 9Router ! + +### 🌐 Déployez partout + +- 💻 **Localhost** - Par défaut, fonctionne hors ligne +- ☁️ **VPS/Cloud** - Partagez entre appareils +- 🐳 **Docker** - Déploiement en une commande +- 🚀 **Cloudflare Workers** - Réseau mondial de périphérie + +
+ +--- + +## 💰 Tarifs en un coup d'œil + +| Niveau | Fournisseur | Coût | Réinitialisation du quota | Idéal pour | +| ---------------------- | --------------------- | --------------- | ------------------------- | ----------------------------------------- | +| **🚀 ÉCONOMIE DE TOKENS** | **RTK (intégré)** | **GRATUIT** | Toujours actif | **Économisez 20 à 40 % de tokens sur CHAQUE requête** | +| **💳 ABONNEMENT** | Claude Code (Pro/Max) | 20 à 200 $/mois | 5 h + hebdomadaire | Déjà abonné | +| | Codex (Plus/Pro) | 20 à 200 $/mois | 5 h + hebdomadaire | Utilisateurs d'OpenAI | +| | GitHub Copilot | 10 à 19 $/mois | Mensuelle | Utilisateurs de GitHub | +| | Cursor IDE | 20 $/mois | Mensuelle | Utilisateurs de Cursor | +| **💰 BON MARCHÉ** | GLM-5.1 / GLM-4.7 | 0,6 $/1M | Quotidienne 10 h | Sauvegarde économique | +| | MiniMax M2.7 | 0,2 $/1M | Fenêtre de 5 heures | L'option la moins chère | +| | Kimi K2.5 | 9 $/mois forfait | 10M tokens/mois | Coût prévisible | +| **🆓 GRATUIT** | Kiro AI | 0 $ | Illimité | Claude 4.5 + GLM-5 + MiniMax gratuits | +| | OpenCode Free | 0 $ | Illimité | Sans authentification, modèles automatiques | +| | Vertex AI | 300 $ de crédit | Nouveaux comptes GCP | Gemini 3 Pro + DeepSeek + GLM-5 | + +**💡 Astuce pro :** Le combo RTK + Kiro AI + OpenCode Free = **0 $ de coût + 20 à 40 % d'économie de tokens** ! + +--- + +### 📊 Comprendre les coûts et la facturation de 9Router + +**La réalité de la facturation de 9Router :** + +✅ **Le logiciel 9Router = GRATUIT pour toujours** (open source, ne facture jamais) +✅ **Les "coûts" du tableau de bord = Affichage/suivi uniquement** (pas de vraies factures) +✅ **Vous payez les fournisseurs directement** (abonnements ou frais d'API) +✅ **Les fournisseurs GRATUITS restent GRATUITS** (iFlow, Kiro, Qwen = 0 $ illimité) +❌ **9Router n'envoie jamais de factures** et ne débite pas votre carte + +**Comment fonctionne l'affichage des coûts :** + +Le tableau de bord affiche des **coûts estimés** comme si vous utilisiez des API payantes directement. Ce n'est **pas une facturation** — c'est un outil de comparaison pour montrer vos économies. + +**Scénario d'exemple :** + +``` +Affichage du tableau de bord : +• Total des requêtes : 1 662 +• Total des tokens : 47M +• Coût affiché : 290 $ + +Vérification de la réalité : +• Fournisseur : iFlow (GRATUIT illimité) +• Paiement réel : 0,00 $ +• Ce que signifie 290 $ : Le montant que vous avez ÉCONOMISÉ en utilisant des modèles gratuits ! +``` + +**Règles de paiement :** + +- **Fournisseurs par abonnement** (Claude Code, Codex) : Payez-les directement via leurs sites web +- **Fournisseurs bon marché** (GLM, MiniMax) : Payez-les directement, 9Router se contente de router +- **Fournisseurs GRATUITS** (iFlow, Kiro, Qwen) : Vraiment gratuits pour toujours, sans frais cachés +- **9Router** : Ne facture jamais rien, jamais + +--- + +## 🎯 Cas d'utilisation + +### Cas 1 : "J'ai un abonnement Claude Pro" + +**Problème :** Le quota expire inutilisé, limites de débit pendant un codage intensif + +**Solution :** + +``` +Combo : "maximize-claude" + 1. cc/claude-opus-4-7 (utilisez l'abonnement à fond) + 2. glm/glm-5.1 (sauvegarde économique quand le quota est épuisé) + 3. kr/claude-sonnet-4.5 (repli gratuit d'urgence) + +Coût mensuel : 20 $ (abonnement) + ~5 $ (sauvegarde) = 25 $ au total +vs. 20 $ + buter sur les limites = frustration +``` + +### Cas 2 : "Je veux un coût zéro" + +**Problème :** Impossible de payer un abonnement, besoin d'un codage IA fiable + +**Solution :** + +``` +Combo : "free-forever" + 1. kr/claude-sonnet-4.5 (Claude 4.5 gratuit illimité) + 2. kr/glm-5 (GLM-5 gratuit via Kiro) + 3. oc/ (OpenCode Free, sans authentification) + +Coût mensuel : 0 $ +Qualité : Modèles prêts pour la production + RTK économise 20 à 40 % de tokens +``` + +### Cas 3 : "J'ai besoin de coder 24h/24 et 7j/7, sans interruptions" + +**Problème :** Délais à respecter, pas de temps d'arrêt possible + +**Solution :** + +``` +Combo : "always-on" + 1. cc/claude-opus-4-7 (meilleure qualité) + 2. cx/gpt-5.5 (deuxième abonnement) + 3. glm/glm-5.1 (bon marché, réinitialisation quotidienne) + 4. minimax/MiniMax-M2.7 (le moins cher, réinitialisation 5 h) + 5. kr/claude-sonnet-4.5 (gratuit illimité) + +Résultat : 5 couches de repli = zéro temps d'arrêt +Coût mensuel : 20 à 200 $ (abonnements) + 10 à 20 $ (sauvegarde) +``` + +### Cas 4 : "Je veux une IA GRATUITE dans OpenClaw" + +**Problème :** Besoin d'un assistant IA dans les applications de messagerie (WhatsApp, Telegram, Slack...), entièrement gratuit + +**Solution :** + +``` +Combo : "openclaw-free" + 1. kr/claude-sonnet-4.5 (Claude 4.5 gratuit) + 2. kr/glm-5 (GLM-5 gratuit) + 3. kr/MiniMax-M2.5 (MiniMax gratuit) + +Coût mensuel : 0 $ +Accès via : WhatsApp, Telegram, Slack, Discord, iMessage, Signal... +``` + +--- + +## ❓ Questions fréquemment posées + +
+📊 Pourquoi mon tableau de bord affiche-t-il des coûts élevés ? + +Le tableau de bord suit votre utilisation de tokens et affiche des **coûts estimés** comme si vous utilisiez des API payantes directement. Ce n'est **pas une facturation réelle** — c'est une référence pour montrer combien vous économisez en utilisant des modèles gratuits ou des abonnements existants via 9Router. + +**Exemple :** + +- **Le tableau de bord affiche :** "290 $ de coût total" +- **La réalité :** Vous utilisez iFlow (GRATUIT illimité) +- **Votre coût réel :** **0,00 $** +- **Ce que signifie 290 $ :** Le montant que vous avez **économisé** en utilisant des modèles gratuits au lieu d'API payantes ! + +L'affichage des coûts est un "suivi d'économies" qui vous aide à comprendre vos habitudes d'utilisation et vos opportunités d'optimisation. + +
+ +
+💳 9Router me facturera-t-il ? + +**Non.** 9Router est un logiciel gratuit et open source qui s'exécute sur votre propre ordinateur. Il ne vous facture jamais rien. + +**Vous ne payez que :** + +- ✅ **Fournisseurs par abonnement** (Claude Code 20 $/mois, Codex 20 à 200 $/mois) → Payez-les directement sur leurs sites web +- ✅ **Fournisseurs bon marché** (GLM, MiniMax) → Payez-les directement, 9Router route simplement vos requêtes +- ❌ **9Router lui-même** → **Ne facture jamais rien, jamais** + +9Router est un proxy/routeur local. Il n'a pas votre carte bancaire, ne peut pas envoyer de factures et ne possède aucun système de facturation. C'est un logiciel entièrement gratuit. + +
+ +
+🆓 Les fournisseurs GRATUITS sont-ils vraiment illimités ? + +**Oui !** Les fournisseurs GRATUITS actuels (Kiro, OpenCode Free, Vertex) sont réellement gratuits et **sans frais cachés**. + +Ce sont des services gratuits offerts par ces entreprises respectives : + +- **Kiro AI** : Claude 4.5 + GLM-5 + MiniMax gratuits et illimités via AWS Builder ID / Google / GitHub OAuth +- **OpenCode Free** : Proxy de passage sans authentification, modèles récupérés automatiquement depuis `opencode.ai/zen/v1/models` +- **Vertex AI** : 300 $ de crédit gratuit pour les nouveaux comptes Google Cloud (90 jours) + +9Router se contente de router vos requêtes vers eux — il n'y a pas de "piège" ni de facturation future. Ce sont de véritables services gratuits, et 9Router les rend faciles à utiliser avec le support du repli. + +**Niveaux gratuits interrompus (non recommandés) :** + +- ❌ **iFlow** : Était gratuit et illimité, désormais payant (2026) +- ❌ **Qwen Code** : Le niveau gratuit OAuth a été interrompu par Alibaba le 15/04/2026 +- ❌ **Gemini CLI** : Fonctionne toujours, mais l'utiliser avec des outils non-CLI (Claude, Codex, Cursor...) peut entraîner des bannissements de compte — utilisez-le uniquement si vous restez sur le Gemini CLI lui-même + +
+ +
+💰 Comment minimiser mes vrais coûts d'IA ? + +**Stratégie gratuit-d'abord :** + +1. **Commencez avec un combo 100 % gratuit :** + + ``` + 1. gc/gemini-3-flash (180K/mois gratuit de Google) + 2. if/kimi-k2-thinking (gratuit illimité d'iFlow) + 3. qw/qwen3-coder-plus (gratuit illimité de Qwen) + ``` + + **Coût : 0 $/mois** + +2. **Ajoutez une sauvegarde économique** uniquement si nécessaire : + + ``` + 4. glm/glm-4.7 (0,6 $/1M de tokens) + ``` + + **Coût supplémentaire : Vous ne payez que ce que vous utilisez réellement** + +3. **Utilisez les fournisseurs par abonnement en dernier :** + - Uniquement si vous les possédez déjà + - 9Router vous aide à maximiser leur valeur grâce au suivi de quota + +**Résultat :** La plupart des utilisateurs peuvent fonctionner à 0 $/mois en utilisant uniquement les niveaux gratuits ! + +
+ +
+📈 Et si mon utilisation explose soudainement ? + +Le repli intelligent de 9Router évite les frais surprises : + +**Scénario :** Vous êtes en sprint de codage et vous épuisez vos quotas + +**Sans 9Router :** + +- ❌ Limite de débit atteinte → Le travail s'arrête → Frustration +- ❌ Ou : Vous accumulez accidentellement d'énormes factures d'API + +**Avec 9Router :** + +- ✅ L'abonnement atteint sa limite → Repli automatique vers le niveau économique +- ✅ Le niveau économique devient coûteux → Repli automatique vers le niveau gratuit +- ✅ N'arrêtez jamais de coder → Coûts prévisibles + +**Vous gardez le contrôle :** Définissez des limites de dépenses par fournisseur dans le tableau de bord, et 9Router les respecte. + +
+ +--- + +## 📖 Guide d'installation + +
+🔐 Fournisseurs par abonnement (maximisez la valeur) + +### Claude Code (Pro/Max) + +```bash +Tableau de bord → Providers → Connectez Claude Code +→ Connexion OAuth → Renouvellement automatique des tokens +→ Suivi de quota de 5 heures + hebdomadaire + +Modèles : + cc/claude-opus-4-7 + cc/claude-opus-4-6 + cc/claude-sonnet-4-6 + cc/claude-haiku-4-5-20251001 +``` + +**Astuce pro :** Utilisez Opus pour les tâches complexes et Sonnet pour la vitesse. 9Router suit le quota par modèle ! + +### OpenAI Codex (Plus/Pro) + +```bash +Tableau de bord → Providers → Connectez Codex +→ Connexion OAuth (port 1455) +→ Réinitialisation de 5 heures + hebdomadaire + +Modèles : + cx/gpt-5.5 + cx/gpt-5.4 + cx/gpt-5.3-codex + cx/gpt-5.2-codex +``` + +### GitHub Copilot + +```bash +Tableau de bord → Providers → Connectez GitHub +→ OAuth via GitHub +→ Réinitialisation mensuelle (le 1er du mois) + +Modèles : + gh/gpt-5.4 + gh/claude-opus-4.7 + gh/claude-sonnet-4.6 + gh/gemini-3.1-pro-preview + gh/grok-code-fast-1 +``` + +### Cursor IDE + +```bash +Tableau de bord → Providers → Connectez Cursor +→ Connexion OAuth +→ Abonnement mensuel + +Modèles : + cu/claude-4.6-opus-max + cu/claude-4.5-sonnet-thinking + cu/gpt-5.3-codex +``` + +
+ +
+💰 Fournisseurs bon marché (sauvegarde) + +### GLM-5.1 / GLM-4.7 (réinitialisation quotidienne, 0,6 $/1M) + +1. Inscrivez-vous : [Zhipu AI](https://open.bigmodel.cn/) +2. Obtenez la clé API depuis le Coding Plan +3. Tableau de bord → Add API Key : + - Fournisseur : `glm` + - API Key : `votre-clé` + +**Utilisation :** `glm/glm-5.1`, `glm/glm-5`, `glm/glm-4.7` + +**Astuce pro :** Le Coding Plan offre un quota 3× pour 1/7 du coût ! Réinitialisation quotidienne à 10 h. + +### MiniMax M2.7 (réinitialisation 5 h, 0,20 $/1M) + +1. Inscrivez-vous : [MiniMax](https://www.minimax.io/) +2. Obtenez la clé API +3. Tableau de bord → Add API Key + +**Utilisation :** `minimax/MiniMax-M2.7`, `minimax/MiniMax-M2.5` + +**Astuce pro :** L'option la moins chère pour les longs contextes (1M de tokens) ! + +### Kimi K2.5 (9 $/mois forfaitaires) + +1. Abonnez-vous : [Moonshot AI](https://platform.moonshot.ai/) +2. Obtenez la clé API +3. Tableau de bord → Add API Key + +**Utilisation :** `kimi/kimi-k2.5`, `kimi/kimi-k2.5-thinking` + +**Astuce pro :** 9 $/mois forfaitaires pour 10M de tokens = coût effectif de 0,90 $/1M ! + +
+ +
+🆓 Fournisseurs GRATUITS (recommandés) + +### Kiro AI (Claude 4.5 + GLM-5 + MiniMax GRATUIT) + +```bash +Tableau de bord → Connectez Kiro +→ AWS Builder ID, AWS IAM Identity Center, Google ou GitHub +→ Utilisation illimitée + +Modèles : + kr/claude-sonnet-4.5 + kr/claude-haiku-4.5 + kr/glm-5 + kr/MiniMax-M2.5 + kr/qwen3-coder-next + kr/deepseek-3.2 +``` + +**Astuce pro :** La meilleure option gratuite pour Claude. Sans clé API, sans paiement, entièrement illimité. + +### OpenCode Free (sans authentification, modèles automatiques) + +```bash +Tableau de bord → Connectez OpenCode Free +→ Aucune connexion requise (proxy de passage) +→ Modèles récupérés automatiquement depuis opencode.ai/zen/v1/models +``` + +**Astuce pro :** La configuration la plus rapide. Connectez-vous et commencez à coder. + +### Vertex AI (300 $ de crédit gratuit pour les nouveaux comptes GCP) + +```bash +Tableau de bord → Connectez Vertex AI +→ Importez le JSON du compte de service Google Cloud +→ Activez l'API Vertex AI dans votre projet GCP + +Modèles : + vertex/gemini-3.1-pro-preview + vertex/gemini-3-flash-preview + vertex/gemini-2.5-flash + +Vertex Partner (Anthropic / DeepSeek / GLM / Qwen via Vertex) : + vertex-partner/glm-5-maas + vertex-partner/deepseek-v3.2-maas + vertex-partner/qwen3-next-80b-a3b-thinking-maas +``` + +**Astuce pro :** Les nouveaux comptes Google Cloud reçoivent 300 $ de crédit gratuit pendant 90 jours. Largement suffisant pour coder tous les jours. + +
+ +
+🎨 Créez des combos + +### Exemple 1 : Maximisez l'abonnement → Sauvegarde économique + +``` +Tableau de bord → Combos → Create New + +Nom : premium-coding +Modèles : + 1. cc/claude-opus-4-7 (Abonnement principal) + 2. glm/glm-5.1 (Sauvegarde économique, 0,6 $/1M) + 3. minimax/MiniMax-M2.7 (Repli le moins cher, 0,20 $/1M) + +Utilisation en CLI : premium-coding + +Exemple de coût mensuel (100M de tokens) : + 80M via Claude (abonnement) : 0 $ supplémentaire + 15M via GLM : 9 $ + 5M via MiniMax : 1 $ + Total : 10 $ + votre abonnement +``` + +### Exemple 2 : Gratuit uniquement (coût zéro) + +``` +Nom : free-combo +Modèles : + 1. kr/claude-sonnet-4.5 (Claude 4.5 gratuit illimité) + 2. kr/glm-5 (GLM-5 gratuit via Kiro) + 3. vertex/gemini-3.1-pro-preview (300 $ de crédit gratuit) + +Coût : 0 $ pour toujours (+ 20 à 40 % d'économie de tokens via RTK) ! +``` + +
+ +
+🔧 Intégration CLI + +### Cursor IDE + +``` +Settings → Models → Advanced : + OpenAI API Base URL : http://localhost:20128/v1 + OpenAI API Key : [depuis le tableau de bord 9router] + Model : cc/claude-opus-4-7 +``` + +Ou utilisez le combo : `premium-coding` + +### Claude Code + +Modifiez `~/.claude/config.json` : + +```json +{ + "anthropic_api_base": "http://localhost:20128/v1", + "anthropic_api_key": "votre-clé-api-9router" +} +``` + +### Codex CLI + +```bash +export OPENAI_BASE_URL="http://localhost:20128" +export OPENAI_API_KEY="votre-clé-api-9router" + +codex "votre prompt" +``` + +### OpenClaw + +**Option 1 — Tableau de bord (recommandée) :** + +``` +Tableau de bord → CLI Tools → OpenClaw → Sélectionnez le modèle → Appliquer +``` + +**Option 2 — Manuel :** Modifiez `~/.openclaw/openclaw.json` : + +```json +{ + "agents": { + "defaults": { + "model": { + "primary": "9router/kr/claude-sonnet-4.5" + } + } + }, + "models": { + "providers": { + "9router": { + "baseUrl": "http://127.0.0.1:20128/v1", + "apiKey": "sk_9router", + "api": "openai-completions", + "models": [ + { + "id": "kr/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5 (Kiro Free)" + } + ] + } + } + } +} +``` + +> **Remarque :** OpenClaw ne fonctionne qu'avec un 9Router local. Utilisez `127.0.0.1` au lieu de `localhost` pour éviter les problèmes de résolution IPv6. + +### Cline / Continue / RooCode + +``` +Provider : OpenAI Compatible +Base URL : http://localhost:20128/v1 +API Key : [depuis le tableau de bord] +Model : cc/claude-opus-4-7 +``` + +
+ +
+🚀 Déploiement + +### Déploiement VPS + +```bash +# Clonez et installez +git clone https://github.com/decolua/9router.git +cd 9router +npm install +npm run build + +# Configuration +export JWT_SECRET="votre-secret-sécurisé-changez-le" +export INITIAL_PASSWORD="votre-mot-de-passe" +export DATA_DIR="/var/lib/9router" +export PORT="20128" +export HOSTNAME="0.0.0.0" +export NODE_ENV="production" +export NEXT_PUBLIC_BASE_URL="http://localhost:20128" +export NEXT_PUBLIC_CLOUD_URL="https://9router.com" +export API_KEY_SECRET="endpoint-proxy-api-key-secret" +export MACHINE_ID_SALT="endpoint-proxy-salt" + +# Démarrage +npm run start + +# Ou utilisez PM2 +npm install -g pm2 +pm2 start npm --name 9router -- start +pm2 save +pm2 startup +``` + +### Docker + +Images publiées (multi-plateformes `linux/amd64` + `linux/arm64`) : + +- Docker Hub : [`decolua/9router`](https://hub.docker.com/r/decolua/9router) +- GHCR : [`ghcr.io/decolua/9router`](https://github.com/decolua/9router/pkgs/container/9router) + +**Démarrage rapide (utilisez l'image publiée) :** + +```bash +docker run -d \ + --name 9router \ + -p 20128:20128 \ + -v "$HOME/.9router:/app/data" \ + -e DATA_DIR=/app/data \ + decolua/9router:latest +``` + +→ Ouvrez http://localhost:20128 + +**Compiler depuis le code source (dev) :** + +```bash +git clone https://github.com/decolua/9router.git +cd 9router/app +docker build -t 9router . +docker run -d --name 9router -p 20128:20128 \ + -v "$HOME/.9router:/app/data" -e DATA_DIR=/app/data 9router +``` + +**Valeurs par défaut du conteneur :** + +- `PORT=20128` +- `HOSTNAME=0.0.0.0` + +**Commandes utiles :** + +```bash +docker logs -f 9router +docker restart 9router +docker stop 9router && docker rm 9router +docker pull decolua/9router:latest # mise à jour vers la dernière version +``` + +**Persistance des données :** `$HOME/.9router/db/data.sqlite` sur l'hôte ↔ `/app/data/db/data.sqlite` dans le conteneur. + +### Variables d'environnement + +| Variable | Par défaut | Description | +| ---------------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `JWT_SECRET` | Généré automatiquement (`~/.9router/jwt-secret`) | Secret de signature JWT pour le cookie d'authentification du tableau de bord (remplacez-le pour partager entre instances) | +| `INITIAL_PASSWORD` | `123456` | Mot de passe de première connexion quand aucun hash n'est enregistré | +| `DATA_DIR` | `~/.9router` | Emplacement principal des données de l'app (SQLite dans `$DATA_DIR/db/data.sqlite`) | +| `PORT` | défaut du framework | Port du service (`20128` dans les exemples) | +| `HOSTNAME` | défaut du framework | Hôte de liaison (Docker utilise `0.0.0.0` par défaut) | +| `NODE_ENV` | défaut du runtime | Définissez `production` pour le déploiement | +| `BASE_URL` | `http://localhost:20128` | URL de base interne côté serveur utilisée par les tâches de synchronisation cloud | +| `CLOUD_URL` | `https://9router.com` | URL de base de l'endpoint de synchronisation cloud côté serveur | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | URL de base publique/rétrocompatible (préférez `BASE_URL` pour le runtime serveur) | +| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | URL cloud publique/rétrocompatible (préférez `CLOUD_URL` pour le runtime serveur) | +| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | Secret HMAC pour les clés API générées | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | Sel pour le hachage stable de l'ID machine | +| `ENABLE_REQUEST_LOGS` | `false` | Active les journaux de requêtes/réponses dans `logs/` | +| `AUTH_COOKIE_SECURE` | `false` | Force le cookie d'authentification `Secure` (mettez `true` derrière un proxy inverse HTTPS) | +| `REQUIRE_API_KEY` | `false` | Applique la clé API Bearer sur les routes `/v1/*` (recommandé pour les déploiements exposés à Internet) | +| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | vide | Proxy sortant optionnel pour les appels aux fournisseurs externes | +| `SEARXNG_URL` | `http://localhost:8888/search` | Endpoint du fournisseur de recherche web SearXNG intégré, sans authentification | + +Remarques : + +- Les variables de proxy en minuscules sont également prises en charge : `http_proxy`, `https_proxy`, `all_proxy`, `no_proxy`. +- `.env` n'est pas intégré à l'image Docker (`.dockerignore`) ; injectez la configuration du runtime avec `--env-file` ou `-e`. +- Sous Windows, `APPDATA` peut être utilisé pour résoudre le chemin de stockage local. +- `INSTANCE_NAME` apparaît dans l'ancienne documentation/les anciens modèles d'environnement, mais n'est actuellement pas utilisé au runtime. + +### Fichiers d'exécution et stockage + +- État principal de l'app : `${DATA_DIR}/db/data.sqlite` (SQLite — fournisseurs, combos, alias, clés, paramètres, historique d'utilisation) +- Sauvegardes automatiques : `${DATA_DIR}/db/backups/` +- Journaux optionnels de requêtes/translator : `/logs/...` quand `ENABLE_REQUEST_LOGS=true` +- `${DATA_DIR}` et `~/.9router` résolvent tous deux le même emplacement dans un conteneur Docker — le lien symbolique `/root/.9router -> /app/data` est créé au moment de la compilation. + +
+ +--- + +## 📊 Modèles disponibles + +
+Afficher tous les modèles disponibles + +**Claude Code (`cc/`)** - Pro/Max : + +- `cc/claude-opus-4-7` +- `cc/claude-opus-4-6` +- `cc/claude-sonnet-4-6` +- `cc/claude-sonnet-4-5-20250929` +- `cc/claude-haiku-4-5-20251001` + +**Codex (`cx/`)** - Plus/Pro : + +- `cx/gpt-5.5` +- `cx/gpt-5.4` +- `cx/gpt-5.3-codex` +- `cx/gpt-5.2-codex` +- `cx/gpt-5.1-codex-max` + +**GitHub Copilot (`gh/`)** : + +- `gh/gpt-5.4` +- `gh/claude-opus-4.7` +- `gh/claude-sonnet-4.6` +- `gh/gemini-3.1-pro-preview` +- `gh/grok-code-fast-1` + +**Cursor (`cu/`)** - Abonnement : + +- `cu/claude-4.6-opus-max` +- `cu/claude-4.5-sonnet-thinking` +- `cu/gpt-5.3-codex` +- `cu/kimi-k2.5` + +**GLM (`glm/`)** - 0,6 $/1M : + +- `glm/glm-5.1` +- `glm/glm-5` +- `glm/glm-4.7` + +**MiniMax (`minimax/`)** - 0,2 $/1M : + +- `minimax/MiniMax-M2.7` +- `minimax/MiniMax-M2.5` + +**Kimi (`kimi/`)** - 9 $/mois forfaitaires : + +- `kimi/kimi-k2.5` +- `kimi/kimi-k2.5-thinking` + +**Kiro (`kr/`)** - GRATUIT illimité : + +- `kr/claude-sonnet-4.5` +- `kr/claude-haiku-4.5` +- `kr/glm-5` +- `kr/MiniMax-M2.5` +- `kr/qwen3-coder-next` +- `kr/deepseek-3.2` + +**OpenCode Free (`oc/`)** - GRATUIT sans authentification : + +- Récupérés automatiquement depuis `opencode.ai/zen/v1/models` + +**Vertex AI (`vertex/`)** - 300 $ de crédit gratuit : + +- `vertex/gemini-3.1-pro-preview` +- `vertex/gemini-3-flash-preview` +- `vertex/gemini-2.5-flash` +- `vertex-partner/glm-5-maas` +- `vertex-partner/deepseek-v3.2-maas` + +
+ +--- + +## 🐛 Dépannage + +**"Language model did not provide messages"** + +- Quota du fournisseur épuisé → Consultez le suivi de quota du tableau de bord +- Solution : utilisez le repli du combo ou passez à un niveau moins cher + +**Limites de débit** + +- Quota d'abonnement épuisé → Repli vers GLM/MiniMax +- Ajoutez le combo : `cc/claude-opus-4-7 → glm/glm-5.1 → kr/claude-sonnet-4.5` + +**Token OAuth expiré** + +- Renouvelé automatiquement par 9Router +- Si les problèmes persistent : Tableau de bord → Provider → Reconnecter + +**Coûts élevés** + +- Activez RTK dans Tableau de bord → Paramètres d'Endpoint (activé par défaut, économise 20 à 40 % de tokens) +- Consultez les statistiques d'utilisation dans le tableau de bord +- Changez le modèle principal pour GLM/MiniMax +- Utilisez le niveau gratuit (Kiro, OpenCode Free, Vertex) pour les tâches non critiques + +**Le tableau de bord s'ouvre sur le mauvais port** + +- Définissez `PORT=20128` et `NEXT_PUBLIC_BASE_URL=http://localhost:20128` + +**La première connexion ne fonctionne pas** + +- Vérifiez `INITIAL_PASSWORD` dans `.env` +- S'il n'est pas défini, le mot de passe de secours est `123456` + +**Pas de journaux de requêtes dans `logs/`** + +- Définissez `ENABLE_REQUEST_LOGS=true` + +--- + +## 🛠️ Pile technologique + +- **Runtime** : Node.js 20+ +- **Framework** : Next.js 16 +- **UI** : React 19 + Tailwind CSS 4 +- **Base de données** : SQLite (better-sqlite3 / node:sqlite / sql.js en secours) +- **Streaming** : Server-Sent Events (SSE) +- **Authentification** : OAuth 2.0 (PKCE) + JWT + clés API + +--- + +## 📝 Référence de l'API + +### Chat Completions + +```bash +POST http://localhost:20128/v1/chat/completions +Authorization: Bearer votre-clé-api +Content-Type: application/json + +{ + "model": "cc/claude-opus-4-6", + "messages": [ + {"role": "user", "content": "Écrivez une fonction pour..."} + ], + "stream": true +} +``` + +### Lister les modèles + +```bash +GET http://localhost:20128/v1/models +Authorization: Bearer votre-clé-api + +→ Renvoie tous les modèles et combos au format OpenAI +``` + +## 📧 Support + +- **Site web** : [9router.com](https://9router.com) +- **GitHub** : [github.com/decolua/9router](https://github.com/decolua/9router) +- **Problèmes** : [github.com/decolua/9router/issues](https://github.com/decolua/9router/issues) + +--- + +## 👥 Contributeurs + +Merci à tous les contributeurs qui ont contribué à améliorer 9Router ! + +[![Contributors](https://contrib.rocks/image?repo=decolua/9router&max=150&columns=15&anon=1&v=20260309)](https://github.com/decolua/9router/graphs/contributors) + +--- + +## 📊 Graphique d'étoiles + +[![Star Chart](https://starchart.cc/decolua/9router.svg?variant=adaptive)](https://starchart.cc/decolua/9router) + +## 🔀 Forks + +**[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** — Un fork TypeScript complet de 9Router. Ajoute plus de 36 fournisseurs, un repli automatique à 4 niveaux, des API multimodales (images, embeddings, audio, TTS), un disjoncteur, un cache sémantique, des évaluations de LLM et un tableau de bord soigné. Plus de 368 tests unitaires. Disponible via npm et Docker. + +--- + +## 🙏 Remerciements + +Construit sur les épaules de géants : + +- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — implémentation originale en Go qui a inspiré ce port JavaScript. +- **[RTK](https://github.com/rtk-ai/rtk)** ![Stars](https://img.shields.io/github/stars/rtk-ai/rtk?style=flat&color=yellow) — économiseur de tokens en Rust. 9Router porte son pipeline de compression en JS → **−20 à 40 % de tokens d'entrée** sur chaque requête. +- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) par **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral _"why use many token when few token do trick"_. 9Router adapte son prompt → **−65 % de tokens de sortie**. +- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Stars](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) par **[@DietrichGebert](https://github.com/DietrichGebert)** — compétence de _"dev sénior fainéant"_. 9Router injecte sa hiérarchie YAGNI-d'abord → **moins de tokens, moins de code, des diffs plus courts**. + +Un immense merci à ces auteurs — sans leur travail, les fonctionnalités d'économie de tokens de 9Router n'existeraient pas. Mettez-leur une ⭐ sur GitHub ! + +--- + +## 📄 Licence + +Licence MIT - voir [LICENSE](LICENSE) pour plus de détails. + +--- + +
+ Conçu avec ❤️ pour les développeurs qui codent 24h/24 et 7j/7 +
+ diff --git a/i18n/README.pt-BR.md b/i18n/README.pt-BR.md new file mode 100644 index 00000000..c141d679 --- /dev/null +++ b/i18n/README.pt-BR.md @@ -0,0 +1,1526 @@ +
+ Painel do 9Router + + # 9Router — roteador de IA GRATUITO e economizador de tokens + + **Nunca pare de codificar. Economize 20-40% de tokens com RTK + fallback automático para modelos de IA GRATUITOS e baratos.** + + **Conecte todas as ferramentas de código de IA (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) a mais de 40 provedores de IA e mais de 100 modelos.** + + [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) + [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) + [![Docker Pulls](https://img.shields.io/docker/pulls/decolua/9router.svg?logo=docker&label=Docker%20pulls)](https://hub.docker.com/r/decolua/9router) + [![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router) + [![Licença](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + +decolua%2F9router | Trendshift + +[🚀 Início rápido](#-início-rápido) • [💡 Recursos](#-principais-recursos) • [📖 Configuração](#-guia-de-configuração) • [🌐 Site](https://9router.com) + +[🇺🇸 English](../README.md) • [🇻🇳 Tiếng Việt](./README.vi.md) • [🇨🇳 中文](./README.zh-CN.md) • [🇯🇵 日本語](./README.ja-JP.md) • [🇷🇺 Русский](./README.ru.md) • [🇹🇭 ไทย](./README.th.md) • [🇮🇷 فارسی](./README.fa_IR.md) • [🇮🇩 Indonesia](./README.id-ID.md) + +
+ +--- + +## 🤔 Por que 9Router? + +**Pare de desperdiçar dinheiro, tokens e atingir limites:** + +- ❌ A cota de assinatura expira sem ser utilizada todos os meses +- ❌ Os limites de requisições interrompem você durante a programação +- ❌ Saídas de ferramentas (`git diff`, `grep`, `ls`...) consomem tokens rapidamente +- ❌ APIs caras ($20–50/mês por provedor) +- ❌ Troca manual entre provedores + +**9Router resolve isso:** + +- ✅ **RTK Token Saver** - Compactação automática do conteúdo de `tool_result`, economize 20-40% de tokens por solicitação +- ✅ **Maximize as assinaturas** - Rastreie a cota, aproveite toda a cota antes da renovação +- ✅ **Auto fallback** - Assinatura → Econômico → Gratuito, sem tempo de inatividade +- ✅ **Múltiplas contas** - Round-robin entre contas por provedor +- ✅ **Universal** - Funciona com Claude Code, Codex, Cursor, Cline, qualquer ferramenta CLI + +--- + +## 🔄 Como funciona + +``` +┌─────────────┐ +│ Sua CLI │ (Claude Code, Codex, OpenClaw, Cursor, Cline...) +│ Ferramenta│ +└──────┬──────┘ + │ http://localhost:20128/v1 + ↓ +┌─────────────────────────────────────────────┐ +│ 9Router (Roteador inteligente) │ +│ • RTK Token Saver (reduz tokens de `tool_result`) │ +│ • Tradução de formatos (OpenAI ↔ Claude) │ +│ • Acompanhamento de cotas │ +│ • Renovação automática de token │ +└──────┬──────────────────────────────────────┘ + │ + ├─→ [Nível 1: ASSINATURA] Claude Code, Codex, GitHub Copilot + │ ↓ cota esgotada + ├─→ [Nível 2: ECONÔMICO] GLM ($0.6/1M), MiniMax ($0.2/1M) + │ ↓ limite de orçamento + └─→ [Nível 3: GRATUITO] Kiro, OpenCode Free, Vertex ($300 em créditos) + +Resultado: programe sem interrupções, com custo mínimo e economia de 20% a 40% dos tokens via RTK +``` + +--- + +## ⚡ Início rápido + +**1. Instale globalmente:** + +```bash +npm install -g 9router +9router +``` + +🎉 O painel abre em `http://localhost:20128` + +**2. Conecte um provedor GRATUITO (sem necessidade de inscrição):** + +Painel → Provedores → Conecte **Kiro AI** (~50 créditos/mês grátis: Claude 4.5 + GLM-5 + MiniMax) ou **OpenCode Free** (sem autenticação) → Pronto! + +**3. Use em sua ferramenta CLI:** + +``` +Configurações do Claude Code/Codex/OpenClaw/Cursor/Cline: + Endpoint: http://localhost:20128/v1 + API Key: [copie do painel] + Model: kr/claude-sonnet-4.5 +``` + +**É isso aí!** Comece a codificar com modelos de IA GRATUITOS. + +**Alternativa: executar a partir do código-fonte (este repositório):** + +Este pacote de repositório é privado (`9router-app`), portanto, a execução pelo código-fonte/Docker é o caminho de desenvolvimento local esperado. + +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev +``` + +Modo de produção: + +```bash +npm run build +PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start +``` + +URLs padrão: + +- Painel: `http://localhost:20128/dashboard` +- API compatível com OpenAI: `http://localhost:20128/v1` + +--- + +## Guias de vídeo + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + Tiết kiệm chi phí LLM với 9Router +
+ 🇻🇳 Tiếng Việt
+ Tiết kiệm chi phí LLM cho OpenClaw no 9Router
por Mì AI
+
+ + 9Router + Claude Code FREE Unlimited Setup +
+ 🇵🇰 اردو / हिन्दी
+ 9Router + Claude Code Configuração ilimitada GRATUITA
por Construir IA com Hamid
+
+ + 9Router Setup Tutorial +
+ 🇺🇸 Inglês
+ 9Router + Claude Code Configuração GRATUITA
por Construir IA com Hamid
+
+ + 9Router Setup Tutorial +
+ 🇺🇸 Inglês
+ 9Router + Claude Code Configuração GRATUITA
por Construir IA com Hamid
+
+ + Claude Code FREE Forever +
+ 🇺🇸 Inglês
+ Claude Code FREE Forever - Modelos ilimitados
por Construa IA com Hamid
+
+ +Claude CLI Free Setup +
+ 🇺🇸 Inglês
+ Configuração gratuita Claude CLI com 9Router 🚀
por CodeVerse Soban
+
+ + Cài đặt OpenClaw Free A-Z +
+ 🇻🇳 Tiếng Việt
+ Cài Đặt OpenClaw Free Từ A-Z + 9Router
por Mai Gia
+
+ + FREE OpenClaw with Claude Opus +
+ 🇺🇸 Inglês
+ FREE OpenClaw + Claude Opus 4.6
por Construir IA com Hamid
+
+ + Claude CLI Free Setup +
+ 🇮🇩 Indonésia
+Koding 24 Jam Anti Rate Limit! Hemat Token AI 65% | Tutorial Configuração Rápida 9Router 🚀
por Krisswuh
+
+ + Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB +
+ 🇮🇩 Indonésia
+ Cara Implante 9Router no Hugging Face GRATUITAMENTE sem parar! | Alternativa VPS RAM 16GB
por Krisswuh
+
+ + این شکلی از هر API ای استفاده کن برای هوش مصنوعی +
+ 🇮🇷 Persa-فارسی
+ این شکلی از هر API ای استفاده کن برای هوش مصنوعی
por Matin SenPai
+
+ + Hướng Dẫn Setup OpenClaw + 9Router: Tạo Bot Zalo AI Tự Động Từ A-Z +
+ 🇻🇳 Tiếng Việt
+ Hướng Dẫn Setup OpenClaw + 9Router: Tạo Bot Zalo AI Tự Động Từ A-Z
por tuanminhhole
+
+ +
+ +> 🎬 **Fez um vídeo sobre o 9Router?** Envie um [Pull Request](https://github.com/decolua/9router/pulls) adicionando seu vídeo a esta seção - nós o mesclaremos! + +--- + +## 🛠️ Ferramentas CLI suportadas + +9Router funciona perfeitamente com todas as principais ferramentas de codificação de IA: + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ Claude Code
+ Código-Claude +
+ OpenClaw
+ OpenClaw +
+ Codex
+ Codex +
+ OpenCode
+ OpenCode +
+ Cursor
+ Cursor +
+ Antigravity
+ antigravidade +
+ Cline
+ Cline +
+ Continue
+ Continuar +
+Droid
+ Droid +
+ Roo
+ Roo +
+ Copilot
+ Copilot +
+Kilo Code
+ Código Kilo +
+ OpenDesign
+ OpenDesign +
+ jcode
+ jcódigo +
+ Grok Build
+ Grok Build +
+ Devin CLI
+ Devin CLI +
+ DeepSeek TUI
+ DeepSeek TUI +
+ Qwen Code
+ Código Qwen +
+
+ +--- + +## 🌐 Provedores compatíveis + +### 🔐 Provedores OAuth + +
+ + + + + + + + + +
+ Claude Code
+ Código-Claude +
+ Antigravity
+ antigravidade +
+ Codex
+ Codex +
+ GitHub
+ GitHub +
+ Cursor
+ Cursor +
+ Kimchi
+ Kimchi +
+
+ +### 🆓 Provedores gratuitos + +
+ + + + + + +
+ Kiro
+ Kiro AI
+ Claude 4.5 + GLM-5 + MiniMax
50 créditos/mês grátis
+
+ OpenCode Free
+ OpenCode grátis
+ Sem autenticação • Modelos de busca automática
Grátis (a lista de modelos varia)
+
+ Vertex AI
+ Vertex AI
+ Gemini 3 Pro + GLM-5 + DeepSeek
$300 créditos grátis
+
+
+ +> **Observação:** Os níveis gratuitos iFlow, Qwen Code e Gemini CLI foram descontinuados em 2026. Em vez disso, use Kiro / OpenCode Free / Vertex. +> +> **Kiro AI** mudou para um modelo pago em setembro de 2025 — o nível gratuito agora é limitado a **50 créditos/mês** (mais 500 créditos de avaliação para novas contas nos primeiros 30 dias). Camadas pagas: Pro $20/mo (1.000 créditos), Pro+ $40/mo (2.000), Pro Max $100/mo (5.000), Power $200/mo (10.000). +> A lista de modelos **OpenCode Free** varia com o tempo (alguns modelos são gratuitos apenas para promoções limitadas) — sujeito a alterações sem aviso prévio. +> **Vertex AI**: o crédito gratuito $300 para novas contas do GCP ainda é válido, mas desde março de 2026 o **endpoint da API Gemini não consome mais esses créditos**. Em vez disso, chame o endpoint **Vertex AI Studio**. + +### 🔑 Provedores de chaves de API (40+) + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ OpenRouter
+OpenRouter +
+ GLM
+ GLM +
+ Kimi
+ Kimi +
+ MiniMax
+MiniMax +
+ OpenAI
+ OpenAI +
+ Anthropic
+ Antrópico +
+ Gemini
+ Gemini +
+ DeepSeek
+ DeepSeek +
+ Groq
+ Groq +
+ xAI
+ xAI +
+ Mistral
+ Mistral +
+ Perplexity
+ Perplexidade +
+ Together
+ Juntos AI +
+ Fireworks
+ Fogos de artifício +
+ Cerebras
+Cerebras +
+ Cohere
+ Cohere +
+ NVIDIA
+ NVIDIA +
+ SiliconFlow
+SiliconFlow +
+

...e mais de 20 provedores, incluindo Nebius, Chutes, Hyperbolic e endpoints personalizados compatíveis com OpenAI/Anthropic

+
+ +### 🏠 Provedores auto-hospedados + +Para fala e incorporações veiculadas em **sua própria** máquina — whisper.cpp, +faster-whisper, Speaches, Kokoro-FastAPI, openai-speech, llama.cpp/llama-server, +vLLM, Infinity, text-embeddings-inference ou qualquer outro serviço compatível com o formato OpenAI. + +| Provedor | Ponto final usado | Servidor típico | +| --- | --- | --- | +| **STT auto-hospedado** | `/v1/audio/transcriptions` | whisper.cpp, faster-whisper | +| **TTS auto-hospedado** | `/v1/audio/speech` | Kokoro-FastAPI, openedai-speech | +| **Incorporação auto-hospedada** | `/v1/embeddings` | llama-server, vLLM, Infinity | + +Todos os outros provedores de voz são um serviço de nuvem nomeado com um endpoint fixo. Estes +três leem seus endereços de **cada conexão**, para que um provedor possa apontar para +várias máquinas e balanceamento de carga entre elas como qualquer outra. + +Defina-o na conexão como `providerSpecificData.baseUrl`: + +| Provedor | Informe | Resultado | +| --- | --- | --- | +| STT auto-hospedado | o URL completo — `http://host:8080/v1/audio/transcriptions` | usado como está | +| TTS auto-hospedado | a raiz do servidor — `http://host:8880` | `+ /v1/audio/speech` | +| Incorporação auto-hospedada | a **base OpenAI**, `/v1` incluída — `http://host:8080/v1` | `+ /embeddings` | + +> **Cuidado com `/v1` em embeddings.** O adaptador anexa `/embeddings`, então +> `http://host:8080` resolve para `http://host:8080/embeddings` e perde o +> rota OpenAI – o llama-server responde **501**. Dê a ele o mesmo URL base de um OpenAI +> o cliente usaria. Um `.../v1/embeddings` completo também é aceito, então um valor colado +> de um exemplo `curl` também funciona. + +A chave API não é verificada pela maioria dos servidores locais, mas o campo não deve estar vazio: +é o que dá à conexão um registro de credenciais, e `baseUrl` reside lá. +Qualquer espaço reservado funciona. + +A incorporação auto-hospedada **não tem fallback na nuvem por design** — uma conexão salva +sem um `baseUrl` é relatado como um erro de configuração, em vez de silenciosamente +voltando para `api.openai.com`, que enviaria seu texto de entrada e chave de API para +terceiros por meio de um provedor chamado "Auto-hospedado". + +--- + +## 💡 Principais recursos + +| Recurso | O que faz | Por que é importante | +| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------- | +| 🚀 **RTK Token Saver** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | Compactar saídas de ferramentas (`git diff`, `grep`, `ls`, `tree`...) antes de enviar para LLM | Economize **20-40% de tokens de entrada** por solicitação | +| 🧠 **Headroom Token Saver** ([Headroom](https://github.com/chopratejas/headroom)) | Proxy `/v1/compress` externo opcional antes do roteamento do provedor | Economize mais tokens de contexto sem alterar clientes | +| 🪨 **Modo Caveman** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | Injetar prompt de fala do homem das cavernas → Respostas do LLM concisas, substância técnica preservada | Economize **até 65% de tokens de produção** | +| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | Injetar prompt "lazy senior dev" → LLM escreve código mínimo YAGNI primeiro (Lite/Full/Ultra) | **Menos tokens de saída, menos refatoração** | +| 🎯 **Fallback inteligente de 3 camadas** | Rota automática: Assinatura → Barato → Grátis | Nunca pare de codificar, tempo de inatividade zero | +| 📊 **Acompanhamento de cotas em tempo real** | Contagem de tokens ao vivo + contagem regressiva de redefinição | Maximizar o valor da assinatura | +| 🔄 **Tradução de formato** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | Funciona com qualquer ferramenta CLI | +| 👥 **Suporte para múltiplas contas** | Várias contas por provedor | Balanceamento de carga + redundância | +| 🔄 **Atualização automática de token** | Os tokens OAuth são atualizados automaticamente | Não é necessário fazer novo login manual | +| 🎨 **Combos Personalizados** | Crie combinações ilimitadas de modelos | Adapte o fallback às suas necessidades | +| 📝 **Registro de requisições** | Modo de depuração com logs completos de solicitação/resposta | Solucione problemas facilmente | +| 💾 **Sincronização na nuvem** | Sincronizar configuração entre dispositivos | Mesma configuração em todos os lugares | +| 📊 **Análise de uso** | Acompanhe tokens, custos e tendências ao longo do tempo | Otimizar gastos | +| 🌐 **Implante em qualquer lugar** | Localhost, VPS, Docker, Cloudflare Workers | Opções flexíveis de implantação | + +Configure `X-9Router-Token-Saver: off` para ignorar todos os economizadores de tokens para uma solicitação de chat. + +
+📖 Detalhes do recurso + +### 🚀 RTK Token Saver + +As saídas da ferramenta (`git diff`, `grep`, `find`, `ls`, `tree`, despejos de log...) geralmente consomem de 30 a 50% do seu orçamento de contexto. O RTK os detecta e aplica compactação inteligente e sem perdas **antes** que a solicitação chegue ao LLM: + +- **Filtros:** `git-diff`, `git-status`, `grep`, `find`, `ls`, `tree`, `dedup-log`, `smart-truncate`, `read-numbered`, `search-list` +- **Detecção automática:** Não é necessária configuração — o RTK inspeciona o primeiro 1 KB de cada `tool_result` e escolhe o filtro correto. +- **Seguro por design:** Se um filtro falhar, gerar uma exceção ou aumentar a saída, o RTK mantém silenciosamente o texto original. Erros nunca interrompem sua solicitação. +- **Universal:** Funciona em todos os formatos (OpenAI, Claude, Gemini, Cursor, Kiro, OpenAI Responses) porque é executado **antes** de qualquer tradução de formato. +- **Padrão ATIVADO:** Alterne a qualquer momento em Painel → Configurações de endpoint. + +``` +Sem RTK: 47 mil tokens enviados ao LLM +Com RTK: 28 mil tokens enviados ao LLM (40% de economia · mesmo contexto · mesma resposta) +``` + +### 🧠 Headroom Token Saver + +O headroom é opcional e funciona separadamente. 9Router chama o endpoint `/v1/compress` local do Headroom e, em seguida, mantém roteamento normal, fallback, autenticação e rastreamento de uso: + +``` +Cliente → 9Router → Headroom /v1/compress → 9Router → provedor +``` + +Configuração local: + +```bash +pip install "headroom-ai[proxy]" +headroom proxy --port 8787 +``` + +Habilite em Painel → Endpoint → Economia de tokens → Headroom. URL padrão: `http://localhost:8787`. + +Exemplos de Docker: + +```bash +# Serviço Headroom na mesma rede Docker +http://headroom:8787 + +# Headroom em execução na máquina host +http://host.docker.internal:8787 +``` + +Se o Headroom estiver inativo ou retornar um erro, o o 9Router seguirá em modo aberto e enviará a solicitação original. + +### 🐴 Ponytail (desenvolvedor sênior preguiçoso) + +O Ponytail injeta um prompt do sistema _"lazy senior dev"_ em cada solicitação, direcionando o LLM para o código mínimo, primeiro YAGNI - preferindo exclusão a adição, stdlib sobre novos deps, one-liners sobre abstrações. Adaptado de [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail). + +- **Lite** — Construa o que for solicitado, nomeie a alternativa mais preguiçosa. +- **Full** — escada YAGNI aplicada: stdlib → nativo → dependências existentes → one-liner → código mínimo. +- **Ultra** — Extremista YAGNI: exclua primeiro, envie a linha única, desafie o restante do requisito na mesma resposta. + +``` +Sem Ponytail: código verboso, abstrações extras e estrutura "por precaução" +Com Ponytail: menor diff funcional, sem abstrações não solicitadas e menos tokens +``` + +Nunca negocie: validação de entrada, tratamento de erros que evita perda de dados, segurança, acessibilidade ou qualquer coisa explicitamente solicitada. Ative em Painel → Endpoint → Ponytail. Pode ser combinado com Caveman (concisão de saída) e RTK (compressão de entrada). + +### 🎯 Fallback inteligente de 3 camadas + +Crie combos com fallback automático: + +``` +Combo: "my-coding-stack" + 1. cc/claude-opus-4-6 (sua assinatura) + 2. glm/glm-4.7 (backup econômico, $0,60/1M) + 3. if/kimi-k2-thinking (fallback gratuito) + +→ Alterna automaticamente quando a cota acaba ou ocorre um erro +``` + +### 📊 Rastreamento de cotas em tempo real + +- Consumo de token por provedor +- Redefinir contagem regressiva (5 horas, diariamente, semanalmente) +- Estimativa de custos para níveis pagos +- Relatórios mensais de gastos + +### 🔄 Tradução de formato + +Tradução perfeita entre formatos: + +- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **Cursor** ↔ **Kiro** ↔ **Vertex** ↔ **Antigravity** ↔ **Ollama** ↔ **OpenAI Responses** +- Sua ferramenta CLI envia formato OpenAI → 9Router traduz → Provedor recebe formato nativo +- Funciona com qualquer ferramenta que suporte endpoints OpenAI personalizados + +### 👥 Suporte para múltiplas contas + +- Adicione várias contas por provedor +- Round-robin automático ou roteamento baseado em prioridade +- Fallback para a próxima conta quando uma delas atingir a cota + +### 🔄 Atualização automática de token + +- Os tokens OAuth são atualizados automaticamente antes da expiração +- Não é necessária reautenticação manual +- Experiência perfeita em todos os provedores + +### 🎨 Combos Personalizados + +- Crie combinações ilimitadas de modelos +- Combine níveis de assinatura, baratos e gratuitos +- Nomeie seus combos para facilitar o acesso +- Compartilhe combos entre dispositivos com Cloud Sync + +### 📝 Registro de requisições + +- Habilite o modo de depuração para logs completos de solicitação/resposta +- Rastreie chamadas de API, cabeçalhos e payloads +- Solucionar problemas de integração +- Exportar logs para análise + +### 💾 Sincronização na nuvem + +- Sincronize provedores, combos e configurações entre dispositivos +- Sincronização automática em segundo plano +- Armazenamento criptografado seguro +- Acesse sua configuração de qualquer lugar + +#### Notas de tempo de execução na nuvem + +- Prefira variáveis de nuvem do lado do servidor na produção: + - `BASE_URL` (URL interna de callback usado pelo agendador de sincronização) + - `CLOUD_URL` (base de endpoint de sincronização em nuvem) +- `NEXT_PUBLIC_BASE_URL` e `NEXT_PUBLIC_CLOUD_URL` ainda são suportados para compatibilidade/UI, mas o tempo de execução do servidor agora prioriza `BASE_URL`/`CLOUD_URL`. +- As solicitações de sincronização na nuvem agora usam o comportamento de tempo limite + falha rápida para evitar o travamento da interface do usuário quando o DNS/rede da nuvem estiver indisponível. + +### 📊 Análise de uso + +- Rastreie o uso de token por provedor e modelo +- Estimativa de custos e tendências de gastos +- Relatórios e insights mensais +- Otimize seus gastos com IA + +> **💡 IMPORTANTE - Compreendendo os custos do painel:** +> +> O "custo" exibido no Usage Analytics é **apenas para fins de rastreamento e comparação**. +> O próprio 9Router **nunca cobra** nada. Você paga apenas aos provedores diretamente (se usar serviços pagos). +> +> **Exemplo:** Se o seu painel mostrar "Custo total $290" ao usar modelos gratuitos do Kiro, isso representa +> o que você pagaria usando APIs pagas diretamente. Seu custo real = **$0** (nível gratuito do Kiro: ~50 créditos/mês). +> +> Pense nisso como um "rastreador de economia" que mostra quanto você está economizando usando modelos gratuitos ou +> roteamento através do 9Router! + +### 🌐 Implante em qualquer lugar + +- 💻 **Localhost** - Padrão, funciona offline +- ☁️ **VPS/Cloud** - Compartilhe entre dispositivos +- 🐳 **Docker** – Implantação com um comando +- 🚀 **Cloudflare Workers** - Rede global de edge + +
+ +--- + +## 💰 Visão geral dos preços + +| Nível | Provedor | Custo | Redefinição de cota | Melhor para | +| ------------------- | --------------------- | ------------ | ---------------- | --------------------------------------- | +| **🚀 ECONOMIA DE TOKEN** | **RTK (integrado)** | **GRÁTIS** | Sempre ligado | **Economize 20-40% de tokens em CADA solicitação** | +| **💳 ASSINATURA** | Claude Code (Pro/Max) | $20-200/mo | 5h + semanalmente | Já inscrito | +| | Codex (Plus/Pro) | $20-200/mo | 5h + semanalmente | Usuários OpenAI | +| | GitHub Copilot | $10-19/mo | Mensalmente | Usuários do GitHub | +| | Cursor IDE | $20/mo | Mensalmente | Usuários de cursor | +| **💰 BARATO** | GLM-5.1/GLM-4.7 | $0.6/1M | Diariamente 10h | Backup de orçamento | +| | MiniMax M2.7 | $0.2/1M | Rolamento de 5 horas | Opção mais barata | +| | Kimi K2.5 | $9/mo plano | 10 milhões de tokens/mês | Custo previsível | + | **🆓 GRÁTIS** | Kiro AI | $0 | 50 créditos/mês | Claude 4.5 + GLM-5 + MiniMax grátis (níveis pagos acima) | + | | OpenCode Free | $0 | Varia* | Sem autenticação, modelos de busca automática (a lista muda ao longo do tempo) | +| | Vertex AI | Créditos $300 | Novas contas do GCP | Gemini 3 Pro + DeepSeek + GLM-5 (use o endpoint Vertex AI Studio para obter créditos gratuitos) | + +**💡 Dica profissional:** combinação RTK + Kiro AI + OpenCode grátis = **custo $0 + economia de token de 20-40%**! + +--- + +### 📊 Compreendendo os custos e faturamento do 9Router + +**Como funciona o faturamento do 9Router:** + +✅ **Software 9Router = GRATUITO para sempre** (código aberto, nunca cobra) +✅ **Painel "custos" = Somente exibição/rastreamento** (não são cobranças reais) +✅ **Você paga diretamente aos provedores** (assinaturas ou taxas de API) +✅ **Provedores GRATUITOS permanecem GRATUITOS** (Kiro ~50 créditos/mês, OpenCode Free, créditos do Vertex $300 = $0 dentro dos limites do nível gratuito) — observe que os níveis gratuitos do iFlow/Qwen/Gemini CLI foram descontinuados em 2026 +❌ **9Router nunca envia faturas** ou cobra seu cartão + +**Como funciona a exibição de custos:** + +O painel mostra **custos estimados** como se você estivesse usando APIs pagas diretamente. Isto **não é cobrança** - é uma ferramenta de comparação para mostrar suas economias. + +**Cenário de exemplo:** + +``` +Exibição no painel: +• Total de requisições: 1,662 +• Total de tokens: 47M +• Custo exibido: $290 + +Custo real: +• Provedor: Kiro (nível gratuito: ~50 créditos/mês) +• Pagamento real: $0.00 +• O que $290 significa: valor ECONOMIZADO ao usar modelos gratuitos! +``` + +**Regras de pagamento:** + +- **Provedores de assinatura** (Claude Code, Codex): pague diretamente por meio de seus sites +- **Provedores baratos** (GLM, MiniMax): pague diretamente, 9Router apenas roteia +- **Provedores GRATUITOS** (iFlow, Kiro, Qwen): Genuinamente grátis para sempre, sem taxas ocultas +- **9Router**: Nunca cobra nada + +--- + +## 🎯 Casos de uso + +### Caso 1: "Tenho assinatura do Claude Pro" + +**Problema:** A cota expira sem ser utilizada, limites de taxa durante codificação pesada + +**Solução:** + +``` +Combo: "maximize-claude" + 1. cc/claude-opus-4-7 (use toda a assinatura) + 2. glm/glm-5.1 (backup econômico quando a cota acabar) + 3. kr/claude-sonnet-4.5 (fallback gratuito de emergência) + +Custo mensal: $20 (assinatura) + ~$5 (backup) = $25 total +versus $20 + atingir limites = frustração +``` + +### Caso 2: "Quero custo zero" + +**Problema:** Não posso pagar assinaturas, preciso de codificação de IA confiável + +**Solução:** + +``` +Combo: "free-forever" + 1. kr/claude-sonnet-4.5 (Claude 4.5 gratuito via Kiro, ~50 créditos/mês) + 2. kr/glm-5 (GLM-5 gratuito via Kiro) + 3. oc/ (OpenCode Free, sem autenticação) + +Custo mensal: $0 +Qualidade: modelos prontos para produção + economia de 20–40% com RTK +``` + +### Caso 3: "Preciso de codificação 24 horas por dia, 7 dias por semana, sem interrupções" + +**Problema:** Prazos, não podemos arcar com o tempo de inatividade + +**Solução:** + +``` +Combo: "always-on" + 1. cc/claude-opus-4-7 (melhor qualidade) + 2. cx/gpt-5.5 (segunda assinatura) + 3. glm/glm-5.1 (econômico, renova diariamente) + 4. minimax/MiniMax-M2.7 (mais econômico, renovação em 5h) + 5. kr/claude-sonnet-4.5 (gratuito via Kiro, ~50 créditos/mês) + +Resultado: 5 camadas de fallback = nenhuma interrupção +Custo mensal: $20-200 (subscriptions) + $10-20 (backup) +``` + +### Caso 4: "Quero IA GRATUITA no OpenClaw" + +**Problema:** Precisa de assistente de IA em aplicativos de mensagens (WhatsApp, Telegram, Slack...), totalmente gratuito + +**Solução:** + +``` +Combo: "openclaw-free" + 1. kr/claude-sonnet-4.5 (Claude 4.5 gratuito) + 2. kr/glm-5 (GLM-5 gratuito) + 3. kr/MiniMax-M2.5 (MiniMax gratuito) + +Custo mensal: $0 +Acesso por: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... +``` + +--- + +## ❓ Perguntas frequentes + +
+📊 Por que meu painel mostra custos altos? + +O painel rastreia o uso do token e exibe **custos estimados** como se você estivesse usando APIs pagas diretamente. Este **não é faturamento real** - é uma referência para mostrar quanto você está economizando usando modelos gratuitos ou assinaturas existentes através do 9Router. + +**Exemplo:** + +- **O painel mostra:** "Custo total $290" +- **Realidade:** Você está usando modelos gratuitos do Kiro (~50 créditos/mês) +- **Seu custo real:** **$0.00** +- **O que significa $290:** Valor que você **economizou** usando modelos gratuitos em vez de APIs pagas! + +A exibição de custos é um "rastreador de economia" para ajudá-lo a entender seus padrões de uso e oportunidades de otimização. + +
+ +
+💳 Serei cobrado pelo 9Router? + +**Não.** 9Router é um software gratuito e de código aberto que roda em seu próprio computador. Nunca cobra nada. + +**Você só paga:** + +- ✅ **Provedores de assinatura** (Claude Code $20/mo, Codex $20-200/mo) → Pague-os diretamente em seus sites +- ✅ **Provedores baratos** (GLM, MiniMax) → Pague diretamente, 9Router apenas encaminha suas solicitações +- ❌ **9Router em si** → **Nunca carrega nada, nunca** + +9Router é um proxy/roteador local. Não possui cartão de crédito, não pode enviar faturas e não possui sistema de cobrança. É um software totalmente gratuito. + +
+ +
+🆓 Os provedores GRATUITOS são realmente ilimitados? + +**Principalmente!** Os provedores GRATUITOS atuais (Kiro, OpenCode Free, Vertex) são genuinamente gratuitos, mas os níveis gratuitos têm limites: + +Estes são serviços gratuitos oferecidos pelas respectivas empresas: + +- **Kiro AI**: ~50 créditos/mês grátis (mais 500 créditos de avaliação para novas contas nos primeiros 30 dias) via AWS Builder ID / Google / GitHub OAuth. Níveis pagos disponíveis acima disso. +- **OpenCode Free**: proxy de passagem sem autenticação, modelos buscados automaticamente em `opencode.ai/zen/v1/models`. A lista de modelos gratuitos varia com o tempo (alguns modelos são gratuitos apenas para promoções limitadas) — sujeita a alterações sem aviso prévio. +- **Vertex AI**: créditos gratuitos $300 para novas contas do Google Cloud (90 dias). Desde março de 2026, o endpoint da API Gemini não consome mais esses créditos. Em vez disso, use o endpoint **Vertex AI Studio**. + +O 9Router apenas encaminha suas solicitações para eles - não há pegadinhas ou cobrança futura do próprio 9Router. Eles são serviços verdadeiramente gratuitos e o 9Router os torna fáceis de usar com suporte alternativo. + +**Níveis gratuitos descontinuados (não mais recomendados):** + +- ❌ **iFlow**: era gratuito e ilimitado, agora alterado para pago (2026) +- ❌ **Qwen Code**: nível OAuth gratuito totalmente descontinuado pelo Alibaba em 15/04/2026 +- ❌ **Gemini CLI**: Serviço totalmente encerrado pelo Google em 18/06/2026 (substituído pelo Antigravity CLI de código fechado). Descontinuado – não use. + +
+ +
+💰 Como posso minimizar meus custos reais de IA? + +**Estratégia Grátis primeiro:** + +1. **Comece com um combo 100% grátis:** + + ``` + 1. kr/glm-5 (GLM-5 gratuito via Kiro, ~50 créditos/mês) + 2. Modelos do OpenCode Free (sem autenticação, obtidos automaticamente) + 3. Vertex AI Gemini 3 Pro (usando o endpoint do Vertex AI Studio com $300 em créditos) + ``` + + **Custo: $0/mês** (dentro do limite de crédito gratuito do Kiro; OpenCode/Vertex sujeito aos limites de nível gratuito) + +2. **Adicione backup barato** apenas se precisar: + + ``` + 4. glm/glm-4.7 ($0,60/1M tokens) + ``` + + **Custo adicional: pague apenas pelo que você realmente usa** + +3. **Use os provedores de assinatura por último:** + - Somente se você já os tiver +- 9Router ajuda a maximizar seu valor por meio do rastreamento de cotas + +**Resultado:** A maioria dos usuários pode operar em $0/mês usando apenas níveis gratuitos! + +
+ +
+📈 E se meu uso aumentar repentinamente? + +O fallback inteligente do 9Router evita cobranças surpresa: + +**Cenário:** você está em um sprint de codificação e ultrapassa suas cotas + +**Sem 9Router:** + +- ❌ Limite de requisições atingido → Paradas de trabalho → Frustração +- ❌ Ou: acidentalmente acumular enormes contas de API + +**Com 9Router:** + +- ✅ Assinatura atinge o limite → Retorno automático para nível barato +- ✅ O nível barato fica caro → Retorno automático para o nível gratuito +- ✅ Nunca pare de codificar → Custos previsíveis + +**Você está no controle:** Defina limites de gastos por provedor no painel e o 9Router os respeita. + +
+ +--- + +## 📖 Guia de configuração + +
+🔐 Provedores de assinatura (maximizar valor) + +### Claude Code (Pro/Max) + +```bash +Painel → Provedores → Conectar Claude Code +→ OAuth login → Renovação automática de token +→ Acompanhamento de cota de 5 horas + semanal + +Modelos: + cc/claude-opus-4-7 + cc/claude-opus-4-6 + cc/claude-sonnet-4-6 + cc/claude-haiku-4-5-20251001 +``` + +**Dica profissional:** Use o Opus para tarefas complexas e o Sonnet para velocidade. 9Router rastreia cota por modelo! + +### OpenAI Codex (Plus/Pro) + +```bash +Painel → Provedores → Conectar Codex +→ OAuth login (port 1455) +→ Renovação de 5 horas + semanal + +Modelos: + cx/gpt-5.5 + cx/gpt-5.4 + cx/gpt-5.3-codex + cx/gpt-5.2-codex +``` + +### GitHub Copilot + +```bash +Painel → Provedores → Conectar GitHub +→ OAuth via GitHub +→ Renovação mensal (dia 1º) + +Modelos: + gh/gpt-5.4 + gh/claude-opus-4.7 + gh/claude-sonnet-4.6 + gh/gemini-3.1-pro-preview + gh/grok-code-fast-1 +``` + +### Cursor IDE + +```bash +Painel → Provedores → Conectar Cursor +→ OAuth login +→ Assinatura mensal + +Modelos: + cu/claude-4.6-opus-max + cu/claude-4.5-sonnet-thinking + cu/gpt-5.3-codex +``` + +
+ +
+💰 Provedores baratos (backup) + +### GLM-5.1 / GLM-4.7 (redefinição diária, $0.6/1M) + +1. Inscreva-se: [Zhipu AI](https://open.bigmodel.cn/) +2. Obtenha a chave API do plano de codificação +3. Painel → Adicionar chave API: + - Provedor: `glm` + - Chave API: `your-key` + +**Usar:** `glm/glm-5.1`, `glm/glm-5`, `glm/glm-4.7` + +**Dica profissional:** O plano de codificação oferece cota 3× com custo de 1/7! Redefinir diariamente às 10h. + +### MiniMax M2.7 (redefinição de 5h, $0.20/1M) + +1. Cadastre-se: [MiniMax](https://www.minimax.io/) +2. Obtenha a chave API +3. Painel → Adicionar chave API + +**Usar:** `minimax/MiniMax-M2.7`, `minimax/MiniMax-M2.5` + +**Dica profissional:** Opção mais barata para contexto longo (1 milhão de tokens)! + +### Kimi K2.5 ($9/mês plano) + +1. Inscreva-se: [Moonshot AI](https://platform.moonshot.ai/) +2. Obtenha a chave API +3. Painel → Adicionar chave API + +**Usar:** `kimi/kimi-k2.5`, `kimi/kimi-k2.5-thinking` + +**Dica profissional:** $9/mês corrigido para 10 milhões de tokens = custo efetivo de $0.90/1M! + +
+ +
+🆓 Provedores GRATUITOS (recomendado) + +### Kiro AI (Claude 4.5 + GLM-5 + MiniMax GRATUITO) + +```bash +Painel → Conectar Kiro +→ AWS Builder ID, AWS IAM Identity Center, Google ou GitHub +→ Uso conforme a cota do plano + +Modelos: + kr/claude-sonnet-4.5 + kr/claude-haiku-4.5 + kr/glm-5 + kr/MiniMax-M2.5 + kr/qwen3-coder-next + kr/deepseek-3.2 +``` + +**Dica profissional:** Melhor opção gratuita para Claude. Sem chave API, sem pagamento, totalmente ilimitado. + +### OpenCode Free (sem autenticação, modelos de busca automática) + +```bash +Painel → Conectar OpenCode Free +→ Login não necessário (proxy de passagem) +→ Modelos obtidos automaticamente de opencode.ai/zen/v1/models +``` + +**Dica profissional:** Configuração mais rápida. Basta conectar e começar a codificar. + +### Vertex AI (créditos gratuitos $300 para novas contas do GCP) + +```bash +Painel → Conectar Vertex AI +→ Envie o JSON da conta de serviço do Google Cloud +→ Ative a API Vertex AI no projeto GCP + +Modelos: + vertex/gemini-3.1-pro-preview + vertex/gemini-3-flash-preview + vertex/gemini-2.5-flash + +Parceiros do Vertex (Anthropic / DeepSeek / GLM / Qwen via Vertex): + vertex-partner/glm-5-maas + vertex-partner/deepseek-v3.2-maas + vertex-partner/qwen3-next-80b-a3b-thinking-maas +``` + +**Dica profissional:** novas contas do Google Cloud recebem créditos $300 gratuitamente por 90 dias. Bastante para codificação diária. + +
+ +
+🎨 Criar Combos + +### Exemplo 1: Maximize a assinatura → Backup barato + +``` +Painel → Combos → Criar novo + +Nome: premium-coding +Modelos: + 1. cc/claude-opus-4-7 (assinatura principal) + 2. glm/glm-5.1 (backup econômico, $0,60/1M) + 3. minimax/MiniMax-M2.7 (fallback mais econômico, $0,20/1M) + +Use na CLI: premium-coding + +Exemplo de custo mensal (100M de tokens): + 80M via Claude (assinatura): $0 extra + 15M via GLM: $9 + 5M via MiniMax: $1 + Total: $10 + sua assinatura +``` + +### Exemplo 2: somente gratuito (custo zero) + +``` +Nome: free-combo +Modelos: + 1. kr/claude-sonnet-4.5 (Claude 4.5 gratuito via Kiro, ~50 créditos/mês) + 2. kr/glm-5 (GLM-5 gratuito via Kiro) + 3. vertex/gemini-3.1-pro-preview ($300 free credits) + +Custo: $0 (+ economia de 20–40% de tokens com RTK)! +``` + +
+ +
+🔧 Integração CLI + +### Cursor IDE + +``` +Configurações → Modelos → Avançado: + OpenAI API Base URL: http://localhost:20128/v1 + OpenAI API Key: [copie do painel do 9Router] + Model: cc/claude-opus-4-7 +``` + +Ou use o combo: `premium-coding` + +### Claude Code + +Editar `~/.claude/config.json`: + +```json +{ + "anthropic_api_base": "http://localhost:20128/v1", + "anthropic_api_key": "your-9router-api-key" +} +``` + +### CLI do Codex + +```bash +export OPENAI_BASE_URL="http://localhost:20128" +export OPENAI_API_KEY="your-9router-api-key" + +codex "seu prompt" +``` + +### OpenClaw + +**Opção 1 — Painel (recomendado):** + +``` +Painel → Ferramentas CLI → OpenClaw → Selecionar modelo → Aplicar +``` + +**Opção 2 — Manual:** Editar `~/.openclaw/openclaw.json`: + +```json +{ + "agents": { + "defaults": { + "model": { + "primary": "9router/kr/claude-sonnet-4.5" + } + } + }, + "models": { + "providers": { + "9router": { + "baseUrl": "http://127.0.0.1:20128/v1", + "apiKey": "sk_9router", + "api": "openai-completions", + "models": [ + { + "id": "kr/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5 (Kiro Free)" + } + ] + } + } + } +} +``` + +> **Nota:** OpenClaw só funciona com 9Router local. Use `127.0.0.1` em vez de `localhost` para evitar problemas de resolução IPv6. + +### Cline / Continue / RooCode + +``` +Provedor: compatível com OpenAI +Base URL: http://localhost:20128/v1 +API Key: [copie do painel] +Model: cc/claude-opus-4-7 +``` + +
+ +
+🚀 Implantação + +### Implantação VPS + +```bash +# Clonar e instalar +git clone https://github.com/decolua/9router.git +cd 9router +npm install +npm run build + +# Configurar +export JWT_SECRET="your-secure-secret-change-this" +export INITIAL_PASSWORD="your-password" +export DATA_DIR="/var/lib/9router" +export PORT="20128" +export HOSTNAME="0.0.0.0" +export NODE_ENV="production" +export NEXT_PUBLIC_BASE_URL="http://localhost:20128" +export NEXT_PUBLIC_CLOUD_URL="https://9router.com" +export API_KEY_SECRET="endpoint-proxy-api-key-secret" +export MACHINE_ID_SALT="endpoint-proxy-salt" + +# Iniciar +npm run start + +# Ou usar PM2 +npm install -g pm2 +pm2 start npm --name 9router -- start +pm2 save +pm2 startup +``` + +### Docker + +Imagens publicadas (multiplataforma `linux/amd64` + `linux/arm64`): + +- Hub Docker: [`decolua/9router`](https://hub.docker.com/r/decolua/9router) +- GHCR: [`ghcr.io/decolua/9router`](https://github.com/decolua/9router/pkgs/container/9router) + +**Início rápido (use imagem publicada):** + +```bash +docker run -d \ + --name 9router \ + -p 20128:20128 \ + -v "$HOME/.9router:/app/data" \ + -e DATA_DIR=/app/data \ + decolua/9router:latest +``` + +→ Abra http://localhost:20128 + +**Compilar a partir do código-fonte (desenvolvedor):** + +```bash +git clone https://github.com/decolua/9router.git +cd 9router/app +docker build -t 9router . +docker run -d --name 9router -p 20128:20128 \ + -v "$HOME/.9router:/app/data" -e DATA_DIR=/app/data 9router +``` + +**Padrões do contêiner:** + +- `PORT=20128` +- `HOSTNAME=0.0.0.0` + +**Comandos úteis:** + +```bash +docker logs -f 9router +docker restart 9router +docker stop 9router && docker rm 9router +docker pull decolua/9router:latest # atualizar para a versão mais recente +``` + +**Persistência de dados:** `$HOME/.9router/db/data.sqlite` no host ↔ `/app/data/db/data.sqlite` no contêiner. + +### Variáveis de Ambiente + +| Variável | Padrão | Descrição | +| ---------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | +| `JWT_SECRET` | Gerado automaticamente (`~/.9router/jwt-secret`) | Segredo de assinatura JWT para cookie de autenticação do painel (substituir para compartilhar entre instâncias) | +| `INITIAL_PASSWORD` | `123456` | Primeira senha de login quando não existe hash salvo | +| `DATA_DIR` | `~/.9router` | Localização principal dos dados do aplicativo (SQLite em `$DATA_DIR/db/data.sqlite`) | +| `PORT` | padrão da estrutura | Porta de serviço (`20128` nos exemplos) | +| `HOSTNAME` | padrão da estrutura | Host de vinculação (o padrão do Docker é `0.0.0.0`) | +| `NODE_ENV` | padrão de tempo de execução | Definir `production` para implantação | +| `BASE_URL` | `http://localhost:20128` | URL base interna do lado do servidor usada por trabalhos de sincronização na nuvem | +| `CLOUD_URL` | `https://9router.com` | URL base do endpoint de sincronização na nuvem do lado do servidor | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | URL de base pública/compatível com versões anteriores (prefira `BASE_URL` para tempo de execução do servidor) | +| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | URL de nuvem pública/compatível com versões anteriores (prefira `CLOUD_URL` para tempo de execução do servidor) | +| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | Segredo HMAC para chaves de API geradas | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | Salt para hashing estável de ID de máquina | +| `ENABLE_REQUEST_LOGS` | `false` | Habilita logs de solicitação/resposta em `logs/` | +| `AUTH_COOKIE_SECURE` | `false` | Forçar cookie de autenticação `Secure` (definir `true` atrás do proxy reverso HTTPS) | +| `REQUIRE_API_KEY` | `false` | Aplicar chave de API Bearer em rotas `/v1/*` (recomendado para implantações expostas à Internet) | +| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | vazio | Proxy de saída opcional para chamadas de provedor upstream | +| `SEARXNG_URL` | `http://localhost:8888/search` | Endpoint para o provedor de pesquisa na web SearXNG não autenticado integrado | + +Notas: + +- Variáveis de proxy em letras minúsculas também são suportadas: `http_proxy`, `https_proxy`, `all_proxy`, `no_proxy`. +- `.env` não está incluído na imagem Docker (`.dockerignore`); injete configuração de tempo de execução com `--env-file` ou `-e`. +- No Windows, `APPDATA` pode ser usado para resolução de caminho de armazenamento local. +- `INSTANCE_NAME` aparece em modelos de documentos/env mais antigos, mas atualmente não é usado em tempo de execução. + +### Arquivos e armazenamento em tempo de execução + +- Estado principal do aplicativo: `${DATA_DIR}/db/data.sqlite` (SQLite — provedores, combos, aliases, chaves, configurações, histórico de uso) +- Backups automáticos: `${DATA_DIR}/db/backups/` +- Logs opcionais de solicitação/tradutor: `/logs/...` quando `ENABLE_REQUEST_LOGS=true` +- `${DATA_DIR}` e `~/.9router` resolvem para o mesmo local em um contêiner Docker — o link simbólico `/root/.9router -> /app/data` é criado no momento da construção. + +
+ +--- + +## 📊 Modelos Disponíveis + +
+Ver todos os modelos disponíveis + +**Claude Code (`cc/`)** - Pro/Máx: + +-`cc/claude-opus-4-7` +-`cc/claude-opus-4-6` +-`cc/claude-sonnet-4-6` +-`cc/claude-sonnet-4-5-20250929` +-`cc/claude-haiku-4-5-20251001` + +**Codex (`cx/`)** - Plus/Pro: + +-`cx/gpt-5.5` +-`cx/gpt-5.4` +-`cx/gpt-5.3-codex` +-`cx/gpt-5.2-codex` +-`cx/gpt-5.1-codex-max` + +**GitHub Copilot (`gh/`)**: + +-`gh/gpt-5.4` +-`gh/claude-opus-4.7` +-`gh/claude-sonnet-4.6` +-`gh/gemini-3.1-pro-preview` +-`gh/grok-code-fast-1` + +**Cursor (`cu/`)** - Assinatura: + +-`cu/claude-4.6-opus-max` +-`cu/claude-4.5-sonnet-thinking` +-`cu/gpt-5.3-codex` +-`cu/kimi-k2.5` + +**GLM (`glm/`)** - $0.6/1M: + +-`glm/glm-5.1` +-`glm/glm-5` +-`glm/glm-4.7` + +**MiniMax (`minimax/`)** - $0.2/1M: + +-`minimax/MiniMax-M2.7` +-`minimax/MiniMax-M2.5` + +**Kimi (`kimi/`)** - $9/mo plana: + +-`kimi/kimi-k2.5` +-`kimi/kimi-k2.5-thinking` + +**Kiro (`kr/`)** - Gratuito (~50 créditos/mês, níveis pagos acima): + +-`kr/claude-sonnet-4.5` +-`kr/claude-haiku-4.5` +-`kr/glm-5` +-`kr/MiniMax-M2.5` +-`kr/qwen3-coder-next` +-`kr/deepseek-3.2` + +**OpenCode grátis (`oc/`)** - GRATUITO sem autenticação: + +- Obtido automaticamente de `opencode.ai/zen/v1/models` + +**Vertex AI (`vertex/`)** - Créditos gratuitos $300: + +-`vertex/gemini-3.1-pro-preview` +-`vertex/gemini-3-flash-preview` +-`vertex/gemini-2.5-flash` +-`vertex-partner/glm-5-maas` +-`vertex-partner/deepseek-v3.2-maas` + +
+ +--- + +## 🐛 Solução de problemas + +**"O modelo de linguagem não forneceu mensagens"** + +- Cota do provedor esgotada → Verifique o rastreador de cota do painel +- Solução: use o combo substituto ou mude para um nível mais barato + +**Limitação de requisições** + +- Cota de assinatura esgotada → Fallback para GLM/MiniMax +- Adicionar combo: `cc/claude-opus-4-7 → glm/glm-5.1 → kr/claude-sonnet-4.5` + +**O token OAuth expirou** + +- Atualizado automaticamente pelo 9Router +- Se os problemas persistirem: Painel → Provedor → Reconectar + +**Custos elevados** + +- Habilite RTK no Dashboard → Configurações de endpoint (padrão LIGADO, economiza 20-40% de tokens) +- Verifique as estatísticas de uso no Dashboard +- Mude o modelo primário para GLM/MiniMax +- Use o nível gratuito (Kiro, OpenCode Free, Vertex) para tarefas não críticas + +**Painel abre na porta errada** + +- Definir `PORT=20128` e `NEXT_PUBLIC_BASE_URL=http://localhost:20128` + +**Primeiro login não funciona** + +- Verifique `INITIAL_PASSWORD` em `.env` +- Se não definida, a senha substituta é `123456` + +**Nenhum registro de solicitação em `logs/`** + +- Definir `ENABLE_REQUEST_LOGS=true` + +--- + +## 🛠️ Pilha de tecnologia + +- **Tempo de execução**: Node.js 20+ +- **Estrutura**: Next.js 16 +- **IU**: React 19 + Tailwind CSS 4 +- **Banco de dados**: SQLite (better-sqlite3 / node:sqlite / fallback para sql.js) +- **Streaming**: eventos enviados pelo servidor (SSE) +- **Autenticação**: OAuth 2.0 (PKCE) + JWT + chaves de API + +--- + +## 📝 Referência da API + +### Chat Completions + +```bash +POST http://localhost:20128/v1/chat/completions +Authorization: Bearer your-api-key +Content-Type: application/json + +{ + "model": "cc/claude-opus-4-6", + "messages": [ + {"role": "user", "content": "Escreva uma função para..."} + ], + "stream": true +} +``` + +### Listar modelos + +```bash +GET http://localhost:20128/v1/models +Authorization: Bearer your-api-key + +→ Retorna todos os modelos e combos no formato OpenAI +``` + +## 📧 Suporte + +- **Site**: [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) + +--- + +## 👥 Colaboradores + +Obrigado a todos os colaboradores que ajudaram a tornar o 9Router melhor! + +[![Contribuidores](https://contrib.rocks/image?repo=decolua/9router&max=150&columns=15&anon=1&v=20260309)](https://github.com/decolua/9router/graphs/contributors) + +--- + +## 📊 Gráfico de estrelas + +[![Gráfico de estrelas](https://starchart.cc/decolua/9router.svg?variant=adaptive)](https://starchart.cc/decolua/9router) + +## 🔀 Forks + +**[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** — Um fork TypeScript completo do 9Router. Adiciona mais de 36 provedores, fallback automático de 4 camadas, APIs multimodais (imagens, incorporações, áudio, TTS), disjuntor, cache semântico, avaliações LLM e um painel sofisticado. Mais de 368 testes de unidade. Disponível via npm e Docker. + +--- + +## 🙏 Agradecimentos + +Construído sobre ombros de gigantes: + +- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — implementação Go original que inspirou esta versão JavaScript. +- **[RTK](https://github.com/rtk-ai/rtk)** ![Stars](https://img.shields.io/github/stars/rtk-ai/rtk?style=flat&color=yellow) — Economizador de tokens em Rust. O 9Router porta seu pipeline de compactação para JS → **−20-40% de tokens de entrada** em cada solicitação. +- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) por **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral _"por que usar muitos tokens quando poucos tokens resolvem"_. 9Router adapta seu prompt → **−65% tokens de saída**. +- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Estrelas](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) por **[@DietrichGebert](https://github.com/DietrichGebert)** — _"desenvolvedor sênior preguiçoso"_ habilidade. 9Router injeta sua abordagem YAGNI-first → **menos tokens, menos código, diferenças mais curtas**. + +Um grande agradecimento a esses autores – sem o trabalho deles, os recursos de economia de tokens do 9Router não existiriam. Dê uma ⭐ a eles no GitHub! + +--- + +## 📄 Licença + +Licença MIT - consulte [LICENSE](../LICENSE) para obter detalhes. + +--- + +
+ Construído com ❤️ para desenvolvedores que codificam 24/7 +
+ diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 860f7097..300fef5a 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -2,7 +2,7 @@ import { PROVIDERS } from "./providers.js"; import REGISTRY from "../providers/registry/index.js"; // PROVIDER_MODELS now built from providers/registry (transport + models co-located) import { PROVIDER_MODELS } from "../providers/index.js"; -import { modelQuotaFamily, modelStrip, modelTargetFormat, normalizeModelId } from "../providers/models/schema.js"; +import { modelQuotaFamily, modelStrip, modelTargetFormat, modelSupportedFormats, normalizeModelId } from "../providers/models/schema.js"; import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js"; export { PROVIDER_MODELS }; @@ -54,6 +54,14 @@ export function getModelTargetFormat(aliasOrId, modelId) { return modelTargetFormat(findModel(models, modelId, aliasOrId)); } +// Declared upstream formats for a model (registry `supportedFormats`). Drives the +// per-model guard on the sourceFormat-matched transport; null when undeclared. +export function getModelSupportedFormats(aliasOrId, modelId) { + const models = PROVIDER_MODELS[aliasOrId]; + if (!models) return null; + return modelSupportedFormats(findModel(models, modelId, aliasOrId)); +} + export function getModelType(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; if (!models) return null; diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index 7f24fd85..07bbb4fc 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -245,6 +245,18 @@ export class AntigravityExecutor extends BaseExecutor { // Strip tools/toolConfig (handled separately) and blacklisted fields that Google rejects const { tools: _originalTools, toolConfig: _originalToolConfig, ...requestWithoutTools } = body.request || {}; stripBlacklisted(requestWithoutTools); + + // Rewrite competitive system prompts (e.g. Zed IDE's Claude prompt) to prevent Antigravity from + // flagging the request and immediately blocking it with a 429 Quota Exhausted response. + if (requestWithoutTools.systemInstruction?.parts) { + const oldText = "You are a Claude agent, built on Anthropic's Claude Agent SDK."; + for (const part of requestWithoutTools.systemInstruction.parts) { + if (typeof part.text === "string" && part.text.includes(oldText)) { + part.text = part.text.split(oldText).join(""); + } + } + } + const generationConfig = { ...(requestWithoutTools.generationConfig || {}) }; if (generationConfig.maxOutputTokens > MAX_ANTIGRAVITY_OUTPUT_TOKENS) { generationConfig.maxOutputTokens = MAX_ANTIGRAVITY_OUTPUT_TOKENS; diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index 8dd03421..bd96ab49 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -10,7 +10,6 @@ import { CodexExecutor } from "./codex.js"; import { CursorExecutor } from "./cursor.js"; import { VertexExecutor } from "./vertex.js"; import { OpenCodeExecutor } from "./opencode.js"; -import { OpenCodeGoExecutor } from "./opencode-go.js"; import { GrokWebExecutor } from "./grok-web.js"; import { GrokCliExecutor } from "./grok-cli.js"; import { PerplexityWebExecutor } from "./perplexity-web.js"; @@ -41,7 +40,6 @@ const executors = { vertex: new VertexExecutor("vertex"), "vertex-partner": new VertexExecutor("vertex-partner"), opencode: new OpenCodeExecutor(), - "opencode-go": new OpenCodeGoExecutor(), "grok-web": new GrokWebExecutor(), "grok-cli": new GrokCliExecutor(), gcli: new GrokCliExecutor(), // Alias @@ -86,7 +84,6 @@ export { CursorExecutor } from "./cursor.js"; export { VertexExecutor } from "./vertex.js"; export { DefaultExecutor } from "./default.js"; export { OpenCodeExecutor } from "./opencode.js"; -export { OpenCodeGoExecutor } from "./opencode-go.js"; export { GrokWebExecutor } from "./grok-web.js"; export { GrokCliExecutor } from "./grok-cli.js"; export { PerplexityWebExecutor } from "./perplexity-web.js"; diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index 1205522b..77616618 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -144,6 +144,12 @@ function normalizeStopReason(value) { return reason || null; } +// Of the reasons stopDisposition() folds into "terminal_incomplete", only these +// mean "usable as far as it got, then the budget ran out" -- the case +// finish_reason "length" exists for. cancelled / pause_turn are abandoned turns +// whose partial content must stay private, so they are deliberately absent. +const KIRO_TRUNCATION_STOP_REASONS = new Set(["model_context_window_exceeded", "max_tokens"]); + function stopDisposition(stopReason, hasToolCalls) { if (["malformed_model_output", "invalid_model_output"].includes(stopReason)) return "retryable_protocol_failure"; if (["cancelled", "pause_turn", "model_context_window_exceeded"].includes(stopReason)) return "terminal_incomplete"; @@ -711,14 +717,25 @@ export class KiroExecutor extends BaseExecutor { }; const emitTools = (controller) => { for (const tool of state.tools.values()) { - const input = parsedToolInput(tool); - if (tool.name === "tool_call") { - if (typeof input.name !== "string" || !input.name.trim()) { - throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool name"); - } - if (!Object.prototype.hasOwnProperty.call(input, "arguments")) { - throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool arguments"); + // Validate per tool, not per turn: one unusable fragment used to throw out + // of emitTools and take every other complete tool call in the same turn + // with it, which the client saw as a turn that answered nothing. + let input; + try { + input = parsedToolInput(tool); + if (tool.name === "tool_call") { + if (typeof input.name !== "string" || !input.name.trim()) { + throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool name"); + } + if (!Object.prototype.hasOwnProperty.call(input, "arguments")) { + throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool arguments"); + } } + } catch (error) { + state.droppedTools = (state.droppedTools || 0) + 1; + state.toolValidationError ||= error.message; + console.error(`[Kiro] dropping unusable tool call ${tool.id} (${tool.name}): ${error.message}`); + continue; } const index = state.toolCounter++; emitDelta(controller, { @@ -729,14 +746,26 @@ export class KiroExecutor extends BaseExecutor { function: { name: tool.name, arguments: "" } }] }); + const serializedInput = JSON.stringify(input); emitDelta(controller, { - tool_calls: [{ index, function: { arguments: JSON.stringify(input) } }] + tool_calls: [{ index, function: { arguments: serializedInput } }] }); + // Tool arguments are billed output like any other completion bytes. They + // were never added to totalContentLength, so the /4 estimator in finish() + // reported OUT 0 -- or the Math.max floor of 1 -- for every turn whose + // entire answer was a tool call. + state.totalContentLength += tool.name.length + serializedInput.length; state.hasToolCalls = true; } state.tools.clear(); state.bufferedToolBytes = 0; - if (state.stopReason === "tool_use" && !state.hasToolCalls) { + // A declared tool turn that emitted no usable call is only fatal when the + // turn produced nothing else. Throwing unconditionally here escaped + // emitTools() with provenance "invalid_tool_call", which the integrity gate + // re-derived into a repair retry -- discarding text the client had already + // been promised. + if (state.stopReason === "tool_use" && !state.hasToolCalls && + !state.hasText && !state.hasReasoning && !state.hasCode) { throw new Error("Kiro tool_use stop reason did not include a complete tool call"); } }; @@ -796,7 +825,6 @@ export class KiroExecutor extends BaseExecutor { emitDelta(controller, { content: event.payload.content }); } else if (eventType === "toolUseEvent") { state.sawToolUse = true; - if (state.toolValidationError) return true; const values = Array.isArray(event.payload) ? event.payload : [event.payload]; if (!values[0]) throw new Error("Kiro toolUseEvent is empty"); for (const value of values) { @@ -924,9 +952,10 @@ export class KiroExecutor extends BaseExecutor { } catch (error) { const bufferExceeded = error.code === "KIRO_BUFFER_EXCEEDED"; if (!bufferExceeded) { + // Keep whatever is already buffered: the rejected fragment belongs to + // one tool, and clearing the map dropped the complete calls too. state.toolValidationError ||= error.message; - state.tools.clear(); - state.bufferedToolBytes = 0; + console.error(`[Kiro] tool fragment rejected, keeping ${state.tools.size} buffered tool(s): ${error.message}`); continue; } fail( @@ -958,7 +987,16 @@ export class KiroExecutor extends BaseExecutor { } state.transportState = "clean_eof"; const declaredDisposition = stopDisposition(state.stopReason, state.sawToolUse); - if (["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(declaredDisposition)) { + // model_context_window_exceeded / max_tokens map to terminal_incomplete. When + // they arrive after the model already streamed content, fail() threw away a + // complete-enough answer; a truncated turn is what finish_reason "length" is + // for. chunkIndex > 0 means at least one delta already reached the client. + const declaredTruncatedAfterOutput = declaredDisposition === "terminal_incomplete" && + KIRO_TRUNCATION_STOP_REASONS.has(state.stopReason) && state.chunkIndex > 0; + if (declaredTruncatedAfterOutput) { + console.error(`[Kiro] truncated after ${state.chunkIndex} chunk(s) (stop_reason=${state.stopReason}); keeping output`); + } + if (!declaredTruncatedAfterOutput && ["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(declaredDisposition)) { const code = declaredDisposition === "retryable_protocol_failure" ? "kiro_retryable_protocol_failure" : declaredDisposition === "terminal_refusal" @@ -975,16 +1013,6 @@ export class KiroExecutor extends BaseExecutor { ); return; } - if (state.toolValidationError) { - fail( - controller, - "invalid_tool_call", - "invalid_kiro_tool_call", - state.toolValidationError, - { transport_state: state.transportState, stop_disposition: "retryable_protocol_failure" } - ); - return; - } try { emitTools(controller); } catch (error) { @@ -997,6 +1025,22 @@ export class KiroExecutor extends BaseExecutor { ); return; } + // Fail only when the turn has nothing usable left. emitTools() validates + // per tool and drops just the unusable ones, so this has to run AFTER it: + // before, the rejected tool was still buffered and tools.size was never 0. + // A turn that also produced text keeps that text -- the dropped call is + // logged, not fatal. + if (state.toolValidationError && !state.hasToolCalls && + !state.hasText && !state.hasReasoning && !state.hasCode) { + fail( + controller, + "invalid_tool_call", + "invalid_kiro_tool_call", + state.toolValidationError, + { transport_state: state.transportState, stop_disposition: "retryable_protocol_failure" } + ); + return; + } const hasOutput = state.hasText || state.hasReasoning || state.hasCode || state.hasToolCalls; if (!hasOutput && !state.explicitStop) { @@ -1011,7 +1055,13 @@ export class KiroExecutor extends BaseExecutor { } const disposition = stopDisposition(state.stopReason, state.hasToolCalls); - if (["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(disposition)) { + // Same reasoning as declaredTruncatedAfterOutput above. + const truncatedAfterOutput = disposition === "terminal_incomplete" && + KIRO_TRUNCATION_STOP_REASONS.has(state.stopReason) && state.chunkIndex > 0; + if (truncatedAfterOutput) { + console.error(`[Kiro] truncated after ${state.chunkIndex} chunk(s) (stop_reason=${state.stopReason}); closing as length`); + } + if (!truncatedAfterOutput && ["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(disposition)) { const code = disposition === "retryable_protocol_failure" ? "kiro_retryable_protocol_failure" : disposition === "terminal_refusal" @@ -1041,18 +1091,24 @@ export class KiroExecutor extends BaseExecutor { total_tokens: prompt + completion }; } - const finishReason = state.hasToolCalls - ? "tool_calls" - : disposition === "length" - ? "length" - : "stop"; + const finishReason = truncatedAfterOutput + ? "length" + : state.hasToolCalls + ? "tool_calls" + : disposition === "length" + ? "length" + : "stop"; controller.enqueue(sseChunk({}, finishReason, state.usage)); controller.enqueue(encoder.encode(SSE_DONE)); state.finished = true; options.onTerminalState?.(diagnostics({ terminal_provenance: state.terminalProvenance || "clean_eventstream_eof", transport_state: state.transportState, - stop_disposition: disposition + // Report what this exit actually did, not the raw disposition. The + // integrity gate re-derives its verdict from stop_disposition, so + // reporting "terminal_incomplete" for a turn we deliberately kept made + // it discard the very bytes we just released to the client. + stop_disposition: truncatedAfterOutput ? "length" : disposition })); }; diff --git a/open-sse/executors/opencode-go.js b/open-sse/executors/opencode-go.js deleted file mode 100644 index 7bf47edb..00000000 --- a/open-sse/executors/opencode-go.js +++ /dev/null @@ -1,49 +0,0 @@ -import { BaseExecutor } from "./base.js"; -import { PROVIDERS } from "../config/providers.js"; -import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; -import { ANTHROPIC_API_VERSION } from "../providers/shared.js"; - -// Models that use /zen/go/v1/messages (Anthropic/Claude format + x-api-key auth) -const MESSAGES_FORMAT_MODELS = new Set([ - "minimax-m3", - "minimax-m2.7", - "minimax-m2.5", - "qwen3.7-max", - "qwen3.7-plus", - "qwen3.6-plus", -]); - -const BASE = "https://opencode.ai/zen/go/v1"; - -export class OpenCodeGoExecutor extends BaseExecutor { - constructor() { - super("opencode-go", PROVIDERS["opencode-go"]); - } - - // buildUrl runs before buildHeaders in BaseExecutor.execute, cache model here - buildUrl(model) { - this._lastModel = model; - return MESSAGES_FORMAT_MODELS.has(model) - ? `${BASE}/messages` - : `${BASE}/chat/completions`; - } - - buildHeaders(credentials, stream = true) { - const key = credentials?.apiKey || credentials?.accessToken; - const headers = { "Content-Type": "application/json" }; - - if (MESSAGES_FORMAT_MODELS.has(this._lastModel)) { - headers["x-api-key"] = key; - headers["anthropic-version"] = ANTHROPIC_API_VERSION; - } else { - headers["Authorization"] = `Bearer ${key}`; - } - - if (stream) headers["Accept"] = "text/event-stream"; - return headers; - } - - transformRequest(model, body) { - return injectReasoningContent({ provider: this.provider, model, body }); - } -} diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js index f7aee211..27a81c00 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.js @@ -1,16 +1,43 @@ +import crypto from "crypto"; import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; +import { resolveSessionId } from "../utils/sessionManager.js"; -// Models that use /zen/v1/messages (claude format) +const OPENCODE_UA = "opencode"; const MESSAGES_MODELS = new Set(); +function generateRequestId() { + return `msg_${crypto.randomUUID().replace(/-/g, "")}`; +} + +function generateSessionId() { + return `ses_${crypto.randomUUID().replace(/-/g, "")}`; +} + +// Normalize any resolved id into opencode's ses_ format (stable per-conversation) +function toOpencodeSession(id) { + const stripped = String(id || "").replace(/^ses_/, "").replace(/-/g, ""); + return stripped ? `ses_${stripped}` : null; +} + +function resolveOpencodeSession(body, credentials) { + return toOpencodeSession(resolveSessionId({ + headers: credentials?.rawHeaders, + body, + connectionId: credentials?.connectionId, + scope: "opencode", + })); +} + export class OpenCodeExecutor extends BaseExecutor { constructor() { super("opencode", PROVIDERS.opencode); + this._currentSessionId = null; } - transformRequest(model, body) { + transformRequest(model, body, stream, credentials) { + this._currentSessionId = resolveOpencodeSession(body, credentials); return injectReasoningContent({ provider: this.provider, model, body }); } @@ -21,12 +48,23 @@ export class OpenCodeExecutor extends BaseExecutor { : `${base}/zen/v1/chat/completions`; } - buildHeaders() { + buildHeaders(credentials, stream = true) { + const raw = credentials?.rawHeaders || {}; + const lower = {}; + for (const [k, v] of Object.entries(raw)) lower[k.toLowerCase()] = v; + + const downstreamUa = lower["user-agent"] || ""; + const isOpencodeDownstream = downstreamUa.toLowerCase().includes("opencode"); + return { "Content-Type": "application/json", "Authorization": "Bearer public", - "x-opencode-client": "desktop", - "Accept": "text/event-stream" + "User-Agent": isOpencodeDownstream ? downstreamUa : OPENCODE_UA, + "x-opencode-client": lower["x-opencode-client"] || "desktop", + "x-opencode-session": lower["x-opencode-session"] || this._currentSessionId || generateSessionId(), + "x-opencode-request": lower["x-opencode-request"] || generateRequestId(), + "x-opencode-project": lower["x-opencode-project"] || "global", + "Accept": stream ? "text/event-stream" : "*/*", }; } } diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.js index 9bcc3859..c720fde2 100644 --- a/open-sse/executors/qoder.js +++ b/open-sse/executors/qoder.js @@ -216,6 +216,52 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio }; } +/** + * Check if a qoder error message indicates a billing/quota block. + * Signatures: code 112 (quota exhausted), code 10605 (queue throttle), pricingUrl field. + */ +function isBillingBlock(inner) { + if (!inner || typeof inner !== "string") return false; + const lowerMsg = inner.toLowerCase(); + // Match: {"code":"112",...}, {"code":"10605",...}, or pricingUrl field + return /\"code\"\s*:\s*\"(112|10605)\"/.test(inner) || lowerMsg.includes("pricingurl"); +} + +/** + * Peek the first SSE frame to detect billing errors before piping. + * Returns { isBilling, statusVal, message, consumed } — `consumed` is every + * byte read so far (including the peeked line) so the caller can re-process + * it and nothing is dropped from the stream. + */ +async function peekFirstQoderFrame(reader, decoder) { + let consumed = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) return { isBilling: false, consumed, upstreamDone: true }; + + consumed += decoder.decode(value, { stream: true }); + const nl = consumed.indexOf("\n"); + if (nl === -1) continue; // need a full line first + + const line = consumed.slice(0, nl).replace(/\r$/, "").trim(); + if (!line.startsWith("data:")) continue; + + const data = line.slice(5).trimStart(); + if (data === "[DONE]") return { isBilling: false, consumed }; + + let envelope; + try { envelope = JSON.parse(data); } catch { return { isBilling: false, consumed }; } + + const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200; + const inner = typeof envelope.body === "string" ? envelope.body : ""; + + if (statusVal !== 200 && isBillingBlock(inner)) { + return { isBilling: true, statusVal, message: inner || `qoder billing block (${statusVal})` }; + } + return { isBilling: false, consumed }; + } +} + /** * Wrap the upstream's `{statusCodeValue, body}` SSE envelope into plain * OpenAI SSE chunks the rest of the chatCore pipeline understands. @@ -230,16 +276,34 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio * [DONE]/error frame (agent keepalive). Non-streaming clients drain via * response.text() which hangs until the socket closes — so on terminal * events we cancel the upstream reader and close our stream immediately. + * + * NEW: Peek first frame to detect billing blocks (code 112/10605/pricingUrl). + * If detected, return 403 response so chatCore marks connection unavailable + * and triggers combo fallback instead of leaking error text into chat. */ -function wrapQoderSSE(response, model) { +async function wrapQoderSSE(response, model) { if (!response.ok || !response.body) return response; const decoder = new TextDecoder(); - const encoder = new TextEncoder(); - let buffer = ""; - let doneEmitted = false; const reader = response.body.getReader(); + // Peek first frame to detect billing block + const peek = await peekFirstQoderFrame(reader, decoder); + if (peek?.isBilling) { + // Billing block detected — return 403 so chatCore fails this connection + await reader.cancel().catch(() => {}); + return new Response( + JSON.stringify({ error: { message: peek.message, code: peek.statusVal } }), + { status: 403, headers: { "Content-Type": "application/json" } } + ); + } + + // Normal flow: re-process every byte the peek consumed, then continue. + let buffer = peek.consumed || ""; + const upstreamDrained = peek.upstreamDone === true; + const encoder = new TextEncoder(); + let doneEmitted = false; + // Process one already-extracted SSE line (no trailing newline). const processLine = (line, controller) => { const trimmed = line.replace(/\r$/, "").trim(); @@ -288,7 +352,28 @@ function wrapQoderSSE(response, model) { // enqueueing would never be re-invoked, hanging consumers like .text(). async start(controller) { try { - while (!doneEmitted) { + // Drain whatever the peek already pulled off the socket first. + let nlSeed; + while ((nlSeed = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nlSeed); + buffer = buffer.slice(nlSeed + 1); + processLine(line, controller); + if (doneEmitted) { + await reader.cancel().catch(() => {}); + controller.close(); + return; + } + } + if (upstreamDrained) { + // Peek hit end-of-stream: flush any trailing partial line. + buffer += decoder.decode(); + if (buffer.length > 0) { + processLine(buffer, controller); + buffer = ""; + } + } + + while (!doneEmitted && !upstreamDrained) { const { done, value } = await reader.read(); if (done) { buffer += decoder.decode(); @@ -473,7 +558,7 @@ export class QoderExecutor extends BaseExecutor { return { response, url, headers, transformedBody: payload }; } - const wrapped = wrapQoderSSE(response, `qoder/${qoderKey}`); + const wrapped = await wrapQoderSSE(response, `qoder/${qoderKey}`); return { response: wrapped, url, headers, transformedBody: payload }; } @@ -497,4 +582,5 @@ export const __test__ = { normalizeMessages, wrapQoderSSE, buildQoderRequestBody, + isBillingBlock, }; diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 1d90992d..cb6ce961 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -1,62 +1,29 @@ -import { - detectFormat, - getTargetFormat, - resolveTransport, -} from "../services/provider.js"; +import { detectFormat, getTargetFormat, resolveTransport } from "../services/provider.js"; import { translateRequest } from "../translator/index.js"; import { applyThinking, extractThinking, stripThinkingSuffix } from "../translator/concerns/thinkingUnified.js"; import { FORMATS } from "../translator/formats.js"; -import { normalizeClaudePassthrough } from "../translator/formats/claude.js"; +import { normalizeClaudePassthrough, anchorClaudeCache } from "../translator/formats/claude.js"; import { createStreamController } from "../utils/streamHandler.js"; import { refreshWithRetry } from "../services/tokenRefresh.js"; import { createRequestLogger } from "../utils/requestLogger.js"; -import { - getModelTargetFormat, - getModelStrip, - getModelUpstreamId, - getModelType, - PROVIDER_ID_TO_ALIAS, -} from "../config/providerModels.js"; +import { getModelTargetFormat, getModelSupportedFormats, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js"; import { PROVIDERS } from "../config/providers.js"; -import { - createErrorResult, - parseUpstreamError, - formatProviderError, -} from "../utils/error.js"; +import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js"; import { HTTP_STATUS, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.js"; import { handleBypassRequest } from "../utils/bypassHandler.js"; -import { - trackPendingRequest, - appendRequestLog, - saveRequestDetail, -} from "@/lib/usageDb.js"; +import { trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; import { getExecutor } from "../executors/index.js"; import { supportsGrokCliReasoningEffort } from "../config/grokCli.js"; -import { - buildRequestDetail, - extractRequestConfig, -} from "./chatCore/requestDetail.js"; +import { buildRequestDetail, extractRequestConfig } from "./chatCore/requestDetail.js"; import { handleForcedSSEToJson } from "./chatCore/sseToJsonHandler.js"; import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js"; -import { - handleStreamingResponse, - buildOnStreamComplete, -} from "./chatCore/streamingHandler.js"; -import { maybeRejectEarlyStreamError } from "../utils/streamErrorPeek.js"; -import { - detectClientTool, - isNativePassthrough, -} from "../utils/clientDetector.js"; +import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.js"; +import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.js"; import { dedupeTools } from "../utils/toolDeduper.js"; import { injectCaveman } from "../rtk/caveman.js"; import { injectPonytail } from "../rtk/ponytail.js"; import { compressMessages, formatRtkLog } from "../rtk/index.js"; -import { - compressWithHeadroom, - formatHeadroomLog, - formatHeadroomSizeLog, - isHeadroomPhantomSavings, -} from "../rtk/headroom.js"; +import { compressWithHeadroom, formatHeadroomLog, formatHeadroomSizeLog, isHeadroomPhantomSavings } from "../rtk/headroom.js"; import { compressWithPxpipe } from "../rtk/pxpipe.js"; import { getCapabilitiesForModel } from "../providers/capabilities.js"; import { stripUnsupportedModalities } from "../translator/concerns/modality.js"; @@ -90,735 +57,410 @@ export function stripContinuityFields(body) { return body; } -export async function handleChatCore({ - body, - modelInfo, - credentials, - log, - onCredentialsRefreshed, - onRequestSuccess, - onDisconnect, - clientRawRequest, - connectionId, - userAgent, - apiKey, - ccFilterNaming, - rtkEnabled, - headroomEnabled, - headroomUrl, - headroomCompressUserMessages, - cavemanEnabled, - cavemanLevel, - ponytailEnabled, - ponytailLevel, - pxpipeEnabled, - pxpipeMinChars, - pxpipeTimeoutMs, - pxpipeTransform, - onPxpipeEvent, - sourceFormatOverride, - providerThinking, - streamErrorPatterns, -}) { - const { provider, model } = modelInfo; - const requestStartTime = Date.now(); - // Stable per-session color so all lines of one CLI conversation share a tag - const sessionSeed = (() => { - try { - return resolveSessionId({ - headers: clientRawRequest?.headers, - body, - connectionId, - scope: provider, - }); - } catch { - return connectionId || ""; - } - })(); - const reqTag = log?.tagForSession - ? log.tagForSession(sessionSeed) - : log?.nextTag - ? log.nextTag() - : ""; +export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking }) { + const { provider, model } = modelInfo; + const requestStartTime = Date.now(); + // Stable per-session color so all lines of one CLI conversation share a tag + const sessionSeed = (() => { + try { + return resolveSessionId({ headers: clientRawRequest?.headers, body, connectionId, scope: provider }); + } catch { + return connectionId || ""; + } + })(); + const reqTag = log?.tagForSession ? log.tagForSession(sessionSeed) : (log?.nextTag ? log.nextTag() : ""); - const sourceFormat = sourceFormatOverride || detectFormat(body); + const sourceFormat = sourceFormatOverride || detectFormat(body); - // Check for bypass patterns (warmup, skip, cc naming) - const bypassResponse = handleBypassRequest( - body, - model, - userAgent, - ccFilterNaming, - ); - if (bypassResponse) return bypassResponse; + // Check for bypass patterns (warmup, skip, cc naming) + const bypassResponse = handleBypassRequest(body, model, userAgent, ccFilterNaming); + if (bypassResponse) return bypassResponse; - const alias = PROVIDER_ID_TO_ALIAS[provider] || provider; - const modelTargetFormat = getModelTargetFormat(alias, model); - // Multi-endpoint providers: pick transport matching sourceFormat → zero translation - const runtimeTransport = resolveTransport(provider, sourceFormat); - const targetFormat = - modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider, credentials); - if (runtimeTransport && credentials) - credentials.runtimeTransport = runtimeTransport; - const stripList = getModelStrip(alias, model); - const upstreamModel = getModelUpstreamId(alias, model); + const alias = PROVIDER_ID_TO_ALIAS[provider] || provider; + const modelTargetFormat = getModelTargetFormat(alias, model); + // Multi-endpoint providers: pick transport matching sourceFormat → zero translation. + // Per-model guard: only use the transport when the model declares support for that + // sourceFormat — opencode-go models differ in endpoint support (kimi/glm only do + // /chat/completions), so without this guard a claude-format request would wrongly + // route kimi to /messages. + const modelSupportedFormats = getModelSupportedFormats(alias, model); + const runtimeTransport = resolveTransport(provider, sourceFormat); + // Per-model guard: when a model declares supportedFormats, only use the + // sourceFormat-matched transport if that format is declared (opencode-go models + // differ — kimi/glm only do /chat/completions). Undeclared models keep the + // upstream default (use the transport), preserving behavior for glm/deepseek/... + const useTransport = (!modelSupportedFormats || modelSupportedFormats.includes(sourceFormat)) ? runtimeTransport : null; + const targetFormat = modelTargetFormat || useTransport?.format || getTargetFormat(provider, credentials); + if (useTransport && credentials) credentials.runtimeTransport = useTransport; + const stripList = getModelStrip(alias, model); + const upstreamModel = getModelUpstreamId(alias, model); - // Inject provider-level thinking config override (only if client hasn't set) - // on/off → extended type (body.thinking), none/low/medium/high → effort type (body.reasoning_effort) - if (providerThinking?.mode && providerThinking.mode !== "auto") { - const mode = providerThinking.mode; - if (mode === "on" && !body.thinking) { - console.log("Injecting provider-level thinking config override: on"); - body = { ...body, thinking: { type: "enabled", budget_tokens: 10000 } }; - } else if (mode === "off" && !body.thinking) { - body = { ...body, thinking: { type: "disabled" } }; - } else if (!body.reasoning_effort) { - body = { ...body, reasoning_effort: mode }; - } - } + // Inject provider-level thinking config override (only if client hasn't set) + // on/off → extended type (body.thinking), none/low/medium/high → effort type (body.reasoning_effort) + if (providerThinking?.mode && providerThinking.mode !== "auto") { + const mode = providerThinking.mode; + if (mode === "on" && !body.thinking) { + console.log("Injecting provider-level thinking config override: on"); + body = { ...body, thinking: { type: "enabled", budget_tokens: 10000 } }; + } else if (mode === "off" && !body.thinking) { + body = { ...body, thinking: { type: "disabled" } }; + } else if (!body.reasoning_effort) { + body = { ...body, reasoning_effort: mode }; + } + } - const clientRequestedStreaming = - body.stream === true || - sourceFormat === FORMATS.ANTIGRAVITY || - sourceFormat === FORMATS.GEMINI || - sourceFormat === FORMATS.GEMINI_CLI; - const providerRequiresStreaming = PROVIDERS[provider]?.forceStream === true; - let stream = providerRequiresStreaming ? true : body.stream !== false; + const clientRequestedStreaming = body.stream === true || sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI; + const providerRequiresStreaming = PROVIDERS[provider]?.forceStream === true; + let stream = providerRequiresStreaming ? true : (body.stream !== false); - // Image generation models require non-streaming (Google v1internal:generateContent) - const modelType = getModelType(alias, model); - const isImageGenModel = - modelType === "imageGen" || /image|imagen|image-generation/i.test(model); - if ( - isImageGenModel && - (provider === "antigravity" || provider === "gemini-cli") - ) { - stream = false; - } + // Image generation models require non-streaming (Google v1internal:generateContent) + const modelType = getModelType(alias, model); + const isImageGenModel = modelType === "imageGen" || /image|imagen|image-generation/i.test(model); + if (isImageGenModel && (provider === "antigravity" || provider === "gemini-cli")) { + stream = false; + } - // DeepSeek-TUI: interactive TUI panel sends stream:true and needs SSE. - // Non-interactive mode (-p flag) sends without stream and can't parse SSE. - // Only force non-streaming when client didn't explicitly request it. - const detectedTool = detectClientTool(clientRawRequest?.headers || {}, body); - if (detectedTool === "deepseek-tui" && body.stream !== true) stream = false; + // DeepSeek-TUI: interactive TUI panel sends stream:true and needs SSE. + // Non-interactive mode (-p flag) sends without stream and can't parse SSE. + // Only force non-streaming when client didn't explicitly request it. + const detectedTool = detectClientTool(clientRawRequest?.headers || {}, body); + if (detectedTool === "deepseek-tui" && body.stream !== true) stream = false; - // Check client Accept header preference for non-streaming requests - // This fixes AI SDK compatibility where clients send Accept: application/json - const acceptHeader = clientRawRequest?.headers?.accept || ""; - const clientPrefersJson = acceptHeader.includes("application/json"); - const clientPrefersSSE = acceptHeader.includes("text/event-stream"); - if ( - clientPrefersJson && - !clientPrefersSSE && - body.stream !== true && - !providerRequiresStreaming - ) { - stream = false; - } + // Check client Accept header preference for non-streaming requests + // This fixes AI SDK compatibility where clients send Accept: application/json + const acceptHeader = clientRawRequest?.headers?.accept || ""; + const clientPrefersJson = acceptHeader.includes("application/json"); + const clientPrefersSSE = acceptHeader.includes("text/event-stream"); + if (clientPrefersJson && !clientPrefersSSE && body.stream !== true && !providerRequiresStreaming) { + stream = false; + } - const reqLogger = await createRequestLogger( - sourceFormat, - targetFormat, - model, - ); - if (clientRawRequest) - reqLogger.logClientRawRequest( - clientRawRequest.endpoint, - clientRawRequest.body, - clientRawRequest.headers, - ); - reqLogger.logRawRequest(body); - log?.debug?.( - "FORMAT", - `${sourceFormat} → ${targetFormat} | stream=${stream}`, - ); + const reqLogger = await createRequestLogger(sourceFormat, targetFormat, model); + if (clientRawRequest) reqLogger.logClientRawRequest(clientRawRequest.endpoint, clientRawRequest.body, clientRawRequest.headers); + reqLogger.logRawRequest(body); + log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`); - // Native passthrough: CLI tool and provider are the same ecosystem - // Skip all translation/normalization — only model and Bearer are swapped - const clientTool = detectClientTool(clientRawRequest?.headers || {}, body); - const passthrough = isNativePassthrough(clientTool, provider); + // Native passthrough: CLI tool and provider are the same ecosystem + // Skip all translation/normalization — only model and Bearer are swapped + const clientTool = detectClientTool(clientRawRequest?.headers || {}, body); + const passthrough = isNativePassthrough(clientTool, provider); - // Expose raw client headers to translators/executors for session-id resolution - if (credentials) credentials.rawHeaders = clientRawRequest?.headers || {}; + // Expose raw client headers to translators/executors for session-id resolution + if (credentials) credentials.rawHeaders = clientRawRequest?.headers || {}; - // Auto-strip media blocks the model can't read (vision/audio/pdf) before translation. - if (!passthrough) { - const caps = getCapabilitiesForModel(provider, model); - if (stripUnsupportedModalities(body, sourceFormat, caps)) { - log?.debug?.( - "MODALITY", - `stripped unsupported media for ${provider}/${model}`, - ); - } - // Convert remote image URLs to base64 for targets that can't fetch URLs. - try { - const n = await prefetchRemoteImages(body, sourceFormat, targetFormat, { - signal: undefined, - }); - if (n > 0) - log?.debug?.( - "MODALITY", - `prefetched ${n} remote image(s) for ${targetFormat}`, - ); - } catch (e) { - log?.warn?.("MODALITY", `image prefetch failed: ${e.message}`); - } - } + // Auto-strip media blocks the model can't read (vision/audio/pdf) before translation. + if (!passthrough) { + const caps = getCapabilitiesForModel(provider, model); + if (stripUnsupportedModalities(body, sourceFormat, caps)) { + log?.debug?.("MODALITY", `stripped unsupported media for ${provider}/${model}`); + } + // Convert remote image URLs to base64 for targets that can't fetch URLs. + try { + const n = await prefetchRemoteImages(body, sourceFormat, targetFormat, { signal: undefined }); + if (n > 0) log?.debug?.("MODALITY", `prefetched ${n} remote image(s) for ${targetFormat}`); + } catch (e) { log?.warn?.("MODALITY", `image prefetch failed: ${e.message}`); } + } - let translatedBody; - let toolNameMap; - let customToolNames; - if (passthrough) { - log?.debug?.( - "PASSTHROUGH", - `${clientTool} → ${provider} | native lossless`, - ); - translatedBody = { ...body, model: stripThinkingSuffix(upstreamModel) }; - if (provider === "codex") { - const suffixThinking = {}; - applyThinking(sourceFormat, upstreamModel, suffixThinking, provider); - if (suffixThinking.reasoning_effort) { - const reasoning = translatedBody.reasoning; - translatedBody.reasoning = { - ...(reasoning && typeof reasoning === "object" && !Array.isArray(reasoning) ? reasoning : {}), - effort: suffixThinking.reasoning_effort, - }; - delete translatedBody.reasoning_effort; - } - } - // Normalize newer Cowork/CC beta shapes (adaptive thinking, mid-conversation system) the API rejects - if (clientTool === "claude") - normalizeClaudePassthrough(translatedBody, translatedBody.model); - } else { - translatedBody = translateRequest( - sourceFormat, - targetFormat, - upstreamModel, - body, - stream, - credentials, - provider, - reqLogger, - stripList, - connectionId, - clientTool, - ); - if (!translatedBody) { - trackPendingRequest(model, provider, connectionId, false, true); - return createErrorResult( - HTTP_STATUS.BAD_REQUEST, - `Failed to translate request for ${sourceFormat} → ${targetFormat}`, - ); - } - toolNameMap = translatedBody._toolNameMap; - delete translatedBody._toolNameMap; - customToolNames = translatedBody._customToolNames; - delete translatedBody._customToolNames; - translatedBody.model = stripThinkingSuffix(upstreamModel); - stripContinuityFields(translatedBody); - } + let translatedBody; + let toolNameMap; + let customToolNames; + if (passthrough) { + log?.debug?.("PASSTHROUGH", `${clientTool} → ${provider} | native lossless`); + translatedBody = { ...body, model: stripThinkingSuffix(upstreamModel) }; + if (provider === "codex") { + const suffixThinking = {}; + applyThinking(sourceFormat, upstreamModel, suffixThinking, provider); + if (suffixThinking.reasoning_effort) { + const reasoning = translatedBody.reasoning; + translatedBody.reasoning = { + ...(reasoning && typeof reasoning === "object" && !Array.isArray(reasoning) ? reasoning : {}), + effort: suffixThinking.reasoning_effort, + }; + delete translatedBody.reasoning_effort; + } + } + // Normalize newer Cowork/CC beta shapes (adaptive thinking, mid-conversation system) the API rejects + if (clientTool === "claude") normalizeClaudePassthrough(translatedBody, translatedBody.model); + } else { + translatedBody = translateRequest(sourceFormat, targetFormat, upstreamModel, body, stream, credentials, provider, reqLogger, stripList, connectionId, clientTool); + if (!translatedBody) { + trackPendingRequest(model, provider, connectionId, false, true); + return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Failed to translate request for ${sourceFormat} → ${targetFormat}`); + } + toolNameMap = translatedBody._toolNameMap; + delete translatedBody._toolNameMap; + customToolNames = translatedBody._customToolNames; + delete translatedBody._customToolNames; + translatedBody.model = stripThinkingSuffix(upstreamModel); + stripContinuityFields(translatedBody); + } - // Dedupe duplicate built-in tools when equivalent MCP tools are present (Claude clients only). - if (clientTool === "claude" && Array.isArray(translatedBody.tools)) { - const { tools: deduped, stripped } = dedupeTools(translatedBody.tools); - if (stripped.length > 0) { - translatedBody.tools = deduped; - log?.debug?.( - "TOOLDEDUP", - `stripped ${stripped.length}: ${stripped.slice(0, 3).join(", ")}${stripped.length > 3 ? "..." : ""}`, - ); - } - } + // Dedupe duplicate built-in tools when equivalent MCP tools are present (Claude clients only). + if (clientTool === "claude" && Array.isArray(translatedBody.tools)) { + const { tools: deduped, stripped } = dedupeTools(translatedBody.tools); + if (stripped.length > 0) { + translatedBody.tools = deduped; + log?.debug?.("TOOLDEDUP", `stripped ${stripped.length}: ${stripped.slice(0, 3).join(", ")}${stripped.length > 3 ? "..." : ""}`); + } + } - // Token savers: applied at the final body just before dispatch - // Covers both passthrough (source shape) and translated (target shape) flows - const finalFormat = passthrough ? sourceFormat : targetFormat; + // Token savers: applied at the final body just before dispatch + // Covers both passthrough (source shape) and translated (target shape) flows + const finalFormat = passthrough ? sourceFormat : targetFormat; - // Request line: one correlated summary (fmt + thinking + counts + account) - if (log?.line) { - const clientModel = clientRawRequest?.body?.model || `${provider}/${model}`; - const msgN = - translatedBody.messages?.length || - translatedBody.input?.length || - translatedBody.contents?.length || - body.messages?.length || - body.input?.length || - 0; - const toolN = translatedBody.tools?.length || body.tools?.length || 0; - const fmtStr = passthrough - ? `FMT: ${sourceFormat} (passthrough)` - : `FMT: ${sourceFormat}→${targetFormat}`; - const showThinking = - provider !== "grok-cli" || supportsGrokCliReasoningEffort(model); - const think = showThinking - ? log.fmtThink?.(extractThinking(translatedBody)) - : null; - const acc = - credentials?.connectionName || - credentials?.connectionId?.slice(0, 8) || - "-"; - const parts = [ - `POST ${clientModel} → ${provider}/${model}`, - fmtStr, - stream ? "STREAM" : "JSON", - `${msgN} MSG`, - ]; - if (toolN) parts.push(`${toolN} TOOL`); - if (think) parts.push(`THINK:${think}`); - parts.push(`ACC:${acc}`); - log.line(reqTag, "▶", parts.join(" · ")); - } + // Request line: one correlated summary (fmt + thinking + counts + account) + if (log?.line) { + const clientModel = clientRawRequest?.body?.model || `${provider}/${model}`; + const msgN = translatedBody.messages?.length || translatedBody.input?.length || translatedBody.contents?.length || body.messages?.length || body.input?.length || 0; + const toolN = translatedBody.tools?.length || body.tools?.length || 0; + const fmtStr = passthrough ? `FMT: ${sourceFormat} (passthrough)` : `FMT: ${sourceFormat}→${targetFormat}`; + const showThinking = provider !== "grok-cli" || supportsGrokCliReasoningEffort(model); + const think = showThinking ? log.fmtThink?.(extractThinking(translatedBody)) : null; + const acc = credentials?.connectionName || credentials?.connectionId?.slice(0, 8) || "-"; + const parts = [ + `POST ${clientModel} → ${provider}/${model}`, + fmtStr, + stream ? "STREAM" : "JSON", + `${msgN} MSG`, + ]; + if (toolN) parts.push(`${toolN} TOOL`); + if (think) parts.push(`THINK:${think}`); + parts.push(`ACC:${acc}`); + log.line(reqTag, "▶", parts.join(" · ")); + } - // TTS models don't support tool messages/function calling - if (getModelType(alias, model) === "tts" && translatedBody.messages) { - translatedBody.messages = translatedBody.messages.filter( - (msg) => msg.role !== "tool", - ); - delete translatedBody.tools; - } + // TTS models don't support tool messages/function calling + if (getModelType(alias, model) === "tts" && translatedBody.messages) { + translatedBody.messages = translatedBody.messages.filter(msg => msg.role !== "tool"); + delete translatedBody.tools; + } - // Per-request opt-out: client can bypass all token savers via header - const tokenSaverEnabled = - clientRawRequest?.headers?.[TOKEN_SAVER_HEADER]?.toLowerCase() !== "off"; + // Per-request opt-out: client can bypass all token savers via header + const tokenSaverEnabled = clientRawRequest?.headers?.[TOKEN_SAVER_HEADER]?.toLowerCase() !== "off"; - // RTK: compress tool_result content - const rtkStats = compressMessages( - translatedBody, - tokenSaverEnabled && rtkEnabled, - ); - const rtkLine = formatRtkLog(rtkStats); - if (rtkLine) console.log(rtkLine); + // RTK: compress tool_result content + const rtkStats = compressMessages(translatedBody, tokenSaverEnabled && rtkEnabled); + const rtkLine = formatRtkLog(rtkStats); + if (rtkLine) console.log(rtkLine); - // Headroom: optional external proxy compression; fail open if proxy is absent. - const headroomDiagnostics = {}; - const headroomStats = await compressWithHeadroom(translatedBody, { - enabled: tokenSaverEnabled && headroomEnabled, - url: headroomUrl, - model: upstreamModel, - format: finalFormat, - compressUserMessages: headroomCompressUserMessages, - diagnostics: headroomDiagnostics, - }); - const headroomLine = formatHeadroomLog(headroomStats); - const headroomSizeLine = formatHeadroomSizeLog(headroomDiagnostics); - if (headroomLine) { - log?.info?.( - "HEADROOM", - `${headroomLine}${headroomSizeLine ? ` | ${headroomSizeLine}` : ""}`, - ); - if (isHeadroomPhantomSavings(headroomStats, headroomDiagnostics)) { - log?.warn?.( - "HEADROOM", - `reported token delta, but outbound JSON shrank <5%; provider may bill near-original payload | ${formatHeadroomSizeLog(headroomDiagnostics)}`, - ); - } - } else if (tokenSaverEnabled && headroomEnabled) - log?.warn?.( - "HEADROOM", - `skipped: ${headroomDiagnostics.reason || "compression unavailable"}${headroomDiagnostics.endpoint ? ` (${headroomDiagnostics.endpoint})` : ""}`, - ); + // Headroom: optional external proxy compression; fail open if proxy is absent. + const headroomDiagnostics = {}; + const headroomStats = await compressWithHeadroom(translatedBody, { enabled: tokenSaverEnabled && headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, diagnostics: headroomDiagnostics }); + const headroomLine = formatHeadroomLog(headroomStats); + const headroomSizeLine = formatHeadroomSizeLog(headroomDiagnostics); + if (headroomLine) { + log?.info?.("HEADROOM", `${headroomLine}${headroomSizeLine ? ` | ${headroomSizeLine}` : ""}`); + if (isHeadroomPhantomSavings(headroomStats, headroomDiagnostics)) { + log?.warn?.("HEADROOM", `reported token delta, but outbound JSON shrank <5%; provider may bill near-original payload | ${formatHeadroomSizeLog(headroomDiagnostics)}`); + } + } else if (tokenSaverEnabled && headroomEnabled) log?.warn?.("HEADROOM", `skipped: ${headroomDiagnostics.reason || "compression unavailable"}${headroomDiagnostics.endpoint ? ` (${headroomDiagnostics.endpoint})` : ""}`); - // Token-saver flags accumulator for the single "⚙" log line below. - const xf = []; + // Token-saver flags accumulator for the single "⚙" log line below. + const xf = []; - // Caveman: inject terse-style system prompt - if (tokenSaverEnabled && cavemanEnabled && cavemanLevel) { - injectCaveman(translatedBody, finalFormat, cavemanLevel); - xf.push(`CAVEMAN:${cavemanLevel}`); - } + // Caveman: inject terse-style system prompt + if (tokenSaverEnabled && cavemanEnabled && cavemanLevel) { + injectCaveman(translatedBody, finalFormat, cavemanLevel); + xf.push(`CAVEMAN:${cavemanLevel}`); + } - // Ponytail: inject lazy-senior-dev system prompt - if (tokenSaverEnabled && ponytailEnabled && ponytailLevel) { - injectPonytail(translatedBody, finalFormat, ponytailLevel); - xf.push(`PONYTAIL:${ponytailLevel}`); - } + // Ponytail: inject lazy-senior-dev system prompt + if (tokenSaverEnabled && ponytailEnabled && ponytailLevel) { + injectPonytail(translatedBody, finalFormat, ponytailLevel); + xf.push(`PONYTAIL:${ponytailLevel}`); + } - // PXPIPE: image bulky context (Claude-format bodies only), last saver before dispatch - let pxpipeSummary = null; - if (pxpipeEnabled) { - const pxpipeResult = await compressWithPxpipe(translatedBody, { - enabled: true, - format: finalFormat, - model: upstreamModel, - minChars: pxpipeMinChars, - timeoutMs: pxpipeTimeoutMs, - transform: pxpipeTransform, - }); - pxpipeSummary = pxpipeResult.summary; - if (pxpipeResult.body) translatedBody = pxpipeResult.body; - if (pxpipeSummary?.applied) - xf.push(`PXPIPE:${pxpipeSummary.imageCount}img`); - try { - onPxpipeEvent?.({ provider, model, ...pxpipeSummary }); - } catch { - /* stats must not break requests */ - } - } + // PXPIPE: image bulky context (Claude-format bodies only), last saver before dispatch + let pxpipeSummary = null; + if (pxpipeEnabled) { + const pxpipeResult = await compressWithPxpipe(translatedBody, { + enabled: true, format: finalFormat, model: upstreamModel, + minChars: pxpipeMinChars, timeoutMs: pxpipeTimeoutMs, transform: pxpipeTransform, + }); + pxpipeSummary = pxpipeResult.summary; + if (pxpipeResult.body) translatedBody = pxpipeResult.body; + if (pxpipeSummary?.applied) xf.push(`PXPIPE:${pxpipeSummary.imageCount}img`); + try { onPxpipeEvent?.({ provider, model, ...pxpipeSummary }); } catch { /* stats must not break requests */ } + } - if (xf.length && log?.line) log.line(reqTag, "⚙", xf.join(" · ")); + if (xf.length && log?.line) log.line(reqTag, "⚙", xf.join(" · ")); - const executor = getExecutor(provider); - trackPendingRequest(model, provider, connectionId, true); - appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch( - () => {}, - ); + // Pin cache breakpoints to the final body — every saver above can reshape + // system/tools/messages, and a stale anchor costs a full prefix rewrite. + if (passthrough && clientTool === "claude") anchorClaudeCache(translatedBody); - const msgCount = - translatedBody.messages?.length || - translatedBody.input?.length || - translatedBody.contents?.length || - translatedBody.request?.contents?.length || - 0; - log?.debug?.( - "REQUEST", - `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`, - ); + const executor = getExecutor(provider); + trackPendingRequest(model, provider, connectionId, true); + appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => { }); - const streamController = createStreamController({ - onDisconnect: (reason) => { - trackPendingRequest(model, provider, connectionId, false); - if (onDisconnect) onDisconnect(reason); - }, - onError: () => trackPendingRequest(model, provider, connectionId, false), - log, - provider, - model, - reqTag, - }); + const msgCount = translatedBody.messages?.length || translatedBody.input?.length || translatedBody.contents?.length || translatedBody.request?.contents?.length || 0; + log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`); - const proxyOptions = { - connectionProxyEnabled: - credentials?.providerSpecificData?.connectionProxyEnabled === true, - connectionProxyUrl: - credentials?.providerSpecificData?.connectionProxyUrl || "", - connectionNoProxy: - credentials?.providerSpecificData?.connectionNoProxy || "", - vercelRelayUrl: credentials?.providerSpecificData?.vercelRelayUrl || "", - }; + const streamController = createStreamController({ + onDisconnect: (reason) => { + trackPendingRequest(model, provider, connectionId, false); + if (onDisconnect) onDisconnect(reason); + }, + onError: () => trackPendingRequest(model, provider, connectionId, false), + log, provider, model, reqTag + }); - if (proxyOptions.vercelRelayUrl) { - const connectionName = - credentials?.connectionName || credentials?.connectionId || "unknown"; - const poolId = - credentials?.providerSpecificData?.connectionProxyPoolId || "none"; - log?.info?.( - "PROXY", - `${provider.toUpperCase()} | ${model} | conn=${connectionName} | pool=${poolId} | vercel-relay=${proxyOptions.vercelRelayUrl}`, - ); - } else if ( - proxyOptions.connectionProxyEnabled && - proxyOptions.connectionProxyUrl - ) { - let maskedProxyUrl = proxyOptions.connectionProxyUrl; - try { - const parsed = new URL(proxyOptions.connectionProxyUrl); - const host = parsed.hostname || ""; - const port = parsed.port ? `:${parsed.port}` : ""; - const protocol = parsed.protocol || "http:"; - maskedProxyUrl = `${protocol}//${host}${port}`; - } catch { - // Keep raw if URL parsing fails - } + const proxyOptions = { + connectionProxyEnabled: credentials?.providerSpecificData?.connectionProxyEnabled === true, + connectionProxyUrl: credentials?.providerSpecificData?.connectionProxyUrl || "", + connectionNoProxy: credentials?.providerSpecificData?.connectionNoProxy || "", + vercelRelayUrl: credentials?.providerSpecificData?.vercelRelayUrl || "", + }; - const poolId = - credentials?.providerSpecificData?.connectionProxyPoolId || "none"; - const connectionName = - credentials?.connectionName || credentials?.connectionId || "unknown"; - log?.info?.( - "PROXY", - `${provider.toUpperCase()} | ${model} | conn=${connectionName} | pool=${poolId} | url=${maskedProxyUrl}`, - ); - } + if (proxyOptions.vercelRelayUrl) { + const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown"; + const poolId = credentials?.providerSpecificData?.connectionProxyPoolId || "none"; + log?.info?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | pool=${poolId} | vercel-relay=${proxyOptions.vercelRelayUrl}`); + } else if (proxyOptions.connectionProxyEnabled && proxyOptions.connectionProxyUrl) { + let maskedProxyUrl = proxyOptions.connectionProxyUrl; + try { + const parsed = new URL(proxyOptions.connectionProxyUrl); + const host = parsed.hostname || ""; + const port = parsed.port ? `:${parsed.port}` : ""; + const protocol = parsed.protocol || "http:"; + maskedProxyUrl = `${protocol}//${host}${port}`; + } catch { + // Keep raw if URL parsing fails + } - if (proxyOptions.connectionProxyEnabled && proxyOptions.connectionNoProxy) { - const connectionName = - credentials?.connectionName || credentials?.connectionId || "unknown"; - log?.debug?.( - "PROXY", - `${provider.toUpperCase()} | ${model} | conn=${connectionName} | no_proxy=${proxyOptions.connectionNoProxy}`, - ); - } + const poolId = credentials?.providerSpecificData?.connectionProxyPoolId || "none"; + const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown"; + log?.info?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | pool=${poolId} | url=${maskedProxyUrl}`); + } - // Execute request - let providerResponse, providerUrl, providerHeaders, finalBody; - // Most executors return their registry format. Cursor AgentService is an - // exception: it is decoded by the executor into OpenAI-compatible output. - let providerResponseFormat = targetFormat; - try { - const result = await executor.execute({ - model, - body: translatedBody, - stream, - credentials, - signal: streamController.signal, - log, - proxyOptions, - }); - providerResponse = result.response; - providerUrl = result.url; - providerHeaders = result.headers; - finalBody = result.transformedBody; - providerResponseFormat = result.responseFormat || targetFormat; - reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); - } catch (error) { - trackPendingRequest(model, provider, connectionId, false, true); - appendRequestLog({ - model, - provider, - connectionId, - status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}`, - }).catch(() => {}); - saveRequestDetail( - buildRequestDetail({ - provider, - model, - connectionId, - apiKey, - latency: { ttft: 0, total: Date.now() - requestStartTime }, - tokens: { prompt_tokens: 0, completion_tokens: 0 }, - request: extractRequestConfig(body, stream), - providerRequest: translatedBody || null, - response: { - error: error.message || String(error), - status: error.name === "AbortError" ? 499 : 502, - thinking: null, - }, - pxpipe: pxpipeSummary, - status: "error", - }), - ).catch(() => {}); + if (proxyOptions.connectionProxyEnabled && proxyOptions.connectionNoProxy) { + const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown"; + log?.debug?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | no_proxy=${proxyOptions.connectionNoProxy}`); + } - if (error.name === "AbortError") { - streamController.handleError(error); - return createErrorResult(499, "Request aborted"); - } - const errMsg = formatProviderError( - error, - provider, - model, - HTTP_STATUS.BAD_GATEWAY, - ); - if (log?.errorLine) { - log.errorLine( - reqTag, - "✗", - `ERROR 502 · ${provider}/${model} · ${Date.now() - requestStartTime}ms\n ${errMsg}${error.stack ? `\n ${error.stack}` : ""}`, - ); - } - return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg); - } + // Execute request + let providerResponse, providerUrl, providerHeaders, finalBody; + // Most executors return their registry format. Cursor AgentService is an + // exception: it is decoded by the executor into OpenAI-compatible output. + let providerResponseFormat = targetFormat; + try { + const result = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions }); + providerResponse = result.response; + providerUrl = result.url; + providerHeaders = result.headers; + finalBody = result.transformedBody; + providerResponseFormat = result.responseFormat || targetFormat; + reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); + } catch (error) { + trackPendingRequest(model, provider, connectionId, false, true); + appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}` }).catch(() => { }); + saveRequestDetail(buildRequestDetail({ + provider, model, connectionId, + latency: { ttft: 0, total: Date.now() - requestStartTime }, + tokens: { prompt_tokens: 0, completion_tokens: 0 }, + request: extractRequestConfig(body, stream), + providerRequest: translatedBody || null, + response: { error: error.message || String(error), status: error.name === "AbortError" ? 499 : 502, thinking: null }, + pxpipe: pxpipeSummary, + status: "error" + })).catch(() => { }); - // Config-driven in-stream error detection: peek the first bytes of a 200 - // stream; if a configured pattern matches, fail fast with 502 so account - // /combo fallback can still run before any byte reaches the client. - const configuredPatterns = streamErrorPatterns?.[provider]; - if ( - providerResponse?.ok && - Array.isArray(configuredPatterns) && - configuredPatterns.length > 0 - ) { - providerResponse = await maybeRejectEarlyStreamError( - providerResponse, - configuredPatterns, - { signal: streamController.signal }, - ); - } + if (error.name === "AbortError") { + streamController.handleError(error); + return createErrorResult(499, "Request aborted"); + } + const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY); + if (log?.errorLine) { + log.errorLine(reqTag, "✗", `ERROR 502 · ${provider}/${model} · ${Date.now() - requestStartTime}ms\n ${errMsg}${error.stack ? `\n ${error.stack}` : ""}`); + } + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg); + } - // Handle 401/403 - try token refresh (skip for noAuth providers) - if ( - !executor.noAuth && - (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || - providerResponse.status === HTTP_STATUS.FORBIDDEN) - ) { - try { - // Mutate credentials after each successful refresh: rotating refresh_token - // providers (xAI/grok-cli) issue a new RT on every refresh; without this, - // refreshWithRetry's 2nd/3rd attempt reuses the already-consumed RT → - // invalid_grant → auth_failed retryable=false. - const newCredentials = await refreshWithRetry( - async () => { - const result = await executor.refreshCredentials(credentials, log); - if ( - result?.refreshToken && - result.refreshToken !== credentials.refreshToken - ) { - if (result.accessToken) - credentials.accessToken = result.accessToken; - credentials.refreshToken = result.refreshToken; - } - return result; - }, - 3, - log, - ); - if (newCredentials?.accessToken || newCredentials?.copilotToken) { - if (log?.line) - log.line(reqTag, "🔑", `TOKEN REFRESHED · ${provider}/${model}`); - Object.assign(credentials, newCredentials); - if (onCredentialsRefreshed) { - try { - await onCredentialsRefreshed(newCredentials); - } catch (e) { - log?.warn?.("TOKEN", `onCredentialsRefreshed failed: ${e.message}`); - } - } - try { - const retryResult = await executor.execute({ - model, - body: translatedBody, - stream, - credentials, - signal: streamController.signal, - log, - proxyOptions, - }); - if (retryResult.response.ok) { - providerResponse = retryResult.response; - providerUrl = retryResult.url; - providerResponseFormat = retryResult.responseFormat || targetFormat; - } - } catch { - log?.warn?.( - "TOKEN", - `${provider.toUpperCase()} | retry after refresh failed`, - ); - } - } else { - log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`); - } - } catch (e) { - log?.warn?.( - "TOKEN", - `${provider.toUpperCase()} | refresh threw: ${e.message}`, - ); - } - } + // Handle 401/403 - try token refresh (skip for noAuth providers) + if (!executor.noAuth && (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN)) { + try { + // Mutate credentials after each successful refresh: rotating refresh_token + // providers (xAI/grok-cli) issue a new RT on every refresh; without this, + // refreshWithRetry's 2nd/3rd attempt reuses the already-consumed RT → + // invalid_grant → auth_failed retryable=false. + const newCredentials = await refreshWithRetry(async () => { + const result = await executor.refreshCredentials(credentials, log); + if (result?.refreshToken && result.refreshToken !== credentials.refreshToken) { + if (result.accessToken) credentials.accessToken = result.accessToken; + credentials.refreshToken = result.refreshToken; + } + return result; + }, 3, log); + if (newCredentials?.accessToken || newCredentials?.copilotToken) { + if (log?.line) log.line(reqTag, "🔑", `TOKEN REFRESHED · ${provider}/${model}`); + Object.assign(credentials, newCredentials); + if (onCredentialsRefreshed) { + try { await onCredentialsRefreshed(newCredentials); } catch (e) { log?.warn?.("TOKEN", `onCredentialsRefreshed failed: ${e.message}`); } + } + try { + const retryResult = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions }); + if (retryResult.response.ok) { + providerResponse = retryResult.response; + providerUrl = retryResult.url; + providerResponseFormat = retryResult.responseFormat || targetFormat; + } + } catch { log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); } + } else { + log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`); + } + } catch (e) { + log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh threw: ${e.message}`); + } + } - // Provider returned error - if (!providerResponse.ok) { - trackPendingRequest(model, provider, connectionId, false, true); - const { statusCode, message, resetsAtMs } = await parseUpstreamError( - providerResponse, - executor, - ); - appendRequestLog({ - model, - provider, - connectionId, - status: `FAILED ${statusCode}`, - }).catch(() => {}); - saveRequestDetail( - buildRequestDetail({ - provider, - model, - connectionId, - apiKey, - latency: { ttft: 0, total: Date.now() - requestStartTime }, - tokens: { prompt_tokens: 0, completion_tokens: 0 }, - request: extractRequestConfig(body, stream), - providerRequest: finalBody || translatedBody || null, - response: { error: message, status: statusCode, thinking: null }, - pxpipe: pxpipeSummary, - status: "error", - }), - ).catch(() => {}); + // Provider returned error + if (!providerResponse.ok) { + trackPendingRequest(model, provider, connectionId, false, true); + const { statusCode, message, resetsAtMs } = await parseUpstreamError(providerResponse, executor); + appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => { }); + saveRequestDetail(buildRequestDetail({ + provider, model, connectionId, + latency: { ttft: 0, total: Date.now() - requestStartTime }, + tokens: { prompt_tokens: 0, completion_tokens: 0 }, + request: extractRequestConfig(body, stream), + providerRequest: finalBody || translatedBody || null, + response: { error: message, status: statusCode, thinking: null }, + pxpipe: pxpipeSummary, + status: "error" + })).catch(() => { }); - const errMsg = formatProviderError( - new Error(message), - provider, - model, - statusCode, - ); - if (log?.errorLine) { - const urlStr = providerUrl ? `\n URL: ${providerUrl}` : ""; - log.errorLine( - reqTag, - "✗", - `ERROR ${statusCode} · ${provider}/${model} · ${Date.now() - requestStartTime}ms${urlStr}\n ${errMsg}`, - ); - } - reqLogger.logError(new Error(message), finalBody || translatedBody); - return createErrorResult(statusCode, errMsg, resetsAtMs); - } + const errMsg = formatProviderError(new Error(message), provider, model, statusCode); + if (log?.errorLine) { + const urlStr = providerUrl ? `\n URL: ${providerUrl}` : ""; + log.errorLine(reqTag, "✗", `ERROR ${statusCode} · ${provider}/${model} · ${Date.now() - requestStartTime}ms${urlStr}\n ${errMsg}`); + } + reqLogger.logError(new Error(message), finalBody || translatedBody); + return createErrorResult(statusCode, errMsg, resetsAtMs); + } - const sharedCtx = { - provider, - model, - body, - stream, - translatedBody, - finalBody, - requestStartTime, - connectionId, - apiKey, - clientRawRequest, - onRequestSuccess, - pxpipe: pxpipeSummary, - reqTag, - log, - streamErrorPatterns, - }; - const appendLog = (extra) => - appendRequestLog({ model, provider, connectionId, ...extra }).catch( - () => {}, - ); - const trackDone = () => - trackPendingRequest(model, provider, connectionId, false); + const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, pxpipe: pxpipeSummary, reqTag, log }; + const appendLog = (extra) => appendRequestLog({ model, provider, connectionId, ...extra }).catch(() => { }); + const trackDone = () => trackPendingRequest(model, provider, connectionId, false); - // Provider forced streaming but client wants JSON - if (!clientRequestedStreaming && providerRequiresStreaming) { - const result = await handleForcedSSEToJson({ - ...sharedCtx, - providerResponse, - sourceFormat, - targetFormat: providerResponseFormat, - customToolNames, - trackDone, - appendLog, - }); - if (result) { - streamController.handleComplete(); - return result; - } - } + // Provider forced streaming but client wants JSON + if (!clientRequestedStreaming && providerRequiresStreaming) { + const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, customToolNames, trackDone, appendLog }); + if (result) { streamController.handleComplete(); return result; } + } - // True non-streaming response - if (!stream) { - const result = await handleNonStreamingResponse({ - ...sharedCtx, - providerResponse, - sourceFormat, - targetFormat: providerResponseFormat, - reqLogger, - toolNameMap, - customToolNames, - trackDone, - appendLog, - }); - streamController.handleComplete(); - return result; - } + // True non-streaming response + if (!stream) { + const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, reqLogger, toolNameMap, customToolNames, trackDone, appendLog }); + streamController.handleComplete(); + return result; + } - // Streaming response - const { onStreamComplete, streamDetailId } = buildOnStreamComplete({ - ...sharedCtx, - }); - return handleStreamingResponse({ - ...sharedCtx, - providerResponse, - sourceFormat, - targetFormat: providerResponseFormat, - userAgent, - reqLogger, - toolNameMap, - customToolNames, - streamController, - onStreamComplete, - streamDetailId, - }); + // Streaming response + const { onStreamComplete, streamDetailId } = buildOnStreamComplete({ ...sharedCtx }); + return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, userAgent, reqLogger, toolNameMap, customToolNames, streamController, onStreamComplete, streamDetailId }); } export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) { - if (!expiresAt) return false; - return new Date(expiresAt).getTime() - Date.now() < bufferMs; + if (!expiresAt) return false; + return new Date(expiresAt).getTime() - Date.now() < bufferMs; } diff --git a/open-sse/handlers/chatCore/requestDetail.js b/open-sse/handlers/chatCore/requestDetail.js index de1b3cad..ebd13df3 100644 --- a/open-sse/handlers/chatCore/requestDetail.js +++ b/open-sse/handlers/chatCore/requestDetail.js @@ -44,13 +44,14 @@ export function extractUsageFromResponse(responseBody) { }; } - // Gemini format - if (responseBody.usageMetadata) { + // Gemini format. Antigravity / gemini-cli wrap the payload in { response: {...} }. + const usageMetadata = responseBody.usageMetadata || responseBody.response?.usageMetadata; + if (usageMetadata) { return { - prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0, - completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0, - cached_tokens: responseBody.usageMetadata.cachedContentTokenCount || 0, - reasoning_tokens: responseBody.usageMetadata.thoughtsTokenCount || 0 + prompt_tokens: usageMetadata.promptTokenCount || 0, + completion_tokens: usageMetadata.candidatesTokenCount || 0, + cached_tokens: usageMetadata.cachedContentTokenCount || 0, + reasoning_tokens: usageMetadata.thoughtsTokenCount || 0 }; } diff --git a/open-sse/handlers/search/callers.js b/open-sse/handlers/search/callers.js index e32d93ed..3c02828e 100644 --- a/open-sse/handlers/search/callers.js +++ b/open-sse/handlers/search/callers.js @@ -29,6 +29,8 @@ * @property {Record} [providerSpecificData] */ +import { assertPublicUrl } from "../../../src/shared/utils/ssrfGuard.js"; + // ── Helpers ───────────────────────────────────────────────────────────── /** @@ -63,12 +65,31 @@ export function getProviderSetting(params, key) { /** * Resolve base URL with optional override from providerOptions.baseUrl. + * + * The override is client-controlled and therefore SSRF-hardened: only public + * http(s) URLs are accepted (internal/private/loopback/metadata addresses are + * rejected via assertPublicUrl). The provider's own configured baseUrl is + * trusted as-is (admin-controlled). + * * @param {SearchProviderConfig} config * @param {SearchRequestParams} params * @returns {string} */ export function resolveBaseUrl(config, params) { const override = getProviderSetting(params, "baseUrl"); + if (override) { + // SSRF guard: client-supplied base URLs must be public http(s) only. + let parsed; + try { + parsed = new URL(override); + } catch { + throw new Error(`Invalid baseUrl: ${override}`); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`Invalid baseUrl protocol: ${parsed.protocol}`); + } + assertPublicUrl(override); + } return (override || config.baseUrl).replace(/\/+$/, ""); } diff --git a/open-sse/handlers/ttsProviders/genericFormats.js b/open-sse/handlers/ttsProviders/genericFormats.js index 2f27f9ea..a5c0cda3 100644 --- a/open-sse/handlers/ttsProviders/genericFormats.js +++ b/open-sse/handlers/ttsProviders/genericFormats.js @@ -51,6 +51,25 @@ async function huggingface({ baseUrl, apiKey, text, modelId }) { return responseToBase64(res, "wav"); } +// Fish Audio: model travels in an HTTP header, the voice is a reference_id, returns binary +async function fishAudio({ baseUrl, apiKey, text, modelId, voiceId }) { + const res = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + "model": modelId || "s2.1-pro-free", + }, + body: JSON.stringify({ + text, + format: "mp3", + ...(voiceId ? { reference_id: voiceId } : {}), + }), + }); + if (!res.ok) await throwUpstreamError(res); + return responseToBase64(res, "mp3"); +} + // Inworld: Basic auth, JSON { audioContent } async function inworld({ baseUrl, apiKey, text, modelId, voiceId }) { const res = await fetch(baseUrl, { @@ -166,4 +185,5 @@ export const FORMAT_HANDLERS = { tortoise, openai: openaiCompat, "minimax-tts": minimaxTts, + "fish-audio": fishAudio, }; diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index e91ad1f2..24a03b04 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -205,6 +205,7 @@ export const PATTERN_CAPABILITIES = [ // ── Gemini (all 2.0+ multimodal + google_search grounding, 1M ctx) ─ { pattern: "*gemini*image*", caps: { vision: true, imageOutput: true, contextWindow: 1048576 } }, + { pattern: "*gemini-3.7*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65536 } }, { pattern: "*gemini-3*pro*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65535 } }, { pattern: "*gemini-3*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65536 } }, { pattern: "*gemini-2.5*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-budget", thinkingRange: { min: 0, max: 24576 }, contextWindow: 1048576, maxOutput: 65536 } }, diff --git a/open-sse/providers/models/schema.js b/open-sse/providers/models/schema.js index c14a3d58..73bf7139 100644 --- a/open-sse/providers/models/schema.js +++ b/open-sse/providers/models/schema.js @@ -38,3 +38,11 @@ export function modelStrip(model) { export function modelTargetFormat(model) { return model?.targetFormat || MODEL_DEFAULTS.targetFormat; } + +// Per-model declared upstream formats (e.g. ["openai", "claude"]). Guards the +// sourceFormat-matched transport for multi-endpoint providers whose models differ +// in endpoint support (opencode-go: kimi/glm only do /chat/completions, minimax/qwen +// also do /messages, deepseek also does /responses). +export function modelSupportedFormats(model) { + return model?.supportedFormats || null; +} diff --git a/open-sse/providers/pricing.js b/open-sse/providers/pricing.js index e9f2b612..cf6ffd55 100644 --- a/open-sse/providers/pricing.js +++ b/open-sse/providers/pricing.js @@ -57,6 +57,10 @@ export const MODEL_PRICING = { "o1-mini": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 }, // === Gemini === + "gemini-3.7-flash": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.7-flash-high": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.7-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.7-flash-low": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, "gemini-3.6-flash": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, "gemini-3.6-flash-high": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, "gemini-3.6-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, diff --git a/open-sse/providers/registry/alitp-intl.js b/open-sse/providers/registry/alitp-intl.js new file mode 100644 index 00000000..bebc4d7f --- /dev/null +++ b/open-sse/providers/registry/alitp-intl.js @@ -0,0 +1,35 @@ +// Token Plan — credit subscription keys on token-plan..maas.aliyuncs.com. +// Fourth Alibaba key type: Coding Plan (alicode/alicode-intl) and Model Studio +// (alims-intl) both reject these keys, and they reject Model Studio keys back. +// Singapore is the only region that serves the plan; eu-central-1 answers +// IllegalEndpoint. The Anthropic surface (/apps/anthropic/v1/messages) is not +// authorized for this plan, so OpenAI-compatible mode is the only transport. +export default { + id: "alitp-intl", + priority: 11, + alias: "alitp-intl", + display: { + name: "Alibaba Token Plan", + icon: "cloud", + color: "#FF6A00", + textIcon: "ATP", + website: "https://www.alibabacloud.com/campaign/ai-landing-page-token", + notice: { + apiKeyUrl: "https://modelstudio.console.alibabacloud.com/?apiKey=1", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions", + headers: {}, + quirks: { preserveCacheControl: true }, + }, + models: [ + { id: "qwen3.8-max-preview", name: "Qwen3.8 Max Preview" }, + { id: "qwen3.7-max", name: "Qwen3.7 Max" }, + { id: "qwen3.7-plus", name: "Qwen3.7 Plus" }, + { id: "qwen3.6-flash", name: "Qwen3.6 Flash" }, + { id: "glm-5.2", name: "GLM 5.2" }, + { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" }, + ], +}; diff --git a/open-sse/providers/registry/antigravity.js b/open-sse/providers/registry/antigravity.js index 1a7c4347..2666552b 100644 --- a/open-sse/providers/registry/antigravity.js +++ b/open-sse/providers/registry/antigravity.js @@ -45,6 +45,9 @@ export default { clientSecret: "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", }, models: [ + { id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)", upstreamModelId: "gemini-3.7-flash-tiered(high)" }, + { id: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)", upstreamModelId: "gemini-3.7-flash-tiered(medium)" }, + { id: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash (Low)", upstreamModelId: "gemini-3.7-flash-tiered(low)" }, { id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", upstreamModelId: "gemini-3.6-flash-tiered(high)" }, { id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", upstreamModelId: "gemini-3.6-flash-tiered(medium)" }, { id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)", upstreamModelId: "gemini-3.6-flash-tiered(low)" }, diff --git a/open-sse/providers/registry/fish-audio.js b/open-sse/providers/registry/fish-audio.js new file mode 100644 index 00000000..f016a247 --- /dev/null +++ b/open-sse/providers/registry/fish-audio.js @@ -0,0 +1,31 @@ +// Fish Audio TTS — the model id travels in an HTTP `model` header rather than the +// JSON body, and the voice is a reference_id (a cloned or preset voice model). +export default { + id: "fish-audio", + alias: "fish", + display: { + name: "Fish Audio", + icon: "record_voice_over", + color: "#1E9BF0", + textIcon: "FA", + website: "https://fish.audio", + notice: { + apiKeyUrl: "https://fish.audio/app/api-keys/", + }, + }, + category: "apikey", + authType: "apikey", + serviceKinds: ["tts"], + ttsConfig: { + baseUrl: "https://api.fish.audio/v1/tts", + authType: "apikey", + authHeader: "bearer", + format: "fish-audio", + models: [ + { id: "s2.1-pro-free", name: "S2.1 Pro Free" }, + { id: "s2.1-pro", name: "S2.1 Pro" }, + { id: "s2-pro", name: "S2 Pro" }, + { id: "s1", name: "S1" }, + ], + }, +}; diff --git a/open-sse/providers/registry/gemini.js b/open-sse/providers/registry/gemini.js index c9b0de9b..df643556 100644 --- a/open-sse/providers/registry/gemini.js +++ b/open-sse/providers/registry/gemini.js @@ -36,6 +36,7 @@ export default { }, }, models: [ + { id: "gemini-3.7-flash", name: "Gemini 3.7 Flash" }, { id: "gemini-3.6-flash", name: "Gemini 3.6 Flash" }, { id: "gemini-3.5-flash-lite", name: "Gemini 3.5 Flash Lite" }, { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, diff --git a/open-sse/providers/registry/glm-cn.js b/open-sse/providers/registry/glm-cn.js index 90a71b4c..cff9eb92 100644 --- a/open-sse/providers/registry/glm-cn.js +++ b/open-sse/providers/registry/glm-cn.js @@ -21,6 +21,7 @@ export default { }, }, models: [ + { id: "glm-5.3", name: "GLM 5.3" }, { id: "glm-5.2", name: "GLM 5.2" }, { id: "glm-5.1", name: "GLM 5.1" }, { id: "glm-5", name: "GLM 5" }, diff --git a/open-sse/providers/registry/glm.js b/open-sse/providers/registry/glm.js index 95e11533..9bc099b2 100644 --- a/open-sse/providers/registry/glm.js +++ b/open-sse/providers/registry/glm.js @@ -45,6 +45,7 @@ export default { }, ], models: [ + { id: "glm-5.3", name: "GLM 5.3" }, { id: "glm-5.2", name: "GLM 5.2" }, { id: "glm-5.1", name: "GLM 5.1" }, { id: "glm-5", name: "GLM 5" }, diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index 467d1c4d..6482293a 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -119,6 +119,8 @@ import p116 from "./tokenrouter.js"; import p117 from "./selfhosted-stt.js"; import p118 from "./selfhosted-tts.js"; import p119 from "./selfhosted-embedding.js"; +import p120 from "./fish-audio.js"; +import p121 from "./alitp-intl.js"; export default [ p0, @@ -239,4 +241,6 @@ export default [ p117, p118, p119, + p120, + p121, ]; diff --git a/open-sse/providers/registry/kimchi.js b/open-sse/providers/registry/kimchi.js index c2ec22b1..1fe0b706 100644 --- a/open-sse/providers/registry/kimchi.js +++ b/open-sse/providers/registry/kimchi.js @@ -14,7 +14,7 @@ export default { }, }, category: "freeTier", - authModes: ["oauth"], + authModes: ["oauth", "apikey"], hasOAuth: true, transport: { baseUrl: "https://llm.kimchi.dev/openai/v1/chat/completions", diff --git a/open-sse/providers/registry/opencode-go.js b/open-sse/providers/registry/opencode-go.js index c980cc85..4b189ba8 100644 --- a/open-sse/providers/registry/opencode-go.js +++ b/open-sse/providers/registry/opencode-go.js @@ -22,20 +22,28 @@ export default { baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", headers: {}, }, + // Multi-endpoint: pick the transport matching the client sourceFormat to skip + // translation. Guarded per-model by `supportedFormats` (see chatCore) because + // opencode-go models differ in endpoint support. + transports: [ + { format: "openai", baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", auth: { combined: true, header: "Authorization", scheme: "bearer" } }, + { format: "claude", baseUrl: "https://opencode.ai/zen/go/v1/messages", auth: { combined: true, header: "x-api-key", scheme: "raw", anthropicVersion: true } }, + { format: "openai-responses", baseUrl: "https://opencode.ai/zen/go/v1/responses", auth: { combined: true, header: "Authorization", scheme: "bearer" } }, + ], models: [ - { id: "glm-5.2", name: "GLM 5.2" }, - { id: "glm-5.1", name: "GLM 5.1" }, - { id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }, - { id: "kimi-k2.6", name: "Kimi K2.6" }, - { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" }, - { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" }, - { id: "mimo-v2.5", name: "MiMo V2.5" }, - { id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" }, - { id: "minimax-m3", name: "MiniMax M3", targetFormat: "claude" }, - { id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" }, - { id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" }, - { id: "qwen3.7-max", name: "Qwen 3.7 Max", targetFormat: "claude" }, - { id: "qwen3.7-plus", name: "Qwen 3.7 Plus", targetFormat: "claude" }, - { id: "qwen3.6-plus", name: "Qwen 3.6 Plus", targetFormat: "claude" }, + { id: "glm-5.2", name: "GLM 5.2", supportedFormats: ["openai"] }, + { id: "glm-5.1", name: "GLM 5.1", supportedFormats: ["openai"] }, + { id: "kimi-k2.7-code", name: "Kimi K2.7 Code", supportedFormats: ["openai"] }, + { id: "kimi-k2.6", name: "Kimi K2.6", supportedFormats: ["openai"] }, + { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportedFormats: ["openai", "claude", "openai-responses"] }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportedFormats: ["openai", "claude", "openai-responses"] }, + { id: "mimo-v2.5", name: "MiMo V2.5", supportedFormats: ["openai"] }, + { id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", supportedFormats: ["openai"] }, + { id: "minimax-m3", name: "MiniMax M3", supportedFormats: ["openai", "claude"] }, + { id: "minimax-m2.7", name: "MiniMax M2.7", supportedFormats: ["openai", "claude"] }, + { id: "minimax-m2.5", name: "MiniMax M2.5", supportedFormats: ["openai", "claude"] }, + { id: "qwen3.7-max", name: "Qwen 3.7 Max", supportedFormats: ["openai", "claude"] }, + { id: "qwen3.7-plus", name: "Qwen 3.7 Plus", supportedFormats: ["openai", "claude"] }, + { id: "qwen3.6-plus", name: "Qwen 3.6 Plus", supportedFormats: ["openai", "claude"] }, ], }; diff --git a/open-sse/services/combo.js b/open-sse/services/combo.js index 8a06195b..e3b9a8e6 100644 --- a/open-sse/services/combo.js +++ b/open-sse/services/combo.js @@ -19,86 +19,66 @@ const TOOL_RESULT_PREFIX = "[Tool result: "; // on tools: drop the request's tools, turn tool/function results into assistant // text, and inline assistant tool_calls names instead of the structured field. function flattenToolHistory(messages) { - return messages - .filter((msg) => msg) - .map((msg) => { - if (msg.role === "tool" || msg.role === "function") { - return { - role: "assistant", - content: `${TOOL_RESULT_PREFIX}${extractTextContent(msg.content) || String(msg.content ?? "")}]`, - }; - } - if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) { - const { tool_calls, ...rest } = msg; - const names = tool_calls - .map((c) => c?.function?.name || c?.name || "tool") - .join(", "); - const base = - extractTextContent(rest.content) || - (typeof rest.content === "string" ? rest.content : ""); - return { - ...rest, - content: `${base}${base ? "\n" : ""}${TOOL_CALL_PREFIX}${names}]`, - }; - } - if (Array.isArray(msg.content)) { - const hasToolUse = msg.content.some((c) => c.type === "tool_use"); - const hasToolResult = msg.content.some((c) => c.type === "tool_result"); - if (hasToolUse || hasToolResult) { - const textParts = []; - const toolNames = []; - const toolResults = []; - for (const block of msg.content) { - if (block.type === "text" && block.text) textParts.push(block.text); - if (block.type === "tool_use") toolNames.push(block.name || "tool"); - if (block.type === "tool_result") - toolResults.push( - extractTextContent(block.content) || - String(block.content ?? ""), - ); - } - const { ...rest } = msg; - let newContent = textParts.join("\n"); - if (toolNames.length > 0) { - newContent = `${newContent}${newContent ? "\n" : ""}${TOOL_CALL_PREFIX}${toolNames.join(", ")}]`; - } - if (toolResults.length > 0) { - newContent = `${newContent}${newContent ? "\n" : ""}${TOOL_RESULT_PREFIX}${toolResults.join("\n")}]`; - } - return { ...rest, content: newContent }; - } - } - return msg; - }); + return messages + .filter((msg) => msg) + .map((msg) => { + if (msg.role === "tool" || msg.role === "function") { + return { role: "assistant", content: `${TOOL_RESULT_PREFIX}${extractTextContent(msg.content) || String(msg.content ?? "")}]` }; + } + if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) { + const { tool_calls, ...rest } = msg; + const names = tool_calls.map((c) => c?.function?.name || c?.name || "tool").join(", "); + const base = extractTextContent(rest.content) || (typeof rest.content === "string" ? rest.content : ""); + return { ...rest, content: `${base}${base ? "\n" : ""}${TOOL_CALL_PREFIX}${names}]` }; + } + if (Array.isArray(msg.content)) { + const hasToolUse = msg.content.some((c) => c.type === "tool_use"); + const hasToolResult = msg.content.some((c) => c.type === "tool_result"); + if (hasToolUse || hasToolResult) { + const textParts = []; + const toolNames = []; + const toolResults = []; + for (const block of msg.content) { + if (block.type === "text" && block.text) textParts.push(block.text); + if (block.type === "tool_use") toolNames.push(block.name || "tool"); + if (block.type === "tool_result") toolResults.push(extractTextContent(block.content) || String(block.content ?? "")); + } + const { ...rest } = msg; + let newContent = textParts.join("\n"); + if (toolNames.length > 0) { + newContent = `${newContent}${newContent ? "\n" : ""}${TOOL_CALL_PREFIX}${toolNames.join(", ")}]`; + } + if (toolResults.length > 0) { + newContent = `${newContent}${newContent ? "\n" : ""}${TOOL_RESULT_PREFIX}${toolResults.join("\n")}]`; + } + return { ...rest, content: newContent }; + } + } + return msg; + }); } // Reorder combo models by capability fit. Stable; never drops a model (fallback intact). // Tier 0: satisfies all hard + all soft. Tier 1: all hard only. Tier 2: rest. export function reorderByCapabilities(models, required) { - if ( - !required || - required.size === 0 || - !Array.isArray(models) || - models.length <= 1 - ) - return models; - const hard = [...required].filter((c) => HARD_CAPS.has(c)); - const soft = [...required].filter((c) => !HARD_CAPS.has(c)); + if (!required || required.size === 0 || !Array.isArray(models) || models.length <= 1) return models; + const hard = [...required].filter((c) => HARD_CAPS.has(c)); + const soft = [...required].filter((c) => !HARD_CAPS.has(c)); - const tierOf = (m) => { - const slash = typeof m === "string" ? m.indexOf("/") : -1; - const provider = slash > 0 ? m.slice(0, slash) : ""; - const model = slash > 0 ? m.slice(slash + 1) : m; - const caps = getCapabilitiesForModel(provider, model); - if (!hard.every((c) => caps[c] === true)) return 2; - return soft.every((c) => caps[c] === true) ? 0 : 1; - }; + const tierOf = (m) => { + const slash = typeof m === "string" ? m.indexOf("/") : -1; + const provider = slash > 0 ? m.slice(0, slash) : ""; + const model = slash > 0 ? m.slice(slash + 1) : m; + const caps = getCapabilitiesForModel(provider, model); + if (!hard.every((c) => caps[c] === true)) return 2; + return soft.every((c) => caps[c] === true) ? 0 : 1; + }; - // Stable sort by tier (Array.prototype.sort is stable in modern engines). - return models - .map((m, i) => ({ m, i, t: tierOf(m) })) - .sort((a, b) => a.t - b.t || a.i - b.i) - .map((x) => x.m); + // Stable sort by tier (Array.prototype.sort is stable in modern engines). + return models + .map((m, i) => ({ m, i, t: tierOf(m) })) + .sort((a, b) => a.t - b.t || a.i - b.i) + .map((x) => x.m); } /** @@ -112,19 +92,19 @@ const comboRotationState = new Map(); // so we return all of them. History media (older turns) must not pin the combo // to a vision model — those get stripped + placeholdered downstream instead. function trailingUserItems(arr) { - if (!Array.isArray(arr) || arr.length === 0) return []; - const isAssistant = (r) => r === "assistant" || r === "model"; - let i = arr.length - 1; - while (i >= 0 && !isAssistant(arr[i]?.role)) i--; - return arr.slice(i + 1); + if (!Array.isArray(arr) || arr.length === 0) return []; + const isAssistant = (r) => r === "assistant" || r === "model"; + let i = arr.length - 1; + while (i >= 0 && !isAssistant(arr[i]?.role)) i--; + return arr.slice(i + 1); } // Detect which capabilities a request needs. Modalities (vision/pdf) are scanned // only on the current user turn; "search" is request-wide (lives in tools). // Returns a Set of: "vision" | "pdf" | "search". export function detectRequiredCapabilities(body) { - const required = new Set(); - if (!body || typeof body !== "object") return required; + const required = new Set(); + if (!body || typeof body !== "object") return required; const addByMime = (mime) => { if (typeof mime !== "string") return; @@ -154,33 +134,67 @@ export function detectRequiredCapabilities(body) { addByMime(b.inlineData?.mimeType || b.fileData?.mimeType); }; - const scanContent = (content) => { - if (Array.isArray(content)) for (const b of content) scanBlock(b); - }; + const scanContent = (content) => { + if (Array.isArray(content)) for (const b of content) scanBlock(b); + }; - // Modalities: current user turn only (trailing user run across each known shape). - for (const m of trailingUserItems(body.messages)) scanContent(m.content); // openai / claude - for (const it of trailingUserItems(body.input)) scanContent(it.content); // responses - const contents = body.contents || body.request?.contents; // gemini / antigravity - for (const c of trailingUserItems(contents)) scanContent(c.parts); + const scanMessage = (m) => { + if (!m || typeof m !== "object") return; - // search: temporarily disabled in auto-switch (feature not wired yet). + // Ollama / Hermes images array (strings or objects) + if (Array.isArray(m.images) && m.images.length > 0) { + required.add("vision"); + } - return required; + // Vercel AI SDK / Hermes attachments / experimental_attachments + const attachments = m.experimental_attachments || m.attachments; + if (Array.isArray(attachments)) { + for (const att of attachments) { + if (!att) continue; + const mime = att.contentType || att.mediaType || (typeof att.url === "string" && att.url.match(/^data:([^;,]+)/)?.[1]); + if (mime) addByMime(mime); + else if (att.url || att.data) required.add("vision"); + } + } + + // Direct message-level modality properties + if (m.image_url || m.image) required.add("vision"); + if (m.audio_url || m.audio) required.add("audioInput"); + + // Scan array content blocks + scanContent(m.content); + + // Scan string content for embedded data URIs + if (typeof m.content === "string") { + if (m.content.includes("data:image/")) required.add("vision"); + else if (m.content.includes("data:audio/")) required.add("audioInput"); + else if (m.content.includes("data:application/pdf")) required.add("pdf"); + } + }; + + // Modalities: current user turn only (trailing user run across each known shape). + for (const m of trailingUserItems(body.messages)) scanMessage(m); // openai / claude / hermes / ollama + for (const it of trailingUserItems(body.input)) scanContent(it.content); // responses + const contents = body.contents || body.request?.contents; // gemini / antigravity + for (const c of trailingUserItems(contents)) scanContent(c.parts); + + // search: temporarily disabled in auto-switch (feature not wired yet). + + return required; } function normalizeStickyLimit(stickyLimit) { - const parsed = Number.parseInt(stickyLimit, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 1; + const parsed = Number.parseInt(stickyLimit, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 1; } function rotateModelsFromIndex(models, currentIndex) { - const rotatedModels = [...models]; - for (let i = 0; i < currentIndex; i++) { - const moved = rotatedModels.shift(); - rotatedModels.push(moved); - } - return rotatedModels; + const rotatedModels = [...models]; + for (let i = 0; i < currentIndex; i++) { + const moved = rotatedModels.shift(); + rotatedModels.push(moved); + } + return rotatedModels; } /** @@ -192,35 +206,34 @@ function rotateModelsFromIndex(models, currentIndex) { * @returns {string[]} Rotated models array */ export function getRotatedModels(models, comboName, strategy, stickyLimit = 1) { - if (!models || models.length <= 1 || strategy !== "round-robin") { - return models; - } + if (!models || models.length <= 1 || strategy !== "round-robin") { + return models; + } - const rotationKey = comboName || "__default__"; - const normalizedStickyLimit = normalizeStickyLimit(stickyLimit); - const existingState = comboRotationState.get(rotationKey); - const state = - typeof existingState === "number" - ? { index: existingState, consecutiveUseCount: 0 } - : existingState || { index: 0, consecutiveUseCount: 0 }; + const rotationKey = comboName || "__default__"; + const normalizedStickyLimit = normalizeStickyLimit(stickyLimit); + const existingState = comboRotationState.get(rotationKey); + const state = typeof existingState === "number" + ? { index: existingState, consecutiveUseCount: 0 } + : (existingState || { index: 0, consecutiveUseCount: 0 }); - const currentIndex = state.index % models.length; - const rotatedModels = rotateModelsFromIndex(models, currentIndex); - const nextUseCount = state.consecutiveUseCount + 1; + const currentIndex = state.index % models.length; + const rotatedModels = rotateModelsFromIndex(models, currentIndex); + const nextUseCount = state.consecutiveUseCount + 1; - if (nextUseCount >= normalizedStickyLimit) { - comboRotationState.set(rotationKey, { - index: (currentIndex + 1) % models.length, - consecutiveUseCount: 0, - }); - } else { - comboRotationState.set(rotationKey, { - index: currentIndex, - consecutiveUseCount: nextUseCount, - }); - } + if (nextUseCount >= normalizedStickyLimit) { + comboRotationState.set(rotationKey, { + index: (currentIndex + 1) % models.length, + consecutiveUseCount: 0, + }); + } else { + comboRotationState.set(rotationKey, { + index: currentIndex, + consecutiveUseCount: nextUseCount, + }); + } - return rotatedModels; + return rotatedModels; } /** @@ -228,8 +241,8 @@ export function getRotatedModels(models, comboName, strategy, stickyLimit = 1) { * @param {string} [comboName] - Combo name to reset; omit to clear all */ export function resetComboRotation(comboName) { - if (comboName) comboRotationState.delete(comboName); - else comboRotationState.clear(); + if (comboName) comboRotationState.delete(comboName); + else comboRotationState.clear(); } /** @@ -239,24 +252,17 @@ export function resetComboRotation(comboName) { * @returns {string[]|null} Array of models or null if not a combo */ export function getComboModelsFromData(modelStr, combosData) { - // Don't check if it's in provider/model format - if (modelStr.includes("/")) return null; - - // Handle both array and object formats - const combos = Array.isArray(combosData) - ? combosData - : combosData?.combos || []; - - const combo = combos.find((c) => c.name === modelStr); - if ( - combo && - combo.enabled !== false && - combo.models && - combo.models.length > 0 - ) { - return combo.models; - } - return null; + // Don't check if it's in provider/model format + if (modelStr.includes("/")) return null; + + // Handle both array and object formats + const combos = Array.isArray(combosData) ? combosData : (combosData?.combos || []); + + const combo = combos.find(c => c.name === modelStr); + if (combo && combo.models && combo.models.length > 0) { + return combo.models; + } + return null; } /** @@ -271,159 +277,108 @@ export function getComboModelsFromData(modelStr, combosData) { * @param {number|string} [options.comboStickyLimit=1] - Requests per combo model before switching * @returns {Promise} */ -export async function handleComboChat({ - body, - models, - handleSingleModel, - log, - comboName, - comboStrategy, - comboStickyLimit = 1, - autoSwitch = true, -}) { - // Apply rotation strategy if enabled - let rotatedModels = getRotatedModels( - models, - comboName, - comboStrategy, - comboStickyLimit, - ); +export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboStrategy, comboStickyLimit = 1, autoSwitch = true }) { + // Apply rotation strategy if enabled + let rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit); - // Auto-switch: float models that satisfy the request's required capabilities to the front. - if (autoSwitch) { - const required = detectRequiredCapabilities(body); - if (required.size > 0) { - const reordered = reorderByCapabilities(rotatedModels, required); - if (reordered[0] !== rotatedModels[0]) { - log.info( - "COMBO", - `auto-switch for [${[...required].join(",")}] → ${reordered[0]}`, - ); - } - rotatedModels = reordered; - } - } + // Auto-switch: float models that satisfy the request's required capabilities to the front. + if (autoSwitch) { + const required = detectRequiredCapabilities(body); + if (required.size > 0) { + const reordered = reorderByCapabilities(rotatedModels, required); + if (reordered[0] !== rotatedModels[0]) { + log.info("COMBO", `auto-switch for [${[...required].join(",")}] → ${reordered[0]}`); + } + rotatedModels = reordered; + } + } + + let lastError = null; + let earliestRetryAfter = null; + let lastStatus = null; - let lastError = null; - let earliestRetryAfter = null; - let lastStatus = null; + for (let i = 0; i < rotatedModels.length; i++) { + const modelStr = rotatedModels[i]; + log.info("COMBO", `Trying model ${i + 1}/${rotatedModels.length}: ${modelStr}`); - for (let i = 0; i < rotatedModels.length; i++) { - const modelStr = rotatedModels[i]; - log.info( - "COMBO", - `Trying model ${i + 1}/${rotatedModels.length}: ${modelStr}`, - ); + try { + const result = await handleSingleModel(body, modelStr); + + // Success (2xx) - return response + if (result.ok) { + log.info("COMBO", `Model ${modelStr} succeeded`); + return result; + } - try { - const result = await handleSingleModel(body, modelStr); + // Extract error info from response + let errorText = result.statusText || ""; + let retryAfter = null; + try { + const errorBody = await result.clone().json(); + errorText = errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText; + retryAfter = errorBody?.retryAfter || null; + } catch { + // Ignore JSON parse errors + } - // Success (2xx) - return response - if (result.ok) { - log.info("COMBO", `Model ${modelStr} succeeded`); - return result; - } + // Track earliest retryAfter across all combo models + if (retryAfter && (!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter))) { + earliestRetryAfter = retryAfter; + } - // Extract error info from response - let errorText = result.statusText || ""; - let retryAfter = null; - try { - const errorBody = await result.clone().json(); - errorText = - errorBody?.error?.message || - errorBody?.error || - errorBody?.message || - errorText; - retryAfter = errorBody?.retryAfter || null; - } catch { - // Ignore JSON parse errors - } + // Normalize error text to string (Worker-safe) + if (typeof errorText !== "string") { + try { errorText = JSON.stringify(errorText); } catch { errorText = String(errorText); } + } - // Track earliest retryAfter across all combo models - if ( - retryAfter && - (!earliestRetryAfter || - new Date(retryAfter) < new Date(earliestRetryAfter)) - ) { - earliestRetryAfter = retryAfter; - } + // Check if should fallback to next model + const { shouldFallback, cooldownMs } = checkFallbackError(result.status, errorText); - // Normalize error text to string (Worker-safe) - if (typeof errorText !== "string") { - try { - errorText = JSON.stringify(errorText); - } catch { - errorText = String(errorText); - } - } + if (!shouldFallback) { + log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { status: result.status }); + return result; + } - // Check if should fallback to next model - const { shouldFallback, cooldownMs } = checkFallbackError( - result.status, - errorText, - ); + // For transient errors (503/502/504), wait for cooldown before falling through + // so a briefly-overloaded provider gets a chance to recover rather than being + // skipped immediately (fixes: combo falls through on transient 503) + if (cooldownMs && cooldownMs > 0 && cooldownMs <= 5000 && + (result.status === 503 || result.status === 502 || result.status === 504)) { + log.info("COMBO", `Model ${modelStr} transient ${result.status}, waiting ${cooldownMs}ms before next`); + await new Promise(r => setTimeout(r, cooldownMs)); + } - if (!shouldFallback) { - log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { - status: result.status, - }); - return result; - } + // Fallback to next model + lastError = errorText || String(result.status); + if (!lastStatus) lastStatus = result.status; + log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status }); + } catch (error) { + // Catch unexpected exceptions to ensure fallback continues + lastError = error.message || String(error); + if (!lastStatus) lastStatus = 500; + log.warn("COMBO", `Model ${modelStr} threw error, trying next`, { error: lastError }); + } + } - // For transient errors (503/502/504), wait for cooldown before falling through - // so a briefly-overloaded provider gets a chance to recover rather than being - // skipped immediately (fixes: combo falls through on transient 503) - if ( - cooldownMs && - cooldownMs > 0 && - cooldownMs <= 5000 && - (result.status === 503 || - result.status === 502 || - result.status === 504) - ) { - log.info( - "COMBO", - `Model ${modelStr} transient ${result.status}, waiting ${cooldownMs}ms before next`, - ); - await new Promise((r) => setTimeout(r, cooldownMs)); - } + // All models failed + // Use 503 (Service Unavailable) rather than 406 (Not Acceptable) — 406 implies + // the request itself is invalid, but here the providers are simply unavailable + // or have no active credentials. 503 is more accurate and retryable by clients. + const allDisabled = lastError && lastError.toLowerCase().includes("no credentials"); + const status = allDisabled ? 503 : (lastStatus || 503); + const msg = lastError || "All combo models unavailable"; - // Fallback to next model - lastError = errorText || String(result.status); - if (!lastStatus) lastStatus = result.status; - log.warn("COMBO", `Model ${modelStr} failed, trying next`, { - status: result.status, - }); - } catch (error) { - // Catch unexpected exceptions to ensure fallback continues - lastError = error.message || String(error); - if (!lastStatus) lastStatus = 500; - log.warn("COMBO", `Model ${modelStr} threw error, trying next`, { - error: lastError, - }); - } - } + if (earliestRetryAfter) { + const retryHuman = formatRetryAfter(earliestRetryAfter); + log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`); + return unavailableResponse(status, msg, earliestRetryAfter, retryHuman); + } - // All models failed - // Use 503 (Service Unavailable) rather than 406 (Not Acceptable) — 406 implies - // the request itself is invalid, but here the providers are simply unavailable - // or have no active credentials. 503 is more accurate and retryable by clients. - const allDisabled = - lastError && lastError.toLowerCase().includes("no credentials"); - const status = allDisabled ? 503 : lastStatus || 503; - const msg = lastError || "All combo models unavailable"; - - if (earliestRetryAfter) { - const retryHuman = formatRetryAfter(earliestRetryAfter); - log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`); - return unavailableResponse(status, msg, earliestRetryAfter, retryHuman); - } - - log.warn("COMBO", `All models failed | ${msg}`); - return new Response(JSON.stringify({ error: { message: msg } }), { - status, - headers: { "Content-Type": "application/json" }, - }); + log.warn("COMBO", `All models failed | ${msg}`); + return new Response( + JSON.stringify({ error: { message: msg } }), + { status, headers: { "Content-Type": "application/json" } } + ); } /** @@ -433,40 +388,37 @@ export async function handleComboChat({ * leaf content→string step reuses the translator's own extractTextContent. */ function extractPanelText(json) { - if (!json || typeof json !== "object") return ""; + if (!json || typeof json !== "object") return ""; - // OpenAI chat completion - const choice = json.choices?.[0]; - if (choice) { - const msg = choice.message ?? choice.delta ?? {}; - const t = extractTextContent(msg.content); - if (t.trim()) return t; - if (typeof choice.text === "string" && choice.text.trim()) - return choice.text; - } + // OpenAI chat completion + const choice = json.choices?.[0]; + if (choice) { + const msg = choice.message ?? choice.delta ?? {}; + const t = extractTextContent(msg.content); + if (t.trim()) return t; + if (typeof choice.text === "string" && choice.text.trim()) return choice.text; + } - // Claude messages (text blocks share OpenAI's {type:"text"} shape) - const claudeText = extractTextContent(json.content); - if (claudeText.trim()) return claudeText; + // Claude messages (text blocks share OpenAI's {type:"text"} shape) + const claudeText = extractTextContent(json.content); + if (claudeText.trim()) return claudeText; - // Gemini (parts carry .text without a type discriminator) - const parts = json.candidates?.[0]?.content?.parts; - if (Array.isArray(parts)) { - const t = parts.map((p) => p?.text || "").join(""); - if (t.trim()) return t; - } + // Gemini (parts carry .text without a type discriminator) + const parts = json.candidates?.[0]?.content?.parts; + if (Array.isArray(parts)) { + const t = parts.map((p) => p?.text || "").join(""); + if (t.trim()) return t; + } - // OpenAI Responses API - if (Array.isArray(json.output)) { - const t = json.output - .flatMap((o) => - Array.isArray(o.content) ? o.content.map((c) => c?.text || "") : [], - ) - .join(""); - if (t.trim()) return t; - } + // OpenAI Responses API + if (Array.isArray(json.output)) { + const t = json.output + .flatMap((o) => (Array.isArray(o.content) ? o.content.map((c) => c?.text || "") : [])) + .join(""); + if (t.trim()) return t; + } - return ""; + return ""; } /** @@ -474,17 +426,17 @@ function extractPanelText(json) { * Preserves the original conversation + system prompt so the judge has full context. */ function appendUserTurn(body, text) { - const next = { ...body }; - if (Array.isArray(body.messages)) { - next.messages = [...body.messages, { role: "user", content: text }]; - } else if (Array.isArray(body.input)) { - next.input = [...body.input, { role: "user", content: text }]; - } else if (Array.isArray(body.contents)) { - next.contents = [...body.contents, { role: "user", parts: [{ text }] }]; - } else { - next.messages = [{ role: "user", content: text }]; - } - return next; + const next = { ...body }; + if (Array.isArray(body.messages)) { + next.messages = [...body.messages, { role: "user", content: text }]; + } else if (Array.isArray(body.input)) { + next.input = [...body.input, { role: "user", content: text }]; + } else if (Array.isArray(body.contents)) { + next.contents = [...body.contents, { role: "user", parts: [{ text }] }]; + } else { + next.messages = [{ role: "user", content: text }]; + } + return next; } /** @@ -497,46 +449,40 @@ function appendUserTurn(body, text) { * reputation of a model brand. */ function buildJudgePrompt(answers) { - const panel = answers - .map((a, i) => `[Source ${i + 1}]\n${a.text}`) - .join("\n\n"); + const panel = answers + .map((a, i) => `[Source ${i + 1}]\n${a.text}`) + .join("\n\n"); - return [ - `You are the JUDGE in a model-fusion panel. ${answers.length} expert models independently answered the user's most recent request. Their responses are below, anonymized by source.`, - "", - "Do NOT mention that multiple models were used, and do NOT refer to the sources. Produce ONE authoritative final answer addressed directly to the user.", - "", - "First, internally analyze the panel along these dimensions: consensus (points most sources agree on — treat as higher-confidence), contradictions (where they disagree — resolve with your own judgment), partial coverage, unique insights only one source surfaced, and blind spots every source missed. Then write the best possible final answer grounded in that analysis — more complete and correct than any single response, with no filler.", - "", - "=== PANEL RESPONSES ===", - panel, - "=== END PANEL RESPONSES ===", - "", - "Now write the final answer to the user's original request.", - ].join("\n"); + return [ + `You are the JUDGE in a model-fusion panel. ${answers.length} expert models independently answered the user's most recent request. Their responses are below, anonymized by source.`, + "", + "Do NOT mention that multiple models were used, and do NOT refer to the sources. Produce ONE authoritative final answer addressed directly to the user.", + "", + "First, internally analyze the panel along these dimensions: consensus (points most sources agree on — treat as higher-confidence), contradictions (where they disagree — resolve with your own judgment), partial coverage, unique insights only one source surfaced, and blind spots every source missed. Then write the best possible final answer grounded in that analysis — more complete and correct than any single response, with no filler.", + "", + "=== PANEL RESPONSES ===", + panel, + "=== END PANEL RESPONSES ===", + "", + "Now write the final answer to the user's original request.", + ].join("\n"); } // Fusion tuning. Overridable per-combo via settings.comboStrategies[name]. const FUSION_DEFAULTS = { - minPanel: 2, // answers needed before stragglers get a grace window - stragglerGraceMs: 8000, // wait this long for laggards once quorum is reached - panelHardTimeoutMs: 90000, // absolute cap so one hung model can't stall forever + minPanel: 2, // answers needed before stragglers get a grace window + stragglerGraceMs: 8000, // wait this long for laggards once quorum is reached + panelHardTimeoutMs: 90000, // absolute cap so one hung model can't stall forever }; // Resolve a Response (or {__error}) within ms; the loser keeps running but is ignored. function withTimeout(promise, ms) { - return new Promise((resolve) => { - const t = setTimeout(() => resolve({ __timeout: true }), ms); - Promise.resolve(promise) - .then((v) => { - clearTimeout(t); - resolve(v); - }) - .catch((e) => { - clearTimeout(t); - resolve({ __error: e }); - }); - }); + return new Promise((resolve) => { + const t = setTimeout(() => resolve({ __timeout: true }), ms); + Promise.resolve(promise) + .then((v) => { clearTimeout(t); resolve(v); }) + .catch((e) => { clearTimeout(t); resolve({ __error: e }); }); + }); } /** @@ -546,41 +492,33 @@ function withTimeout(promise, ms) { * still preferring a full panel when everyone is fast. Bounded by a hard timeout. * Returns a sparse array aligned to `calls` (undefined = not yet / dropped). */ -function collectPanel( - calls, - { minPanel, stragglerGraceMs, panelHardTimeoutMs }, -) { - return new Promise((resolve) => { - const out = new Array(calls.length); - let settled = 0; - let ok = 0; - let finished = false; - let graceTimer = null; - const finish = () => { - if (finished) return; - finished = true; - clearTimeout(hardTimer); - if (graceTimer) clearTimeout(graceTimer); - resolve(out); - }; - const hardTimer = setTimeout(finish, panelHardTimeoutMs); - calls.forEach((p, i) => { - Promise.resolve(p) - .then((v) => { - out[i] = v; - }) - .catch((e) => { - out[i] = { __error: e }; - }) - .finally(() => { - settled++; - if (out[i] && out[i].ok) ok++; - if (settled === calls.length) return finish(); - if (ok >= minPanel && !graceTimer) - graceTimer = setTimeout(finish, stragglerGraceMs); - }); - }); - }); +function collectPanel(calls, { minPanel, stragglerGraceMs, panelHardTimeoutMs }) { + return new Promise((resolve) => { + const out = new Array(calls.length); + let settled = 0; + let ok = 0; + let finished = false; + let graceTimer = null; + const finish = () => { + if (finished) return; + finished = true; + clearTimeout(hardTimer); + if (graceTimer) clearTimeout(graceTimer); + resolve(out); + }; + const hardTimer = setTimeout(finish, panelHardTimeoutMs); + calls.forEach((p, i) => { + Promise.resolve(p) + .then((v) => { out[i] = v; }) + .catch((e) => { out[i] = { __error: e }; }) + .finally(() => { + settled++; + if (out[i] && out[i].ok) ok++; + if (settled === calls.length) return finish(); + if (ok >= minPanel && !graceTimer) graceTimer = setTimeout(finish, stragglerGraceMs); + }); + }); + }); } /** @@ -606,111 +544,82 @@ function collectPanel( * @param {Object} [options.tuning] - Override FUSION_DEFAULTS (minPanel, grace, timeout) * @returns {Promise} */ -export async function handleFusionChat({ - body, - models, - handleSingleModel, - log, - comboName, - judgeModel, - tuning, -}) { - const panel = Array.isArray(models) ? models.filter(Boolean) : []; - if (panel.length === 0) { - return new Response( - JSON.stringify({ error: { message: "Fusion combo has no models" } }), - { status: 400, headers: { "Content-Type": "application/json" } }, - ); - } +export async function handleFusionChat({ body, models, handleSingleModel, log, comboName, judgeModel, tuning }) { + const panel = Array.isArray(models) ? models.filter(Boolean) : []; + if (panel.length === 0) { + return new Response( + JSON.stringify({ error: { message: "Fusion combo has no models" } }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } - // A single-model fusion has nothing to fuse — just answer directly. - if (panel.length === 1) { - return handleSingleModel(body, panel[0]); - } + // A single-model fusion has nothing to fuse — just answer directly. + if (panel.length === 1) { + return handleSingleModel(body, panel[0]); + } - const cfg = { ...FUSION_DEFAULTS, ...(tuning || {}) }; - const minPanel = Math.min(Math.max(2, cfg.minPanel), panel.length); - const judge = judgeModel && judgeModel.trim() ? judgeModel.trim() : panel[0]; - log.info( - "FUSION", - `Combo "${comboName}" | panel=${panel.length} [${panel.join(", ")}] | judge=${judge} | quorum=${minPanel}`, - ); + const cfg = { ...FUSION_DEFAULTS, ...(tuning || {}) }; + const minPanel = Math.min(Math.max(2, cfg.minPanel), panel.length); + const judge = judgeModel && judgeModel.trim() ? judgeModel.trim() : panel[0]; + log.info("FUSION", `Combo "${comboName}" | panel=${panel.length} [${panel.join(", ")}] | judge=${judge} | quorum=${minPanel}`); - // 1. Fan out to the panel in parallel: non-streaming, tools stripped (we want prose). - const { tools, tool_choice, ...rest } = body; - const panelBody = { ...rest, stream: false }; + // 1. Fan out to the panel in parallel: non-streaming, tools stripped (we want prose). + const { tools, tool_choice, stream_options, ...rest } = body; + // Fusion runs panel models non-streaming; drop stream_options too, or providers + // like DeepSeek reject it with "stream_options should be set along with stream = true". + // See issue #3024. + const panelBody = { ...rest, stream: false }; - // Flatten tool turns to prose so panel models keep context without emitting tool_calls. - if (Array.isArray(panelBody.messages)) { - panelBody.messages = flattenToolHistory(panelBody.messages); - } else if (Array.isArray(panelBody.input)) { - panelBody.input = flattenToolHistory(panelBody.input); - } + // Flatten tool turns to prose so panel models keep context without emitting tool_calls. + if (Array.isArray(panelBody.messages)) { + panelBody.messages = flattenToolHistory(panelBody.messages); + } else if (Array.isArray(panelBody.input)) { + panelBody.input = flattenToolHistory(panelBody.input); + } - const t0 = Date.now(); - const calls = panel.map((m) => - withTimeout(handleSingleModel(panelBody, m, true), cfg.panelHardTimeoutMs), - ); - const settled = await collectPanel(calls, { ...cfg, minPanel }); - log.info("FUSION", `fan-out collected in ${Date.now() - t0}ms`); + const t0 = Date.now(); + const calls = panel.map((m) => withTimeout(handleSingleModel(panelBody, m, true), cfg.panelHardTimeoutMs)); + const settled = await collectPanel(calls, { ...cfg, minPanel }); + log.info("FUSION", `fan-out collected in ${Date.now() - t0}ms`); - // 2. Collect successful answers. - const answers = []; - for (let i = 0; i < settled.length; i++) { - const res = settled[i]; - const model = panel[i]; - if (!res) { - log.warn("FUSION", `Panel ${model} dropped (straggler/timeout)`); - continue; - } - if (res.__timeout) { - log.warn("FUSION", `Panel ${model} timed out`); - continue; - } - if (res.__error) { - log.warn("FUSION", `Panel ${model} threw`, { - error: res.__error?.message || String(res.__error), - }); - continue; - } - if (!res.ok) { - log.warn("FUSION", `Panel ${model} failed`, { status: res.status }); - continue; - } - try { - const json = await res.clone().json(); - const text = extractPanelText(json); - if (text) { - answers.push({ model, text }); - log.info("FUSION", `Panel ${model} ok (${text.length} chars)`); - } else { - log.warn("FUSION", `Panel ${model} returned empty content`); - } - } catch (e) { - log.warn("FUSION", `Panel ${model} unparseable`, { - error: e.message || String(e), - }); - } - } + // 2. Collect successful answers. + const answers = []; + for (let i = 0; i < settled.length; i++) { + const res = settled[i]; + const model = panel[i]; + if (!res) { log.warn("FUSION", `Panel ${model} dropped (straggler/timeout)`); continue; } + if (res.__timeout) { log.warn("FUSION", `Panel ${model} timed out`); continue; } + if (res.__error) { log.warn("FUSION", `Panel ${model} threw`, { error: res.__error?.message || String(res.__error) }); continue; } + if (!res.ok) { log.warn("FUSION", `Panel ${model} failed`, { status: res.status }); continue; } + try { + const json = await res.clone().json(); + const text = extractPanelText(json); + if (text) { + answers.push({ model, text }); + log.info("FUSION", `Panel ${model} ok (${text.length} chars)`); + } else { + log.warn("FUSION", `Panel ${model} returned empty content`); + } + } catch (e) { + log.warn("FUSION", `Panel ${model} unparseable`, { error: e.message || String(e) }); + } + } - // 3. Degrade gracefully when the panel is too thin to fuse. - if (answers.length === 0) { - log.warn("FUSION", "All panel models failed"); - return new Response( - JSON.stringify({ error: { message: "All fusion panel models failed" } }), - { status: 503, headers: { "Content-Type": "application/json" } }, - ); - } - if (answers.length === 1) { - log.info( - "FUSION", - `Only ${answers[0].model} succeeded — answering directly (no fusion)`, - ); - return handleSingleModel(body, answers[0].model); - } + // 3. Degrade gracefully when the panel is too thin to fuse. + if (answers.length === 0) { + log.warn("FUSION", "All panel models failed"); + return new Response( + JSON.stringify({ error: { message: "All fusion panel models failed" } }), + { status: 503, headers: { "Content-Type": "application/json" } } + ); + } + if (answers.length === 1) { + log.info("FUSION", `Only ${answers[0].model} succeeded — answering directly (no fusion)`); + return handleSingleModel(body, answers[0].model); + } - // 4. Judge analyzes + writes one final answer (streams to client if requested). - const judgeBody = appendUserTurn(body, buildJudgePrompt(answers)); - log.info("FUSION", `Judging ${answers.length} answers with ${judge}`); - return handleSingleModel(judgeBody, judge); + // 4. Judge analyzes + writes one final answer (streams to client if requested). + const judgeBody = appendUserTurn(body, buildJudgePrompt(answers)); + log.info("FUSION", `Judging ${answers.length} answers with ${judge}`); + return handleSingleModel(judgeBody, judge); } diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 1ac0b000..eebe537c 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -35,7 +35,7 @@ const USAGE_HANDLERS = { github: (c) => getGitHubUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), "gemini-cli": (c) => getGeminiUsage(c.accessToken, c.providerDataWithProjectId, c.proxyOptions), antigravity: (c) => getAntigravityUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), - claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions), + claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions, { force: c.force }), codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions), kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), qoder: async (c) => { @@ -60,7 +60,7 @@ const USAGE_HANDLERS = { commandcode: (c) => getCommandCodeUsage(c.apiKey, c.proxyOptions), }; -export async function getUsageForProvider(connection, proxyOptions = null) { +export async function getUsageForProvider(connection, proxyOptions = null, options = {}) { const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection; const providerDataWithProjectId = { ...(providerSpecificData || {}), @@ -69,5 +69,13 @@ export async function getUsageForProvider(connection, proxyOptions = null) { const handler = USAGE_HANDLERS[provider]; if (!handler) return { message: `Usage API not implemented for ${provider}` }; - return await handler({ provider, accessToken, apiKey, providerSpecificData, providerDataWithProjectId, proxyOptions }); + return await handler({ + provider, + accessToken, + apiKey, + providerSpecificData, + providerDataWithProjectId, + proxyOptions, + force: options.force === true, + }); } diff --git a/open-sse/services/usage/claude.js b/open-sse/services/usage/claude.js index 85ab8e6f..ce3e01a0 100644 --- a/open-sse/services/usage/claude.js +++ b/open-sse/services/usage/claude.js @@ -19,7 +19,43 @@ const CLAUDE_CONFIG = { const OAUTH_429_COOLDOWN_MS = 180000; const oauthCooldown = new Map(); -export async function getClaudeUsage(accessToken, proxyOptions = null) { +// Dedup + short TTL cache per access token. Many tabs / many accounts / auto-refresh +// all funnel through here; without this each call hits Anthropic and triggers 429. +const USAGE_CACHE_TTL_MS = 300000; +const usageCache = new Map(); // token -> { promise } | { result, expiresAt } + +export async function getClaudeUsage(accessToken, proxyOptions = null, options = {}) { + const force = options?.force === true; + + // Serve in-flight or fresh cached result (skip on manual force) + if (!force && accessToken) { + const hit = usageCache.get(accessToken); + if (hit?.promise) return hit.promise; + if (hit && hit.expiresAt > Date.now()) return hit.result; + } + + const stale = (!force && accessToken && usageCache.get(accessToken)?.result) || null; + + const promise = (async () => { + const result = await fetchClaudeUsageRaw(accessToken, proxyOptions); + // Only cache real quota data, not soft-failure {message: ...} payloads + if (accessToken && result?.quotas) { + usageCache.set(accessToken, { + result, + expiresAt: Date.now() + USAGE_CACHE_TTL_MS, + }); + return result; + } + // Soft failure (429/error): prefer the last good read over a transient error + if (stale) return stale; + return result; + })(); + + if (accessToken) usageCache.set(accessToken, { promise }); + return promise; +} + +async function fetchClaudeUsageRaw(accessToken, proxyOptions = null) { try { // Skip OAuth usage call while this token is cooling down from a recent 429 const cooldownUntil = oauthCooldown.get(accessToken); diff --git a/open-sse/services/usage/google.js b/open-sse/services/usage/google.js index e79c018e..c7564c00 100644 --- a/open-sse/services/usage/google.js +++ b/open-sse/services/usage/google.js @@ -161,6 +161,9 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro if (data.models) { // Filter only recommended/important models (must match PROVIDER_MODELS ag ids) const importantModels = [ + 'gemini-3.7-flash-high', + 'gemini-3.7-flash-medium', + 'gemini-3.7-flash-low', 'gemini-3.6-flash-high', 'gemini-3.6-flash-medium', 'gemini-3.6-flash-low', diff --git a/open-sse/translator/concerns/modality.js b/open-sse/translator/concerns/modality.js index b3bba2de..d2d62f4e 100644 --- a/open-sse/translator/concerns/modality.js +++ b/open-sse/translator/concerns/modality.js @@ -62,6 +62,19 @@ function stripOpenAI(body, caps) { if (!Array.isArray(body.messages)) return; const last = body.messages.length - 1; body.messages.forEach((msg, i) => { + if (caps.vision === false) { + if (Array.isArray(msg.images)) delete msg.images; + if (Array.isArray(msg.experimental_attachments)) { + msg.experimental_attachments = msg.experimental_attachments.filter( + (a) => !(a?.contentType?.startsWith("image/") || (typeof a?.url === "string" && a.url.startsWith("data:image/"))) + ); + } + if (Array.isArray(msg.attachments)) { + msg.attachments = msg.attachments.filter( + (a) => !(a?.contentType?.startsWith("image/") || (typeof a?.url === "string" && a.url.startsWith("data:image/"))) + ); + } + } if (!Array.isArray(msg.content)) return; const removed = new Set(); msg.content = filterBlocks(msg.content, capForOpenAIBlock, caps, removed, i === last); diff --git a/open-sse/translator/formats/claude.js b/open-sse/translator/formats/claude.js index 128917ec..81e72a44 100644 --- a/open-sse/translator/formats/claude.js +++ b/open-sse/translator/formats/claude.js @@ -9,6 +9,9 @@ import { PROVIDERS } from "../../providers/index.js"; import { getCapabilitiesForModel } from "../../providers/capabilities.js"; import { DEFAULT_MAX_TOKENS } from "../../config/runtimeConfig.js"; +const CACHE_CONTROL_5M = { type: "ephemeral" }; +const CACHE_CONTROL_1H = { type: "ephemeral", ttl: "1h" }; + // Check if message has valid non-empty content export function hasValidContent(msg) { if (typeof msg.content === "string" && msg.content.trim()) return true; @@ -124,32 +127,38 @@ export function normalizeClaudePassthrough(body, model = "") { if (Object.keys(body.output_config).length === 0) delete body.output_config; } - // 2. Hoist mid-conversation system messages into the top-level system field + // 2. Fold mid-conversation system messages into the neighbouring turn. + // Hoisting them into body.system would insert volatile content (token counters, + // reminders) ahead of the whole conversation and invalidate the prefix cache on + // every request. Folding in place keeps the cached prefix stable. if (Array.isArray(body.messages)) { - const systemBlocks = []; const messages = []; for (const msg of body.messages) { - if (msg.role === ROLE.SYSTEM) { - const text = typeof msg.content === "string" - ? msg.content - : Array.isArray(msg.content) - ? msg.content.map(b => (typeof b === "string" ? b : b?.text || "")).join("\n") - : ""; - if (text.trim()) systemBlocks.push({ type: CLAUDE_BLOCK.TEXT, text }); + if (msg.role !== ROLE.SYSTEM) { + messages.push(msg); continue; } - messages.push(msg); - } + const text = typeof msg.content === "string" + ? msg.content + : Array.isArray(msg.content) + ? msg.content.map(b => (typeof b === "string" ? b : b?.text || "")).join("\n") + : ""; + if (!text.trim()) continue; - if (systemBlocks.length > 0) { - const existing = Array.isArray(body.system) - ? body.system - : typeof body.system === "string" && body.system.trim() - ? [{ type: "text", text: body.system }] - : []; - body.system = [...existing, ...systemBlocks]; - body.messages = messages; + // Copy-on-write: the caller's body is reused across account-fallback + // attempts, so folding must never mutate the original message. + const block = { type: CLAUDE_BLOCK.TEXT, text }; + const prev = messages[messages.length - 1]; + if (prev?.role === ROLE.USER) { + const content = typeof prev.content === "string" + ? [{ type: CLAUDE_BLOCK.TEXT, text: prev.content }] + : Array.isArray(prev.content) ? [...prev.content] : []; + messages[messages.length - 1] = { ...prev, content: [...content, block] }; + continue; + } + messages.push({ role: ROLE.USER, content: [block] }); } + body.messages = messages; } // 3. Drop thinking blocks whose signature is not Claude's (combo mixes models, @@ -182,6 +191,70 @@ export function normalizeClaudePassthrough(body, model = "") { return body; } +// Put a 5m breakpoint on the last cache-eligible block of a message. +// thinking/redacted_thinking blocks do not accept cache_control. +function markLastCacheableBlock(msg) { + if (!Array.isArray(msg?.content)) return false; + for (let i = msg.content.length - 1; i >= 0; i--) { + const block = msg.content[i]; + if (typeof block !== "object" || block === null) continue; + if (block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING) continue; + block.cache_control = { ...CACHE_CONTROL_5M }; + return true; + } + return false; +} + +// Re-anchor cache breakpoints on a Claude passthrough body (same policy as +// prepareClaudeRequest): last tool + last system block at 1h, last assistant at 5m. +// The client's own markers point at pre-normalization offsets, so they are dropped. +// Must run LAST, after every step that can reshape system/tools/messages +// (normalize, tool dedupe, token savers) — otherwise the anchor drifts off the tail. +export function anchorClaudeCache(body) { + if (!body || typeof body !== "object") return body; + + if (Array.isArray(body.system)) { + const last = body.system.length - 1; + body.system.forEach((block, i) => { + if (typeof block !== "object" || block === null) return; + if (i === last) block.cache_control = { ...CACHE_CONTROL_1H }; + else delete block.cache_control; + }); + } + + if (Array.isArray(body.tools)) { + const last = body.tools.length - 1; + body.tools.forEach((tool, i) => { + if (i === last) tool.cache_control = { ...CACHE_CONTROL_1H }; + else delete tool.cache_control; + }); + } + + if (Array.isArray(body.messages)) { + let anchored = null; + for (let i = body.messages.length - 1; i >= 0; i--) { + const msg = body.messages[i]; + if (!Array.isArray(msg.content)) continue; + for (const block of msg.content) delete block.cache_control; + + // Prefer the last assistant turn: it ends a completed exchange, so the + // prefix up to it stays byte-stable across the following requests. + if (anchored || msg.role !== ROLE.ASSISTANT) continue; + anchored = markLastCacheableBlock(msg); + } + + // First turn of a conversation has no assistant yet — anchor the final + // message instead, so the opening prompt is cached rather than paid twice. + if (!anchored) { + for (let i = body.messages.length - 1; i >= 0 && !anchored; i--) { + anchored = markLastCacheableBlock(body.messages[i]); + } + } + } + + return body; +} + // Prepare request for Claude format endpoints // - Cleanup cache_control // - Filter empty messages diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index 8651e819..972b0610 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -287,6 +287,18 @@ export function claudeToKiroRequest(model, body, stream, credentials) { toolSpecs, nameMap, }); + // canonicalizeKiroConversation() already ran its second-chance repair (flatten + // every structured tool turn to text, then re-validate). A body that is STILL + // invalid here cannot be made shippable, and Kiro answers it with + // 400 {"message":"Improperly formed request.","reason":"REQUEST_BODY_INVALID"}. + // Fail locally instead: chatCore turns a falsy return into a 400 without + // spending an upstream call or a per-account cooldown. The taxonomy + // (role:N | pair:N | id:N | spec:N | orphan:0 | current) names the offending + // turn so the shape can be diagnosed from the log alone. + if (!canonical.valid) { + console.error(`[Kiro] refusing invalid conversation (claude → kiro): ${(canonical.errors || []).join(", ") || "unknown"} | turns=${(canonical.history || []).length + 1}`); + return null; + } const replayCurrent = canonical.currentMessage.userInputMessage; const userInputMessage = { content: replayCurrent.content || "", diff --git a/open-sse/translator/request/openai-responses.js b/open-sse/translator/request/openai-responses.js index b821f3a4..29e43152 100644 --- a/open-sse/translator/request/openai-responses.js +++ b/open-sse/translator/request/openai-responses.js @@ -421,6 +421,7 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) if (body.reasoning !== undefined) result.reasoning = body.reasoning; if (body.reasoning_effort !== undefined) result.reasoning = { effort: body.reasoning_effort, summary: "auto" }; if (body.service_tier !== undefined) result.service_tier = body.service_tier; + if (body.prompt_cache_key !== undefined) result.prompt_cache_key = body.prompt_cache_key; return result; } diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index aa776949..3e30d2f5 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -379,6 +379,18 @@ export function openaiToKiroRequest(model, body, stream, credentials) { toolSpecs, nameMap, }); + // canonicalizeKiroConversation() already ran its second-chance repair (flatten + // every structured tool turn to text, then re-validate). A body that is STILL + // invalid here cannot be made shippable, and Kiro answers it with + // 400 {"message":"Improperly formed request.","reason":"REQUEST_BODY_INVALID"}. + // Fail locally instead: chatCore turns a falsy return into a 400 without + // spending an upstream call or a per-account cooldown. The taxonomy + // (role:N | pair:N | id:N | spec:N | orphan:0 | current) names the offending + // turn so the shape can be diagnosed from the log alone. + if (!canonical.valid) { + console.error(`[Kiro] refusing invalid conversation (openai → kiro): ${(canonical.errors || []).join(", ") || "unknown"} | turns=${(canonical.history || []).length + 1}`); + return null; + } const replayCurrent = canonical.currentMessage.userInputMessage; const payload = { diff --git a/open-sse/translator/response/kiro-to-claude.js b/open-sse/translator/response/kiro-to-claude.js index 1c9ece5b..455672b1 100644 --- a/open-sse/translator/response/kiro-to-claude.js +++ b/open-sse/translator/response/kiro-to-claude.js @@ -75,6 +75,15 @@ export function kiroToClaudeResponse(chunk, state) { ? data.usage.completion_tokens : 0; state.usage = { input_tokens: promptTokens, output_tokens: outputTokens }; + // Claude clients read cache_read/cache_creation to price a turn and to size + // their prompt cache. Both spellings are accepted because the Kiro executor + // emits the Chat shape and passthrough responses use the nested details form. + const cacheRead = data.usage.cache_read_input_tokens + ?? data.usage.prompt_tokens_details?.cached_tokens; + const cacheCreation = data.usage.cache_creation_input_tokens + ?? data.usage.prompt_tokens_details?.cache_creation_tokens; + if (typeof cacheRead === "number") state.usage.cache_read_input_tokens = cacheRead; + if (typeof cacheCreation === "number") state.usage.cache_creation_input_tokens = cacheCreation; } // First chunk → emit message_start. @@ -254,6 +263,13 @@ export function kiroToClaudeNonStreaming(data) { usage: { input_tokens: usage.prompt_tokens || 0, output_tokens: usage.completion_tokens || 0, + // Same cache preservation as the streaming path above. + ...(typeof (usage.cache_read_input_tokens ?? usage.prompt_tokens_details?.cached_tokens) === "number" + ? { cache_read_input_tokens: usage.cache_read_input_tokens ?? usage.prompt_tokens_details.cached_tokens } + : {}), + ...(typeof (usage.cache_creation_input_tokens ?? usage.prompt_tokens_details?.cache_creation_tokens) === "number" + ? { cache_creation_input_tokens: usage.cache_creation_input_tokens ?? usage.prompt_tokens_details.cache_creation_tokens } + : {}), }, }; } diff --git a/open-sse/translator/response/openai-responses.js b/open-sse/translator/response/openai-responses.js index 92e3cb52..ff55bb4e 100644 --- a/open-sse/translator/response/openai-responses.js +++ b/open-sse/translator/response/openai-responses.js @@ -99,8 +99,8 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { } } - // Handle tool_calls - if (delta.tool_calls) { + // Handle tool_calls (empty array is truthy; require a real call) + if (delta.tool_calls && delta.tool_calls.length) { closeMessage(state, emit, idx); for (const tc of delta.tool_calls) { emitToolCall(state, emit, tc); diff --git a/package.json b/package.json index 4374a515..f1c2c97f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.5.50", + "version": "0.5.55", "description": "9Router web dashboard", "private": true, "scripts": { @@ -9,10 +9,10 @@ "build": "next build --webpack", "postbuild": "node scripts/copy-standalone-assets.mjs", "postbuild:bun": "node scripts/copy-standalone-assets.mjs", - "start": "next start --port 20127", + "start": "node custom-server.js --port 20127", "dev:bun": "bun --bun next dev --webpack --port 20127", "build:bun": "bun --bun next build --webpack", - "start:bun": "bun ./.next/standalone/server.js", + "start:bun": "bun ./.next/standalone/custom-server.js", "cli:pack": "npm --prefix cli run pack:cli", "cli:publish": "npm --prefix cli run publish:cli" }, @@ -23,8 +23,10 @@ "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.7.0", "@next/third-parties": "^16.2.9", + "@node-saml/node-saml": "^5.1.0", "@xyflow/react": "^12.10.1", "bcryptjs": "^3.0.3", + "chalk": "^5.6.2", "confbox": "^0.2.4", "express": "^5.2.1", "http-proxy-middleware": "^3.0.5", @@ -37,6 +39,7 @@ "node-machine-id": "^1.1.12", "open": "^11.0.0", "ora": "^9.1.0", + "prop-types": "^15.8.1", "react": "19.2.4", "react-dom": "19.2.4", "react-is": "^16.13.1", diff --git a/scripts/copy-standalone-assets.mjs b/scripts/copy-standalone-assets.mjs index bfaf6e0d..c0bc0a5a 100644 --- a/scripts/copy-standalone-assets.mjs +++ b/scripts/copy-standalone-assets.mjs @@ -29,6 +29,14 @@ export function copyStandaloneAssets({ projectRoot = process.cwd(), distDir = pr cpSync(publicSource, publicDestination, { recursive: true, force: true }); console.log(`[standalone-assets] Copied public assets to ${publicDestination}`); } + + // Without it beside server.js the standalone build serves requests unsanitized. + const serverWrapperSource = resolve(projectRoot, "custom-server.js"); + const serverWrapperDestination = resolve(standaloneDir, "custom-server.js"); + if (existsSync(serverWrapperSource)) { + cpSync(serverWrapperSource, serverWrapperDestination, { force: true }); + console.log(`[standalone-assets] Copied custom-server.js to ${serverWrapperDestination}`); + } } if (process.argv[1] && resolve(process.argv[1]) === resolve(dirname(fileURLToPath(import.meta.url)), "copy-standalone-assets.mjs")) { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js index 974372d7..9ef6cddf 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js @@ -170,7 +170,7 @@ export default function HermesToolCard({ ? selectedApiKey : (!cloudEnabled ? "sk_9router" : ""); - const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n`; + const yamlContent = `model:\n default: "${selectedModel || "provider/model-id"}"\n provider: "custom"\n base_url: "${getEffectiveBaseUrl()}"\n api_key: \${OPENAI_API_KEY}\n`; const envContent = `OPENAI_API_KEY=${keyToUse}\n`; return [ diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index 18727bcf..2a70e19c 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -11,1585 +11,1686 @@ import { LOCALE_COOKIE, normalizeLocale } from "@/i18n/config"; import { LOCALE_FLAGS } from "@/shared/constants/locales"; function getLocaleFromCookie() { - if (typeof document === "undefined") return "en"; - const cookie = document.cookie - .split(";") - .find((c) => c.trim().startsWith(`${LOCALE_COOKIE}=`)); - const value = cookie ? decodeURIComponent(cookie.split("=")[1]) : "en"; - return normalizeLocale(value); + if (typeof document === "undefined") return "en"; + const cookie = document.cookie + .split(";") + .find((c) => c.trim().startsWith(`${LOCALE_COOKIE}=`)); + const value = cookie ? decodeURIComponent(cookie.split("=")[1]) : "en"; + return normalizeLocale(value); } export default function ProfilePage() { - const { theme, setTheme, isDark } = useTheme(); - const [locale, setLocale] = useState("en"); - const [langOpen, setLangOpen] = useState(false); - const [shutdownOpen, setShutdownOpen] = useState(false); - const [isShuttingDown, setIsShuttingDown] = useState(false); - const [settings, setSettings] = useState({ fallbackStrategy: "fill-first" }); - const [loading, setLoading] = useState(true); - const [passwords, setPasswords] = useState({ - current: "", - new: "", - confirm: "", - }); - const [passStatus, setPassStatus] = useState({ type: "", message: "" }); - const [passLoading, setPassLoading] = useState(false); - const [dbLoading, setDbLoading] = useState(false); - const [dbStatus, setDbStatus] = useState({ type: "", message: "" }); - const [dbAuth, setDbAuth] = useState({ open: false, mode: "", password: "" }); - const pendingImportRef = useRef(null); - const [oidcForm, setOidcForm] = useState({ - authMode: "password", - oidcIssuerUrl: "", - oidcClientId: "", - oidcScopes: "openid profile email", - oidcLoginLabel: "Sign in with OIDC", - }); - const [oidcClientSecret, setOidcClientSecret] = useState(""); - const [oidcStatus, setOidcStatus] = useState({ type: "", message: "" }); - const [oidcLoading, setOidcLoading] = useState(false); - const [oidcTestLoading, setOidcTestLoading] = useState(false); - const [oidcTestStatus, setOidcTestStatus] = useState({ - type: "", - message: "", - }); - const [oidcRedirectUri, setOidcRedirectUri] = useState( - "/api/auth/oidc/callback", - ); - const [oidcExpanded, setOidcExpanded] = useState(false); - const importFileRef = useRef(null); - const [proxyForm, setProxyForm] = useState({ - outboundProxyEnabled: false, - outboundProxyUrl: "", - outboundNoProxy: "", - }); - const [proxyStatus, setProxyStatus] = useState({ type: "", message: "" }); - const [proxyLoading, setProxyLoading] = useState(false); - const [proxyTestLoading, setProxyTestLoading] = useState(false); + const { theme, setTheme, isDark } = useTheme(); + const [locale, setLocale] = useState(() => getLocaleFromCookie()); + const [langOpen, setLangOpen] = useState(false); + const [shutdownOpen, setShutdownOpen] = useState(false); + const [isShuttingDown, setIsShuttingDown] = useState(false); + const [settings, setSettings] = useState({ fallbackStrategy: "fill-first" }); + const [loading, setLoading] = useState(true); + const [passwords, setPasswords] = useState({ current: "", new: "", confirm: "" }); + const [passStatus, setPassStatus] = useState({ type: "", message: "" }); + const [passLoading, setPassLoading] = useState(false); + const [dbLoading, setDbLoading] = useState(false); + const [dbStatus, setDbStatus] = useState({ type: "", message: "" }); + const [dbAuth, setDbAuth] = useState({ open: false, mode: "", password: "" }); + const pendingImportRef = useRef(null); + const [oidcForm, setOidcForm] = useState({ + authMode: "password", + oidcIssuerUrl: "", + oidcClientId: "", + oidcScopes: "openid profile email", + oidcLoginLabel: "Sign in with OIDC", + }); + const [oidcClientSecret, setOidcClientSecret] = useState(""); + const [oidcStatus, setOidcStatus] = useState({ type: "", message: "" }); + const [oidcLoading, setOidcLoading] = useState(false); + const [oidcTestLoading, setOidcTestLoading] = useState(false); + const [oidcTestStatus, setOidcTestStatus] = useState({ type: "", message: "" }); + const [oidcExpanded, setOidcExpanded] = useState(false); - useEffect(() => { - setLocale(getLocaleFromCookie()); - }, [langOpen]); + const origin = typeof window !== "undefined" ? window.location.origin : ""; + const oidcRedirectUri = origin ? `${origin}/api/auth/oidc/callback` : "/api/auth/oidc/callback"; + const samlAcsUrl = origin ? `${origin}/api/auth/saml/acs` : "/api/auth/saml/acs"; + const samlMetadataUrl = origin ? `${origin}/api/auth/saml/metadata` : "/api/auth/saml/metadata"; + + // SAML State + const [ssoTypeTab, setSsoTypeTab] = useState("saml"); + const [samlForm, setSamlForm] = useState({ + samlEntryPoint: "", + samlIssuer: "urn:9router:sp", + samlCert: "", + samlLoginLabel: "Sign in with SAML SSO", + samlAttributeEmail: "email", + samlAttributeName: "name", + }); + const [samlStatus, setSamlStatus] = useState({ type: "", message: "" }); + const [samlLoading, setSamlLoading] = useState(false); + const [samlTestLoading, setSamlTestLoading] = useState(false); + const [samlTestStatus, setSamlTestStatus] = useState({ type: "", message: "" }); + const [showSamlGuide, setShowSamlGuide] = useState(false); + const idpMetadataFileRef = useRef(null); + const certFileRef = useRef(null); - useEffect(() => { - fetch("/api/settings") - .then((res) => res.json()) - .then((data) => { - setSettings(data); - setOidcForm({ - authMode: data?.authMode || "password", - oidcIssuerUrl: data?.oidcIssuerUrl || "", - oidcClientId: data?.oidcClientId || "", - oidcScopes: data?.oidcScopes || "openid profile email", - oidcLoginLabel: data?.oidcLoginLabel || "Sign in with OIDC", - }); - setOidcClientSecret(""); - if (data?.authMode === "oidc" || data?.authMode === "both") - setOidcExpanded(true); - setProxyForm({ - outboundProxyEnabled: data?.outboundProxyEnabled === true, - outboundProxyUrl: data?.outboundProxyUrl || "", - outboundNoProxy: data?.outboundNoProxy || "", - }); - setLoading(false); - }) - .catch((err) => { - console.error("Failed to fetch settings:", err); - setLoading(false); - }); - }, []); + const importFileRef = useRef(null); + const [proxyForm, setProxyForm] = useState({ + outboundProxyEnabled: false, + outboundProxyUrl: "", + outboundNoProxy: "", + }); + const [proxyStatus, setProxyStatus] = useState({ type: "", message: "" }); + const [proxyLoading, setProxyLoading] = useState(false); + const [proxyTestLoading, setProxyTestLoading] = useState(false); - useEffect(() => { - if (typeof window !== "undefined") { - setOidcRedirectUri(`${window.location.origin}/api/auth/oidc/callback`); - } - }, []); + useEffect(() => { + fetch("/api/settings") + .then((res) => res.json()) + .then((data) => { + setSettings(data); + setOidcForm({ + authMode: data?.authMode || "password", + oidcIssuerUrl: data?.oidcIssuerUrl || "", + oidcClientId: data?.oidcClientId || "", + oidcScopes: data?.oidcScopes || "openid profile email", + oidcLoginLabel: data?.oidcLoginLabel || "Sign in with OIDC", + }); + setOidcClientSecret(""); + setSsoTypeTab(data?.ssoType || "saml"); + setSamlForm({ + samlEntryPoint: data?.samlEntryPoint || "", + samlIssuer: data?.samlIssuer || "urn:9router:sp", + samlCert: data?.samlCert || "", + samlLoginLabel: data?.samlLoginLabel || "Sign in with SAML SSO", + samlAttributeEmail: data?.samlAttributeEmail || "email", + samlAttributeName: data?.samlAttributeName || "name", + }); + if ( + data?.authMode === "sso" || + data?.authMode === "saml" || + data?.authMode === "oidc" || + data?.authMode === "both" + ) { + setOidcExpanded(true); + } + setProxyForm({ + outboundProxyEnabled: data?.outboundProxyEnabled === true, + outboundProxyUrl: data?.outboundProxyUrl || "", + outboundNoProxy: data?.outboundNoProxy || "", + }); + setLoading(false); + }) + .catch((err) => { + console.error("Failed to fetch settings:", err); + setLoading(false); + }); + }, []); - const updateOutboundProxy = async (e) => { - e.preventDefault(); - if (settings.outboundProxyEnabled !== true) return; - setProxyLoading(true); - setProxyStatus({ type: "", message: "" }); + const updateOutboundProxy = async (e) => { + e.preventDefault(); + if (settings.outboundProxyEnabled !== true) return; + setProxyLoading(true); + setProxyStatus({ type: "", message: "" }); - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - outboundProxyUrl: proxyForm.outboundProxyUrl, - outboundNoProxy: proxyForm.outboundNoProxy, - }), - }); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + outboundProxyUrl: proxyForm.outboundProxyUrl, + outboundNoProxy: proxyForm.outboundNoProxy, + }), + }); - const data = await res.json(); - if (res.ok) { - setSettings((prev) => ({ ...prev, ...data })); - setProxyStatus({ type: "success", message: "Proxy settings applied" }); - } else { - setProxyStatus({ - type: "error", - message: data.error || "Failed to update proxy settings", - }); - } - } catch (err) { - setProxyStatus({ type: "error", message: "An error occurred" }); - } finally { - setProxyLoading(false); - } - }; + const data = await res.json(); + if (res.ok) { + setSettings((prev) => ({ ...prev, ...data })); + setProxyStatus({ type: "success", message: "Proxy settings applied" }); + } else { + setProxyStatus({ type: "error", message: data.error || "Failed to update proxy settings" }); + } + } catch (err) { + setProxyStatus({ type: "error", message: "An error occurred" }); + } finally { + setProxyLoading(false); + } + }; - const testOutboundProxy = async () => { - if (settings.outboundProxyEnabled !== true) return; + const testOutboundProxy = async () => { + if (settings.outboundProxyEnabled !== true) return; - const proxyUrl = (proxyForm.outboundProxyUrl || "").trim(); - if (!proxyUrl) { - setProxyStatus({ - type: "error", - message: "Please enter a Proxy URL to test", - }); - return; - } + const proxyUrl = (proxyForm.outboundProxyUrl || "").trim(); + if (!proxyUrl) { + setProxyStatus({ type: "error", message: "Please enter a Proxy URL to test" }); + return; + } - setProxyTestLoading(true); - setProxyStatus({ type: "", message: "" }); + setProxyTestLoading(true); + setProxyStatus({ type: "", message: "" }); - try { - const res = await fetch("/api/settings/proxy-test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ proxyUrl }), - }); + try { + const res = await fetch("/api/settings/proxy-test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ proxyUrl }), + }); - const data = await res.json(); - if (res.ok && data?.ok) { - setProxyStatus({ - type: "success", - message: `Proxy test OK (${data.status}) in ${data.elapsedMs}ms`, - }); - } else { - setProxyStatus({ - type: "error", - message: data?.error || "Proxy test failed", - }); - } - } catch (err) { - setProxyStatus({ type: "error", message: "An error occurred" }); - } finally { - setProxyTestLoading(false); - } - }; + const data = await res.json(); + if (res.ok && data?.ok) { + setProxyStatus({ + type: "success", + message: `Proxy test OK (${data.status}) in ${data.elapsedMs}ms`, + }); + } else { + setProxyStatus({ + type: "error", + message: data?.error || "Proxy test failed", + }); + } + } catch (err) { + setProxyStatus({ type: "error", message: "An error occurred" }); + } finally { + setProxyTestLoading(false); + } + }; - const updateOutboundProxyEnabled = async (outboundProxyEnabled) => { - setProxyLoading(true); - setProxyStatus({ type: "", message: "" }); + const updateOutboundProxyEnabled = async (outboundProxyEnabled) => { + setProxyLoading(true); + setProxyStatus({ type: "", message: "" }); - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ outboundProxyEnabled }), - }); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ outboundProxyEnabled }), + }); - const data = await res.json(); - if (res.ok) { - setSettings((prev) => ({ ...prev, ...data })); - setProxyForm((prev) => ({ - ...prev, - outboundProxyEnabled: data?.outboundProxyEnabled === true, - })); - setProxyStatus({ - type: "success", - message: outboundProxyEnabled ? "Proxy enabled" : "Proxy disabled", - }); - } else { - setProxyStatus({ - type: "error", - message: data.error || "Failed to update proxy settings", - }); - } - } catch (err) { - setProxyStatus({ type: "error", message: "An error occurred" }); - } finally { - setProxyLoading(false); - } - }; + const data = await res.json(); + if (res.ok) { + setSettings((prev) => ({ ...prev, ...data })); + setProxyForm((prev) => ({ ...prev, outboundProxyEnabled: data?.outboundProxyEnabled === true })); + setProxyStatus({ + type: "success", + message: outboundProxyEnabled ? "Proxy enabled" : "Proxy disabled", + }); + } else { + setProxyStatus({ type: "error", message: data.error || "Failed to update proxy settings" }); + } + } catch (err) { + setProxyStatus({ type: "error", message: "An error occurred" }); + } finally { + setProxyLoading(false); + } + }; - const handlePasswordChange = async (e) => { - e.preventDefault(); - if (passwords.new !== passwords.confirm) { - setPassStatus({ type: "error", message: "Passwords do not match" }); - return; - } + const handlePasswordChange = async (e) => { + e.preventDefault(); + if (passwords.new !== passwords.confirm) { + setPassStatus({ type: "error", message: "Passwords do not match" }); + return; + } - setPassLoading(true); - setPassStatus({ type: "", message: "" }); + setPassLoading(true); + setPassStatus({ type: "", message: "" }); - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - currentPassword: passwords.current, - newPassword: passwords.new, - }), - }); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + currentPassword: passwords.current, + newPassword: passwords.new, + }), + }); - const data = await res.json(); + const data = await res.json(); - if (res.ok) { - setPassStatus({ - type: "success", - message: "Password updated successfully", - }); - setPasswords({ current: "", new: "", confirm: "" }); - } else { - setPassStatus({ - type: "error", - message: data.error || "Failed to update password", - }); - } - } catch (err) { - setPassStatus({ type: "error", message: "An error occurred" }); - } finally { - setPassLoading(false); - } - }; + if (res.ok) { + setPassStatus({ type: "success", message: "Password updated successfully" }); + setPasswords({ current: "", new: "", confirm: "" }); + } else { + setPassStatus({ type: "error", message: data.error || "Failed to update password" }); + } + } catch (err) { + setPassStatus({ type: "error", message: "An error occurred" }); + } finally { + setPassLoading(false); + } + }; - const updateFallbackStrategy = async (strategy) => { - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ fallbackStrategy: strategy }), - }); - if (res.ok) { - setSettings((prev) => ({ ...prev, fallbackStrategy: strategy })); - } - } catch (err) { - console.error("Failed to update settings:", err); - } - }; + const updateFallbackStrategy = async (strategy) => { + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fallbackStrategy: strategy }), + }); + if (res.ok) { + setSettings(prev => ({ ...prev, fallbackStrategy: strategy })); + } + } catch (err) { + console.error("Failed to update settings:", err); + } + }; - const updateComboStrategy = async (strategy) => { - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ comboStrategy: strategy }), - }); - if (res.ok) { - setSettings((prev) => ({ ...prev, comboStrategy: strategy })); - } - } catch (err) { - console.error("Failed to update combo strategy:", err); - } - }; + const updateComboStrategy = async (strategy) => { + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ comboStrategy: strategy }), + }); + if (res.ok) { + setSettings(prev => ({ ...prev, comboStrategy: strategy })); + } + } catch (err) { + console.error("Failed to update combo strategy:", err); + } + }; - const handleGlobalTimeoutChange = async (e) => { - const raw = e.target.value.replace(/[^0-9]/g, ""); - const numTimeout = parseInt(raw, 10); - // Enforce a sane minimum (1s) so a stray "60" never becomes a 60ms - // connect timeout — same guard as the per-provider timeout input. - const patchValue = - raw !== "" && Number.isFinite(numTimeout) && numTimeout > 0 - ? Math.max(1000, numTimeout) - : null; + const updateStickyLimit = async (limit) => { + const numLimit = parseInt(limit); + if (isNaN(numLimit) || numLimit < 1) return; - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ defaultTimeoutMs: patchValue }), - }); - if (res.ok) { - setSettings((prev) => ({ ...prev, defaultTimeoutMs: patchValue })); - } - } catch (err) { - console.error("Failed to update default timeout:", err); - } - }; + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ stickyRoundRobinLimit: numLimit }), + }); + if (res.ok) { + setSettings(prev => ({ ...prev, stickyRoundRobinLimit: numLimit })); + } + } catch (err) { + console.error("Failed to update sticky limit:", err); + } + }; - const updateStickyLimit = async (limit) => { - const numLimit = parseInt(limit); - if (isNaN(numLimit) || numLimit < 1) return; + const updateComboStickyLimit = async (limit) => { + const numLimit = parseInt(limit); + if (isNaN(numLimit) || numLimit < 1) return; - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ stickyRoundRobinLimit: numLimit }), - }); - if (res.ok) { - setSettings((prev) => ({ ...prev, stickyRoundRobinLimit: numLimit })); - } - } catch (err) { - console.error("Failed to update sticky limit:", err); - } - }; + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ comboStickyRoundRobinLimit: numLimit }), + }); + if (res.ok) { + setSettings(prev => ({ ...prev, comboStickyRoundRobinLimit: numLimit })); + } + } catch (err) { + console.error("Failed to update combo sticky limit:", err); + } + }; - const updateComboStickyLimit = async (limit) => { - const numLimit = parseInt(limit); - if (isNaN(numLimit) || numLimit < 1) return; + const updateRequireLogin = async (requireLogin) => { + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requireLogin }), + }); + if (res.ok) { + setSettings(prev => ({ ...prev, requireLogin })); + } + } catch (err) { + console.error("Failed to update require login:", err); + } + }; - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ comboStickyRoundRobinLimit: numLimit }), - }); - if (res.ok) { - setSettings((prev) => ({ - ...prev, - comboStickyRoundRobinLimit: numLimit, - })); - } - } catch (err) { - console.error("Failed to update combo sticky limit:", err); - } - }; + const updateOidcForm = (field, value) => { + setOidcForm((prev) => ({ ...prev, [field]: value })); + }; - const updateRequireLogin = async (requireLogin) => { - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ requireLogin }), - }); - if (res.ok) { - setSettings((prev) => ({ ...prev, requireLogin })); - } - } catch (err) { - console.error("Failed to update require login:", err); - } - }; + const saveOidcSettings = async (authMode = oidcForm.authMode || "password") => { + const issuerUrl = oidcForm.oidcIssuerUrl.trim(); + const clientId = oidcForm.oidcClientId.trim(); + const scopes = oidcForm.oidcScopes.trim(); + const loginLabel = oidcForm.oidcLoginLabel.trim(); + const secret = oidcClientSecret.trim(); - const updateOidcForm = (field, value) => { - setOidcForm((prev) => ({ ...prev, [field]: value })); - }; + if (authMode !== "password" && (!issuerUrl || !clientId || !secret) && !settings.oidcConfigured) { + setOidcStatus({ type: "error", message: "Issuer URL, client ID, and client secret are required to enable OIDC." }); + return; + } - const saveOidcSettings = async ( - authMode = oidcForm.authMode || "password", - ) => { - const issuerUrl = oidcForm.oidcIssuerUrl.trim(); - const clientId = oidcForm.oidcClientId.trim(); - const scopes = oidcForm.oidcScopes.trim(); - const loginLabel = oidcForm.oidcLoginLabel.trim(); - const secret = oidcClientSecret.trim(); + setOidcLoading(true); + setOidcStatus({ type: "", message: "" }); + setOidcTestStatus({ type: "", message: "" }); - if ( - authMode !== "password" && - (!issuerUrl || !clientId || !secret) && - !settings.oidcConfigured - ) { - setOidcStatus({ - type: "error", - message: - "Issuer URL, client ID, and client secret are required to enable OIDC.", - }); - return; - } + try { + const payload = { + authMode, + ssoType: "oidc", + oidcIssuerUrl: issuerUrl, + oidcClientId: clientId, + oidcScopes: scopes || "openid profile email", + oidcLoginLabel: loginLabel || "Sign in with OIDC", + }; + if (secret) { + payload.oidcClientSecret = secret; + } - setOidcLoading(true); - setOidcStatus({ type: "", message: "" }); - setOidcTestStatus({ type: "", message: "" }); + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); - try { - const payload = { - authMode, - oidcIssuerUrl: issuerUrl, - oidcClientId: clientId, - oidcScopes: scopes || "openid profile email", - oidcLoginLabel: loginLabel || "Sign in with OIDC", - }; - if (secret) { - payload.oidcClientSecret = secret; - } + const data = await res.json(); + if (res.ok) { + setSettings((prev) => ({ ...prev, ...data })); + setOidcForm({ + authMode: data?.authMode || authMode, + oidcIssuerUrl: data?.oidcIssuerUrl || issuerUrl, + oidcClientId: data?.oidcClientId || clientId, + oidcScopes: data?.oidcScopes || scopes || "openid profile email", + oidcLoginLabel: data?.oidcLoginLabel || loginLabel || "Sign in with OIDC", + }); + setOidcClientSecret(""); + setOidcStatus({ + type: "success", + message: + authMode === "oidc" + ? "OIDC login enabled" + : authMode === "both" + ? "Password and OIDC login enabled" + : "OIDC settings saved", + }); + } else { + setOidcStatus({ type: "error", message: data.error || "Failed to save OIDC settings" }); + } + } catch (err) { + setOidcStatus({ type: "error", message: "An error occurred" }); + } finally { + setOidcLoading(false); + } + }; - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); + const testOidcConnection = async () => { + const issuerUrl = oidcForm.oidcIssuerUrl.trim(); + const clientId = oidcForm.oidcClientId.trim(); + const scopes = oidcForm.oidcScopes.trim(); + const secret = oidcClientSecret.trim(); - const data = await res.json(); - if (res.ok) { - setSettings((prev) => ({ ...prev, ...data })); - setOidcForm({ - authMode: data?.authMode || authMode, - oidcIssuerUrl: data?.oidcIssuerUrl || issuerUrl, - oidcClientId: data?.oidcClientId || clientId, - oidcScopes: data?.oidcScopes || scopes || "openid profile email", - oidcLoginLabel: - data?.oidcLoginLabel || loginLabel || "Sign in with OIDC", - }); - setOidcClientSecret(""); - setOidcStatus({ - type: "success", - message: - authMode === "oidc" - ? "OIDC login enabled" - : authMode === "both" - ? "Password and OIDC login enabled" - : "OIDC settings saved", - }); - } else { - setOidcStatus({ - type: "error", - message: data.error || "Failed to save OIDC settings", - }); - } - } catch (err) { - setOidcStatus({ type: "error", message: "An error occurred" }); - } finally { - setOidcLoading(false); - } - }; + if (!issuerUrl || !clientId) { + setOidcTestStatus({ type: "error", message: "Issuer URL and client ID are required to test the connection." }); + return; + } - const testOidcConnection = async () => { - const issuerUrl = oidcForm.oidcIssuerUrl.trim(); - const clientId = oidcForm.oidcClientId.trim(); - const scopes = oidcForm.oidcScopes.trim(); - const secret = oidcClientSecret.trim(); + setOidcTestLoading(true); + setOidcStatus({ type: "", message: "" }); + setOidcTestStatus({ type: "", message: "" }); - if (!issuerUrl || !clientId) { - setOidcTestStatus({ - type: "error", - message: - "Issuer URL and client ID are required to test the connection.", - }); - return; - } + try { + const saveRes = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + authMode: oidcForm.authMode || settings.authMode || "password", + oidcIssuerUrl: issuerUrl, + oidcClientId: clientId, + oidcScopes: scopes || "openid profile email", + oidcLoginLabel: oidcForm.oidcLoginLabel.trim() || "Sign in with OIDC", + ...(secret ? { oidcClientSecret: secret } : {}), + }), + }); - setOidcTestLoading(true); - setOidcStatus({ type: "", message: "" }); - setOidcTestStatus({ type: "", message: "" }); + const saved = await saveRes.json().catch(() => ({})); + if (!saveRes.ok) { + setOidcTestStatus({ + type: "error", + message: saved.error || "Failed to save OIDC settings before testing", + }); + return; + } - try { - const saveRes = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - authMode: oidcForm.authMode || settings.authMode || "password", - oidcIssuerUrl: issuerUrl, - oidcClientId: clientId, - oidcScopes: scopes || "openid profile email", - oidcLoginLabel: oidcForm.oidcLoginLabel.trim() || "Sign in with OIDC", - ...(secret ? { oidcClientSecret: secret } : {}), - }), - }); + const res = await fetch("/api/auth/oidc/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + issuerUrl: saved.oidcIssuerUrl || issuerUrl, + clientId: saved.oidcClientId || clientId, + scopes: saved.oidcScopes || scopes || "openid profile email", + }), + }); - const saved = await saveRes.json().catch(() => ({})); - if (!saveRes.ok) { - setOidcTestStatus({ - type: "error", - message: saved.error || "Failed to save OIDC settings before testing", - }); - return; - } + const data = await res.json().catch(() => ({})); + if (res.ok && data?.ok) { + const statusMessage = data.clientSecretTested + ? data.clientSecretValid === true + ? `Connection OK. Discovery loaded from ${data.issuerUrl}. Client secret validated too.` + : `Connection OK. Discovery loaded from ${data.issuerUrl}. Client secret was not checked.` + : `Connection OK. Discovery loaded from ${data.issuerUrl}.`; + setOidcTestStatus({ + type: "success", + message: statusMessage, + }); + } else { + setOidcTestStatus({ type: "error", message: data.error || "OIDC connection test failed" }); + } + } catch (err) { + setOidcTestStatus({ type: "error", message: "An error occurred" }); + } finally { + setOidcTestLoading(false); + } + }; - const res = await fetch("/api/auth/oidc/test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - issuerUrl: saved.oidcIssuerUrl || issuerUrl, - clientId: saved.oidcClientId || clientId, - scopes: saved.oidcScopes || scopes || "openid profile email", - }), - }); + const updateSamlForm = (field, value) => { + setSamlForm((prev) => ({ ...prev, [field]: value })); + }; - const data = await res.json().catch(() => ({})); - if (res.ok && data?.ok) { - const statusMessage = data.clientSecretTested - ? data.clientSecretValid === true - ? `Connection OK. Discovery loaded from ${data.issuerUrl}. Client secret validated too.` - : `Connection OK. Discovery loaded from ${data.issuerUrl}. Client secret was not checked.` - : `Connection OK. Discovery loaded from ${data.issuerUrl}.`; - setOidcTestStatus({ - type: "success", - message: statusMessage, - }); - } else { - setOidcTestStatus({ - type: "error", - message: data.error || "OIDC connection test failed", - }); - } - } catch (err) { - setOidcTestStatus({ type: "error", message: "An error occurred" }); - } finally { - setOidcTestLoading(false); - } - }; + const handleIdpMetadataUpload = (event) => { + const file = event.target.files?.[0]; + if (idpMetadataFileRef.current) idpMetadataFileRef.current.value = ""; + if (!file) return; - const updateObservabilityEnabled = async (enabled) => { - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enableObservability: enabled }), - }); - if (res.ok) { - setSettings((prev) => ({ ...prev, enableObservability: enabled })); - } - } catch (err) { - console.error("Failed to update enableObservability:", err); - } - }; + const reader = new FileReader(); + reader.onload = (e) => { + try { + const xmlText = e.target?.result || ""; + const parser = new DOMParser(); + const doc = parser.parseFromString(xmlText, "text/xml"); + const parserError = doc.querySelector("parsererror"); + if (parserError) { + setSamlStatus({ type: "error", message: "Unable to parse valid SAML IdP metadata from XML file" }); + return; + } - const updateLogLevel = async (logLevel) => { - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ logLevel }), - }); - if (res.ok) { - setSettings((prev) => ({ ...prev, logLevel })); - } - } catch (err) { - console.error("Failed to update logLevel:", err); - } - }; + const entityID = doc.documentElement.getAttribute("entityID") || ""; + const ssoNodes = Array.from(doc.querySelectorAll("SingleSignOnService, *|SingleSignOnService")); + let ssoUrl = ""; + for (const node of ssoNodes) { + const binding = node.getAttribute("Binding") || ""; + const location = node.getAttribute("Location") || ""; + if (location) { + ssoUrl = location; + if (binding.includes("HTTP-Redirect")) break; + } + } - const updateShowOnlyComboModels = async (showOnlyComboModels) => { - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ showOnlyComboModels }), - }); - if (res.ok) { - setSettings((prev) => ({ ...prev, showOnlyComboModels })); - } - } catch (err) { - console.error("Failed to update showOnlyComboModels:", err); - } - }; + const certNodes = Array.from(doc.querySelectorAll("X509Certificate, *|X509Certificate")); + let certStr = ""; + if (certNodes.length > 0) { + certStr = certNodes[0].textContent.trim(); + } - const reloadSettings = async () => { - try { - const res = await fetch("/api/settings"); - if (!res.ok) return; - const data = await res.json(); - setSettings(data); - } catch (err) { - console.error("Failed to reload settings:", err); - } - }; + setSamlForm((prev) => ({ + ...prev, + samlEntryPoint: ssoUrl || prev.samlEntryPoint, + samlIssuer: prev.samlIssuer || "urn:9router:sp", + samlCert: certStr || prev.samlCert, + })); - const handleExportDatabase = async (password) => { - setDbLoading(true); - setDbStatus({ type: "", message: "" }); - try { - const res = await fetch("/api/settings/database", { - headers: { "x-9r-password": password }, - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || "Failed to export database"); - } + setSamlStatus({ + type: "success", + message: `IdP Metadata imported! (SSO URL: ${ssoUrl ? "found" : "not found"}, EntityID: ${entityID ? "found" : "not found"}, Cert: ${certStr ? "found" : "not found"})`, + }); + } catch (err) { + setSamlStatus({ type: "error", message: "Error reading IdP Metadata XML file" }); + } + }; + reader.readAsText(file); + }; - const payload = await res.json(); - const content = JSON.stringify(payload, null, 2); - const blob = new Blob([content], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - const stamp = new Date().toISOString().replace(/[.:]/g, "-"); - anchor.href = url; - anchor.download = `9router-backup-${stamp}.json`; - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - URL.revokeObjectURL(url); + const handleCertFileUpload = (event) => { + const file = event.target.files?.[0]; + if (certFileRef.current) certFileRef.current.value = ""; + if (!file) return; - setDbStatus({ type: "success", message: "Database backup downloaded" }); - } catch (err) { - setDbStatus({ - type: "error", - message: err.message || "Failed to export database", - }); - } finally { - setDbLoading(false); - } - }; + const reader = new FileReader(); + reader.onload = (e) => { + const text = e.target?.result || ""; + setSamlForm((prev) => ({ ...prev, samlCert: text.trim() })); + setSamlStatus({ type: "success", message: "Certificate file loaded into configuration." }); + }; + reader.readAsText(file); + }; - const handleImportDatabase = (event) => { - const file = event.target.files?.[0]; - if (importFileRef.current) importFileRef.current.value = ""; - if (!file) return; - pendingImportRef.current = file; - setDbStatus({ type: "", message: "" }); - setDbAuth({ open: true, mode: "import", password: "" }); - }; + const saveSamlSettings = async (targetAuthMode = oidcForm.authMode || "password") => { + setSamlLoading(true); + setSamlStatus({ type: "", message: "" }); + setSamlTestStatus({ type: "", message: "" }); - const runImportDatabase = async (password) => { - const file = pendingImportRef.current; - if (!file) return; - setDbLoading(true); - try { - const raw = await file.text(); - const payload = JSON.parse(raw); + try { + const payload = { + authMode: targetAuthMode, + ssoType: "saml", + samlEntryPoint: samlForm.samlEntryPoint.trim(), + samlIssuer: samlForm.samlIssuer.trim() || "urn:9router:sp", + samlCert: samlForm.samlCert.trim(), + samlLoginLabel: samlForm.samlLoginLabel.trim() || "Sign in with SAML SSO", + samlAttributeEmail: samlForm.samlAttributeEmail.trim() || "email", + samlAttributeName: samlForm.samlAttributeName.trim() || "name", + }; - const res = await fetch("/api/settings/database", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...payload, password }), - }); + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); - const data = await res.json().catch(() => ({})); - if (!res.ok) { - throw new Error(data.error || "Failed to import database"); - } + const data = await res.json(); + if (res.ok) { + setSettings((prev) => ({ ...prev, ...data })); + setSamlForm({ + samlEntryPoint: data?.samlEntryPoint || payload.samlEntryPoint, + samlIssuer: data?.samlIssuer || payload.samlIssuer, + samlCert: data?.samlCert || payload.samlCert, + samlLoginLabel: data?.samlLoginLabel || payload.samlLoginLabel, + samlAttributeEmail: data?.samlAttributeEmail || payload.samlAttributeEmail, + samlAttributeName: data?.samlAttributeName || payload.samlAttributeName, + }); + setSamlStatus({ + type: "success", + message: + targetAuthMode === "sso" || targetAuthMode === "saml" + ? "SAML SSO login enabled" + : targetAuthMode === "both" + ? "Password and SAML SSO login enabled" + : "SAML 2.0 settings saved", + }); + } else { + setSamlStatus({ type: "error", message: data.error || "Failed to save SAML settings" }); + } + } catch { + setSamlStatus({ type: "error", message: "An error occurred while saving SAML settings" }); + } finally { + setSamlLoading(false); + } + }; - await reloadSettings(); - setDbStatus({ - type: "success", - message: "Database imported successfully", - }); - } catch (err) { - setDbStatus({ - type: "error", - message: err.message || "Invalid backup file", - }); - } finally { - pendingImportRef.current = null; - setDbLoading(false); - } - }; + const testSamlConnection = async () => { + setSamlTestLoading(true); + setSamlStatus({ type: "", message: "" }); + setSamlTestStatus({ type: "", message: "" }); - // Confirm password modal, then run export or import. - const handleDbAuthConfirm = async () => { - const { mode, password } = dbAuth; - setDbAuth({ open: false, mode: "", password: "" }); - if (mode === "export") await handleExportDatabase(password); - else if (mode === "import") await runImportDatabase(password); - }; + try { + const res = await fetch("/api/auth/saml/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + samlEntryPoint: samlForm.samlEntryPoint.trim(), + samlIssuer: samlForm.samlIssuer.trim(), + samlCert: samlForm.samlCert.trim(), + }), + }); - const observabilityEnabled = settings.enableObservability === true; + const data = await res.json(); + if (res.ok && data.ok) { + setSamlTestStatus({ type: "success", message: data.message || "SAML configuration verified!" }); + } else { + setSamlTestStatus({ type: "error", message: data.error || "SAML configuration test failed" }); + } + } catch { + setSamlTestStatus({ type: "error", message: "An error occurred while testing SAML configuration" }); + } finally { + setSamlTestLoading(false); + } + }; - const handleShutdown = async () => { - setIsShuttingDown(true); - try { - await fetch("/api/version/shutdown", { method: "POST" }); - } catch (e) { - // Expected to fail as server shuts down; ignore error - } - setIsShuttingDown(false); - setShutdownOpen(false); - }; + const updateObservabilityEnabled = async (enabled) => { + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enableObservability: enabled }), + }); + if (res.ok) { + setSettings(prev => ({ ...prev, enableObservability: enabled })); + } + } catch (err) { + console.error("Failed to update enableObservability:", err); + } + }; - const handleLogout = async () => { - try { - const res = await fetch("/api/auth/logout", { method: "POST" }); - if (res.ok) { - window.location.assign("/login"); - } - } catch (err) { - console.error("Failed to logout:", err); - } - }; + const reloadSettings = async () => { + try { + const res = await fetch("/api/settings"); + if (!res.ok) return; + const data = await res.json(); + setSettings(data); + } catch (err) { + console.error("Failed to reload settings:", err); + } + }; - return ( -
-
- {/* Local Mode Info */} - -
-
-
- - computer - -
-
-

Local Mode

-

- Running on your machine -

-
-
-
- {["light", "dark", "system"].map((option) => ( - - ))} -
-
-
-
-
-

- Database Location -

-

- ~/.9router/db/data.sqlite -

-
-
-
- - - -
- {dbStatus.message && ( -

- {dbStatus.message} -

- )} -
-
+ const handleExportDatabase = async (password) => { + setDbLoading(true); + setDbStatus({ type: "", message: "" }); + try { + const res = await fetch("/api/settings/database", { + headers: { "x-9r-password": password }, + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || "Failed to export database"); + } - {/* Language */} - -
-
- - language - -
-

Language

-
- -
+ const payload = await res.json(); + const content = JSON.stringify(payload, null, 2); + const blob = new Blob([content], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + const stamp = new Date().toISOString().replace(/[.:]/g, "-"); + anchor.href = url; + anchor.download = `9router-backup-${stamp}.json`; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); - {/* Security */} - -
-
- - shield - -
-

Security

-
-
-
-
-

- Require login -

-

- When ON, dashboard requires password. When OFF, access without - login. -

-
- updateRequireLogin(!settings.requireLogin)} - disabled={loading} - /> -
- {settings.requireLogin === true && ( -
- {settings.hasPassword && ( -
- - - setPasswords({ ...passwords, current: e.target.value }) - } - required - /> -
- )} - {/* {!settings.hasPassword && ( + setDbStatus({ type: "success", message: "Database backup downloaded" }); + } catch (err) { + setDbStatus({ type: "error", message: err.message || "Failed to export database" }); + } finally { + setDbLoading(false); + } + }; + + const handleImportDatabase = (event) => { + const file = event.target.files?.[0]; + if (importFileRef.current) importFileRef.current.value = ""; + if (!file) return; + pendingImportRef.current = file; + setDbStatus({ type: "", message: "" }); + setDbAuth({ open: true, mode: "import", password: "" }); + }; + + const runImportDatabase = async (password) => { + const file = pendingImportRef.current; + if (!file) return; + setDbLoading(true); + try { + const raw = await file.text(); + const payload = JSON.parse(raw); + + const res = await fetch("/api/settings/database", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...payload, password }), + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.error || "Failed to import database"); + } + + await reloadSettings(); + setDbStatus({ type: "success", message: "Database imported successfully" }); + } catch (err) { + setDbStatus({ type: "error", message: err.message || "Invalid backup file" }); + } finally { + pendingImportRef.current = null; + setDbLoading(false); + } + }; + + // Confirm password modal, then run export or import. + const handleDbAuthConfirm = async () => { + const { mode, password } = dbAuth; + setDbAuth({ open: false, mode: "", password: "" }); + if (mode === "export") await handleExportDatabase(password); + else if (mode === "import") await runImportDatabase(password); + }; + + const observabilityEnabled = settings.enableObservability === true; + + const handleShutdown = async () => { + setIsShuttingDown(true); + try { + await fetch("/api/version/shutdown", { method: "POST" }); + } catch (e) { + // Expected to fail as server shuts down; ignore error + } + setIsShuttingDown(false); + setShutdownOpen(false); + }; + + const handleLogout = async () => { + try { + const res = await fetch("/api/auth/logout", { method: "POST" }); + if (res.ok) { + window.location.assign("/login"); + } + } catch (err) { + console.error("Failed to logout:", err); + } + }; + + return ( +
+
+ {/* Local Mode Info */} + +
+
+
+ computer +
+
+

Local Mode

+

Running on your machine

+
+
+
+ {["light", "dark", "system"].map((option) => ( + + ))} +
+
+
+
+
+

Database Location

+

~/.9router/db/data.sqlite

+
+
+
+ + + +
+ {dbStatus.message && ( +

+ {dbStatus.message} +

+ )} +
+
+ + {/* Language */} + +
+
+ language +
+

Language

+
+ +
+ + {/* Security */} + +
+
+ shield +
+

Security

+
+
+
+
+

Require login

+

+ When ON, dashboard requires password. When OFF, access without login. +

+
+ updateRequireLogin(!settings.requireLogin)} + disabled={loading} + /> +
+ {settings.requireLogin === true && ( + + {settings.hasPassword && ( +
+ + setPasswords({ ...passwords, current: e.target.value })} + required + /> +
+ )} + {/* {!settings.hasPassword && (

Setting password for the first time. Leave current password empty or use default: 123456

)} */} -
-
- - - setPasswords({ ...passwords, new: e.target.value }) - } - required - /> -
-
- - - setPasswords({ ...passwords, confirm: e.target.value }) - } - required - /> -
-
+
+
+ + setPasswords({ ...passwords, new: e.target.value })} + required + /> +
+
+ + setPasswords({ ...passwords, confirm: e.target.value })} + required + /> +
+
- {passStatus.message && ( -

- {passStatus.message} -

- )} + {passStatus.message && ( +

+ {passStatus.message} +

+ )} -
- -
- - )} -
-
+
+ +
+ + )} +
+ - {/* OIDC */} - - - {oidcExpanded && ( -
-

- Use Authentik or any OIDC provider to sign in to the dashboard. - You can enable password-only, OIDC-only, or both for the - dashboard; model API access still uses API keys. -

+ {/* Single Sign-On (SSO) */} + + + {oidcExpanded && ( +
+

+ Configure enterprise Single Sign-On (SSO) for dashboard access using SAML 2.0 or OIDC. +

-
- -
- {[ - { - value: "password", - title: "Password only", - desc: "Keep the legacy password login.", - }, - { - value: "oidc", - title: "OIDC only", - desc: "Require OIDC for dashboard access.", - }, - { - value: "both", - title: "Both", - desc: "Allow either password or OIDC.", - }, - ].map((option) => { - const active = oidcForm.authMode === option.value; - return ( - - ); - })} -
-
+ {/* SSO Protocol Switcher Tabs */} +
+ +
+ + +
+
-
-
- - - updateOidcForm("oidcIssuerUrl", e.target.value) - } - disabled={loading || oidcLoading} - /> -
+ {/* Auth Mode selection */} +
+ +
+ {[ + { + value: "password", + title: "Password only", + desc: "Keep legacy password login.", + }, + { + value: "sso", + title: `${ssoTypeTab === "saml" ? "SAML" : "OIDC"} only`, + desc: "Require SSO for dashboard access.", + }, + { + value: "both", + title: "Both", + desc: "Allow password or SSO login.", + }, + ].map((option) => { + const currentMode = oidcForm.authMode; + const active = + option.value === "password" + ? currentMode === "password" + : option.value === "sso" + ? currentMode === "sso" || currentMode === "saml" || currentMode === "oidc" + : currentMode === "both"; + return ( + + ); + })} +
+
-
- - - updateOidcForm("oidcClientId", e.target.value) - } - disabled={loading || oidcLoading} - /> -
+ {ssoTypeTab === "saml" ? ( + /* SAML Configuration Panel */ +
+ {/* IdP Setup Guidelines Banner & Collapsible Drawer */} +
+ -
- - setOidcClientSecret(e.target.value)} - disabled={loading || oidcLoading} - /> -

- This value is write-only after saving. -

-
+ {showSamlGuide && ( +
+
+

🔑 Required Service Provider (SP) Values for your IdP Setup:

+
    +
  • + Assertion Consumer Service (ACS) URL:{" "} + {samlAcsUrl} +
  • +
  • + SP Entity ID / Audience URI:{" "} + {samlForm.samlIssuer || "urn:9router:sp"} +
  • +
  • + NameID Format:{" "} + EmailAddress or Unspecified +
  • +
+
-
- - - updateOidcForm("oidcScopes", e.target.value) - } - disabled={loading || oidcLoading} - /> -
+
+
+

+ ☁️ AWS IAM Identity Center +

+
    +
  1. Applications → Add application → Select Add custom SAML 2.0 application.
  2. +
  3. Set Application ACS URL to {samlAcsUrl}.
  4. +
  5. Set Application SAML audience to {samlForm.samlIssuer || "urn:9router:sp"}.
  6. +
  7. Under Attribute mappings, map Subject or email to ${`{user:email}`}.
  8. +
  9. Download IAM Identity Center SAML metadata XML file and use 1-Click Import below!
  10. +
+
-
- - - updateOidcForm("oidcLoginLabel", e.target.value) - } - disabled={loading || oidcLoading} - /> -
-
+
+

+ 🔷 Microsoft Entra ID (Azure AD) +

+
    +
  1. Enterprise Applications → New application → Create your own application.
  2. +
  3. Select Single sign-on → SAML.
  4. +
  5. Identifier (Entity ID): {samlForm.samlIssuer || "urn:9router:sp"}
  6. +
  7. Reply URL (ACS): {samlAcsUrl}
  8. +
  9. Download Federation Metadata XML and import or copy X.509 Certificate.
  10. +
+
-
-

Redirect URI

- - {oidcRedirectUri} - -
+
+

+ 🟢 Okta / Auth0 +

+
    +
  1. Applications → Create App Integration → Select SAML 2.0.
  2. +
  3. Single Sign-On URL: {samlAcsUrl}
  4. +
  5. Audience URI (SP Entity ID): {samlForm.samlIssuer || "urn:9router:sp"}
  6. +
  7. Name ID format: EmailAddress.
  8. +
  9. Download Identity Provider metadata XML or copy the X.509 cert.
  10. +
+
-
- - -
+
+

+ 🛡️ Keycloak / Authentik +

+
    +
  1. Clients → Create client → Select SAML.
  2. +
  3. Client ID: {samlForm.samlIssuer || "urn:9router:sp"}
  4. +
  5. Master SAML Processing URL: {samlAcsUrl}
  6. +
  7. Export SAML Descriptor XML or copy IDP Certificate PEM.
  8. +
+
+
+
+ )} +
- {oidcTestStatus.message && ( -

- {oidcTestStatus.message} -

- )} + {/* Quick Import Card */} +
+
+

1-Click IdP Metadata XML Import

+

Auto-fill SSO URL, Issuer & Cert from XML metadata

+
+ + +
- {oidcStatus.message && ( -

- {oidcStatus.message} -

- )} +
+
+ + updateSamlForm("samlEntryPoint", e.target.value)} + disabled={loading || samlLoading} + /> +
- {settings.authMode === "oidc" && ( -

- OIDC login is currently active. Password login is disabled - until you switch back. -

- )} +
+ + updateSamlForm("samlIssuer", e.target.value)} + disabled={loading || samlLoading} + /> +
- {settings.authMode === "both" && ( -

- Password and OIDC login are both active. -

- )} -
- )} - +
+
+ + + +
+