Merge remote-tracking branch 'upstream/master'

# Conflicts:
#	.gitignore
#	open-sse/handlers/chatCore.js
This commit is contained in:
decolua
2026-07-16 11:59:46 +07:00
162 changed files with 9368 additions and 1287 deletions
+3
View File
@@ -34,5 +34,8 @@ NEXT_PUBLIC_CLOUD_URL=https://9router.com
# ALL_PROXY=socks5://127.0.0.1:7890
# NO_PROXY=localhost,127.0.0.1
# Optional SearXNG endpoint for the built-in unauthenticated web-search provider.
# SEARXNG_URL=http://searxng:8080/search
# Currently unused by application runtime (kept as reference)
# INSTANCE_NAME=9router
+1
View File
@@ -85,3 +85,4 @@ graphify-out/*
.script/
.codegraph/
.PR/
.next-analyze/*
+53
View File
@@ -1,3 +1,56 @@
# v0.5.30 (2026-07-10)
## Features
- **Perplexity**: add Agent API provider (#2492)
- **Grok CLI**: add Grok CLI / Grok Build provider with OAuth device-code flow (#2502)
- **Featherless**: add OpenAI-compatible provider presets
- **SearXNG**: configure endpoint via SEARXNG_URL env (#2499)
- **Providers**: add max thinking level for gpt-5.6-sol (#2500)
- **Headroom**: add extras detection and install UI (#2403)
- **Headroom**: activate/uninstall extras + fix interpreter detection
- **PXPipe**: PXPIPE token saver — multimodal prompt compression (#2465)
- **Proxy-Pools**: auto-rotate strategy for no-auth providers (#2409)
## Fixes
- **Cloudflare-AI**: support accountId in bulk key import (#2449)
- **DB**: backup on schema change, MCP child cleanup, codex models, usage providers OOM
- **Codex**: avoid bare-email OAuth dedup (#2477)
- **CLI**: allow staged app bundle builds (#2479)
- **Headroom**: compress Kiro conversation state (#2488)
- **Gemini-CLI**: raise output floor for thinking and add validated toolConfig (#2486)
- **GitHub**: label Copilot profiles by account identity (#2498)
- **OpenAI-to-Claude**: unwrap bare {function:{…}} tools without parent type (#2473)
- **Translator**: clamp thinking effort max->xhigh for OpenAI format (#2466)
- **RTK/find**: detect and group Windows backslash-style find output (#2448)
- **Codex**: handle fast tier and capacity SSE (#2452)
- **Volcengine-ark**: clamp Kimi max_tokens to 32768 endpoint cap
- **Antigravity**: align provider fingerprint with IDE Desktop 2.1.1 (#2389)
- **Pricing**: update Claude/Codex model rates and add new models
## Improvements
- **i18n(zh-CN)**: complete Chinese translations for all UI strings (#2436)
- **API**: caching for tunnel and version status endpoints
- **Perf**: faster dev startup and lighter bundle
# v0.5.20 (2026-07-07)
## Features
- **Thinking**: per-model thinking level picker on provider page — appends `(level)` suffix to copied model names for forced reasoning effort across all formats (openai, claude, gemini, deepseek, kimi, qwen, zai, minimax, hunyuan, step)
- **RTK**: add JS-native git-log filter (#2423)
- **Caveman**: add targeted upstream-aligned style rules (#2424)
- **i18n**: add Farsi (fa) language support (#2385)
## Fixes
- **Thinking**: strip `(level)` suffix from upstream `body.model` so providers no longer reject requests
- **Translator**: preserve developer instructions in openai-responses conversion (#2434)
- **count_tokens**: count structured Anthropic blocks (#2419)
- **Volcengine-ark**: clamp GLM-5 max_tokens to model output ceiling (#2428)
- **Kimi**: normalize reasoning_effort to backend enum (#2427)
- **Claude**: reconcile max_tokens vs thinking budget and lift per-model ceiling (#2381)
- **Kiro**: deliver system prompt natively, add Opus 4.5/4.7/4.8, tolerate dash version ids (#2366)
- **Headroom**: proxy dashboard through app (#2372)
- **MITM**: recover from stale lock file on server start
# v0.5.18 (2026-07-03)
## Features
+91
View File
@@ -0,0 +1,91 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
9Router (`9router-app`) — a local AI routing gateway + Next.js dashboard. It exposes one OpenAI-compatible endpoint (`/v1/*`) and routes traffic across 40+ upstream providers with format translation, model-combo fallback, multi-account fallback, OAuth/API-key credential management, token refresh, quota/usage tracking, and optional cloud sync.
Two published artifacts live in this one repo:
- The **dashboard + gateway** (root `package.json`, `9router-app`) — the Next.js server that does the actual routing.
- The **CLI launcher** (`cli/`, published to npm as `9router`) — a separate package that installs/starts the server and manages the tray. It has its own `package.json`, version, and build.
The code lives in `src/` (Next.js app + dashboard/compat APIs), `open-sse/` (the provider-agnostic routing/translation engine), `cli/` (the launcher package), and `tests/`.
## Commands
Dashboard/gateway (run from repo root):
```bash
cp .env.example .env
npm install
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev # dev (webpack, port 20127 by default via next dev)
npm run build && PORT=20128 HOSTNAME=0.0.0.0 npm run start # production
```
- Bun variants: `npm run dev:bun` / `build:bun` / `start:bun`.
- Default runtime port is **20128** (dashboard at `/dashboard`, API at `/v1`).
- Lint: `npx eslint .` (config `eslint.config.mjs`, extends `eslint-config-next`).
CLI package (`cli/`):
```bash
npm run cli:pack # build + npm pack from root
cd cli && npm run dev # nodemon watch
```
Tests (vitest, in `tests/`, an **independent** ESM package — not wired into root `npm test`):
```bash
npm install # ROOT deps first — tests import from src/ which needs `open`, `undici`, etc.
cd tests && npm install # then tests' own deps (vitest) → tests/node_modules (allowed by tests/.gitignore)
npx vitest run # all tests; auto-discovers tests/vitest.config.js
npx vitest run unit/capabilities.test.js # single file (path relative to tests/)
```
> The committed `tests/package.json` `test` script hardcodes Unix paths (`NODE_PATH=/tmp/node_modules …`) — a shared-install workaround from upstream. On Windows (or anywhere), ignore it and use the `npx vitest` form above; `vitest.config.js` resolves the `open-sse`/`@/` aliases from the repo root regardless of where vitest lives.
>
> **The suite is NOT expected to be all-green on a plain checkout.** ~938 pass, ~64 fail. Judge regressions with `tests/__baseline__/verify-no-regression.mjs`, not a raw run. Expected red:
> - 26 catalogued in `tests/__baseline__/known-fails.txt` (rtk, oauth-cursor-auto-import, translator-request-normalization, …).
> - `unit/embeddings.cloud.test.js` imports `cloud/src/handlers/embeddings.js` — the `cloud/` worker dir is **not in this repo**, so it always fails here.
> - `unit/xai-oauth-service.test.js` times out (5s) when the xAI endpoint-discovery fetch isn't reachable/mocked.
> - `real/*.real.test.js` make live provider calls — need credentials, skip otherwise.
- `*.real.test.js` under `tests/translator/real/` make live provider calls — skip unless credentials are set.
- Regression baselines: `tests/__baseline__/verify-*.mjs` compare against committed snapshots (providers, aliases, OAuth URLs). Run these after touching provider registry / alias logic.
## Architecture
Two authoritative docs already exist — read them before working in these areas rather than re-deriving:
- `docs/ARCHITECTURE.md` — full system: request lifecycle, combo/account fallback, OAuth + token refresh, cloud sync, data model.
- `open-sse/AGENTS.md` — the routing/translation engine's own conventions and "how to add a provider/executor/translator". **Read this before editing anything under `open-sse/`.**
### Request flow (the thing to understand first)
`src/app/api/v1/*` route (Next rewrite maps `/v1/*` → `/api/v1/*` in `next.config.mjs`)
→ `src/sse/handlers/chat.js` (parse, combo expansion, account-selection loop)
→ `open-sse/handlers/chatCore.js` (detect source format, translate request, dispatch to executor, retry/refresh, stream setup)
→ `open-sse/executors/*` (per-provider upstream call; `default.js` handles any OpenAI-compatible provider)
→ `open-sse/translator/*` (client format ↔ provider format)
→ SSE back to client.
`src/sse/` is the app-side entry glue; `open-sse/` is the provider-agnostic engine (also usable standalone). Cross that boundary consciously.
### Translator engine (`open-sse/translator/`)
- Pivots through **OpenAI as the intermediate format**. A translator registered on an exact `source:target` pair (e.g. `claude:kiro`) runs as a **direct route**, skipping the lossy double-hop. Prefer a direct route for fragile pairs (thinking blocks, tool ids, non-base64 images, `is_error`).
- Translators **self-register** via `register(from, to, reqFn, resFn)` as an import side effect — a new translator file MUST be imported in `open-sse/translator/index.js` or it never runs.
- Never hardcode role/block/model strings — use `open-sse/translator/schema/` and `open-sse/config/` constants. Config-driven and DRY is enforced by convention here.
### Provider registry (`open-sse/providers/registry/*`)
- One file per provider. `providers/registry/index.js` is an **auto-generated** static import list — regenerate it with `scripts/migrate-registry.mjs` / `injectDisplayToRegistry.mjs`, don't hand-edit.
- Add a provider: copy `providers/REGISTRY_TEMPLATE.js`, add models to `config/providerModels.js`. Only add an executor for non-OpenAI-compatible upstreams.
### Persistence — IMPORTANT (ARCHITECTURE.md is stale here)
State is **no longer `db.json`**. It's a SQLite layer under `src/lib/db/` with an adapter fallback chain (`driver.js`): `bun:sqlite` → `better-sqlite3` (optional native dep) → `node:sqlite` (Node ≥22.5) → `sql.js` (pure-JS fallback, always works). `better-sqlite3` is deliberately in `optionalDependencies` so install never fails without build tools.
- `src/lib/localDb.js` is a **backward-compat shim** re-exporting `src/lib/db/index.js`. New code should import from `@/lib/db/index.js`; per-entity logic lives in `src/lib/db/repos/*`. Schema/migrations in `src/lib/db/migrations/`.
- DB file location resolves via `src/lib/db/paths.js` (`DATA_DIR`, else `~/.9router/`).
- Usage/logs (`src/lib/usageDb.js`, `usage.json` + `log.txt`) still live under `~/.9router` and do **not** follow `DATA_DIR`.
### RTK token saver (`open-sse/rtk/`)
Pre-translate hooks that compress `tool_result` content in-place to cut tokens. **Fail-open**: any error returns null and leaves the body untouched — never throw out of them. Skips `is_error`/`status:"error"` results to preserve traces.
## Conventions & gotchas
- Plain JavaScript (ESM), no TypeScript. `@/*` path alias → `src/*` (`jsconfig.json`).
- `custom-server.js` wraps the Next standalone server to derive client IP from the TCP socket and strip attacker-controlled `X-Forwarded-For` — trusting forwarding headers only from a loopback reverse proxy. Preserve this when touching request/IP/rate-limit code.
- Security-sensitive env: `JWT_SECRET` (session cookie), `INITIAL_PASSWORD` (default `123456` — must override), `API_KEY_SECRET`, `MACHINE_ID_SALT`. Full env contract in `.env.example` and ARCHITECTURE.md's env matrix.
- Binary/protobuf upstreams (kiro EventStream, cursor protobuf, commandcode NDJSON) don't round-trip through OpenAI — they're handled inside their own executor, not the translator.
- Versioning: root and `cli/` are versioned independently; changes are logged in `CHANGELOG.md`. Commit style is Conventional Commits (`fix(translator): …`, `feat(...)`).
+132 -73
View File
@@ -13,11 +13,12 @@
[![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router)
[![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE)
<a href="https://trendshift.io/repositories/22628" target="_blank"><img src="https://trendshift.io/api/badge/repositories/22628" alt="decolua%2F9router | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com)
<a href="https://trendshift.io/repositories/22628" target="_blank"><img src="https://trendshift.io/api/badge/repositories/22628" alt="decolua%2F9router | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[🚀 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)
[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md)
</div>
---
@@ -114,6 +115,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run
```
Default URLs:
- Dashboard: `http://localhost:20128/dashboard`
- OpenAI-compatible API: `http://localhost:20128/v1`
@@ -125,6 +127,20 @@ Default URLs:
<table>
<tr>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=X69n5Lm06Yw">
<img src="https://img.youtube.com/vi/X69n5Lm06Yw/maxresdefault.jpg" alt="Tiết kiệm chi phí LLM với 9Router" width="300"/>
</a><br/>
<b>🇻🇳 Tiếng Việt</b><br/>
<sub>Tiết kiệm chi phí LLM cho OpenClaw với 9Router<br/>by <a href="https://www.youtube.com/c/M%C3%ACAIblog">Mì AI</a></sub>
</td>
<td align="center" width="320">
<a href="https://youtu.be/VQAw612S27Y">
<img src="https://img.youtube.com/vi/VQAw612S27Y/maxresdefault.jpg" alt="9Router + Claude Code FREE Unlimited Setup" width="300"/>
</a><br/>
<b>🇵🇰 اردو / हिन्दी</b><br/>
<sub>9Router + Claude Code FREE Unlimited Setup<br/>by <a href="https://www.youtube.com/@BuildAIWithHamid">Build AI With Hamid</a></sub>
</td>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=raEyZPg5xE0">
<img src="https://img.youtube.com/vi/raEyZPg5xE0/maxresdefault.jpg" alt="9Router Setup Tutorial" width="300"/>
@@ -132,12 +148,15 @@ Default URLs:
<b>🇺🇸 English</b><br/>
<sub>9Router + Claude Code FREE Setup<br/>by <a href="https://www.youtube.com/@BuildAIWithHamid">Build AI With Hamid</a></sub>
</td>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=X69n5Lm06Yw">
<img src="https://img.youtube.com/vi/X69n5Lm06Yw/maxresdefault.jpg" alt="Tiết kiệm chi phí LLM với 9Router" width="300"/>
</tr>
<tr>
<td align="center" width="320">
<a href="https://youtu.be/3dF5GIYMrcQ?si=bAyfyiHbARJQAHj_">
<img src="https://img.youtube.com/vi/3dF5GIYMrcQ/hqdefault.jpg" alt="9Router Setup Tutorial" width="300"/>
</a><br/>
<b>🇻🇳 Tiếng Việt</b><br/>
<sub>Tiết kiệm chi phí LLM cho OpenClaw với 9Router<br/>by <a href="https://www.youtube.com/c/M%C3%ACAIblog">Mì AI</a></sub>
<b>🇺🇸 English</b><br/>
<sub>9Router + Claude Code FREE Setup<br/>by <a href="https://www.youtube.com/@BuildAIWithHamid">Build AI With Hamid</a></sub>
</td>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=o3qYCyjrFYg">
@@ -146,8 +165,6 @@ Default URLs:
<b>🇺🇸 English</b><br/>
<sub>Claude Code FREE Forever — Unlimited Models<br/>by <a href="https://www.youtube.com/@BuildAIWithHamid">Build AI With Hamid</a></sub>
</td>
</tr>
<tr>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=Ttpc26m39Dw">
<img src="https://img.youtube.com/vi/Ttpc26m39Dw/maxresdefault.jpg" alt="Claude CLI Free Setup" width="300"/>
@@ -155,7 +172,10 @@ Default URLs:
<b>🇺🇸 English</b><br/>
<sub>Claude CLI Free Setup with 9Router 🚀<br/>by <a href="https://www.youtube.com/@CodeVerseSoban">CodeVerse Soban</a></sub>
</td>
<td align="center" width="320">
</tr>
<tr>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=G-5A_D5Pm6Y">
<img src="https://img.youtube.com/vi/G-5A_D5Pm6Y/maxresdefault.jpg" alt="Cài đặt OpenClaw Free A-Z" width="300"/>
</a><br/>
@@ -169,8 +189,6 @@ Default URLs:
<b>🇺🇸 English</b><br/>
<sub>FREE OpenClaw + Claude Opus 4.6<br/>by <a href="https://www.youtube.com/@BuildAIWithHamid">Build AI With Hamid</a></sub>
</td>
</tr>
<tr>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=CkVZZUSTXAI">
<img src="https://img.youtube.com/vi/CkVZZUSTXAI/mqdefault.jpg" alt="Claude CLI Free Setup" width="300"/>
@@ -178,7 +196,11 @@ Default URLs:
<b>🇮🇩 Indonesia</b><br/>
<sub>Koding 24 Jam Anti Rate Limit! Hemat Token AI 65% | Tutorial Quick Setup 9Router 🚀<br/>by <a href="https://www.youtube.com/@krisswuh">Krisswuh</a></sub>
</td>
<td align="center" width="320">
</tr>
<tr>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=TXGv4eofe1I">
<img src="https://img.youtube.com/vi/TXGv4eofe1I/mqdefault.jpg" alt="Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB" width="300"/>
</a><br/>
@@ -186,6 +208,7 @@ Default URLs:
<sub>Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB<br/>by <a href="https://www.youtube.com/@krisswuh">Krisswuh</a></sub>
</td>
</tr>
</table>
</div>
@@ -408,22 +431,22 @@ Default URLs:
## 💡 Key Features
| Feature | What It Does | Why It Matters |
|---------|--------------|----------------|
| 🚀 **RTK Token Saver** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | Compress tool outputs (`git diff`, `grep`, `ls`, `tree`...) before sending to LLM | Save **20-40% input tokens** per request |
| 🧠 **Headroom Token Saver** ([Headroom](https://github.com/chopratejas/headroom)) | Optional external `/v1/compress` proxy before provider routing | Save more context tokens without changing clients |
| 🪨 **Caveman Mode** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | Inject caveman-speak prompt → LLM replies terse, technical substance preserved | Save **up to 65% output tokens** |
| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | Inject "lazy senior dev" prompt → LLM writes minimal, YAGNI-first code (Lite/Full/Ultra) | **Fewer output tokens, less refactoring** |
| 🎯 **Smart 3-Tier Fallback** | Auto-route: Subscription → Cheap → Free | Never stop coding, zero downtime |
| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown | Maximize subscription value |
| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | Works with any CLI tool |
| 👥 **Multi-Account Support** | Multiple accounts per provider | Load balancing + redundancy |
| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically | No manual re-login needed |
| 🎨 **Custom Combos** | Create unlimited model combinations | Tailor fallback to your needs |
| 📝 **Request Logging** | Debug mode with full request/response logs | Troubleshoot issues easily |
| 💾 **Cloud Sync** | Sync config across devices | Same setup everywhere |
| 📊 **Usage Analytics** | Track tokens, cost, trends over time | Optimize spending |
| 🌐 **Deploy Anywhere** | Localhost, VPS, Docker, Cloudflare Workers | Flexible deployment options |
| Feature | What It Does | Why It Matters |
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------- |
| 🚀 **RTK Token Saver** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | Compress tool outputs (`git diff`, `grep`, `ls`, `tree`...) before sending to LLM | Save **20-40% input tokens** per request |
| 🧠 **Headroom Token Saver** ([Headroom](https://github.com/chopratejas/headroom)) | Optional external `/v1/compress` proxy before provider routing | Save more context tokens without changing clients |
| 🪨 **Caveman Mode** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | Inject caveman-speak prompt → LLM replies terse, technical substance preserved | Save **up to 65% output tokens** |
| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | Inject "lazy senior dev" prompt → LLM writes minimal, YAGNI-first code (Lite/Full/Ultra) | **Fewer output tokens, less refactoring** |
| 🎯 **Smart 3-Tier Fallback** | Auto-route: Subscription → Cheap → Free | Never stop coding, zero downtime |
| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown | Maximize subscription value |
| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | Works with any CLI tool |
| 👥 **Multi-Account Support** | Multiple accounts per provider | Load balancing + redundancy |
| 🔄 **Auto Token Refresh** | OAuth tokens refresh automatically | No manual re-login needed |
| 🎨 **Custom Combos** | Create unlimited model combinations | Tailor fallback to your needs |
| 📝 **Request Logging** | Debug mode with full request/response logs | Troubleshoot issues easily |
| 💾 **Cloud Sync** | Sync config across devices | Same setup everywhere |
| 📊 **Usage Analytics** | Track tokens, cost, trends over time | Optimize spending |
| 🌐 **Deploy Anywhere** | Localhost, VPS, Docker, Cloudflare Workers | Flexible deployment options |
Set `X-9Router-Token-Saver: off` to bypass all token savers for one chat request.
@@ -476,7 +499,7 @@ If Headroom is down or returns an error, 9Router fails open and sends the origin
### 🐴 Ponytail (Lazy Senior Dev)
Ponytail injects a *"lazy senior dev"* system prompt into every request, biasing the LLM toward minimal, YAGNI-first code — deletion over addition, stdlib over new deps, one-liners over abstractions. Adapted from [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail).
Ponytail injects a _"lazy senior dev"_ system prompt into every request, biasing the LLM toward minimal, YAGNI-first code — deletion over addition, stdlib over new deps, one-liners over abstractions. Adapted from [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail).
- **Lite** — Build what's asked, name the lazier alternative.
- **Full** — YAGNI ladder enforced: stdlib → native → existing deps → one-liner → minimal code.
@@ -512,6 +535,7 @@ Combo: "my-coding-stack"
### 🔄 Format Translation
Seamless translation between formats:
- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **Cursor** ↔ **Kiro** ↔ **Vertex** ↔ **Antigravity** ↔ **Ollama** ↔ **OpenAI Responses**
- Your CLI tool sends OpenAI format → 9Router translates → Provider receives native format
- Works with any tool that supports custom OpenAI endpoints
@@ -565,14 +589,14 @@ Seamless translation between formats:
- Optimize your AI spending
> **💡 IMPORTANT - Understanding Dashboard Costs:**
>
> The "cost" displayed in Usage Analytics is **for tracking and comparison purposes only**.
>
> The "cost" displayed in Usage Analytics is **for tracking and comparison purposes only**.
> 9Router itself **never charges** you anything. You only pay providers directly (if using paid services).
>
> **Example:** If your dashboard shows "$290 total cost" while using iFlow models, this represents
>
> **Example:** If your dashboard shows "$290 total cost" while using iFlow models, this represents
> what you would have paid using paid APIs directly. Your actual cost = **$0** (iFlow is free unlimited).
>
> Think of it as a "savings tracker" showing how much you're saving by using free models or
>
> Think of it as a "savings tracker" showing how much you're saving by using free models or
> routing through 9Router!
### 🌐 Deploy Anywhere
@@ -588,19 +612,19 @@ Seamless translation between formats:
## 💰 Pricing at a Glance
| Tier | Provider | Cost | Quota Reset | Best For |
|------|----------|------|-------------|----------|
| **🚀 TOKEN SAVER** | **RTK (built-in)** | **FREE** | Always on | **Save 20-40% tokens on EVERY request** |
| **💳 SUBSCRIPTION** | Claude Code (Pro/Max) | $20-200/mo | 5h + weekly | Already subscribed |
| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users |
| | GitHub Copilot | $10-19/mo | Monthly | GitHub users |
| | Cursor IDE | $20/mo | Monthly | Cursor users |
| **💰 CHEAP** | GLM-5.1 / GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup |
| | MiniMax M2.7 | $0.2/1M | 5-hour rolling | Cheapest option |
| | Kimi K2.5 | $9/mo flat | 10M tokens/mo | Predictable cost |
| **🆓 FREE** | Kiro AI | $0 | Unlimited | Claude 4.5 + GLM-5 + MiniMax free |
| | OpenCode Free | $0 | Unlimited | No auth, auto-fetch models |
| | Vertex AI | $300 credits | New GCP accounts | Gemini 3 Pro + DeepSeek + GLM-5 |
| Tier | Provider | Cost | Quota Reset | Best For |
| ------------------- | --------------------- | ------------ | ---------------- | --------------------------------------- |
| **🚀 TOKEN SAVER** | **RTK (built-in)** | **FREE** | Always on | **Save 20-40% tokens on EVERY request** |
| **💳 SUBSCRIPTION** | Claude Code (Pro/Max) | $20-200/mo | 5h + weekly | Already subscribed |
| | Codex (Plus/Pro) | $20-200/mo | 5h + weekly | OpenAI users |
| | GitHub Copilot | $10-19/mo | Monthly | GitHub users |
| | Cursor IDE | $20/mo | Monthly | Cursor users |
| **💰 CHEAP** | GLM-5.1 / GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup |
| | MiniMax M2.7 | $0.2/1M | 5-hour rolling | Cheapest option |
| | Kimi K2.5 | $9/mo flat | 10M tokens/mo | Predictable cost |
| **🆓 FREE** | Kiro AI | $0 | Unlimited | Claude 4.5 + GLM-5 + MiniMax free |
| | OpenCode Free | $0 | Unlimited | No auth, auto-fetch models |
| | Vertex AI | $300 credits | New GCP accounts | Gemini 3 Pro + DeepSeek + GLM-5 |
**💡 Pro Tip:** RTK + Kiro AI + OpenCode Free combo = **$0 cost + 20-40% token savings**!
@@ -621,6 +645,7 @@ Seamless translation between formats:
The dashboard shows **estimated costs** as if you were using paid APIs directly. This is **not billing** - it's a comparison tool to show your savings.
**Example Scenario:**
```
Dashboard Display:
• Total Requests: 1,662
@@ -634,6 +659,7 @@ Reality Check:
```
**Payment Rules:**
- **Subscription providers** (Claude Code, Codex): Pay them directly via their websites
- **Cheap providers** (GLM, MiniMax): Pay them directly, 9Router just routes
- **FREE providers** (iFlow, Kiro, Qwen): Genuinely free forever, no hidden charges
@@ -648,6 +674,7 @@ Reality Check:
**Problem:** Quota expires unused, rate limits during heavy coding
**Solution:**
```
Combo: "maximize-claude"
1. cc/claude-opus-4-7 (use subscription fully)
@@ -663,6 +690,7 @@ vs. $20 + hitting limits = frustration
**Problem:** Can't afford subscriptions, need reliable AI coding
**Solution:**
```
Combo: "free-forever"
1. kr/claude-sonnet-4.5 (Claude 4.5 free unlimited)
@@ -678,6 +706,7 @@ Quality: Production-ready models + RTK saves 20-40% tokens
**Problem:** Deadlines, can't afford downtime
**Solution:**
```
Combo: "always-on"
1. cc/claude-opus-4-7 (best quality)
@@ -695,6 +724,7 @@ Monthly cost: $20-200 (subscriptions) + $10-20 (backup)
**Problem:** Need AI assistant in messaging apps (WhatsApp, Telegram, Slack...), completely free
**Solution:**
```
Combo: "openclaw-free"
1. kr/claude-sonnet-4.5 (Claude 4.5 free)
@@ -715,6 +745,7 @@ Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
The dashboard tracks your token usage and displays **estimated costs** as if you were using paid APIs directly. This is **not actual billing** - it's a reference to show how much you're saving by using free models or existing subscriptions through 9Router.
**Example:**
- **Dashboard shows:** "$290 total cost"
- **Reality:** You're using iFlow (FREE unlimited)
- **Your actual cost:** **$0.00**
@@ -730,6 +761,7 @@ The cost display is a "savings tracker" to help you understand your usage patter
**No.** 9Router is free, open-source software that runs on your own computer. It never charges you anything.
**You only pay:**
- ✅ **Subscription providers** (Claude Code $20/mo, Codex $20-200/mo) → Pay them directly on their websites
- ✅ **Cheap providers** (GLM, MiniMax) → Pay them directly, 9Router just routes your requests
- ❌ **9Router itself** → **Never charges anything, ever**
@@ -744,6 +776,7 @@ The cost display is a "savings tracker" to help you understand your usage patter
**Yes!** The current FREE providers (Kiro, OpenCode Free, Vertex) are genuinely free with **no hidden charges**.
These are free services offered by those respective companies:
- **Kiro AI**: Free unlimited Claude 4.5 + GLM-5 + MiniMax via AWS Builder ID / Google / GitHub OAuth
- **OpenCode Free**: No-auth passthrough proxy, models auto-fetched from `opencode.ai/zen/v1/models`
- **Vertex AI**: $300 free credits for new Google Cloud accounts (90 days)
@@ -751,6 +784,7 @@ These are free services offered by those respective companies:
9Router just routes your requests to them - there's no "catch" or future billing. They're truly free services, and 9Router makes them easy to use with fallback support.
**Discontinued free tiers (no longer recommended):**
- ❌ **iFlow**: Was free unlimited, now changed to paid (2026)
- ❌ **Qwen Code**: Free OAuth tier discontinued by Alibaba on 2026-04-15
- ❌ **Gemini CLI**: Still works, but using it with non-CLI tools (Claude, Codex, Cursor...) may result in account bans — only use if you stick to Gemini CLI itself
@@ -763,17 +797,21 @@ These are free services offered by those respective companies:
**Free-First Strategy:**
1. **Start with 100% free combo:**
```
1. gc/gemini-3-flash (180K/month free from Google)
2. if/kimi-k2-thinking (unlimited free from iFlow)
3. qw/qwen3-coder-plus (unlimited free from Qwen)
```
**Cost: $0/month**
2. **Add cheap backup** only if you need it:
```
4. glm/glm-4.7 ($0.6/1M tokens)
```
**Additional cost: Only pay for what you actually use**
3. **Use subscription providers last:**
@@ -792,10 +830,12 @@ These are free services offered by those respective companies:
**Scenario:** You're on a coding sprint and blow through your quotas
**Without 9Router:**
- ❌ Hit rate limit → Work stops → Frustration
- ❌ Or: Accidentally rack up huge API bills
**With 9Router:**
- ✅ Subscription hits limit → Auto-fallback to cheap tier
- ✅ Cheap tier gets expensive → Auto-fallback to free tier
- ✅ Never stop coding → Predictable costs
@@ -1119,6 +1159,7 @@ pm2 startup
### Docker
Published images (multi-platform `linux/amd64` + `linux/arm64`):
- Docker Hub: [`decolua/9router`](https://hub.docker.com/r/decolua/9router)
- GHCR: [`ghcr.io/decolua/9router`](https://github.com/decolua/9router/pkgs/container/9router)
@@ -1146,6 +1187,7 @@ docker run -d --name 9router -p 20128:20128 \
```
**Container defaults:**
- `PORT=20128`
- `HOSTNAME=0.0.0.0`
@@ -1162,26 +1204,28 @@ docker pull decolua/9router:latest # update to latest
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `JWT_SECRET` | Auto-generated (`~/.9router/jwt-secret`) | JWT signing secret for dashboard auth cookie (override to share across instances) |
| `INITIAL_PASSWORD` | `123456` | First login password when no saved hash exists |
| `DATA_DIR` | `~/.9router` | Main app data location (SQLite at `$DATA_DIR/db/data.sqlite`) |
| `PORT` | framework default | Service port (`20128` in examples) |
| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) |
| `NODE_ENV` | runtime default | Set `production` for deploy |
| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL used by cloud sync jobs |
| `CLOUD_URL` | `https://9router.com` | Server-side cloud sync endpoint base URL |
| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | Backward-compatible/public base URL (prefer `BASE_URL` for server runtime) |
| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | Backward-compatible/public cloud URL (prefer `CLOUD_URL` for server runtime) |
| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys |
| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | Salt for stable machine ID hashing |
| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs under `logs/` |
| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (set `true` behind HTTPS reverse proxy) |
| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` routes (recommended for internet-exposed deploys) |
| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | empty | Optional outbound proxy for upstream provider calls |
| Variable | Default | Description |
| ---------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- |
| `JWT_SECRET` | Auto-generated (`~/.9router/jwt-secret`) | JWT signing secret for dashboard auth cookie (override to share across instances) |
| `INITIAL_PASSWORD` | `123456` | First login password when no saved hash exists |
| `DATA_DIR` | `~/.9router` | Main app data location (SQLite at `$DATA_DIR/db/data.sqlite`) |
| `PORT` | framework default | Service port (`20128` in examples) |
| `HOSTNAME` | framework default | Bind host (Docker defaults to `0.0.0.0`) |
| `NODE_ENV` | runtime default | Set `production` for deploy |
| `BASE_URL` | `http://localhost:20128` | Server-side internal base URL used by cloud sync jobs |
| `CLOUD_URL` | `https://9router.com` | Server-side cloud sync endpoint base URL |
| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | Backward-compatible/public base URL (prefer `BASE_URL` for server runtime) |
| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | Backward-compatible/public cloud URL (prefer `CLOUD_URL` for server runtime) |
| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | HMAC secret for generated API keys |
| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | Salt for stable machine ID hashing |
| `ENABLE_REQUEST_LOGS` | `false` | Enables request/response logs under `logs/` |
| `AUTH_COOKIE_SECURE` | `false` | Force `Secure` auth cookie (set `true` behind HTTPS reverse proxy) |
| `REQUIRE_API_KEY` | `false` | Enforce Bearer API key on `/v1/*` routes (recommended for internet-exposed deploys) |
| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | empty | Optional outbound proxy for upstream provider calls |
| `SEARXNG_URL` | `http://localhost:8888/search` | Endpoint for the built-in unauthenticated SearXNG web-search provider |
Notes:
- Lowercase proxy variables are also supported: `http_proxy`, `https_proxy`, `all_proxy`, `no_proxy`.
- `.env` is not baked into Docker image (`.dockerignore`); inject runtime config with `--env-file` or `-e`.
- On Windows, `APPDATA` can be used for local storage path resolution.
@@ -1204,6 +1248,7 @@ Notes:
<summary><b>View all available models</b></summary>
**Claude Code (`cc/`)** - Pro/Max:
- `cc/claude-opus-4-7`
- `cc/claude-opus-4-6`
- `cc/claude-sonnet-4-6`
@@ -1211,6 +1256,7 @@ Notes:
- `cc/claude-haiku-4-5-20251001`
**Codex (`cx/`)** - Plus/Pro:
- `cx/gpt-5.5`
- `cx/gpt-5.4`
- `cx/gpt-5.3-codex`
@@ -1218,6 +1264,7 @@ Notes:
- `cx/gpt-5.1-codex-max`
**GitHub Copilot (`gh/`)**:
- `gh/gpt-5.4`
- `gh/claude-opus-4.7`
- `gh/claude-sonnet-4.6`
@@ -1225,25 +1272,30 @@ Notes:
- `gh/grok-code-fast-1`
**Cursor (`cu/`)** - Subscription:
- `cu/claude-4.6-opus-max`
- `cu/claude-4.5-sonnet-thinking`
- `cu/gpt-5.3-codex`
- `cu/kimi-k2.5`
**GLM (`glm/`)** - $0.6/1M:
- `glm/glm-5.1`
- `glm/glm-5`
- `glm/glm-4.7`
**MiniMax (`minimax/`)** - $0.2/1M:
- `minimax/MiniMax-M2.7`
- `minimax/MiniMax-M2.5`
**Kimi (`kimi/`)** - $9/mo flat:
- `kimi/kimi-k2.5`
- `kimi/kimi-k2.5-thinking`
**Kiro (`kr/`)** - FREE unlimited:
- `kr/claude-sonnet-4.5`
- `kr/claude-haiku-4.5`
- `kr/glm-5`
@@ -1252,9 +1304,11 @@ Notes:
- `kr/deepseek-3.2`
**OpenCode Free (`oc/`)** - FREE no-auth:
- Auto-fetched from `opencode.ai/zen/v1/models`
**Vertex AI (`vertex/`)** - $300 free credits:
- `vertex/gemini-3.1-pro-preview`
- `vertex/gemini-3-flash-preview`
- `vertex/gemini-2.5-flash`
@@ -1268,31 +1322,38 @@ Notes:
## 🐛 Troubleshooting
**"Language model did not provide messages"**
- Provider quota exhausted → Check dashboard quota tracker
- Solution: Use combo fallback or switch to cheaper tier
**Rate limiting**
- Subscription quota out → Fallback to GLM/MiniMax
- Add combo: `cc/claude-opus-4-7 → glm/glm-5.1 → kr/claude-sonnet-4.5`
**OAuth token expired**
- Auto-refreshed by 9Router
- If issues persist: Dashboard → Provider → Reconnect
**High costs**
- Enable RTK in Dashboard → Endpoint settings (default ON, saves 20-40% tokens)
- Check usage stats in Dashboard
- Switch primary model to GLM/MiniMax
- Use free tier (Kiro, OpenCode Free, Vertex) for non-critical tasks
**Dashboard opens on wrong port**
- Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128`
**First login not working**
- Check `INITIAL_PASSWORD` in `.env`
- If unset, fallback password is `123456`
**No request logs under `logs/`**
- Set `ENABLE_REQUEST_LOGS=true`
---
@@ -1355,8 +1416,6 @@ Thanks to all contributors who helped make 9Router better!
[![Star Chart](https://starchart.cc/decolua/9router.svg?variant=adaptive)](https://starchart.cc/decolua/9router)
## 🔀 Forks
**[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** — A full-featured TypeScript fork of 9Router. Adds 36+ providers, 4-tier auto-fallback, multi-modal APIs (images, embeddings, audio, TTS), circuit breaker, semantic cache, LLM evaluations, and a polished dashboard. 368+ unit tests. Available via npm and Docker.
@@ -1369,8 +1428,8 @@ Built on the shoulders of giants:
- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — original Go implementation that inspired this JavaScript port.
- **[RTK](https://github.com/rtk-ai/rtk)** ![Stars](https://img.shields.io/github/stars/rtk-ai/rtk?style=flat&color=yellow) — Rust token-saver. 9Router ports its compression pipeline to JS → **−20-40% input tokens** on every request.
- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) by **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral *"why use many token when few token do trick"*. 9Router adapts its prompt → **−65% output tokens**.
- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Stars](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) by **[@DietrichGebert](https://github.com/DietrichGebert)** — *"lazy senior dev"* skill. 9Router injects its YAGNI-first ladder → **fewer tokens, less code, shorter diffs**.
- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) by **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral _"why use many token when few token do trick"_. 9Router adapts its prompt → **−65% output tokens**.
- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Stars](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) by **[@DietrichGebert](https://github.com/DietrichGebert)** — _"lazy senior dev"_ skill. 9Router injects its YAGNI-first ladder → **fewer tokens, less code, shorter diffs**.
Huge thanks to these authors — without their work, 9Router's token-saving features wouldn't exist. ⭐ them on GitHub!
+42 -20
View File
@@ -4,8 +4,28 @@ const { spawn, exec, execSync } = require("child_process");
const path = require("path");
const fs = require("fs");
const https = require("https");
const net = require("net");
const os = require("os");
// Poll until the server accepts TCP connections on port, or timeout — avoids blind fixed waits.
function waitServerReady(port, { timeoutMs = 15000, intervalMs = 150 } = {}) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve) => {
const tryConnect = () => {
const socket = net.connect({ host: "127.0.0.1", port }, () => {
socket.destroy();
resolve(true);
});
socket.on("error", () => {
socket.destroy();
if (Date.now() >= deadline) return resolve(false);
setTimeout(tryConnect, intervalMs);
});
};
tryConnect();
});
}
// Native spinner - no external dependency
function createSpinner(text) {
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -212,17 +232,18 @@ function killCloudflaredByAppPort(appPort) {
function killAllAppProcesses(appPort) {
return new Promise((resolve) => {
try {
// Kill MIT first (privileged process, needs special handling)
killProxyByPidFile();
// Kill cloudflared/tailscale by PID file (precise, only this app's tunnel)
killTunnelByPidFile();
// Background: MITM + tunnel/cloudflared run on separate ports/processes —
// killing them doesn't free the app port, so don't block the critical path.
// Server-side MITM manager has stale-lock recovery and starts deferred (~3s).
setImmediate(() => {
try { killProxyByPidFile(); } catch {}
try { killTunnelByPidFile(); } catch {}
try { killCloudflaredByAppPort(appPort); } catch {}
});
const platform = process.platform;
let pids = [];
// Catch stale PID files: kill cloudflared bound to this app's port
pids.push(...killCloudflaredByAppPort(appPort));
if (platform === "win32") {
// Windows: use WMI to get full CommandLine (tasklist /V doesn't include it)
try {
@@ -499,14 +520,11 @@ if (!fs.existsSync(serverPath)) {
process.exit(1);
}
// Check for updates FIRST, then start server
checkForUpdate().then((latestVersion) => {
killAllAppProcesses(port).then(() => {
return killProcessOnPort(port);
}).then(() => {
startServer(latestVersion);
});
});
// Start server immediately; run update check in parallel (not on the critical path).
const updatePromise = checkForUpdate();
killAllAppProcesses(port)
.then(() => killProcessOnPort(port))
.then(() => startServer(updatePromise));
// Show interface selection menu
async function showInterfaceMenu(latestVersion) {
@@ -556,7 +574,9 @@ async function showInterfaceMenu(latestVersion) {
const MAX_RESTARTS = 2;
const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s
function startServer(latestVersion) {
function startServer(updatePromise) {
// Accept either a Promise (parallel update check) or a resolved value.
const latestVersionPromise = Promise.resolve(updatePromise);
const displayHost = getDisplayHost();
const url = `http://${displayHost}:${port}/dashboard`;
// Surface real network exposure when bound to all interfaces (default 0.0.0.0).
@@ -677,17 +697,19 @@ function startServer(latestVersion) {
console.log(`\n🚀 ${pkg.name} v${pkg.version}`);
console.log(`Server: http://${displayHost}:${port}`);
setTimeout(() => {
waitServerReady(port).then(() => {
initTrayIcon();
console.log("\n💡 Router is now running in system tray. Close this terminal if you want.");
console.log(" Right-click tray icon to open dashboard or quit.\n");
}, 2000);
});
return;
}
// Wait for server to be ready, then show interface menu loop + tray
setTimeout(async () => {
waitServerReady(port).then(async () => {
// Resolve parallel update check (already running); don't block server start on it.
const latestVersion = await latestVersionPromise;
// Start tray icon alongside TUI
initTrayIcon();
@@ -772,7 +794,7 @@ function startServer(latestVersion) {
cleanup();
process.exit(1);
}
}, 3000);
});
function attachServerEvents() {
server.on("error", (err) => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "9router",
"version": "0.5.18",
"version": "0.5.30",
"description": "9Router CLI - Start and manage 9Router server",
"bin": {
"9router": "./cli.js"
+1 -1
View File
@@ -7,7 +7,7 @@ const { execSync } = require("child_process");
const cliDir = path.resolve(__dirname, "..");
const appDir = path.resolve(cliDir, "..");
const rootDir = path.resolve(appDir, "..");
const cliAppDir = path.join(cliDir, "app");
const cliAppDir = process.env.NINEROUTER_CLI_APP_DIR || path.join(cliDir, "app");
const buildHomeDir = path.join(cliDir, ".build-home");
const buildDistDirName = ".next-cli-build";
const buildDistDir = path.join(appDir, buildDistDirName);
+2 -1
View File
@@ -12,7 +12,8 @@ const BUILD_CONFIG = {
const cliDir = path.resolve(__dirname, "..");
const appDir = path.resolve(cliDir, "..");
const cliMitmDir = path.join(cliDir, "app", "src", "mitm");
const cliAppDir = process.env.NINEROUTER_CLI_APP_DIR || path.join(cliDir, "app");
const cliMitmDir = path.join(cliAppDir, "src", "mitm");
// Bundle everything — no externals. This keeps MITM runtime self-contained so
// it can be copied to DATA_DIR/runtime/ and spawned from there (escapes
// node_modules file locks that block `npm i -g 9router@latest` on Windows).
+2
View File
@@ -30,6 +30,8 @@ const nextConfig = {
proxyClientMaxBodySize,
// Cache fetch responses across HMR refreshes for faster dev reloads.
serverComponentsHmrCache: true,
// Tree-shake heavy barrel imports to cut compile + bundle size
optimizePackageImports: ["@xyflow/react", "@dnd-kit/core", "@dnd-kit/sortable", "material-symbols", "marked"],
},
webpack: (config, { isServer }) => {
// Ignore fs/path modules in browser bundle
+3 -2
View File
@@ -1,5 +1,6 @@
import { platform, arch } from "os";
import { PROVIDERS, PROVIDER_OAUTH } from "./providers.js";
import { ANTIGRAVITY_IDE_USER_AGENT } from "../providers/shared.js";
// === Gemini CLI === derive từ registry gemini-cli.transport
export const GEMINI_CLI_VERSION = PROVIDERS["gemini-cli"]?.cliVersion;
@@ -59,7 +60,7 @@ export function getPlatformEnum() {
}
export function getPlatformUserAgent() {
return `antigravity/1.104.0 ${platform()}/${arch()}`;
return ANTIGRAVITY_IDE_USER_AGENT;
}
export const CLIENT_METADATA = {
@@ -129,7 +130,7 @@ export const AG_DEFAULT_TOOLS = new Set([
// Antigravity chat/stream headers
export const ANTIGRAVITY_HEADERS = {
"User-Agent": `antigravity/1.107.0 ${platform()}/${arch()}`
"User-Agent": ANTIGRAVITY_IDE_USER_AGENT
};
// Cloud Code Assist API
+35 -12
View File
@@ -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 } from "../providers/models/schema.js";
import { modelQuotaFamily, modelStrip, modelTargetFormat, normalizeModelId } from "../providers/models/schema.js";
import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js";
export { PROVIDER_MODELS };
@@ -18,46 +18,69 @@ export function getDefaultModel(aliasOrId) {
return models?.[0]?.id || null;
}
// Providers whose registry uses dots in version numbers (e.g. "claude-sonnet-4.5").
// For these, we tolerate clients sending dashes ("claude-sonnet-4-5") by normalizing
// digit-hyphen-digit to digit-dot-digit before lookup. Other providers are left untouched.
const DOT_VERSION_PROVIDERS = new Set(["kr", "kiro"]);
// Find a registry entry by id. For Kiro models, tolerates dash/dot version separators
// ("claude-sonnet-4-5" ~= "claude-sonnet-4.5"). Other providers use exact match only.
function findModel(models, modelId, aliasOrId) {
if (!models) return undefined;
const found = models.find(m => m.id === modelId);
if (found) return found;
if (!DOT_VERSION_PROVIDERS.has(aliasOrId)) return undefined;
const normalized = normalizeModelId(modelId);
if (normalized === modelId) return undefined;
return models.find(m => m.id === normalized);
}
export function isValidModel(aliasOrId, modelId, passthroughProviders = new Set()) {
if (passthroughProviders.has(aliasOrId)) return true;
const models = PROVIDER_MODELS[aliasOrId];
if (!models) return false;
return models.some(m => m.id === modelId);
return !!findModel(models, modelId, aliasOrId);
}
export function findModelName(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId];
if (!models) return modelId;
const found = models.find(m => m.id === modelId);
const found = findModel(models, modelId, aliasOrId);
return found?.name || modelId;
}
export function getModelTargetFormat(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId];
if (!models) return null;
return modelTargetFormat(models.find(m => m.id === modelId));
return modelTargetFormat(findModel(models, modelId, aliasOrId));
}
export function getModelType(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId];
if (!models) return null;
const found = models.find(m => m.id === modelId);
const found = findModel(models, modelId, aliasOrId);
return found?.kind || found?.type || null;
}
export function getModelUpstreamId(aliasOrId, modelId) {
// Split off thinking suffix "(level)" so lookup hits the base id; re-append it to
// the result so downstream applyThinking still sees the suffix (body.model is stripped separately).
const sufMatch = typeof modelId === "string" ? modelId.match(/\([^()]+\)\s*$/) : null;
const suffix = sufMatch ? sufMatch[0] : "";
const baseId = suffix ? modelId.slice(0, sufMatch.index).trim() : modelId;
const models = PROVIDER_MODELS[aliasOrId];
const found = models?.find(m => m.id === modelId);
if (found?.upstreamModelId) return found.upstreamModelId;
if (aliasOrId === "cx" && typeof modelId === "string" && modelId.endsWith(CODEX_REVIEW_SUFFIX)) {
return modelId.slice(0, -CODEX_REVIEW_SUFFIX.length);
const found = findModel(models, baseId, aliasOrId);
if (found?.upstreamModelId) return found.upstreamModelId + suffix;
if (found?.id) return found.id + suffix;
if (aliasOrId === "cx" && typeof baseId === "string" && baseId.endsWith(CODEX_REVIEW_SUFFIX)) {
return baseId.slice(0, -CODEX_REVIEW_SUFFIX.length) + suffix;
}
return modelId;
return baseId + suffix;
}
export function getModelQuotaFamily(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId];
return modelQuotaFamily(models?.find(m => m.id === modelId));
return modelQuotaFamily(findModel(models, modelId, aliasOrId));
}
// OAuth short aliases — derived from registry `alias` (single source). everything else: alias = id.
@@ -79,5 +102,5 @@ export function getModelsByProviderId(providerId) {
// Get strip list for a model entry (explicit opt-in only)
// Returns array of content types to strip, e.g. ["image", "audio"]
export function getModelStrip(alias, modelId) {
return modelStrip(PROVIDER_MODELS[alias]?.find(m => m.id === modelId));
return modelStrip(findModel(PROVIDER_MODELS[alias], modelId, alias));
}
+9
View File
@@ -39,6 +39,15 @@ function envMs(name, def) {
return Number.isFinite(n) && n > 0 ? n : def;
}
function envUrl(name, def) {
const raw = process.env[name]?.trim();
return raw || def;
}
// SearXNG endpoint used by the unauthenticated web-search provider.
// Configure this for a separate Docker service or remote SearXNG instance.
export const SEARXNG_URL = envUrl("SEARXNG_URL", "http://localhost:8888/search");
// Inter-chunk stall timeout (once tokens are flowing). Generous headroom so
// slow reasoning models aren't aborted mid-stream. Env: STREAM_STALL_TIMEOUT_MS.
export const STREAM_STALL_TIMEOUT_MS = envMs("STREAM_STALL_TIMEOUT_MS", 360 * 1000);
+39 -20
View File
@@ -1,7 +1,7 @@
import crypto from "crypto";
import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js";
import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, INTERNAL_REQUEST_HEADER, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX } from "../config/appConstants.js";
import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX } from "../config/appConstants.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { resolveSessionId } from "../utils/sessionManager.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
@@ -18,7 +18,8 @@ function sanitizeFunctionName(name) {
const MAX_RETRY_AFTER_MS = 10000;
const ANTIGRAVITY_TRANSIENT_RETRY_MAX_MS = 15000;
const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 16384;
const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 64000;
const ANTIGRAVITY_IDE_REQUEST_ID_RE = /^agent\/[^/]+\/\d+\/[^/]+\/\d+$/;
const ANTIGRAVITY_TRANSIENT_ERROR_PATTERNS = [
/high\s+traffic/i,
@@ -87,6 +88,27 @@ function parseImageConfig(model) {
return config;
}
function uuidFromSeed(seed) {
const bytes = crypto.createHash("sha256").update(String(seed || "antigravity")).digest().subarray(0, 16);
bytes[6] = (bytes[6] & 0x0f) | 0x50;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = bytes.toString("hex");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
function buildIdeRequestId({ body, request, credentials, model, requestType }) {
if (ANTIGRAVITY_IDE_REQUEST_ID_RE.test(body?.requestId || "")) {
return body.requestId;
}
const sessionId = request?.sessionId || body?.request?.sessionId || credentials?._clientSessionId || credentials?.connectionId || credentials?.email || "anonymous";
const conversationId = uuidFromSeed(`antigravity:conversation:${sessionId}`);
const trajectoryId = uuidFromSeed(`antigravity:trajectory:${sessionId}:${model}:${requestType}`);
const contentCount = Array.isArray(request?.contents) ? request.contents.length : 1;
const step = Math.max(1, contentCount * 2 - 1);
return `agent/${conversationId}/${Date.now()}/${trajectoryId}/${step}`;
}
export class AntigravityExecutor extends BaseExecutor {
constructor() {
super("antigravity", PROVIDERS.antigravity);
@@ -104,14 +126,10 @@ export class AntigravityExecutor extends BaseExecutor {
// sessionId comes from transformRequest output; base.execute runs transformRequest before
// buildHeaders, so we read it from instance state cached there (fallback: explicit arg).
buildHeaders(credentials, stream = true, sessionId = null) {
const sid = sessionId || this._lastSessionId;
return {
"Content-Type": "application/json",
"Authorization": `Bearer ${credentials.accessToken}`,
"User-Agent": this.config.headers?.["User-Agent"] || ANTIGRAVITY_HEADERS["User-Agent"],
[INTERNAL_REQUEST_HEADER.name]: INTERNAL_REQUEST_HEADER.value,
...(sid && { "X-Machine-Session-Id": sid }),
"Accept": stream ? "text/event-stream" : "application/json"
};
}
@@ -142,25 +160,26 @@ export class AntigravityExecutor extends BaseExecutor {
});
this._lastSessionId = sessionId;
const request = {
contents,
generationConfig: {
temperature: 1.0,
topP: 0.95,
topK: 40,
maxOutputTokens: 8192,
imageConfig,
},
sessionId,
// No tools, no systemInstruction, no safetySettings for image gen
};
return {
project: projectId,
model: cleanModel,
userAgent: "antigravity",
requestType: "image_gen",
requestId: `agent-${crypto.randomUUID()}`,
request: {
contents,
generationConfig: {
temperature: 1.0,
topP: 0.95,
topK: 40,
maxOutputTokens: 8192,
imageConfig,
},
sessionId,
// No tools, no systemInstruction, no safetySettings for image gen
},
requestId: buildIdeRequestId({ body, request, credentials, model: cleanModel, requestType: "image_gen" }),
request,
};
}
@@ -248,7 +267,7 @@ export class AntigravityExecutor extends BaseExecutor {
model: model,
userAgent: "antigravity",
requestType: "agent",
requestId: `agent-${crypto.randomUUID()}`,
requestId: buildIdeRequestId({ body, request: transformedRequest, credentials, model, requestType: "agent" }),
request: transformedRequest
};
}
+114 -30
View File
@@ -8,13 +8,21 @@ import {
import { normalizeResponsesInput } from "../translator/formats/responsesApi.js";
import { fetchImageAsBase64 } from "../translator/concerns/image.js";
import { getModelUpstreamId } from "../config/providerModels.js";
import { DEFAULT_RETRY_CONFIG, resolveRetryEntry } from "../config/runtimeConfig.js";
import { DEFAULT_RETRY_CONFIG, HTTP_STATUS, resolveRetryEntry } from "../config/runtimeConfig.js";
import { dbg } from "../utils/debugLog.js";
import { resolveSessionId } from "../utils/sessionManager.js";
// SSE error patterns inside 200-OK body that should trigger retry as if 503
const CODEX_SSE_OVERLOADED_PATTERNS = ["server_is_overloaded", "service_unavailable_error"];
const CODEX_SSE_PEEK_BYTES = 4096;
// SSE error patterns inside 200-OK bodies. Some retry same account first; capacity rotates accounts.
const CODEX_SSE_RETRY_PATTERNS = ["server_is_overloaded", "service_unavailable_error"];
const CODEX_SSE_ACCOUNT_FALLBACK_PATTERNS = ["selected model is at capacity", "model_at_capacity"];
const CODEX_SSE_USER_OUTPUT_PATTERNS = [
"event: response.output_text.delta",
"event: response.function_call_arguments.delta",
'"type":"response.output_text.delta"',
'"type":"response.function_call_arguments.delta"',
];
const CODEX_SSE_PEEK_BYTES = 256 * 1024;
const CODEX_MODEL_CAPACITY_MESSAGE = "Selected model is at capacity. Please try a different model.";
// Server-generated item id prefixes that Codex /responses cannot resolve when store=false
const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/;
@@ -116,6 +124,62 @@ function resolveCacheSessionId(body, credentials) {
});
}
function normalizeReasoningEffort(value) {
return value === "max" ? "xhigh" : value;
}
function findNestedMessage(value, depth = 0) {
if (!value || depth > 6 || typeof value === "string") return null;
if (Array.isArray(value)) {
for (const item of value) {
const found = findNestedMessage(item, depth + 1);
if (found) return found;
}
return null;
}
if (typeof value !== "object") return null;
if (typeof value.message === "string" && value.message.trim()) return value.message;
if (typeof value.error?.message === "string" && value.error.message.trim()) return value.error.message;
if (typeof value.response?.error?.message === "string" && value.response.error.message.trim()) return value.response.error.message;
for (const child of Object.values(value)) {
const found = findNestedMessage(child, depth + 1);
if (found) return found;
}
return null;
}
function extractSseErrorMessage(text, fallback) {
const exact = text?.match(/Selected model is at capacity\. Please try a different model\./i)?.[0];
if (exact) return exact;
for (const line of String(text || "").split(/\r?\n/)) {
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (!data || data === "[DONE]") continue;
try {
const message = findNestedMessage(JSON.parse(data));
if (message) return message;
} catch {
// Ignore non-JSON SSE data lines.
}
}
return fallback || CODEX_MODEL_CAPACITY_MESSAGE;
}
function codexSseErrorResponse(status, message) {
return new Response(JSON.stringify({
error: {
message,
type: status >= 500 ? "server_error" : "invalid_request_error",
code: status === HTTP_STATUS.SERVICE_UNAVAILABLE ? "service_unavailable" : "upstream_error",
}
}), {
status,
headers: { "Content-Type": "application/json" },
});
}
/**
* Codex Executor - handles OpenAI Codex API (Responses API format)
* Automatically injects default instructions if missing
@@ -135,10 +199,17 @@ export class CodexExecutor extends BaseExecutor {
headers["session_id"] = this._currentSessionId || credentials?.connectionId || "default";
// Identify client type to Codex backend (matches official codex CLI)
if (!headers["originator"]) headers["originator"] = "codex_cli_rs";
// Workspace binding header — improves account scope + cache affinity
const workspaceId = credentials?.providerSpecificData?.workspaceId;
if (typeof workspaceId === "string" && workspaceId && !headers["chatgpt-account-id"]) {
headers["chatgpt-account-id"] = workspaceId;
// Account/workspace binding header — required when multiple Codex accounts
// are configured. OAuth import stores ChatGPT account ID as chatgptAccountId;
// older/custom rows may use workspaceId/accountId. Prefer explicit workspaceId
// but fall back to chatgptAccountId so requests don't cross-bind to the wrong
// OpenAI account and surface as token_invalid after adding another account.
const accountId =
credentials?.providerSpecificData?.workspaceId ||
credentials?.providerSpecificData?.chatgptAccountId ||
credentials?.providerSpecificData?.accountId;
if (typeof accountId === "string" && accountId && !headers["ChatGPT-Account-ID"]) {
headers["ChatGPT-Account-ID"] = accountId;
}
return headers;
}
@@ -198,7 +269,7 @@ export class CodexExecutor extends BaseExecutor {
let attempt = 0;
while (true) {
const result = await super.execute(args);
const peek = await this._peekSseOverloaded(result.response);
const peek = await this._peekSseTransientError(result.response);
if (!peek.matched) {
// Replace body with re-assembled stream (prefix bytes already read + rest)
if (peek.replacementBody) {
@@ -210,48 +281,57 @@ export class CodexExecutor extends BaseExecutor {
}
return result;
}
if (peek.accountFallback) {
args.log?.warn?.("RETRY", `CODEX | SSE account fallback "${peek.message}"`);
result.response = codexSseErrorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, peek.message || CODEX_MODEL_CAPACITY_MESSAGE);
return result;
}
if (attempt >= attempts) {
args.log?.warn?.("RETRY", `CODEX | SSE overloaded "${peek.matched}" — retries exhausted (${attempt}/${attempts})`);
// Out of retries → return with replacement body so client gets the error
if (peek.replacementBody) {
result.response = new Response(peek.replacementBody, {
status: result.response.status,
statusText: result.response.statusText,
headers: result.response.headers,
});
}
result.response = codexSseErrorResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, peek.message || peek.matched);
return result;
}
attempt++;
args.log?.debug?.("RETRY", `CODEX | SSE "${peek.matched}" retry ${attempt}/${attempts} after ${delayMs / 1000}s`);
dbg("CODEX", `SSE overloaded "${peek.matched}" → retry ${attempt}/${attempts} in ${delayMs}ms`);
try { await result.response.body?.cancel?.(); } catch { /* noop */ }
await new Promise(r => setTimeout(r, delayMs));
}
}
// Peek first N bytes of SSE body to detect upstream "overloaded" errors.
// Returns { matched: string|null, replacementBody: ReadableStream|null }.
// Caller MUST use replacementBody (original body has been read).
async _peekSseOverloaded(response) {
if (!response || !response.ok || !response.body) return { matched: null, replacementBody: null };
// Peek first N bytes of SSE body to detect upstream transient errors.
// Returns { matched: string|null, message: string|null, accountFallback: boolean, replacementBody: ReadableStream|null }.
// Caller must use replacementBody when no error matched (original body has been read).
async _peekSseTransientError(response) {
if (!response || !response.ok || !response.body) return { matched: null, message: null, accountFallback: false, replacementBody: null };
const reader = response.body.getReader();
const decoder = new TextDecoder();
const chunks = [];
let text = "";
let matched = null;
let accountFallback = false;
try {
while (text.length < CODEX_SSE_PEEK_BYTES) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
text += decoder.decode(value, { stream: true });
const hit = CODEX_SSE_OVERLOADED_PATTERNS.find(p => text.includes(p));
if (hit) { matched = hit; break; }
const lowerText = text.toLowerCase();
const accountHit = CODEX_SSE_ACCOUNT_FALLBACK_PATTERNS.find(p => lowerText.includes(p));
if (accountHit) { matched = accountHit; accountFallback = true; break; }
const retryHit = CODEX_SSE_RETRY_PATTERNS.find(p => lowerText.includes(p));
if (retryHit) { matched = retryHit; break; }
if (CODEX_SSE_USER_OUTPUT_PATTERNS.some(p => lowerText.includes(p))) break;
}
} catch (e) {
dbg("CODEX", `peek read error: ${e.message}`);
}
if (matched) {
try { await reader.cancel(); } catch { /* noop */ }
try { reader.releaseLock(); } catch { /* noop */ }
return { matched, message: extractSseErrorMessage(text, matched), accountFallback, replacementBody: null };
}
reader.releaseLock();
// Re-assemble stream: prefix chunks + remaining upstream body
@@ -273,7 +353,7 @@ export class CodexExecutor extends BaseExecutor {
try { upstreamReader?.cancel(reason); } catch { /* noop */ }
},
});
return { matched, replacementBody };
return { matched: null, message: null, accountFallback: false, replacementBody };
}
// Parse Codex usage_limit_reached to extract precise resetsAtMs; fallback to default otherwise
@@ -347,7 +427,7 @@ export class CodexExecutor extends BaseExecutor {
// Extract thinking level from model name suffix
// e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default)
const effortLevels = ['none', 'low', 'medium', 'high', 'xhigh'];
const effortLevels = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'];
let modelEffort = null;
for (const level of effortLevels) {
if (body.model.endsWith(`-${level}`)) {
@@ -360,10 +440,11 @@ export class CodexExecutor extends BaseExecutor {
// Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium)
if (!body.reasoning) {
const effort = body.reasoning_effort || modelEffort || 'low';
const effort = normalizeReasoningEffort(body.reasoning_effort || modelEffort || 'low');
body.reasoning = { effort, summary: "auto" };
} else if (!body.reasoning.summary) {
body.reasoning.summary = "auto";
} else {
body.reasoning.effort = normalizeReasoningEffort(body.reasoning.effort);
if (!body.reasoning.summary) body.reasoning.summary = "auto";
}
delete body.reasoning_effort;
@@ -391,6 +472,9 @@ export class CodexExecutor extends BaseExecutor {
delete body.safety_identifier; // Droid CLI sends this but Codex doesn't support it
delete body.previous_response_id; // store=false → backend can't resolve previous resp; avoid 404
if (body.service_tier === "fast") body.service_tier = "priority";
if (body.service_tier && body.service_tier !== "priority") delete body.service_tier;
// Final allowlist filter — strip any unknown field that could trigger upstream "routing_unsupported"
for (const k of Object.keys(body)) {
if (!RESPONSES_API_ALLOWLIST.has(k)) delete body[k];
+397
View File
@@ -0,0 +1,397 @@
import crypto from "node:crypto";
import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js";
import {
refreshProviderCredentials,
shouldRefreshCredentials,
} from "../services/oauthCredentialManager.js";
import { normalizeResponsesInput } from "../translator/formats/responsesApi.js";
import { getModelUpstreamId } from "../config/providerModels.js";
import { resolveSessionId } from "../utils/sessionManager.js";
import { getConsistentMachineId } from "../shared/machineId.js";
// Server-generated item id prefixes that /responses cannot resolve when store=false
const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/;
// Hosted tool types executed server-side by Grok CLI backend
const HOSTED_TOOL_TYPES = new Set([
"web_search",
"x_search",
"web_search_preview",
"file_search",
"image_generation",
"code_interpreter",
"mcp",
"local_shell",
]);
// Fields accepted by cli-chat-proxy Responses API (mirrors Codex allowlist + Grok extras)
const RESPONSES_API_ALLOWLIST = new Set([
"model",
"input",
"instructions",
"tools",
"tool_choice",
"stream",
"store",
"reasoning",
"include",
"temperature",
"top_p",
"max_output_tokens",
"parallel_tool_calls",
"text",
"metadata",
"prompt_cache_key",
]);
const EFFORT_LEVELS = ["low", "medium", "high"];
// Per-session last turn index so multi-turn headers never go backwards within this process
const sessionTurnStore = new Map();
/**
* Count user turns in a Responses `input` array.
* Official CLI sets x-grok-turn-idx to the 1-based conversation turn (≈ user messages).
* HAR: first chat turn → "1".
*/
export function countGrokCliUserTurns(input) {
if (!Array.isArray(input)) return 1;
let n = 0;
for (const item of input) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const type = typeof item.type === "string" ? item.type : "";
// Responses message items (type omitted or "message") with role user
if (item.role === "user" && (!type || type === "message")) n += 1;
}
return Math.max(1, n);
}
/**
* Resolve monotonic turn index for a session.
* Prefers user-message count from the payload (full history clients), but never
* decreases vs the last index observed for the same sessionId in this process.
*/
export function resolveGrokCliTurnIdx(sessionId, input) {
const fromInput = countGrokCliUserTurns(input);
if (!sessionId) return fromInput;
const prev = sessionTurnStore.get(sessionId) || 0;
const turn = Math.max(fromInput, prev);
sessionTurnStore.set(sessionId, turn);
return turn;
}
/** Test helper — clear in-memory turn counters */
export function _resetGrokCliTurnStore() {
sessionTurnStore.clear();
}
function stripStoredItemReferences(body) {
if (!Array.isArray(body.input)) return;
body.input = body.input.filter((item) => {
if (typeof item === "string" && SERVER_ID_PATTERN.test(item)) return false;
if (item && typeof item === "object" && !Array.isArray(item)) {
if (item.type === "item_reference") return false;
if (typeof item.id === "string" && SERVER_ID_PATTERN.test(item.id)) delete item.id;
}
return true;
});
}
/**
* Flatten Chat Completions tool shape → Responses flat format.
* Keep hosted tools (web_search / x_search) passthrough.
*/
function normalizeGrokCliTools(body) {
if (!Array.isArray(body.tools)) return;
const validNames = new Set();
body.tools = body.tools.filter((tool) => {
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false;
const type = typeof tool.type === "string" ? tool.type : "";
if (type !== "function") {
// Hosted tools: { type: "web_search" } / { type: "x_search" }
if (HOSTED_TOOL_TYPES.has(type)) return true;
// Nested function shape without type
if (!type && tool.function) {
// fall through to function flatten below
} else if (!type || typeof tool.name === "string") {
// treat as bare function if name present
} else {
return false;
}
}
const isFunction =
type === "function" || type === "" || tool.function || typeof tool.name === "string";
if (!isFunction || HOSTED_TOOL_TYPES.has(type)) {
return HOSTED_TOOL_TYPES.has(type);
}
const fn =
tool.function && typeof tool.function === "object" && !Array.isArray(tool.function)
? tool.function
: null;
const rawName =
typeof tool.name === "string" ? tool.name : typeof fn?.name === "string" ? fn.name : "";
const name = rawName.trim();
if (!name) return false;
const description =
typeof tool.description === "string"
? tool.description
: typeof fn?.description === "string"
? fn.description
: "";
const parameters =
tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters)
? tool.parameters
: fn?.parameters && typeof fn.parameters === "object" && !Array.isArray(fn.parameters)
? fn.parameters
: { type: "object", properties: {} };
for (const k of Object.keys(tool)) delete tool[k];
tool.type = "function";
tool.name = name.slice(0, 128);
if (description) tool.description = description;
tool.parameters = parameters;
validNames.add(name);
return true;
});
if (body.tool_choice && typeof body.tool_choice === "object" && !Array.isArray(body.tool_choice)) {
if (body.tool_choice.type === "function") {
const n = typeof body.tool_choice.name === "string" ? body.tool_choice.name.trim() : "";
if (!n || !validNames.has(n)) delete body.tool_choice;
}
}
}
function resolveEffortFromModel(modelId) {
if (!modelId || typeof modelId !== "string") return null;
for (const level of EFFORT_LEVELS) {
if (modelId.endsWith(`-${level}`)) return level;
}
return null;
}
/**
* Grok CLI Executor — OpenAI Responses API on cli-chat-proxy.grok.com
* Auth: OAuth device-code access token (xai-grok-cli).
*/
export class GrokCliExecutor extends BaseExecutor {
constructor() {
super("grok-cli", PROVIDERS["grok-cli"]);
this._currentSessionId = null;
this._currentReqId = null;
this._currentTurnIdx = 1;
this._agentId = null;
}
buildUrl() {
return this.config.baseUrl;
}
async refreshCredentials(credentials, log) {
if (!credentials?.refreshToken) return null;
return refreshProviderCredentials("grok-cli", credentials, log);
}
needsRefresh(credentials) {
return shouldRefreshCredentials("grok-cli", credentials);
}
buildHeaders(credentials, stream = true) {
const headers = super.buildHeaders(credentials, stream);
// Static fingerprint from registry
const staticHeaders = this.config.headers || {};
for (const [k, v] of Object.entries(staticHeaders)) {
if (v != null && headers[k] === undefined) headers[k] = v;
}
// Ensure token-auth marker is present even if headers map was overridden
headers["x-xai-token-auth"] = this.config.tokenAuth || "xai-grok-cli";
headers["x-grok-client-identifier"] =
this.config.clientIdentifier || headers["x-grok-client-identifier"] || "grok-pager";
headers["x-grok-client-version"] =
this.config.clientVersion || headers["x-grok-client-version"] || "0.2.93";
headers["x-authenticateresponse"] = "authenticate-response";
const sessionId = this._currentSessionId || credentials?.connectionId || crypto.randomUUID();
const reqId = this._currentReqId || crypto.randomUUID();
headers["x-grok-session-id"] = sessionId;
// CLI uses the same id for conv + session on chat turns
headers["x-grok-conv-id"] = sessionId;
headers["x-grok-req-id"] = reqId;
headers["x-grok-turn-idx"] = String(this._currentTurnIdx || 1);
if (this._agentId) headers["x-grok-agent-id"] = this._agentId;
// Surface model override (CLI always sets this)
if (this._currentModel) headers["x-grok-model-override"] = this._currentModel;
if (this.config.compactionAt) {
headers["x-compaction-at"] = String(this.config.compactionAt);
}
// Identity: mapTokens stores email top-level AND in providerSpecificData;
// fall back either way so OAuth connections always fingerprint like the CLI.
const psd = credentials?.providerSpecificData || {};
const email = psd.email || credentials?.email;
const userId = psd.userId || credentials?.userId || credentials?.providerUserId;
if (email) headers["x-email"] = email;
if (userId) headers["x-userid"] = userId;
return headers;
}
parseError(response, bodyText) {
// 402 personal-team-blocked:spending-limit → surface as payment/quota for fallback
if (response.status === 402 && bodyText) {
try {
const json = JSON.parse(bodyText);
const code = json?.code || "";
const msg = json?.error || json?.message || bodyText;
return {
status: 402,
message: typeof msg === "string" ? msg : bodyText,
code: typeof code === "string" ? code : undefined,
};
} catch {
/* fall through */
}
}
return super.parseError(response, bodyText);
}
transformRequest(model, body, stream, credentials) {
// Session / request ids for headers — stable per client conversation when possible
this._currentSessionId = resolveSessionId({
headers: credentials?.rawHeaders,
body,
connectionId: credentials?.connectionId || credentials?.id,
workspaceId: credentials?.providerSpecificData?.workspaceId,
scope: "grok-cli",
});
this._currentReqId = crypto.randomUUID();
this._agentId =
credentials?.providerSpecificData?.deviceId ||
credentials?.providerSpecificData?.agentId ||
null;
// Normalize Responses input
const normalized = normalizeResponsesInput(body.input);
if (normalized) body.input = normalized;
// Chat Completions clients arrive with messages[] — translator should have
// converted already, but guard empty input.
if (!body.input || (Array.isArray(body.input) && body.input.length === 0)) {
if (Array.isArray(body.messages) && body.messages.length > 0) {
// Soft fallback: map messages → input messages (string content only)
body.input = body.messages.map((m) => ({
type: "message",
role: m.role || "user",
content: typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""),
}));
delete body.messages;
} else {
body.input = [{ type: "message", role: "user", content: "..." }];
}
}
// Keep role:"system" as-is — official grok-pager HAR sends system, not developer
// (Codex converts system→developer; Grok CLI does not).
stripStoredItemReferences(body);
normalizeGrokCliTools(body);
// Turn index after input is finalized (user-message count, monotonic per session)
this._currentTurnIdx = resolveGrokCliTurnIdx(this._currentSessionId, body.input);
body.stream = true;
body.store = false;
// Resolve upstream model id (strip effort suffix virtual models)
let modelEffort = resolveEffortFromModel(body.model || model);
let resolvedModel = body.model || model;
if (modelEffort) {
resolvedModel = resolvedModel.replace(new RegExp(`-${modelEffort}$`), "");
}
resolvedModel = getModelUpstreamId("gcli", resolvedModel) || resolvedModel;
// Also try provider id key
if (resolvedModel === (body.model || model)) {
resolvedModel = getModelUpstreamId("grok-cli", resolvedModel) || resolvedModel;
}
body.model = resolvedModel;
this._currentModel = resolvedModel;
// Reasoning effort priority: explicit > reasoning_effort > model suffix > default high
if (!body.reasoning || typeof body.reasoning !== "object") {
const effort = body.reasoning_effort || modelEffort || "high";
body.reasoning = { effort, summary: "concise" };
} else {
if (!body.reasoning.effort) {
body.reasoning.effort = body.reasoning_effort || modelEffort || "high";
}
if (!body.reasoning.summary) body.reasoning.summary = "concise";
}
delete body.reasoning_effort;
// Encrypted reasoning for multi-turn continuity (CLI always requests this)
if (body.reasoning?.effort && body.reasoning.effort !== "none") {
const include = Array.isArray(body.include) ? body.include : [];
if (!include.includes("reasoning.encrypted_content")) {
include.push("reasoning.encrypted_content");
}
body.include = include;
}
// Drop Chat Completions leftovers that Responses rejects
delete body.messages;
delete body.max_tokens;
delete body.max_completion_tokens;
delete body.n;
delete body.seed;
delete body.logprobs;
delete body.top_logprobs;
delete body.frequency_penalty;
delete body.presence_penalty;
delete body.logit_bias;
delete body.user;
delete body.stream_options;
delete body.prompt_cache_retention;
delete body.safety_identifier;
delete body.previous_response_id; // store=false → cannot resolve
for (const k of Object.keys(body)) {
if (!RESPONSES_API_ALLOWLIST.has(k)) delete body[k];
}
return body;
}
async execute(args) {
// Lazy-resolve stable agent id once per process if connection has none
if (!this._agentId && !args.credentials?.providerSpecificData?.deviceId) {
try {
const mid = await getConsistentMachineId("grok-cli-agent");
// Format as UUID-ish for header aesthetics
this._agentId = [
mid.slice(0, 8),
mid.slice(8, 12),
"5" + mid.slice(13, 16),
"a" + mid.slice(17, 20),
mid.slice(0, 12).padEnd(12, "0"),
].join("-");
} catch {
this._agentId = crypto.randomUUID();
}
} else if (args.credentials?.providerSpecificData?.deviceId) {
this._agentId = args.credentials.providerSpecificData.deviceId;
}
return super.execute(args);
}
}
export default GrokCliExecutor;
+5
View File
@@ -13,6 +13,7 @@ import { QwenExecutor } from "./qwen.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";
import { OllamaLocalExecutor } from "./ollama-local.js";
import { CommandCodeExecutor } from "./commandcode.js";
@@ -39,6 +40,9 @@ const executors = {
opencode: new OpenCodeExecutor(),
"opencode-go": new OpenCodeGoExecutor(),
"grok-web": new GrokWebExecutor(),
"grok-cli": new GrokCliExecutor(),
gcli: new GrokCliExecutor(), // Alias
gb: new GrokCliExecutor(), // Alias (Grok Build)
"perplexity-web": new PerplexityWebExecutor(),
"ollama-local": new OllamaLocalExecutor(),
commandcode: new CommandCodeExecutor(),
@@ -77,6 +81,7 @@ export { QwenExecutor } from "./qwen.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";
export { OllamaLocalExecutor } from "./ollama-local.js";
export { CommandCodeExecutor } from "./commandcode.js";
+67 -13
View File
@@ -1,8 +1,8 @@
import { detectFormat, getTargetFormat, resolveTransport } from "../services/provider.js";
import { translateRequest } from "../translator/index.js";
import { stripThinkingSuffix } from "../translator/concerns/thinkingUnified.js";
import { FORMATS } from "../translator/formats.js";
import { normalizeClaudePassthrough } from "../translator/formats/claude.js";
import { COLORS } from "../utils/stream.js";
import { createStreamController } from "../utils/streamHandler.js";
import { refreshWithRetry } from "../services/tokenRefresh.js";
import { createRequestLogger } from "../utils/requestLogger.js";
@@ -23,9 +23,12 @@ 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 { compressWithPxpipe } from "../rtk/pxpipe.js";
import { getCapabilitiesForModel } from "../providers/capabilities.js";
import { stripUnsupportedModalities } from "../translator/concerns/modality.js";
import { prefetchRemoteImages } from "../translator/concerns/prefetch.js";
import { extractThinking } from "../translator/concerns/thinkingUnified.js";
import { resolveSessionId } from "../utils/sessionManager.js";
/**
* Core chat handler - shared between SSE and Worker
@@ -34,9 +37,18 @@ import { prefetchRemoteImages } from "../translator/concerns/prefetch.js";
* @param {object} options.credentials - Provider credentials
* @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses")
*/
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, sourceFormatOverride, providerThinking }) {
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);
@@ -123,9 +135,9 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
let toolNameMap;
if (passthrough) {
log?.debug?.("PASSTHROUGH", `${clientTool} → ${provider} | native lossless`);
translatedBody = { ...body, model: upstreamModel };
translatedBody = { ...body, model: stripThinkingSuffix(upstreamModel) };
// Normalize newer Cowork/CC beta shapes (adaptive thinking, mid-conversation system) the API rejects
if (clientTool === "claude") normalizeClaudePassthrough(translatedBody, upstreamModel);
if (clientTool === "claude") normalizeClaudePassthrough(translatedBody, translatedBody.model);
} else {
translatedBody = translateRequest(sourceFormat, targetFormat, upstreamModel, body, stream, credentials, provider, reqLogger, stripList, connectionId, clientTool);
if (!translatedBody) {
@@ -134,7 +146,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
}
toolNameMap = translatedBody._toolNameMap;
delete translatedBody._toolNameMap;
translatedBody.model = upstreamModel;
translatedBody.model = stripThinkingSuffix(upstreamModel);
}
// Dedupe duplicate built-in tools when equivalent MCP tools are present (Claude clients only).
@@ -150,6 +162,26 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
// 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 think = log.fmtThink?.(extractThinking(translatedBody));
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");
@@ -172,22 +204,37 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
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 | ${headroomSizeLine}`);
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})` : ""}`);
// Caveman: inject terse-style system prompt
if (tokenSaverEnabled && cavemanEnabled && cavemanLevel) {
injectCaveman(translatedBody, finalFormat, cavemanLevel);
log?.debug?.("CAVEMAN", `${cavemanLevel} | ${finalFormat}`);
xf.push(`CAVEMAN:${cavemanLevel}`);
}
// Ponytail: inject lazy-senior-dev system prompt
if (tokenSaverEnabled && ponytailEnabled && ponytailLevel) {
injectPonytail(translatedBody, finalFormat, ponytailLevel);
log?.debug?.("PONYTAIL", `${ponytailLevel} | ${finalFormat}`);
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 */ }
}
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(() => { });
@@ -201,7 +248,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
if (onDisconnect) onDisconnect(reason);
},
onError: () => trackPendingRequest(model, provider, connectionId, false),
log, provider, model
log, provider, model, reqTag
});
const proxyOptions = {
@@ -256,6 +303,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
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(() => { });
@@ -264,7 +312,9 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
return createErrorResult(499, "Request aborted");
}
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
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);
}
@@ -273,7 +323,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
try {
const newCredentials = await refreshWithRetry(() => executor.refreshCredentials(credentials, log), 3, log);
if (newCredentials?.accessToken || newCredentials?.copilotToken) {
log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`);
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}`); }
@@ -302,16 +352,20 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
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);
console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`);
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 };
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);
@@ -6,7 +6,7 @@ import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTrackin
import { createErrorResult } from "../../utils/error.js";
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js";
import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats } from "./requestDetail.js";
import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats, formatDoneLine } from "./requestDetail.js";
import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
import { decloakToolNames } from "../../utils/claudeCloaking.js";
@@ -198,7 +198,7 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source
/**
* Handle non-streaming response from provider.
*/
export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, trackDone, appendLog }) {
export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, trackDone, appendLog, pxpipe, reqTag, log }) {
trackDone();
const contentType = providerResponse.headers.get("content-type") || "";
let responseBody;
@@ -235,7 +235,8 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
const usage = extractUsageFromResponse(responseBody);
appendLog({ tokens: usage, status: "200 OK" });
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint });
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, silent: true });
if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency: { total: Date.now() - requestStartTime } }));
const translatedResponse = needsTranslation(targetFormat, sourceFormat)
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat)
@@ -296,6 +297,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
thinking: translatedResponse?.choices?.[0]?.message?.reasoning_content || translatedResponse?.reasoning_content || null,
finish_reason: translatedResponse?.choices?.[0]?.finish_reason || "unknown"
},
pxpipe,
status: "success"
}, { endpoint: clientRawRequest?.endpoint || null })).catch(err => {
console.error("[RequestDetail] Failed to save:", err.message);
+25 -4
View File
@@ -69,12 +69,31 @@ export function buildRequestDetail(base, overrides = {}) {
providerRequest: base.providerRequest || null,
providerResponse: base.providerResponse || null,
response: base.response || {},
pxpipe: base.pxpipe || undefined,
status: base.status || "success",
...overrides
};
}
export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, endpoint, label = "USAGE" }) {
// Build the "done" summary: duration, ttft, in/out tokens with cache breakdown
export function formatDoneLine({ usage, latency }) {
const u = usage || {};
const inTok = u.prompt_tokens ?? u.input_tokens ?? 0;
const outTok = u.completion_tokens ?? u.output_tokens ?? 0;
const cacheRead = u.cache_read_input_tokens ?? u.cached_tokens ?? u.prompt_tokens_details?.cached_tokens ?? 0;
const cacheCreate = u.cache_creation_input_tokens ?? 0;
let inStr = `IN ${inTok}`;
if (cacheRead || cacheCreate) {
const parts = [];
if (cacheRead) parts.push(`↻${cacheRead}`);
if (cacheCreate) parts.push(`+${cacheCreate}`);
inStr += ` (CACHE ${parts.join(" ")})`;
}
const ttftStr = latency?.ttft ? ` · TTFT ${latency.ttft}ms` : "";
return `DONE ${latency?.total ?? 0}ms${ttftStr} · ${inStr} · OUT ${outTok}`;
}
export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, endpoint, label = "USAGE", silent = false }) {
if (!tokens || typeof tokens !== "object") return;
const inTokens = tokens.input_tokens ?? tokens.prompt_tokens ?? 0;
@@ -82,9 +101,11 @@ export function saveUsageStats({ provider, model, tokens, connectionId, apiKey,
if (inTokens === 0 && outTokens === 0) return;
const time = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
const accountSuffix = connectionId ? ` | account=${connectionId.slice(0, 8)}...` : "";
console.log(`${COLORS.green}[${time}] 📊 [${label}] ${provider.toUpperCase()} | in=${inTokens} | out=${outTokens}${accountSuffix}${COLORS.reset}`);
if (!silent) {
const time = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
const accountSuffix = connectionId ? ` | account=${connectionId.slice(0, 8)}...` : "";
console.log(`${COLORS.green}[${time}] 📊 [${label}] ${provider.toUpperCase()} | in=${inTokens} | out=${outTokens}${accountSuffix}${COLORS.reset}`);
}
// Canonicalize to one storage convention (prompt_tokens cache-inclusive) so
// cached/cache-creation tokens survive to cost calc + stats. See canonicalizeUsage.
@@ -3,7 +3,7 @@ import { createErrorResult } from "../../utils/error.js";
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
import { FORMATS } from "../../translator/formats.js";
import { PROVIDERS } from "../../config/providers.js";
import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js";
import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js";
// Responses-API providers (e.g. codex) may emit SSE without content-type + use Responses output shape
const isResponsesProvider = (p) => PROVIDERS[p]?.format === FORMATS.OPENAI_RESPONSES;
@@ -102,7 +102,7 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
* Handle case: provider forced streaming but client wants JSON.
* Supports both Codex/Responses API SSE and standard Chat Completions SSE.
*/
export async function handleForcedSSEToJson({ providerResponse, sourceFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, trackDone, appendLog }) {
export async function handleForcedSSEToJson({ providerResponse, sourceFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, trackDone, appendLog, reqTag, log }) {
const contentType = providerResponse.headers.get("content-type") || "";
const isSSE = contentType.includes("text/event-stream") || (contentType === "" && isResponsesProvider(provider));
if (!isSSE) return null; // not handled here
@@ -124,7 +124,8 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr
const usage = jsonResponse.usage || {};
appendLog({ tokens: usage, status: "200 OK" });
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint });
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, silent: true });
if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency: { total: Date.now() - requestStartTime } }));
const { msgItem, textContent } = pickAssistantMessageForChatCompletion(jsonResponse.output);
const totalLatency = Date.now() - requestStartTime;
@@ -200,7 +201,8 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr
const usage = parsed.usage || {};
appendLog({ tokens: usage, status: "200 OK" });
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint });
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, silent: true });
if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency: { total: Date.now() - requestStartTime } }));
const totalLatency = Date.now() - requestStartTime;
saveRequestDetail(buildRequestDetail({
+10 -5
View File
@@ -5,7 +5,7 @@ import { pipeWithDisconnect } from "../../utils/streamHandler.js";
import { PROVIDERS } from "../../config/providers.js";
import { STREAM_STALL_TIMEOUT_MS } from "../../config/runtimeConfig.js";
import { buildAbortedResponsesTerminalBytes } from "../../utils/responsesStreamHelpers.js";
import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js";
import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js";
import { saveRequestDetail } from "@/lib/usageDb.js";
import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js";
@@ -43,7 +43,7 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent,
/**
* Handle streaming response — pipe provider SSE through transform stream to client.
*/
export async function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId }) {
export async function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId, pxpipe, reqTag, log }) {
if (onRequestSuccess) {
Promise.resolve()
.then(onRequestSuccess)
@@ -67,7 +67,8 @@ export async function handleStreamingResponse({ providerResponse, provider, mode
const shortMsg = sanitizedTitle
|| (bodyText.length < 200 ? bodyText.replace(/<[^>]*>/g, '').trim().slice(0, 160) : `Upstream returned non-SSE response (${upstreamContentType})`);
const status = providerResponse.status || 502;
console.warn(`[STREAM] ${provider} | ${model} | blocked pipe: ${shortMsg} [${status}]`);
if (log?.errorLine) log.errorLine(reqTag, "✗", `BLOCKED ${status} · ${provider}/${model} · non-SSE (${upstreamContentType})\n ${shortMsg}`);
else console.warn(`[STREAM] ${provider} | ${model} | blocked pipe: ${shortMsg} [${status}]`);
streamController?.handleError?.(new Error(`upstream non-SSE: ${status}`));
return {
success: false,
@@ -94,6 +95,7 @@ export async function handleStreamingResponse({ providerResponse, provider, mode
providerRequest: finalBody || translatedBody || null,
providerResponse: "[Streaming - raw response not captured]",
response: { content: "[Streaming in progress...]", thinking: null, type: "streaming" },
pxpipe,
status: "success"
}, { id: streamDetailId })).catch(err => {
console.error("[RequestDetail] Failed to save streaming request:", err.message);
@@ -108,7 +110,7 @@ export async function handleStreamingResponse({ providerResponse, provider, mode
/**
* Build onStreamComplete callback for streaming usage tracking.
*/
export function buildOnStreamComplete({ provider, model, connectionId, apiKey, requestStartTime, body, stream, finalBody, translatedBody, clientRawRequest }) {
export function buildOnStreamComplete({ provider, model, connectionId, apiKey, requestStartTime, body, stream, finalBody, translatedBody, clientRawRequest, pxpipe, reqTag, log }) {
const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const onStreamComplete = (contentObj, usage, ttftAt) => {
@@ -127,12 +129,15 @@ export function buildOnStreamComplete({ provider, model, connectionId, apiKey, r
providerRequest: finalBody || translatedBody || null,
providerResponse: safeContent,
response: { content: safeContent, thinking: safeThinking, type: "streaming" },
pxpipe,
status: "success"
}, { id: streamDetailId })).catch(err => {
console.error("[RequestDetail] Failed to update streaming content:", err.message);
});
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, label: "STREAM USAGE" });
// Persist stream usage to DB (no console line; the "📊 done" line below is authoritative)
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, label: "STREAM USAGE", silent: true });
if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency }));
};
return { onStreamComplete, streamDetailId };
+47
View File
@@ -273,6 +273,53 @@ const CHAT_SEARCH_CONFIG = {
const tokens = data?.usage?.total_tokens || 0;
return { text, citations, tokens };
}
},
"perplexity-agent": {
endpoint: () => searchEndpoint("perplexity-agent"),
buildBody: (query, model) => ({
model,
input: query,
tools: [{ type: "web_search" }]
}),
buildHeaders: (token) => ({
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
}),
extractAnswer: (data) => {
const output = Array.isArray(data?.output) ? data.output : [];
let text = "";
const citations = [];
for (const item of output) {
const parts = Array.isArray(item?.content) ? item.content : [];
for (const p of parts) {
if (typeof p?.text === "string") text += p.text;
const anns = Array.isArray(p?.annotations) ? p.annotations : [];
for (const a of anns) {
const c = normalizeCitation(a?.url ? a : a?.url_citation);
if (c) citations.push(c);
}
}
const results = Array.isArray(item?.results) ? item.results : [];
for (const r of results) {
const url = r?.url || r?.link;
if (!url) continue;
citations.push({
url,
title: r?.title || "",
snippet: r?.snippet || ""
});
}
}
if (!citations.length && Array.isArray(data?.citations)) {
for (const c of data.citations) {
const n = normalizeCitation(c);
if (n) citations.push(n);
}
}
const tokens = data?.usage?.total_tokens || 0;
return { text, citations, tokens };
}
}
};
+2
View File
@@ -186,6 +186,8 @@ export const PATTERN_CAPABILITIES = [
// ── Grok (vision + Live Search) ──────────────────────────────────
{ pattern: "*grok*image*", caps: { imageOutput: true } },
{ pattern: "*grok-code*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 256000 } },
// Grok 4.5 (Grok CLI / Grok Build): 500k context per cli-chat-proxy /v1/models
{ pattern: "*grok-4.5*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 500000, maxOutput: 64000 } },
{ pattern: "*grok-4*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 256000 } },
{ pattern: "*grok-3*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 131072 } },
{ pattern: "*grok*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 256000 } },
+9
View File
@@ -1,5 +1,14 @@
import { deriveModelName } from "./namePatterns.js";
// Normalize version separators in a model id: hyphen between two digits becomes a dot.
// Registry ids use dots for versions ("claude-sonnet-4.5") but clients (CLIs, aliases)
// often send them with dashes ("claude-sonnet-4-5"). Only digit-digit hyphens are
// touched, so word/suffix hyphens stay intact ("-thinking", "-agentic", "qwen3-coder-next").
export function normalizeModelId(modelId) {
if (typeof modelId !== "string") return modelId;
return modelId.replace(/(\d)-(\d)/g, "$1.$2");
}
// Model defaults centralized (was scattered as `m.kind || "llm"`, `quotaFamily || "normal"`, etc.)
export const MODEL_DEFAULTS = {
kind: "llm",
+24 -22
View File
@@ -28,6 +28,7 @@ export const MODEL_PRICING = {
"claude-sonnet-4.6": { input: 3.00, output: 15.00, cached: 0.30, reasoning: 22.50, cache_creation: 3.00 },
"claude-opus-4-5-thinking": { input: 5.00, output: 25.00, cached: 0.50, reasoning: 37.50, cache_creation: 5.00 },
"claude-opus-4-6-thinking": { input: 5.00, output: 25.00, cached: 0.50, reasoning: 37.50, cache_creation: 5.00 },
"claude-fable-5": { input: 10.00, output: 50.00, cached: 1.00, reasoning: 50.00, cache_creation: 12.50 },
// === OpenAI / GPT ===
"gpt-3.5-turbo": { input: 0.50, output: 1.50, cached: 0.25, reasoning: 2.25, cache_creation: 0.50 },
@@ -36,22 +37,22 @@ export const MODEL_PRICING = {
"gpt-4o": { input: 2.50, output: 10.00, cached: 1.25, reasoning: 15.00, cache_creation: 2.50 },
"gpt-4o-mini": { input: 0.15, output: 0.60, cached: 0.075, reasoning: 0.90, cache_creation: 0.15 },
"gpt-4.1": { input: 2.50, output: 10.00, cached: 1.25, reasoning: 15.00, cache_creation: 2.50 },
"gpt-5": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 },
"gpt-5-mini": { input: 0.75, output: 3.00, cached: 0.375, reasoning: 4.50, cache_creation: 0.75 },
"gpt-5-codex": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 },
"gpt-5.1": { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 },
"gpt-5.1-codex": { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 },
"gpt-5": { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 },
"gpt-5-mini": { input: 0.25, output: 2.00, cached: 0.125, reasoning: 2.00, cache_creation: 0.25 },
"gpt-5-codex": { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 },
"gpt-5.1": { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 },
"gpt-5.1-codex": { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 },
"gpt-5.1-codex-mini": { input: 1.50, output: 6.00, cached: 0.75, reasoning: 9.00, cache_creation: 1.50 },
"gpt-5.1-codex-mini-high": { input: 2.00, output: 8.00, cached: 1.00, reasoning: 12.00, cache_creation: 2.00 },
"gpt-5.1-codex-max": { input: 8.00, output: 32.00, cached: 4.00, reasoning: 48.00, cache_creation: 8.00 },
"gpt-5.2": { input: 5.00, output: 20.00, cached: 2.50, reasoning: 30.00, cache_creation: 5.00 },
"gpt-5.2-codex": { input: 5.00, output: 20.00, cached: 2.50, reasoning: 30.00, cache_creation: 5.00 },
"gpt-5.3-codex": { input: 6.00, output: 24.00, cached: 3.00, reasoning: 36.00, cache_creation: 6.00 },
"gpt-5.3-codex-xhigh": { input: 10.00, output: 40.00, cached: 5.00, reasoning: 60.00, cache_creation: 10.00 },
"gpt-5.3-codex-high": { input: 8.00, output: 32.00, cached: 4.00, reasoning: 48.00, cache_creation: 8.00 },
"gpt-5.3-codex-low": { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 },
"gpt-5.3-codex-none": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 },
"gpt-5.2": { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 },
"gpt-5.2-codex": { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 },
"gpt-5.3-codex": { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 },
"gpt-5.3-codex-spark": { input: 3.00, output: 12.00, cached: 0.30, reasoning: 12.00, cache_creation: 3.00 },
"gpt-5.6": { input: 2.50, output: 15.00, cached: 0.25, reasoning: 15.00, cache_creation: 2.50 },
"gpt-5.6-luna": { input: 1.00, output: 6.00, cached: 0.10, reasoning: 6.00, cache_creation: 1.00 },
"gpt-5.6-terra": { input: 2.50, output: 15.00, cached: 0.25, reasoning: 15.00, cache_creation: 2.50 },
"gpt-5.6-sol": { input: 5.00, output: 30.00, cached: 0.50, reasoning: 30.00, cache_creation: 5.00 },
"o1": { input: 15.00, output: 60.00, cached: 7.50, reasoning: 90.00, cache_creation: 15.00 },
"o1-mini": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 },
@@ -122,7 +123,7 @@ export const MODEL_PRICING = {
* Keyed by provider alias (cc, cx, gc, gh, ...) or provider id (openai, anthropic, ...).
*/
export const PROVIDER_PRICING = {
// GitHub Copilot (gh) — gpt-5.3-codex has different rate than canonical
// GitHub Copilot (gh) — explicit override, matches canonical gpt-5.3-codex rate
gh: {
"gpt-5.3-codex": { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 },
},
@@ -140,11 +141,11 @@ export const PATTERN_PRICING = [
{ pattern: "*-codex-max", pricing: { input: 8.00, output: 32.00, cached: 4.00, reasoning: 48.00, cache_creation: 8.00 } },
{ pattern: "*-codex-mini-*", pricing: { input: 1.50, output: 6.00, cached: 0.75, reasoning: 9.00, cache_creation: 1.50 } },
{ pattern: "*-codex-mini", pricing: { input: 1.50, output: 6.00, cached: 0.75, reasoning: 9.00, cache_creation: 1.50 } },
{ pattern: "*-codex-low", pricing: { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 } },
{ pattern: "*-codex-none", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } },
{ pattern: "*-codex-low", pricing: { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 } },
{ pattern: "*-codex-none", pricing: { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 } },
{ pattern: "*-codex-spark", pricing: { input: 3.00, output: 12.00, cached: 0.30, reasoning: 12.00, cache_creation: 3.00 } },
{ pattern: "codex-*", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } },
{ pattern: "*-codex", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } },
{ pattern: "codex-*", pricing: { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 } },
{ pattern: "*-codex", pricing: { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 } },
// --- Claude ---
{ pattern: "claude-opus-*", pricing: { input: 5.00, output: 25.00, cached: 0.50, reasoning: 25.00, cache_creation: 6.25 } },
@@ -161,11 +162,12 @@ export const PATTERN_PRICING = [
{ pattern: "gemini-*", pricing: { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 } },
// --- GPT (specific first, generic last) ---
{ pattern: "gpt-5.3-*", pricing: { input: 6.00, output: 24.00, cached: 3.00, reasoning: 36.00, cache_creation: 6.00 } },
{ pattern: "gpt-5.2-*", pricing: { input: 5.00, output: 20.00, cached: 2.50, reasoning: 30.00, cache_creation: 5.00 } },
{ pattern: "gpt-5.1-*", pricing: { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 } },
{ pattern: "gpt-5-*", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } },
{ pattern: "gpt-5*", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } },
{ pattern: "gpt-5.6-*", pricing: { input: 2.50, output: 15.00, cached: 0.25, reasoning: 15.00, cache_creation: 2.50 } },
{ pattern: "gpt-5.3-*", pricing: { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 } },
{ pattern: "gpt-5.2-*", pricing: { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 } },
{ pattern: "gpt-5.1-*", pricing: { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 } },
{ pattern: "gpt-5-*", pricing: { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 } },
{ pattern: "gpt-5*", pricing: { input: 1.25, output: 10.00, cached: 0.625, reasoning: 10.00, cache_creation: 1.25 } },
{ pattern: "gpt-4o-*", pricing: { input: 0.15, output: 0.60, cached: 0.075, reasoning: 0.90, cache_creation: 0.15 } },
{ pattern: "gpt-4o", pricing: { input: 2.50, output: 10.00, cached: 1.25, reasoning: 15.00, cache_creation: 2.50 } },
{ pattern: "gpt-4*", pricing: { input: 2.50, output: 10.00, cached: 1.25, reasoning: 15.00, cache_creation: 2.50 } },
+3 -7
View File
@@ -1,5 +1,4 @@
import { platform, arch } from "os";
import { ANTIGRAVITY_OAUTH_CLIENT } from "../shared.js";
import { ANTIGRAVITY_IDE_BASE_URL, ANTIGRAVITY_IDE_USER_AGENT, ANTIGRAVITY_OAUTH_CLIENT } from "../shared.js";
export default {
id: "antigravity",
@@ -20,13 +19,10 @@ export default {
category: "oauth",
serviceKinds: ["llm", "image"],
transport: {
baseUrls: [
"https://daily-cloudcode-pa.googleapis.com",
"https://daily-cloudcode-pa.sandbox.googleapis.com",
],
baseUrls: [ANTIGRAVITY_IDE_BASE_URL],
format: "antigravity",
headers: {
"User-Agent": "antigravity/1.107.0 darwin/arm64",
"User-Agent": ANTIGRAVITY_IDE_USER_AGENT,
},
retry: {
"429": {
+2 -4
View File
@@ -60,12 +60,10 @@ export default {
},
},
models: [
{ id: "claude-fable-5", name: "Claude Fable 5" },
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
{ id: "claude-opus-4-7", name: "Claude Opus 4.7" },
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ id: "claude-opus-4-5-20251101", name: "Claude 4.5 Opus" },
{ id: "claude-sonnet-4-5-20250929", name: "Claude 4.5 Sonnet" },
{ id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" },
],
oauth: {
+6 -10
View File
@@ -45,22 +45,18 @@ export default {
},
},
models: [
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol" },
{ id: "gpt-5.6-sol-review", name: "GPT 5.6 Sol Review", upstreamModelId: "gpt-5.6-sol", quotaFamily: "review" },
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra" },
{ id: "gpt-5.6-terra-review", name: "GPT 5.6 Terra Review", upstreamModelId: "gpt-5.6-terra", quotaFamily: "review" },
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna" },
{ id: "gpt-5.6-luna-review", name: "GPT 5.6 Luna Review", upstreamModelId: "gpt-5.6-luna", quotaFamily: "review" },
{ id: "gpt-5.5", name: "GPT 5.5" },
{ id: "gpt-5.5-review", name: "GPT 5.5 Review", upstreamModelId: "gpt-5.5", quotaFamily: "review" },
{ id: "gpt-5.4", name: "GPT 5.4" },
{ id: "gpt-5.4-review", name: "GPT 5.4 Review", upstreamModelId: "gpt-5.4", quotaFamily: "review" },
{ id: "gpt-5.4-mini", name: "GPT 5.4 Mini" },
{ id: "gpt-5.4-mini-review", name: "GPT 5.4 Mini Review", upstreamModelId: "gpt-5.4-mini", quotaFamily: "review" },
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex" },
{ id: "gpt-5.3-codex-review", name: "GPT 5.3 Codex Review", upstreamModelId: "gpt-5.3-codex", quotaFamily: "review" },
{ id: "gpt-5.3-codex-xhigh", name: "GPT 5.3 Codex (xHigh)" },
{ id: "gpt-5.3-codex-xhigh-review", name: "GPT 5.3 Codex (xHigh) Review", upstreamModelId: "gpt-5.3-codex-xhigh", quotaFamily: "review" },
{ id: "gpt-5.3-codex-high", name: "GPT 5.3 Codex (High)" },
{ id: "gpt-5.3-codex-high-review", name: "GPT 5.3 Codex (High) Review", upstreamModelId: "gpt-5.3-codex-high", quotaFamily: "review" },
{ id: "gpt-5.3-codex-low", name: "GPT 5.3 Codex (Low)" },
{ id: "gpt-5.3-codex-low-review", name: "GPT 5.3 Codex (Low) Review", upstreamModelId: "gpt-5.3-codex-low", quotaFamily: "review" },
{ id: "gpt-5.3-codex-none", name: "GPT 5.3 Codex (None)" },
{ id: "gpt-5.3-codex-none-review", name: "GPT 5.3 Codex (None) Review", upstreamModelId: "gpt-5.3-codex-none", quotaFamily: "review" },
{ id: "gpt-5.3-codex-spark", name: "GPT 5.3 Codex Spark" },
{ id: "gpt-5.3-codex-spark-review", name: "GPT 5.3 Codex Spark Review", upstreamModelId: "gpt-5.3-codex-spark", quotaFamily: "review" },
{ id: "gpt-5.5-image", name: "GPT 5.5 Image", capabilities: ["text2img","edit"], params: ["size","quality","background","image_detail","output_format"], kind: "image" },
@@ -0,0 +1,34 @@
export default {
id: "featherless",
priority: 65,
alias: "featherless",
aliases: [
"fl",
],
uiAlias: "fl",
display: {
name: "Featherless",
icon: "flutter_dash",
color: "#111827",
textIcon: "FL",
website: "https://featherless.ai",
notice: {
apiKeyUrl: "https://featherless.ai/account/api-keys",
},
},
category: "apikey",
authType: "apikey",
transport: {
baseUrl: "https://api.featherless.ai/v1/chat/completions",
validateUrl: "https://api.featherless.ai/v1/models",
},
models: [
{ id: "deepseek-ai/DeepSeek-V4-Pro", name: "DeepSeek V4 Pro" },
{ id: "deepseek-ai/DeepSeek-V4-Flash", name: "DeepSeek V4 Flash" },
{ id: "zai-org/GLM-5.2", name: "GLM 5.2" },
{ id: "zai-org/GLM-5.1", name: "GLM 5.1" },
{ id: "moonshotai/Kimi-K2.7-Code", name: "Kimi K2.7 Code" },
{ id: "moonshotai/Kimi-K2.6", name: "Kimi K2.6" },
{ id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5" },
],
};
+86
View File
@@ -0,0 +1,86 @@
/**
* Grok CLI / Grok Build (cli-chat-proxy.grok.com)
*
* Source of truth: HAR capture of official grok-shell/grok-pager 0.2.93
* talking to https://cli-chat-proxy.grok.com (OpenAI Responses API).
*
* Distinct from:
* - `xai` → api.x.ai (API key / Grok Build OAuth PKCE)
* - `grok-web` → grok.com web SSO cookie
*/
export default {
id: "grok-cli",
priority: 275,
alias: "gcli",
aliases: ["grok-build", "gb"],
uiAlias: "gcli",
display: {
name: "Grok CLI (Grok Build)",
icon: "auto_awesome",
color: "#1DA1F2",
textIcon: "GC",
website: "https://x.ai",
notice: {
text: "Sign in with your xAI / Grok account via device code. Uses Grok Build subscription credits (cli-chat-proxy.grok.com).",
signupUrl: "https://grok.com/supergrok",
},
},
category: "oauth",
authModes: ["oauth"],
hasOAuth: true,
thinkingConfig: {
options: ["low", "medium", "high"],
defaultMode: "high",
},
transport: {
baseUrl: "https://cli-chat-proxy.grok.com/v1/responses",
format: "openai-responses",
forceStream: true,
modelsUrl: "https://cli-chat-proxy.grok.com/v1/models",
userUrl: "https://cli-chat-proxy.grok.com/v1/user",
billingUrl: "https://cli-chat-proxy.grok.com/v1/billing",
clientVersion: "0.2.93",
clientIdentifier: "grok-pager",
tokenAuth: "xai-grok-cli",
headers: {
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
"x-xai-token-auth": "xai-grok-cli",
"x-grok-client-identifier": "grok-pager",
"x-grok-client-version": "0.2.93",
"x-authenticateresponse": "authenticate-response",
},
// Compaction threshold mirrored from CLI (x-compaction-at)
compactionAt: 400000,
// Quota tracker: official CLI polls billing?format=credits + user?include=subscription
usage: {
url: "https://cli-chat-proxy.grok.com/v1/billing?format=credits",
userUrl: "https://cli-chat-proxy.grok.com/v1/user?include=subscription",
},
retry: {
429: { attempts: 2, delayMs: 2000 },
502: { attempts: 2, delayMs: 1500 },
503: { attempts: 2, delayMs: 1500 },
},
},
models: [
{ id: "grok-4.5", name: "Grok 4.5" },
{ id: "grok-4.5-high", name: "Grok 4.5 (High)", upstreamModelId: "grok-4.5" },
{ id: "grok-4.5-medium", name: "Grok 4.5 (Medium)", upstreamModelId: "grok-4.5" },
{ id: "grok-4.5-low", name: "Grok 4.5 (Low)", upstreamModelId: "grok-4.5" },
],
features: {
usage: true,
},
oauth: {
// Same public client_id as Grok CLI / existing xai OAuth
clientId: "b1a00492-073a-47ea-816f-4c329264a828",
deviceCodeUrl: "https://auth.x.ai/oauth2/device/code",
tokenUrl: "https://auth.x.ai/oauth2/token",
refreshUrl: "https://auth.x.ai/oauth2/token",
// HAR scope includes conversations read/write beyond the api-only xai scope
scope:
"openid profile email offline_access grok-cli:access api:access conversations:read conversations:write",
referrer: "grok-build",
refreshLeadMs: 5 * 60 * 1000,
},
};
+74 -68
View File
@@ -1,4 +1,4 @@
// Auto-generated: static imports of all registry entries
// Auto-generated: static imports for all registry entries
import p0 from "./alicode-intl.js";
import p1 from "./alicode.js";
import p2 from "./anthropic.js";
@@ -30,72 +30,75 @@ import p27 from "./edge-tts.js";
import p28 from "./elevenlabs.js";
import p29 from "./exa.js";
import p30 from "./fal-ai.js";
import p31 from "./firecrawl.js";
import p32 from "./fireworks.js";
import p33 from "./gemini-cli.js";
import p34 from "./gemini.js";
import p35 from "./github.js";
import p36 from "./gitlab.js";
import p37 from "./glm-cn.js";
import p38 from "./glm.js";
import p39 from "./google-pse.js";
import p40 from "./google-tts.js";
import p41 from "./grok-web.js";
import p42 from "./groq.js";
import p43 from "./huggingface.js";
import p44 from "./hyperbolic.js";
import p45 from "./iflow.js";
import p46 from "./inworld.js";
import p47 from "./jina-ai.js";
import p48 from "./jina-reader.js";
import p49 from "./kilocode.js";
import p50 from "./kimchi.js";
import p51 from "./kimi-coding.js";
import p52 from "./kimi.js";
import p53 from "./kiro.js";
import p54 from "./linkup.js";
import p55 from "./local-device.js";
import p56 from "./mimo-free.js";
import p57 from "./minimax-cn.js";
import p58 from "./minimax.js";
import p59 from "./mistral.js";
import p60 from "./mmf.js";
import p61 from "./nanobanana.js";
import p62 from "./nebius.js";
import p63 from "./nvidia.js";
import p64 from "./ollama-local.js";
import p65 from "./ollama.js";
import p66 from "./openai.js";
import p67 from "./opencode-go.js";
import p68 from "./opencode.js";
import p69 from "./openrouter.js";
import p70 from "./perplexity-web.js";
import p71 from "./perplexity.js";
import p72 from "./playht.js";
import p73 from "./qoder.js";
import p74 from "./qwen.js";
import p75 from "./recraft.js";
import p76 from "./runwayml.js";
import p77 from "./sdwebui.js";
import p78 from "./searchapi.js";
import p79 from "./searxng.js";
import p80 from "./serper.js";
import p81 from "./siliconflow.js";
import p82 from "./stability-ai.js";
import p83 from "./tavily.js";
import p84 from "./together.js";
import p85 from "./topaz.js";
import p86 from "./tortoise.js";
import p87 from "./venice.js";
import p88 from "./vercel-ai-gateway.js";
import p89 from "./vertex-partner.js";
import p90 from "./vertex.js";
import p91 from "./volcengine-ark.js";
import p92 from "./voyage-ai.js";
import p93 from "./xai.js";
import p94 from "./xiaomi-mimo.js";
import p95 from "./xiaomi-tokenplan.js";
import p96 from "./youcom.js";
import p31 from "./featherless.js";
import p32 from "./firecrawl.js";
import p33 from "./fireworks.js";
import p34 from "./gemini-cli.js";
import p35 from "./gemini.js";
import p36 from "./github.js";
import p37 from "./gitlab.js";
import p38 from "./glm-cn.js";
import p39 from "./glm.js";
import p40 from "./google-pse.js";
import p41 from "./google-tts.js";
import p42 from "./grok-cli.js";
import p43 from "./grok-web.js";
import p44 from "./groq.js";
import p45 from "./huggingface.js";
import p46 from "./hyperbolic.js";
import p47 from "./iflow.js";
import p48 from "./inworld.js";
import p49 from "./jina-ai.js";
import p50 from "./jina-reader.js";
import p51 from "./kilocode.js";
import p52 from "./kimchi.js";
import p53 from "./kimi-coding.js";
import p54 from "./kimi.js";
import p55 from "./kiro.js";
import p56 from "./linkup.js";
import p57 from "./local-device.js";
import p58 from "./mimo-free.js";
import p59 from "./minimax-cn.js";
import p60 from "./minimax.js";
import p61 from "./mistral.js";
import p62 from "./mmf.js";
import p63 from "./nanobanana.js";
import p64 from "./nebius.js";
import p65 from "./nvidia.js";
import p66 from "./ollama-local.js";
import p67 from "./ollama.js";
import p68 from "./openai.js";
import p69 from "./opencode-go.js";
import p70 from "./opencode.js";
import p71 from "./openrouter.js";
import p72 from "./perplexity-web.js";
import p73 from "./perplexity.js";
import p74 from "./perplexity-agent.js";
import p75 from "./playht.js";
import p76 from "./qoder.js";
import p77 from "./qwen.js";
import p78 from "./recraft.js";
import p79 from "./runwayml.js";
import p80 from "./sdwebui.js";
import p81 from "./searchapi.js";
import p82 from "./searxng.js";
import p83 from "./serper.js";
import p84 from "./siliconflow.js";
import p85 from "./stability-ai.js";
import p86 from "./tavily.js";
import p87 from "./together.js";
import p88 from "./topaz.js";
import p89 from "./tortoise.js";
import p90 from "./venice.js";
import p91 from "./vercel-ai-gateway.js";
import p92 from "./vertex-partner.js";
import p93 from "./vertex.js";
import p94 from "./volcengine-ark.js";
import p95 from "./voyage-ai.js";
import p96 from "./xai.js";
import p97 from "./xiaomi-mimo.js";
import p98 from "./xiaomi-tokenplan.js";
import p99 from "./youcom.js";
export default [
p0,
@@ -194,5 +197,8 @@ export default [
p93,
p94,
p95,
p96
p96,
p97,
p98,
p99
];
+19
View File
@@ -42,19 +42,38 @@ export default {
},
},
models: [
// Opus (added per kiro.dev/changelog/models and kiro.dev/docs/models)
{ id: "claude-opus-4.8", name: "Claude Opus 4.8" },
{ id: "claude-opus-4.8-thinking", name: "Claude Opus 4.8 (Thinking)" },
{ id: "claude-opus-4.8-agentic", name: "Claude Opus 4.8 (Agentic)" },
{ id: "claude-opus-4.8-thinking-agentic", name: "Claude Opus 4.8 (Thinking + Agentic)" },
{ id: "claude-opus-4.7", name: "Claude Opus 4.7" },
{ id: "claude-opus-4.7-thinking", name: "Claude Opus 4.7 (Thinking)" },
{ id: "claude-opus-4.7-agentic", name: "Claude Opus 4.7 (Agentic)" },
{ id: "claude-opus-4.7-thinking-agentic", name: "Claude Opus 4.7 (Thinking + Agentic)" },
{ id: "claude-opus-4.5", name: "Claude Opus 4.5" },
{ id: "claude-opus-4.5-thinking", name: "Claude Opus 4.5 (Thinking)" },
{ id: "claude-opus-4.5-agentic", name: "Claude Opus 4.5 (Agentic)" },
{ id: "claude-opus-4.5-thinking-agentic", name: "Claude Opus 4.5 (Thinking + Agentic)" },
// Sonnet
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
// Haiku
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
// Non-Anthropic
{ id: "deepseek-3.2", name: "DeepSeek 3.2", strip: ["image","audio"] },
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next", strip: ["image","audio"] },
{ id: "glm-5", name: "GLM 5" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
// Thinking variants
{ id: "claude-sonnet-5-thinking", name: "Claude Sonnet 5 (Thinking)" },
{ id: "claude-sonnet-4.5-thinking", name: "Claude Sonnet 4.5 (Thinking)" },
{ id: "claude-haiku-4.5-thinking", name: "Claude Haiku 4.5 (Thinking)" },
// Agentic variants
{ id: "claude-sonnet-5-agentic", name: "Claude Sonnet 5 (Agentic)" },
{ id: "claude-sonnet-4.5-agentic", name: "Claude Sonnet 4.5 (Agentic)" },
{ id: "claude-haiku-4.5-agentic", name: "Claude Haiku 4.5 (Agentic)" },
// Thinking + Agentic variants
{ id: "claude-sonnet-5-thinking-agentic", name: "Claude Sonnet 5 (Thinking + Agentic)" },
{ id: "claude-sonnet-4.5-thinking-agentic", name: "Claude Sonnet 4.5 (Thinking + Agentic)" },
{ id: "claude-haiku-4.5-thinking-agentic", name: "Claude Haiku 4.5 (Thinking + Agentic)" },
@@ -0,0 +1,49 @@
export default {
id: "perplexity-agent",
priority: 181,
alias: "perplexity-agent",
aliases: [
"pplx-agent",
"pplx-responses",
],
uiAlias: "pa",
display: {
name: "Perplexity Agent",
icon: "travel_explore",
color: "#20808D",
textIcon: "PA",
website: "https://www.perplexity.ai",
notice: {
text: "Perplexity Agent API exposes GPT, Claude, Gemini, Grok, GLM, Kimi, and Sonar models through one OpenAI-compatible Responses API.",
apiKeyUrl: "https://www.perplexity.ai/settings/api",
},
},
category: "apikey",
authType: "apikey",
transport: {
baseUrl: "https://api.perplexity.ai/v1/responses",
validateUrl: "https://api.perplexity.ai/v1/models",
format: "openai-responses",
},
models: [
{ id: "perplexity/sonar", name: "Perplexity Sonar" },
{ id: "openai/gpt-5.5", name: "GPT-5.5" },
{ id: "openai/gpt-5.4", name: "GPT-5.4" },
{ id: "openai/gpt-5.4-mini", name: "GPT-5.4 Mini" },
{ id: "anthropic/claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ id: "anthropic/claude-opus-4-8", name: "Claude Opus 4.8" },
{ id: "google/gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" },
{ id: "xai/grok-4.20-reasoning", name: "Grok 4.20 Reasoning" },
{ id: "perplexity/glm-5.2", name: "GLM 5.2" },
{ id: "perplexity/kimi-k2.7-code", name: "Kimi K2.7 Code" },
{ id: "nvidia/nemotron-3-super-120b-a12b", name: "Nemotron 3 Super 120B" },
],
serviceKinds: ["llm", "webSearch"],
searchViaChat: {
defaultModel: "perplexity/sonar",
endpoint: "https://api.perplexity.ai/v1/responses",
pricingUrl: "https://docs.perplexity.ai/docs/agent-api/models",
},
modelsFetcher: { url: "https://api.perplexity.ai/v1/models", type: "openai" },
passthroughModels: true,
};
+3 -1
View File
@@ -1,3 +1,5 @@
import { SEARXNG_URL } from "../../config/runtimeConfig.js";
export default {
id: "searxng",
alias: "searxng",
@@ -15,7 +17,7 @@ export default {
],
noAuth: true,
searchConfig: {
baseUrl: "http://localhost:8888/search",
baseUrl: SEARXNG_URL,
method: "GET",
authType: "none",
authHeader: "none",
+7
View File
@@ -54,6 +54,13 @@ export const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/v1/messages";
export const OPENAI_COMPAT_BASE = "https://api.openai.com/v1";
export const ANTHROPIC_COMPAT_BASE = "https://api.anthropic.com/v1";
// Official Antigravity IDE Desktop 2.1.1 fingerprint captured from macOS arm64.
// Keep this static even when 9router runs on Linux: the provider profile is
// intentionally matching the IDE client, not the server host.
export const ANTIGRAVITY_IDE_VERSION = "2.1.1";
export const ANTIGRAVITY_IDE_BASE_URL = "https://cloudcode-pa.googleapis.com";
export const ANTIGRAVITY_IDE_USER_AGENT = `antigravity/ide/${ANTIGRAVITY_IDE_VERSION} darwin/arm64`;
// Antigravity OAuth client credentials (public CLI client — duplicated in usage.js + src/lib/oauth)
export const ANTIGRAVITY_OAUTH_CLIENT = {
clientId: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
+48
View File
@@ -0,0 +1,48 @@
// Resolve valid thinking levels per model — drives UI level picker (suffix "model(level)").
// Reuses capabilities.js (thinkingFormat/canDisable) so this file only maps format→levels (DRY).
import { getCapabilitiesForModel } from "./capabilities.js";
import { matchPattern } from "./pricing.js";
// Shared level sets (deduped) — verified against provider docs + wire in thinkingUnified.applyFormat.
const L = {
base: ["none", "low", "medium", "high"], // qwen, step, hunyuan, gemini-budget
onOff: ["none", "thinking"], // zai (binary), minimax (adaptive)
openai: ["none", "minimal", "low", "medium", "high", "xhigh"], // GPT-5.x / o-series (no "max")
levelMax: ["none", "low", "medium", "high", "max"], // claude-adaptive, kimi
budgetX: ["none", "low", "medium", "high", "xhigh", "max"], // claude-budget
gemini: ["minimal", "low", "medium", "high"], // gemini-3 thinkingLevel (no disable)
hiMax: ["none", "high", "max"], // deepseek (low/med→high, xhigh→max)
};
// thinkingFormat → valid selectable levels (source of truth for UI options).
const FORMAT_LEVELS = {
openai: L.openai,
"claude-adaptive": L.levelMax,
"claude-budget": L.budgetX,
"gemini-level": L.gemini,
"gemini-budget": L.base,
zai: L.onOff,
qwen: L.base,
kimi: L.levelMax,
deepseek: L.hiMax,
minimax: L.onOff,
hunyuan: L.base,
step: L.base,
};
// Model-name pattern overrides (glob, first match wins) — more precise than format default.
const PATTERN_THINKING = [
// gpt-5.6-sol accepts max (maps to xhigh on wire); live probe rejected ultra.
{ pattern: "*gpt-5.6-sol*", levels: ["none", "minimal", "low", "medium", "high", "xhigh", "max"] },
{ pattern: "*codex*", levels: ["low", "medium", "high", "xhigh"] }, // codex cannot disable thinking
];
// Returns valid thinking levels for a model, or null when the model has no reasoning.
export function getThinkingLevels(provider, model) {
const caps = getCapabilitiesForModel(provider, model);
if (!caps.reasoning) return null;
const hit = PATTERN_THINKING.find((p) => matchPattern(p.pattern, model));
let levels = hit?.levels || FORMAT_LEVELS[caps.thinkingFormat] || L.base;
if (caps.thinkingCanDisable === false) levels = levels.filter((l) => l !== "none");
return levels;
}
+10 -2
View File
@@ -1,9 +1,10 @@
// Port of auto_detect_filter (rtk/src/cmds/system/pipe_cmd.rs:132-188) + JS extras
// Order: git-diff → git-status → build-output → grep → find → tree → ls → search-list
// → read-numbered → dedup-log → smart-truncate → null
// Detection order: git-log → git-diff → git-status → build-output → grep → find → tree → ls → search-list
// → read-numbered → dedup-log → smart-truncate → null
import { DETECT_WINDOW, READ_NUMBERED_MIN_HIT_RATIO, SMART_TRUNCATE_MIN_LINES } from "./constants.js";
import { gitDiff } from "./filters/gitDiff.js";
import { gitStatus } from "./filters/gitStatus.js";
import { gitLog } from "./filters/gitLog.js";
import { buildOutput } from "./filters/buildOutput.js";
import { grep } from "./filters/grep.js";
import { find } from "./filters/find.js";
@@ -17,6 +18,7 @@ import { searchList, SEARCH_LIST_HEADER_RE } from "./filters/searchList.js";
const RE_GIT_DIFF = /^diff --git /m;
const RE_GIT_DIFF_HUNK = /^@@ /m;
const RE_GIT_STATUS = /^On branch |^nothing to commit|^Changes (not |to be )|^Untracked files:/m;
const RE_GIT_LOG = /^[*|/\\ ]*commit [0-9a-f]{7,40}$/m;
const RE_PORCELAIN = /^[ MADRCU?!][ MADRCU?!] \S/m;
const RE_BUILD_OUTPUT = /^(npm (warn|error|ERR!)|yarn (warn|error)|\s*Compiling\s+\S+|\s*Downloading\s+\S+|added \d+ package|\[ERROR\]|BUILD (SUCCESS|FAILED)|\s*Finished\s+|Successfully (installed|built)|ERROR:)/im;
const RE_TREE_GLYPH = /[├└]──|│ /;
@@ -27,6 +29,7 @@ export function autoDetectFilter(text) {
// Rust: floor_char_boundary to avoid UTF-8 split — JS .slice() by char is safe
const head = text.length > DETECT_WINDOW ? text.slice(0, DETECT_WINDOW) : text;
if (RE_GIT_LOG.test(head)) return gitLog;
if (RE_GIT_DIFF.test(head) || RE_GIT_DIFF_HUNK.test(head)) return gitDiff;
if (RE_GIT_STATUS.test(head)) return gitStatus;
@@ -81,6 +84,11 @@ function isGrepLine(line) {
function isPathLike(line) {
const t = line.trim();
if (t.length === 0) return false;
// A drive-letter prefix (e.g. "C:\Users\me" or "C:/Users/me") marks a
// Windows absolute path, so treat the whole line as path-like. Trailing
// colons (e.g. "C:\path\file.js:10") are tolerated, matching grep-style
// suffixes on Windows dumps.
if (/^[A-Za-z]:[\\/]/.test(t)) return true;
if (t.includes(":")) return false;
return t.startsWith(".") || t.startsWith("/") || t.includes("/");
}
+34 -2
View File
@@ -18,6 +18,14 @@ const SHARED_AUTO_CLARITY = "Auto-Clarity: drop caveman for security warnings, i
const SHARED_PERSISTENCE = "ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure.";
const SHARED_NO_INVENTED_ABBREV = "No invented abbreviations. Standard well-known tech acronyms (DB, API, HTTP, URL, JSON, ID, OS, CPU) OK. Names of code symbols, function names, API names, error strings: keep verbatim.";
const SHARED_PRESERVE_LANGUAGE = "Preserve the user's dominant language. User wrote Vietnamese, reply Vietnamese. User wrote English, reply English. Wenyan/classical-Chinese levels override this language-preservation rule. Code identifiers, error strings, file paths, commands: keep in their original form regardless of language.";
const SHARED_NO_SELF_REFERENCE = 'No self-reference. Do not name or announce the style (no "caveman mode", no "me caveman think", no "compressed mode active"). Just respond.';
const SHARED_NO_DECORATION = 'No decorative emoji. No narrating tool calls ("I will now search", "I used X to find Y"). No status phrases ("Sure!", "Of course!", "I\'d be happy to"). No causal arrow shorthand ("A -> B -> fails"). State the thing, the action, the reason. Then next step.';
export const CAVEMAN_PROMPTS = {
[CAVEMAN_LEVELS.LITE]: [
"Respond tersely. Keep grammar and full sentences but drop filler, hedging and pleasantries (just/really/basically/sure/of course/I'd be happy to).",
@@ -26,6 +34,10 @@ export const CAVEMAN_PROMPTS = {
SHARED_BOUNDARIES,
SHARED_AUTO_CLARITY,
SHARED_PERSISTENCE,
SHARED_NO_INVENTED_ABBREV,
SHARED_PRESERVE_LANGUAGE,
SHARED_NO_SELF_REFERENCE,
SHARED_NO_DECORATION,
].join(" "),
[CAVEMAN_LEVELS.FULL]: [
@@ -36,16 +48,24 @@ export const CAVEMAN_PROMPTS = {
SHARED_BOUNDARIES,
SHARED_AUTO_CLARITY,
SHARED_PERSISTENCE,
SHARED_NO_INVENTED_ABBREV,
SHARED_PRESERVE_LANGUAGE,
SHARED_NO_SELF_REFERENCE,
SHARED_NO_DECORATION,
].join(" "),
[CAVEMAN_LEVELS.ULTRA]: [
"Respond ultra-terse. Maximum compression. Telegraphic.",
"Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, use arrows for causality (X → Y). One word when one word enough.",
"Pattern: [thing] → [result]. [fix].",
"Strip conjunctions. One word when one word enough.",
"Pattern: [thing] [action] [reason]. [next step].",
SHARED_EXAMPLES,
SHARED_BOUNDARIES,
SHARED_AUTO_CLARITY,
SHARED_PERSISTENCE,
SHARED_NO_INVENTED_ABBREV,
SHARED_PRESERVE_LANGUAGE,
SHARED_NO_SELF_REFERENCE,
SHARED_NO_DECORATION,
].join(" "),
[CAVEMAN_LEVELS.WENYAN_LITE]: [
@@ -55,6 +75,10 @@ export const CAVEMAN_PROMPTS = {
SHARED_BOUNDARIES,
SHARED_AUTO_CLARITY,
SHARED_PERSISTENCE,
SHARED_NO_INVENTED_ABBREV,
SHARED_PRESERVE_LANGUAGE,
SHARED_NO_SELF_REFERENCE,
SHARED_NO_DECORATION,
].join(" "),
[CAVEMAN_LEVELS.WENYAN]: [
@@ -65,6 +89,10 @@ export const CAVEMAN_PROMPTS = {
SHARED_BOUNDARIES,
SHARED_AUTO_CLARITY,
SHARED_PERSISTENCE,
SHARED_NO_INVENTED_ABBREV,
SHARED_PRESERVE_LANGUAGE,
SHARED_NO_SELF_REFERENCE,
SHARED_NO_DECORATION,
].join(" "),
[CAVEMAN_LEVELS.WENYAN_ULTRA]: [
@@ -74,5 +102,9 @@ export const CAVEMAN_PROMPTS = {
SHARED_BOUNDARIES,
SHARED_AUTO_CLARITY,
SHARED_PERSISTENCE,
SHARED_NO_INVENTED_ABBREV,
SHARED_PRESERVE_LANGUAGE,
SHARED_NO_SELF_REFERENCE,
SHARED_NO_DECORATION,
].join(" "),
};
+1
View File
@@ -4,6 +4,7 @@ export const MIN_COMPRESS_SIZE = 500; // bytes; skip tiny blobs
export const DETECT_WINDOW = 1024; // autodetect peeks first N chars
export const GIT_DIFF_HUNK_MAX_LINES = 100; // per-hunk line cap
export const GIT_DIFF_CONTEXT_KEEP = 3; // context lines around changes
export const GIT_LOG_MAX_LINES = 200; // gitLog line cap
export const DEDUP_LINE_MAX = 2000; // dedupLog truncation cap
// Rust pipe_cmd.rs parity caps
+7 -5
View File
@@ -9,16 +9,17 @@ export function find(input) {
const byDir = new Map();
for (const path of lines) {
const lastSlash = path.lastIndexOf("/");
// Accept both Unix ("/a/b") and Windows ("C:\a\b") separators
const lastSep = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
let dir;
let basename;
if (lastSlash === -1) {
if (lastSep === -1) {
dir = ".";
basename = path;
} else {
// Rust: PathBuf::from(path).parent().display() + file_name().display()
dir = path.slice(0, lastSlash) || "/";
basename = path.slice(lastSlash + 1);
dir = path.slice(0, lastSep) || "/";
basename = path.slice(lastSep + 1);
}
if (!byDir.has(dir)) byDir.set(dir, []);
byDir.get(dir).push(basename);
@@ -31,7 +32,8 @@ export function find(input) {
const showDirs = dirs.slice(0, FIND_TOTAL_DIR_MAX);
for (const dir of showDirs) {
const files = byDir.get(dir);
out += `${dir}/ (${files.length})\n`;
const dirLabel = dir.replace(/\\/g, "/");
out += `${dirLabel}/ (${files.length})\n`;
const showFiles = files.slice(0, FIND_PER_DIR_MAX);
for (const f of showFiles) out += ` ${f}\n`;
if (files.length > FIND_PER_DIR_MAX) {
+99
View File
@@ -0,0 +1,99 @@
// JS-native git-log filter
// Compresses `git log` output: keeps commit headers, subjects, Author/Date;
// drops body padding, decoration, embedded diff lines.
import { GIT_LOG_MAX_LINES } from "../constants.js";
export function gitLog(text, maxLines = GIT_LOG_MAX_LINES) {
if (!text) return "";
const input = String(text);
const lines = input.split("\n");
const out = [];
let skipped = 0;
let inCommit = false;
let subjectSeen = false;
function pushLine(l) {
if (out.length < maxLines) {
out.push(l);
return true;
}
skipped++;
return false;
}
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
const line = raw.trimEnd();
const trimmed = line.trim();
// commit <sha> header — starts new commit entry
// Also matched with leading graph decoration (`* commit abc1234...` — --graph without --oneline)
if (/^commit [0-9a-f]{7,40}$/i.test(trimmed) || /^[*|/\\ ]+commit [0-9a-f]{7,40}/i.test(trimmed)) {
inCommit = true;
subjectSeen = false;
pushLine(line);
continue;
}
if (inCommit) {
// Author / Date — keep as-is (already column 0 in raw, or graph-prefix stripped by commit-header match)
if (/^[*|/\\ ]*(Author|Date):/i.test(trimmed)) {
pushLine(trimmed);
continue;
}
// blank — skip
if (trimmed === "") continue;
// indented subject (4 spaces, optionally preceded by graph decoration) — first one is subject
if (!subjectSeen && /^[*|/\\ ]* \S/.test(line)) {
pushLine(" Subject: " + trimmed);
subjectSeen = true;
continue;
}
// stat summary: "N file(s) changed, N insertions(+), N deletions(-)"
if (/^\d+ file\w* changed/.test(trimmed)) {
pushLine(" " + trimmed);
continue;
}
// embedded diff header — one-line marker
if (/^diff --git /.test(trimmed)) {
pushLine(" ... diff body omitted");
continue;
}
// everything else in commit body — drop
continue;
}
// Not in a commit block (--oneline / --graph modes):
// Graph decoration + sha + subject: "*|/\\ <sha7> <subject>"
const graphMatch = trimmed.match(/^[*|/\\ ]+([0-9a-f]{7,40}\s+.+)/i);
if (graphMatch) {
pushLine(graphMatch[1]);
continue;
}
// Plain oneline: "<sha7> <subject>"
if (/^[0-9a-f]{7,40}\s+/.test(trimmed)) {
pushLine(trimmed);
continue;
}
// Pure graph decoration (no sha) — drop
if (/^[*|/\\ ]+$/.test(trimmed) && /[*|/\\]/.test(trimmed)) {
continue;
}
// catch-all pass-through
pushLine(trimmed);
}
if (skipped > 0) out.push(`... (${skipped} more lines)`);
const result = out.join("\n");
if (!result && input) return input;
if (result.length > input.length) return input;
return result;
}
gitLog.filterName = "git-log";
+133
View File
@@ -18,6 +18,8 @@ function jsonBytes(value) {
function messagePayload(body) {
if (Array.isArray(body?.messages)) return body.messages;
if (Array.isArray(body?.input)) return body.input;
const kiro = collectKiroHeadroomMessages(body);
if (kiro) return kiro.messages;
return null;
}
@@ -81,6 +83,121 @@ function hasUnsafeResponsesInputForCompression(body) {
});
}
function collectKiroHeadroomMessages(body) {
const state = body?.conversationState;
if (!state || typeof state !== "object") return null;
const messages = [];
const targets = [];
const addTextTarget = (role, text, target, extra = {}) => {
if (typeof text !== "string") return;
messages.push({ role, content: text, ...extra });
targets.push(target);
};
const toToolCalls = (toolUses) => {
if (!Array.isArray(toolUses) || toolUses.length === 0) return undefined;
const calls = toolUses.map((toolUse) => ({
id: toolUse?.toolUseId,
type: "function",
function: {
name: toolUse?.name || "",
arguments: JSON.stringify(toolUse?.input || {}),
},
})).filter((call) => call.id || call.function.name);
return calls.length > 0 ? calls : undefined;
};
const visit = (item) => {
const user = item?.userInputMessage;
if (user) {
addTextTarget("system", user.systemInstruction, { object: user, key: "systemInstruction" });
addTextTarget("user", user.content, { object: user, key: "content" });
const toolResults = user.userInputMessageContext?.toolResults;
if (Array.isArray(toolResults)) {
for (const toolResult of toolResults) {
const content = toolResult?.content;
if (!Array.isArray(content)) continue;
for (const part of content) {
addTextTarget(
"tool",
part?.text,
{ object: part, key: "text" },
toolResult?.toolUseId ? { tool_call_id: toolResult.toolUseId } : {}
);
}
}
}
return;
}
const assistant = item?.assistantResponseMessage;
if (assistant) {
const toolCalls = toToolCalls(assistant.toolUses);
addTextTarget(
"assistant",
assistant.content,
{ object: assistant, key: "content" },
toolCalls ? { tool_calls: toolCalls } : {}
);
}
};
if (Array.isArray(state.history)) {
for (const item of state.history) visit(item);
}
if (state.currentMessage) visit(state.currentMessage);
return messages.length > 0 ? { messages, targets } : null;
}
function textFromHeadroomMessage(message) {
const content = message?.content;
if (typeof content === "string") return content;
if (!Array.isArray(content)) return null;
const parts = [];
for (const part of content) {
if (typeof part === "string") {
parts.push(part);
} else if (typeof part?.text === "string") {
parts.push(part.text);
}
}
return parts.length > 0 ? parts.join("\n") : null;
}
function applyKiroHeadroomMessages(projection, compressedMessages, diagnostics) {
if (!Array.isArray(compressedMessages) || compressedMessages.length !== projection.messages.length) {
setDiagnostic(diagnostics, "proxy response did not match Kiro message count");
return false;
}
const updates = [];
for (let i = 0; i < projection.messages.length; i++) {
const expected = projection.messages[i];
const actual = compressedMessages[i];
if (!actual || actual.role !== expected.role) {
setDiagnostic(diagnostics, "proxy response did not preserve Kiro message order");
return false;
}
const text = textFromHeadroomMessage(actual);
if (text === null) {
setDiagnostic(diagnostics, "proxy response missing Kiro text content");
return false;
}
updates.push({ target: projection.targets[i], text });
}
for (const update of updates) {
update.target.object[update.target.key] = update.text;
}
return true;
}
// POST messages to Headroom /v1/compress; returns compressed messages + stats or null.
async function callCompress(url, messages, model, timeoutMs, compressUserMessages, diagnostics) {
const endpoint = buildCompressEndpoint(url);
@@ -171,6 +288,22 @@ export async function compressWithHeadroom(body, { enabled, url, model, format,
return data;
}
// Kiro shape: conversationState.history/currentMessage are projected to
// OpenAI messages for the proxy, then copied back into the original Kiro
// fields. Keep the provider payload shape intact for Kiro's executor.
if (format === "kiro") {
const projection = collectKiroHeadroomMessages(body);
if (!projection) {
setDiagnostic(diagnostics, "Kiro request did not project to messages[]");
return null;
}
const data = await callCompress(url, projection.messages, model, timeoutMs, compressUserMessages, diagnostics || {});
if (!data) return null;
if (!applyKiroHeadroomMessages(projection, data.messages, diagnostics)) return null;
if (diagnostics) diagnostics.after = captureSizeSnapshot(body);
return data;
}
// OpenAI shape: messages/input go straight to the proxy.
const key = Array.isArray(body.messages) ? "messages"
: Array.isArray(body.input) ? "input"
+104
View File
@@ -0,0 +1,104 @@
// PXPIPE: render bulky Claude-format context as dense PNGs via pxpipe-proxy's
// library API (transformAnthropicMessages). Fail-open like every token saver:
// any error/timeout returns { body: null, summary } and leaves the request untouched.
import { FORMATS } from "../translator/formats.js";
const DEFAULT_TIMEOUT_MS = 15000;
const DEFAULT_MIN_CHARS = 25000;
// pxpipe's own profitability gate assumes ~4 chars/token; reuse it for the
// estimated before/after numbers surfaced in stats (marked "estimated" in UI).
const EST_CHARS_PER_TOKEN = 4;
function bodyChars(body) {
try {
return JSON.stringify(body)?.length || 0;
} catch {
return 0;
}
}
function estTokens(chars) {
return Math.round(chars / EST_CHARS_PER_TOKEN);
}
function skipped(reason, extra = {}) {
return { body: null, summary: { applied: false, reason, ...extra } };
}
// Transform a Claude-format request body through pxpipe. Returns
// { body: <new body object> | null, summary } — body is null when nothing changed.
// opts.transform is injected by the host (src side) so open-sse stays free of
// filesystem/install concerns and remains usable standalone.
export async function compressWithPxpipe(body, { enabled, format, model, minChars, timeoutMs, transform } = {}) {
if (!enabled) return skipped("disabled");
if (typeof transform !== "function") return skipped("not_installed");
if (!body) return skipped("missing_body");
if (format !== FORMATS.CLAUDE) return skipped("unsupported_format", { detail: format });
const startedAt = Date.now();
const originalChars = bodyChars(body);
const threshold = Number(minChars) > 0 ? Number(minChars) : DEFAULT_MIN_CHARS;
if (originalChars < threshold) {
return skipped("below_threshold", { originalChars, threshold });
}
try {
const encoded = new TextEncoder().encode(JSON.stringify(body));
const budget = Number(timeoutMs) > 0 ? Number(timeoutMs) : DEFAULT_TIMEOUT_MS;
// transformAnthropicMessages is local CPU work and can't be aborted; race a
// timer and discard the result if it loses (input body is never mutated).
const result = await Promise.race([
transform({
body: encoded,
model,
options: { minCompressChars: threshold },
}),
new Promise((resolve) => setTimeout(() => resolve(null), budget)),
]);
if (!result) return skipped("timeout", { originalChars, durationMs: Date.now() - startedAt });
if (!result.applied) {
return skipped(result.reason || "passthrough", {
detail: result.detail,
originalChars,
durationMs: Date.now() - startedAt,
});
}
const newBody = JSON.parse(new TextDecoder().decode(result.body));
const compressedBodyChars = bodyChars(newBody);
const info = result.info || {};
const imagedChars = info.compressedChars || 0;
// The transformed body is BIGGER in bytes (base64 PNGs) but cheaper in tokens:
// images bill by pixels (Anthropic: pixels/750), not by encoded length. So the
// after-estimate is remaining-text tokens + image tokens — never chars/4 of the
// new body. Provider-billed usage recorded per request stays the ground truth.
const imageTokensEst = info.imageTokens
|| (info.imagePixels ? Math.round(info.imagePixels / 750) : (info.imageCount || 0) * 4761);
const summary = {
applied: true,
reason: "applied",
originalChars,
compressedBodyChars,
imagedChars,
imageCount: info.imageCount || 0,
imageBytes: info.imageBytes || 0,
tokensBeforeEst: info.baselineTokens || estTokens(originalChars),
tokensAfterEst: estTokens(Math.max(0, originalChars - imagedChars)) + imageTokensEst,
durationMs: Date.now() - startedAt,
cacheOwnsControl: result.cache?.ownsCacheControl === true,
};
summary.tokensSavedEst = Math.max(0, summary.tokensBeforeEst - summary.tokensAfterEst);
summary.savedPct = summary.tokensBeforeEst > 0
? +((summary.tokensSavedEst / summary.tokensBeforeEst) * 100).toFixed(2)
: 0;
return { body: newBody, summary };
} catch (e) {
return skipped("transform_error", { detail: e?.message || String(e), originalChars, durationMs: Date.now() - startedAt });
}
}
export function formatPxpipeLog(summary) {
if (!summary) return null;
if (!summary.applied) return null;
return `imaged ${summary.imagedChars}ch → ${summary.imageCount} image(s) | est ${summary.tokensBeforeEst}→${summary.tokensAfterEst} tokens (-${summary.savedPct}%) | ${summary.durationMs}ms`;
}
+2
View File
@@ -1,6 +1,7 @@
import { FILTERS } from "./constants.js";
import { gitDiff } from "./filters/gitDiff.js";
import { gitStatus } from "./filters/gitStatus.js";
import { gitLog } from "./filters/gitLog.js";
import { grep } from "./filters/grep.js";
import { find } from "./filters/find.js";
import { dedupLog } from "./filters/dedupLog.js";
@@ -13,6 +14,7 @@ import { searchList } from "./filters/searchList.js";
const REGISTRY = {
[FILTERS.GIT_DIFF]: gitDiff,
[FILTERS.GIT_STATUS]: gitStatus,
[FILTERS.GIT_LOG]: gitLog,
[FILTERS.GREP]: grep,
[FILTERS.FIND]: find,
[FILTERS.DEDUP_LOG]: dedupLog,
+4
View File
@@ -129,6 +129,9 @@ const REFRESH_HANDLERS = {
github: (c, log) => refreshGitHubToken(c.refreshToken, log),
kiro: (c, log) => refreshKiroToken(c.refreshToken, c.providerSpecificData, log),
xai: (c, log) => refreshXaiToken(c.refreshToken, log),
// Grok CLI shares xAI OAuth client + token endpoint (device-code tokens refresh the same way)
"grok-cli": (c, log) => refreshXaiToken(c.refreshToken, log),
gcli: (c, log) => refreshXaiToken(c.refreshToken, log),
"codebuddy-cn": (c, log) => refreshCodebuddyToken(c.refreshToken, log),
vertex: vertexRefreshHandler,
"vertex-partner": vertexRefreshHandler
@@ -187,6 +190,7 @@ export function formatProviderCredentials(provider, credentials, log) {
case "openai":
case "openrouter":
case "xai":
case "grok-cli":
return {
apiKey: credentials.apiKey,
accessToken: credentials.accessToken
+2
View File
@@ -11,6 +11,7 @@ export { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits };
import { getKiroUsage } from "./usage/kiro.js";
import { getMiniMaxUsage } from "./usage/minimax.js";
import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.js";
import { getGrokCliUsage } from "./usage/grok-cli.js";
import {
getQwenUsage,
getIflowUsage,
@@ -43,6 +44,7 @@ const USAGE_HANDLERS = {
"minimax-cn": (c) => getMiniMaxUsage(c.apiKey, c.provider, c.proxyOptions),
"vercel-ai-gateway": (c) => getVercelAiGatewayUsage(c.apiKey, c.proxyOptions),
"codebuddy-cn": (c) => getCodeBuddyCnUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions),
"grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
};
export async function getUsageForProvider(connection, proxyOptions = null) {
+4 -6
View File
@@ -2,15 +2,15 @@
* Google usage handlers (Gemini CLI + Antigravity)
*/
import { CLIENT_METADATA, getPlatformUserAgent } from "../../config/appConstants.js";
import { ANTIGRAVITY_OAUTH_CLIENT } from "../../providers/shared.js";
import { CLIENT_METADATA } from "../../config/appConstants.js";
import { ANTIGRAVITY_IDE_USER_AGENT, ANTIGRAVITY_IDE_VERSION, ANTIGRAVITY_OAUTH_CLIENT } from "../../providers/shared.js";
import { U, parseResetTime, normalizeCloudCodeProjectId, fetchWithTimeout } from "./shared.js";
// Antigravity API config (from Quotio) — urls from registry, oauth client + dynamic UA kept here
const ANTIGRAVITY_CONFIG = {
...U("antigravity"),
...ANTIGRAVITY_OAUTH_CLIENT,
userAgent: getPlatformUserAgent(),
userAgent: ANTIGRAVITY_IDE_USER_AGENT,
};
/**
@@ -129,8 +129,7 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro
"User-Agent": ANTIGRAVITY_CONFIG.userAgent,
"Content-Type": "application/json",
"X-Client-Name": "antigravity",
"X-Client-Version": "1.107.0",
"x-request-source": "local", // MITM bypass
"X-Client-Version": ANTIGRAVITY_IDE_VERSION,
},
body: JSON.stringify({
...(projectId ? { project: projectId } : {})
@@ -229,7 +228,6 @@ async function getAntigravitySubscriptionInfo(accessToken, proxyOptions = null)
"Authorization": `Bearer ${accessToken}`,
"User-Agent": ANTIGRAVITY_CONFIG.userAgent,
"Content-Type": "application/json",
"x-request-source": "local", // MITM bypass
},
body: JSON.stringify({ metadata: CLIENT_METADATA, mode: 1 }),
}, 10000, proxyOptions);
+274
View File
@@ -0,0 +1,274 @@
/**
* Grok CLI / Grok Build usage handler
*
* Source of truth: official grok-shell/grok-pager traffic to cli-chat-proxy.grok.com
* GET /v1/billing?format=credits
* GET /v1/user?include=subscription
*
* Observed billing shape (protobuf-json style `{ val: number }`):
* {
* config: {
* currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", start, end },
* onDemandCap: { val },
* onDemandUsed: { val },
* prepaidBalance: { val },
* isUnifiedBillingUser: true,
* billingPeriodStart, billingPeriodEnd
* }
* }
*
* Exhausted free/promo accounts return cap=0/used=0/prepaid=0 and chat 402s with
* personal-team-blocked:spending-limit. Paid/sub accounts surface non-zero cap
* or prepaidBalance; richer credit fields are parsed opportunistically if present.
*/
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
import { U, parseResetTime, toFiniteNumber } from "./shared.js";
const USAGE = U("grok-cli");
const BILLING_URL = USAGE.url || "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
const USER_URL = USAGE.userUrl || "https://cli-chat-proxy.grok.com/v1/user?include=subscription";
/** Unwrap protobuf-json `{ val: n }` or plain numbers/strings. */
function unwrapVal(value, fallback = 0) {
if (value == null) return fallback;
if (typeof value === "object" && !Array.isArray(value) && "val" in value) {
return toFiniteNumber(value.val, fallback);
}
return toFiniteNumber(value, fallback);
}
function buildGrokCliHeaders(accessToken, providerSpecificData = {}) {
const psd = providerSpecificData || {};
const headers = {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
"x-xai-token-auth": "xai-grok-cli",
"x-grok-client-identifier": "grok-pager",
"x-grok-client-version": "0.2.93",
};
const email = psd.email;
const userId = psd.userId || psd.principalId;
if (email) headers["x-email"] = email;
if (userId) headers["x-userid"] = userId;
return headers;
}
function resolvePlan(user, config) {
const tier = typeof user?.subscriptionTier === "string" ? user.subscriptionTier.trim() : "";
if (tier) {
return tier
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
if (user?.hasGrokCodeAccess === true) return "Grok Code";
if (config?.isUnifiedBillingUser === true) return "Grok Build";
return "Grok Build";
}
function makeQuota({ used, total, resetAt, unlimited = false }) {
const safeTotal = Math.max(0, toFiniteNumber(total, 0));
const safeUsed = Math.max(0, toFiniteNumber(used, 0));
// Do NOT set absolute `remaining` — QuotaTable's getRemainingPercentage treats
// `remaining` as a 0–100 percentage (same trap as Qoder credits).
if (unlimited || safeTotal === 0) {
return {
used: safeUsed,
total: 0,
remainingPercentage: unlimited ? 100 : 0,
resetAt: resetAt || null,
unlimited: true,
};
}
const remaining = Math.max(0, safeTotal - safeUsed);
const remainingPercentage = (remaining / safeTotal) * 100;
return {
used: safeUsed,
total: safeTotal,
remainingPercentage,
resetAt: resetAt || null,
unlimited: false,
};
}
/**
* Map billing JSON → normalized quotas object for the dashboard.
* Returns { quotas, periodEnd, exhaustedHint } or empty quotas when nothing usable.
*/
export function parseGrokCliBilling(billing, user = null) {
const root = billing && typeof billing === "object" ? billing : {};
const config =
root.config && typeof root.config === "object" && !Array.isArray(root.config)
? root.config
: root;
const periodEnd =
parseResetTime(config.billingPeriodEnd) ||
parseResetTime(config.currentPeriod?.end) ||
parseResetTime(root.billingPeriodEnd) ||
null;
const quotas = {};
// Primary: on-demand spending window (subscription / promo credits)
const onDemandCap = unwrapVal(config.onDemandCap ?? root.onDemandCap, NaN);
const onDemandUsed = unwrapVal(config.onDemandUsed ?? root.onDemandUsed, NaN);
if (Number.isFinite(onDemandCap) && onDemandCap > 0) {
const used = Number.isFinite(onDemandUsed) ? Math.max(0, onDemandUsed) : 0;
quotas["On-demand"] = makeQuota({
used,
total: onDemandCap,
resetAt: periodEnd,
});
} else if (Number.isFinite(onDemandCap) && onDemandCap === 0 && Number.isFinite(onDemandUsed)) {
// Cap 0 is the exhausted free/promo state (chat returns 402 spending-limit).
// UI treats total===0 as unlimited, so use a synthetic 1/1 depleted row.
quotas["On-demand"] = {
used: 1,
total: 1,
remainingPercentage: 0,
resetAt: periodEnd,
unlimited: false,
};
}
// Prepaid top-up balance (remaining credits; no fixed allotment known)
const prepaid = unwrapVal(config.prepaidBalance ?? root.prepaidBalance, NaN);
if (Number.isFinite(prepaid) && prepaid > 0) {
// Show full bar against the current balance (0 spent of this remaining pot).
quotas["Prepaid"] = {
used: 0,
total: prepaid,
remainingPercentage: 100,
resetAt: null,
unlimited: false,
};
}
// Opportunistic richer credit envelopes (future / other account types)
const creditBags = [
root.credits,
root.creditBalance,
root.usage,
config.credits,
config.includedCredits,
config.subscriptionCredits,
].filter((bag) => bag && typeof bag === "object" && !Array.isArray(bag));
for (const bag of creditBags) {
const total = unwrapVal(
bag.total ?? bag.limit ?? bag.cap ?? bag.allocation ?? bag.amount,
NaN,
);
const used = unwrapVal(bag.used ?? bag.spent ?? bag.consumed, NaN);
const remaining = unwrapVal(bag.remaining ?? bag.balance ?? bag.left, NaN);
if (Number.isFinite(total) && total > 0) {
const resolvedUsed = Number.isFinite(used)
? used
: Number.isFinite(remaining)
? Math.max(0, total - remaining)
: 0;
if (!quotas.Credits) {
quotas.Credits = makeQuota({
used: resolvedUsed,
total,
resetAt: parseResetTime(bag.resetAt || bag.resetsAt || bag.end) || periodEnd,
});
}
} else if (Number.isFinite(remaining) && remaining >= 0 && !quotas.Credits) {
quotas.Credits = {
used: 0,
total: remaining > 0 ? remaining : 1,
remainingPercentage: remaining > 0 ? 100 : 0,
resetAt: periodEnd,
unlimited: false,
};
}
}
// Exhausted when every finite quota bar is at 0% remaining
const exhausted =
Object.keys(quotas).length > 0 &&
Object.values(quotas).every(
(q) => q.unlimited !== true && (q.remainingPercentage ?? 100) <= 0,
);
return {
plan: resolvePlan(user, config),
quotas,
periodEnd,
exhausted,
rawConfig: config,
};
}
/**
* @param {string} accessToken
* @param {object|null} providerSpecificData
* @param {object|null} proxyOptions
*/
export async function getGrokCliUsage(accessToken, providerSpecificData = null, proxyOptions = null) {
if (!accessToken) {
return { message: "Grok CLI access token not available." };
}
const headers = buildGrokCliHeaders(accessToken, providerSpecificData);
try {
// Fetch billing + user profile in parallel (same pattern as official CLI startup)
const [billingRes, userRes] = await Promise.all([
proxyAwareFetch(
BILLING_URL,
{ method: "GET", headers },
proxyOptions,
),
proxyAwareFetch(
USER_URL,
{ method: "GET", headers },
proxyOptions,
).catch(() => null),
]);
if (billingRes.status === 401 || billingRes.status === 403) {
return { message: "Grok CLI authentication expired. Please re-authorize." };
}
if (!billingRes.ok) {
const errText = await billingRes.text().catch(() => "");
const trimmed = errText ? `: ${errText.slice(0, 200)}` : "";
return { message: `Grok CLI billing API error (${billingRes.status})${trimmed}` };
}
const billing = await billingRes.json().catch(() => null);
if (!billing || typeof billing !== "object") {
return { message: "Grok CLI billing response was not JSON." };
}
let user = null;
if (userRes?.ok) {
user = await userRes.json().catch(() => null);
}
const parsed = parseGrokCliBilling(billing, user);
if (!parsed.quotas || Object.keys(parsed.quotas).length === 0) {
return {
plan: parsed.plan,
message:
"Grok Build connected, but no credit allotment was returned. Free promo may be exhausted — upgrade at https://grok.com/supergrok or add credits at https://grok.com/?_s=usage.",
quotas: {},
};
}
// Dashboard hides QuotaTable whenever `message` is set, so only attach a
// message when there are no quota rows to render. Depleted accounts keep
// the 0% On-demand bar without a blocking message.
return {
plan: parsed.plan,
quotas: parsed.quotas,
};
} catch (error) {
return { message: `Grok CLI usage error: ${error.message}` };
}
}
@@ -1,3 +1,5 @@
import { getCapabilitiesForModel } from "../../providers/capabilities.js";
// Strip request params a given provider/model rejects upstream (e.g. HTTP 400).
// Config-driven: add a rule instead of scattering `delete body.x` across executors.
@@ -12,6 +14,13 @@ const STRIP_RULES = [
{ provider: "github", match: (m) => /claude/i.test(m) && !/claude.*(opus|sonnet).*4\.6/i.test(m), drop: ["thinking", "reasoning_effort"] },
// Cloudflare Workers AI: content must be plain string, rejects OpenAI content-part array (#1926)
{ provider: "cloudflare-ai", flattenContent: true },
{ provider: "volcengine-ark", match: /glm-5/i, clampToModelMaxOutput: true },
// VolcEngine Ark caps the Kimi family at max_tokens <= 32768, but the model's
// advertised ceiling is far higher (Kimi-K2.7-Code resolves to maxOutput 262144),
// so clampToModelMaxOutput alone leaves it uncapped and the request 400s with
// "integer above maximum value, expected <= 32768". Pin an explicit endpoint cap;
// min() with the model ceiling still applies if a variant's own limit is lower.
{ provider: "volcengine-ark", match: /kimi/i, maxOutputCap: 32768, clampToModelMaxOutput: true },
];
// Test a rule's match (regex or predicate) against the model id.
@@ -20,6 +29,12 @@ function matches(rule, model) {
return typeof rule.match === "function" ? rule.match(model) : rule.match.test(model);
}
function clampNumber(body, key, ceiling) {
if (typeof body[key] === "number" && Number.isFinite(body[key]) && body[key] > ceiling) {
body[key] = ceiling;
}
}
// Remove unsupported params from body in place; returns body.
export function stripUnsupportedParams(provider, model, body) {
if (!model || !body || typeof body !== "object") return body;
@@ -39,6 +54,22 @@ export function stripUnsupportedParams(provider, model, body) {
}
}
}
if (rule.clampToModelMaxOutput || Number.isFinite(rule.maxOutputCap)) {
const modelCeiling = getCapabilitiesForModel(provider, model).maxOutput;
const candidates = [];
if (rule.clampToModelMaxOutput && Number.isFinite(modelCeiling) && modelCeiling > 0) {
candidates.push(modelCeiling);
}
if (Number.isFinite(rule.maxOutputCap) && rule.maxOutputCap > 0) {
candidates.push(rule.maxOutputCap);
}
if (candidates.length > 0) {
const ceiling = Math.min(...candidates);
clampNumber(body, "max_tokens", ceiling);
clampNumber(body, "max_completion_tokens", ceiling);
clampNumber(body, "max_output_tokens", ceiling);
}
}
}
return body;
}

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