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(...)`).
+78 -19
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>
<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)
[🚀 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>
</tr>
<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 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,6 +172,9 @@ 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>
</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"/>
@@ -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,6 +196,10 @@ 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>
</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"/>
@@ -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>
@@ -409,7 +432,7 @@ 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** |
@@ -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
@@ -589,7 +613,7 @@ 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 |
@@ -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`
@@ -1163,7 +1205,7 @@ 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`) |
@@ -1180,8 +1222,10 @@ docker pull decolua/9router:latest # update to latest
| `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);
+35 -16
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,14 +160,7 @@ export class AntigravityExecutor extends BaseExecutor {
});
this._lastSessionId = sessionId;
return {
project: projectId,
model: cleanModel,
userAgent: "antigravity",
requestType: "image_gen",
requestId: `agent-${crypto.randomUUID()}`,
request: {
const request = {
contents,
generationConfig: {
temperature: 1.0,
@@ -160,7 +171,15 @@ export class AntigravityExecutor extends BaseExecutor {
},
sessionId,
// No tools, no systemInstruction, no safetySettings for image gen
},
};
return {
project: projectId,
model: cleanModel,
userAgent: "antigravity",
requestType: "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);
+22 -1
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;
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;
}
+9 -1
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
// 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 0100 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;
}
@@ -20,6 +20,13 @@ const FORMAT_TO_NATIVE = {
kiro: "kiro",
};
// Strip a trailing thinking suffix "model(value)" → "model" (no-op when absent).
export function stripThinkingSuffix(model) {
if (typeof model !== "string") return model;
const m = model.match(/^(.*)\([^()]+\)\s*$/);
return m ? m[1].trim() : model;
}
// Parse model-name suffix "model(value)" → { cleanModel, override }.
// value: level name (high) | number (8192) | auto | none. null override when absent.
export function parseSuffix(model) {
@@ -132,18 +139,66 @@ function toGeminiThinkingLevel(cfg) {
return effortToThinkingLevel(raw);
}
function toKimiReasoningEffort(cfg) {
const level = toLevel(cfg);
if (level === "auto") return "high";
if (level === "minimal") return "low";
if (level === "xhigh") return "max";
if (["low", "medium", "high", "max"].includes(level)) return level;
return null;
}
const GEMINI_LEVEL_OUTPUT_FLOOR = {
minimal: 4096,
low: 8192,
medium: 16384,
high: 65535,
};
function geminiBudgetOutputFloor(budget) {
if (budget === -1) return 32768;
if (!Number.isFinite(budget)) return 32768;
if (budget <= 1024) return 8192;
if (budget <= 8192) return 16384;
if (budget <= 24576) return 32768;
return 65535;
}
function geminiLevelOutputFloor(level) {
return GEMINI_LEVEL_OUTPUT_FLOOR[level] || GEMINI_LEVEL_OUTPUT_FLOOR.high;
}
// Gemini nests thinkingConfig under generationConfig. gemini-cli / antigravity wrap
// the whole request in a { request: { generationConfig } } envelope — target the
// envelope's generationConfig when present, else the top-level one.
function getGeminiGenerationConfig(body) {
if (body.request && typeof body.request === "object") {
if (!body.request.generationConfig || typeof body.request.generationConfig !== "object") {
body.request.generationConfig = {};
}
return body.request.generationConfig;
}
if (!body.generationConfig || typeof body.generationConfig !== "object") {
body.generationConfig = {};
}
return body.generationConfig;
}
function setGeminiThinking(body, tc) {
const gc = body.request?.generationConfig
? body.request.generationConfig
: (body.generationConfig && typeof body.generationConfig === "object"
? body.generationConfig
: (body.generationConfig = {}));
const gc = getGeminiGenerationConfig(body);
gc.thinkingConfig = tc;
}
function ensureGeminiOutputFloor(body, floor, caps) {
const cap = Number.isFinite(caps?.maxOutput) ? caps.maxOutput : floor;
const target = Math.min(floor, cap);
const gc = getGeminiGenerationConfig(body);
const current = Number(gc.maxOutputTokens);
if (!Number.isFinite(current) || current < target) {
gc.maxOutputTokens = target;
}
}
// Strip every known thinking field from a body (used before re-applying / when unsupported).
function stripAll(body) {
delete body.thinking;
@@ -168,7 +223,8 @@ function applyFormat(fmt, body, cfg, caps) {
case "openai": {
if (none && canDisable) { body.reasoning_effort = "none"; break; }
const level = toLevel(eff);
if (level) body.reasoning_effort = level;
// OpenAI reasoning_effort enum caps at "xhigh" (no "max"); clamp Claude Code's "max".
if (level) body.reasoning_effort = level === "max" ? "xhigh" : level;
break;
}
case "claude-adaptive": {
@@ -192,12 +248,14 @@ function applyFormat(fmt, body, cfg, caps) {
case "gemini-level": {
const level = none ? "minimal" : toGeminiThinkingLevel(eff);
setGeminiThinking(body, { thinkingLevel: level, includeThoughts: level !== "minimal" });
ensureGeminiOutputFloor(body, geminiLevelOutputFloor(level), caps);
break;
}
case "gemini-budget": {
if (none && canDisable) { setGeminiThinking(body, { thinkingBudget: 0, includeThoughts: false }); break; }
const budget = toBudget(eff, caps.thinkingRange);
setGeminiThinking(body, { thinkingBudget: budget ?? -1, includeThoughts: true });
ensureGeminiOutputFloor(body, geminiBudgetOutputFloor(budget ?? -1), caps);
break;
}
case "zai": {
@@ -223,8 +281,8 @@ function applyFormat(fmt, body, cfg, caps) {
}
case "kimi": {
if (none && canDisable) { body.thinking = { type: "disabled" }; break; }
const level = toLevel(eff);
if (level) body.reasoning_effort = level === "max" ? "high" : level;
const effort = toKimiReasoningEffort(eff);
if (effort) body.reasoning_effort = effort;
break;
}
case "minimax": {
+19 -2
View File
@@ -192,10 +192,27 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne
delete body.output_config;
}
// Clamp max_tokens to the model output ceiling (never above DEFAULT_MAX_TOKENS)
// Clamp max_tokens to the model's real output ceiling. Models whose caps
// declare a higher maxOutput (e.g. Opus 4.8 / Sonnet 4.6 = 128000) are allowed
// up to it, so max-effort thinking gets full budget; others fall back to the
// conservative 64000 default.
if (body.max_tokens) {
const ceiling = Math.min(getCapabilitiesForModel(provider, body.model).maxOutput, DEFAULT_MAX_TOKENS);
const ceiling = getCapabilitiesForModel(provider, body.model).maxOutput || DEFAULT_MAX_TOKENS;
if (body.max_tokens > ceiling) body.max_tokens = ceiling;
// Reconcile against thinking budget. applyThinking (thinkingUnified.js) runs
// AFTER adjustMaxTokens capped max_tokens, and the claude-budget format maps
// max effort → budget_tokens 128000 — larger than the clamped max_tokens.
// Anthropic requires max_tokens strictly greater than budget_tokens (else 400).
// Prefer raising max_tokens to preserve the requested thinking depth; if the
// budget alone meets/exceeds the ceiling, cap output and shrink the budget so
// some tokens remain for the answer.
if (body.thinking?.type === "enabled" && body.thinking.budget_tokens && body.thinking.budget_tokens >= body.max_tokens) {
body.max_tokens = Math.min(body.thinking.budget_tokens + 1024, ceiling);
if (body.thinking.budget_tokens >= body.max_tokens) {
body.thinking.budget_tokens = Math.max(1024, body.max_tokens - 1024);
}
}
}
// 1. System: remove all cache_control, add only to last block with ttl 1h
+9 -5
View File
@@ -3,9 +3,13 @@ import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../config/runtimeConf
/**
* Adjust max_tokens based on request context
* @param {object} body - Request body
* @param {number} [ceiling=DEFAULT_MAX_TOKENS] - Upper bound for max_tokens.
* Callers with model context (e.g. openai-to-claude) pass the model's real
* maxOutput so high-output models (Opus 4.8 = 128000) aren't pre-clamped to
* the conservative 64000 default before the model-aware step sees them.
* @returns {number} Adjusted max_tokens
*/
export function adjustMaxTokens(body) {
export function adjustMaxTokens(body, ceiling = DEFAULT_MAX_TOKENS) {
let maxTokens = body.max_tokens || DEFAULT_MAX_TOKENS;
// Auto-increase for tool calling to prevent truncated arguments (min never above max)
@@ -16,14 +20,14 @@ export function adjustMaxTokens(body) {
}
// Ensure max_tokens > thinking.budget_tokens (Claude API requirement)
// Claude API requires strictly greater, so add buffer instead of using DEFAULT_MAX_TOKENS
// which could equal budget_tokens when budget_tokens >= 64000
// Claude API requires strictly greater, so add buffer instead of using the
// ceiling which could equal budget_tokens when budget_tokens >= ceiling
if (body.thinking?.budget_tokens && maxTokens <= body.thinking.budget_tokens) {
maxTokens = body.thinking.budget_tokens + 1024;
}
// Never exceed the global ceiling
if (maxTokens > DEFAULT_MAX_TOKENS) maxTokens = DEFAULT_MAX_TOKENS;
// Never exceed the ceiling
if (maxTokens > ceiling) maxTokens = ceiling;
return maxTokens;
}
+21 -9
View File
@@ -404,7 +404,10 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
let finalContent = currentMessage?.userInputMessage?.content || "";
// System prompt → prepend to the user content.
// System prompt: pass via native systemInstruction field (Kiro/Q API supports it)
// and also prepend as <instructions> in user content as fallback for upstreams
// that don't support the native field.
let systemInstruction = undefined;
if (body.system) {
let systemText = "";
if (typeof body.system === "string") {
@@ -412,7 +415,10 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
} else if (Array.isArray(body.system)) {
systemText = body.system.map((s) => s.text || "").join("\n");
}
if (systemText) finalContent = `${systemText}\n\n${finalContent}`;
if (systemText) {
systemInstruction = systemText;
finalContent = `<instructions>\n${systemText}\n</instructions>\n\n${finalContent}`;
}
}
// Prefix order: thinking_mode tag, timestamp marker, then agentic prompt.
@@ -423,12 +429,7 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
if (agentic) prefixParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
finalContent = `${prefixParts.join("\n\n")}\n\n${finalContent}`;
const payload = {
conversationState: {
chatTriggerType: "MANUAL",
conversationId: uuidv4(),
currentMessage: {
userInputMessage: {
const userInputMessage = {
content: finalContent,
modelId: upstreamModel,
origin: "AI_EDITOR",
@@ -439,7 +440,18 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
...(currentMessage?.userInputMessage?.images && {
images: currentMessage.userInputMessage.images,
}),
},
};
if (systemInstruction) {
userInputMessage.systemInstruction = systemInstruction;
}
const payload = {
conversationState: {
chatTriggerType: "MANUAL",
conversationId: uuidv4(),
currentMessage: {
userInputMessage,
},
history,
},
@@ -129,14 +129,15 @@ function fixMissingToolResponsesOpenAI(messages) {
}
}
// Wrap mid-conversation system text so it ends as a user turn (avoids Anthropic prefill 400)
// Wrap mid-conversation system text so it ends as a user turn (avoids Anthropic prefill 400).
// Uses <instructions> tags that Claude models treat as authoritative directives.
function systemReminderText(content) {
const parts = Array.isArray(content)
? content.filter(c => c?.type === CLAUDE_BLOCK.TEXT).map(c => c.text || "")
: [typeof content === "string" ? content : ""];
const text = parts.filter(Boolean).join("\n");
if (!text.trim()) return "";
return `<system-reminder>\n${text}\n</system-reminder>`;
return `<instructions>\n${text}\n</instructions>`;
}
// Convert single Claude message - returns single message or array of messages
+71 -13
View File
@@ -31,11 +31,12 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
let currentAssistantMsg = null;
let pendingToolResults = [];
let pendingReasoning = "";
let pendingReasoningEncrypted = "";
const inputItems = normalizeResponsesInput(body.input);
if (!inputItems) return body;
// Extract reasoning text from summary[].text or encrypted_content fallback
// Extract reasoning text from summary[].text (encrypted_content is continuity-only)
const extractReasoningText = (item) => {
if (Array.isArray(item.summary)) {
const txt = item.summary.map(s => s?.text || "").filter(Boolean).join("\n");
@@ -48,6 +49,13 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
return "";
};
const attachPendingReasoning = (msg) => {
if (pendingReasoning) msg.reasoning_content = pendingReasoning;
if (pendingReasoningEncrypted) msg.encrypted_content = pendingReasoningEncrypted;
pendingReasoning = "";
pendingReasoningEncrypted = "";
};
for (const item of inputItems) {
// Determine item type - Droid CLI sends role-based items without 'type' field
// Fallback: if no type but has role property, treat as message
@@ -80,11 +88,12 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
})
: item.content;
const msg = { role: item.role, content };
// Attach buffered reasoning to assistant turn (required by xiaomi-mimo thinking mode)
if (item.role === ROLE.ASSISTANT && pendingReasoning) {
msg.reasoning_content = pendingReasoning;
}
// Attach buffered reasoning to assistant turn (required by xiaomi-mimo + store=false continuity)
if (item.role === ROLE.ASSISTANT) attachPendingReasoning(msg);
else {
pendingReasoning = "";
pendingReasoningEncrypted = "";
}
result.messages.push(msg);
}
else if (itemType === RESPONSES_ITEM.FUNCTION_CALL) {
@@ -95,10 +104,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
content: null,
tool_calls: []
};
if (pendingReasoning) {
currentAssistantMsg.reasoning_content = pendingReasoning;
pendingReasoning = "";
}
attachPendingReasoning(currentAssistantMsg);
}
// Skip items with empty/missing name — Codex/OpenAI reject nameless tool calls (#444)
if (!item.name || typeof item.name !== "string" || item.name.trim() === "") continue;
@@ -132,9 +138,15 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
});
}
else if (itemType === RESPONSES_ITEM.REASONING) {
// Buffer reasoning text; attached to next assistant message/function_call
// Buffer reasoning text; attached to next assistant message/function_call.
// Also stash encrypted_content so a later openai→responses hop can restore
// the store=false continuity blob (Grok CLI / Codex multi-turn).
const txt = extractReasoningText(item);
if (txt) pendingReasoning = pendingReasoning ? `${pendingReasoning}\n${txt}` : txt;
if (typeof item.encrypted_content === "string" && item.encrypted_content) {
// Prefer attaching to the next assistant message we create
pendingReasoningEncrypted = item.encrypted_content;
}
continue;
}
}
@@ -203,6 +215,43 @@ function normalizeToolParameters(params) {
return params;
}
/**
* Build a Responses `reasoning` input item from Chat Completions assistant fields.
* Preserves encrypted blobs needed by store=false multi-turn (Grok CLI / Codex).
* Returns null when the message has nothing useful to re-send.
*/
function buildReasoningInputItem(msg) {
if (!msg || typeof msg !== "object") return null;
const encrypted =
(typeof msg.encrypted_content === "string" && msg.encrypted_content) ||
(typeof msg.reasoning_encrypted_content === "string" && msg.reasoning_encrypted_content) ||
(typeof msg.reasoning?.encrypted_content === "string" && msg.reasoning.encrypted_content) ||
"";
let summaryText = "";
if (typeof msg.reasoning_content === "string" && msg.reasoning_content.trim()) {
summaryText = msg.reasoning_content;
} else if (typeof msg.reasoning === "string" && msg.reasoning.trim()) {
summaryText = msg.reasoning;
} else if (Array.isArray(msg.reasoning_details)) {
summaryText = msg.reasoning_details
.map((d) => (typeof d?.text === "string" ? d.text : typeof d?.content === "string" ? d.content : ""))
.filter(Boolean)
.join("\n");
}
if (!encrypted && !summaryText) return null;
const item = { type: RESPONSES_ITEM.REASONING };
if (summaryText) {
item.summary = [{ type: RESPONSES_ITEM.SUMMARY_TEXT, text: summaryText }];
}
// encrypted_content is the continuity token for store=false backends
if (encrypted) item.encrypted_content = encrypted;
return item;
}
/**
* Convert OpenAI Chat Completions to OpenAI Responses API format
*/
@@ -222,17 +271,26 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials)
const messages = body.messages || [];
for (const msg of messages) {
if (msg.role === ROLE.SYSTEM) {
// Use first system message as instructions
if (msg.role === ROLE.SYSTEM || msg.role === ROLE.DEVELOPER) {
// Use the first instruction-bearing message as instructions.
// OpenAI recommends role="developer" for GPT-5/Codex as the system-level prompt.
if (!hasSystemMessage) {
result.instructions = typeof msg.content === "string" ? msg.content : "";
hasSystemMessage = true;
}
continue; // Skip system messages in input
continue; // Skip instruction messages in input
}
// Convert user/assistant messages to input items
if (msg.role === ROLE.USER || msg.role === ROLE.ASSISTANT) {
// Multi-turn continuity for store=false Responses backends (Codex / Grok CLI):
// re-emit a reasoning item before the assistant message when the chat-format
// history carried reasoning text and/or encrypted_content from a prior turn.
if (msg.role === ROLE.ASSISTANT) {
const reasoningItem = buildReasoningInputItem(msg);
if (reasoningItem) result.input.push(reasoningItem);
}
const contentType = msg.role === ROLE.USER ? RESPONSES_ITEM.INPUT_TEXT : RESPONSES_ITEM.OUTPUT_TEXT;
const content = typeof msg.content === "string"
? [{ type: contentType, text: msg.content }]
@@ -6,6 +6,7 @@ import { safeParseJSON } from "../concerns/json.js";
import { parseDataUri } from "../concerns/image.js";
import { extractTextContent } from "../formats/gemini.js";
import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js";
import { getCapabilitiesForModel } from "../../providers/capabilities.js";
// Empty prefix matches real Claude Code behavior (no tool name prefix).
// Previously "proxy_" was used but this is a detectable fingerprint difference.
@@ -15,9 +16,13 @@ const CLAUDE_OAUTH_TOOL_PREFIX = "";
export function openaiToClaudeRequest(model, body, stream) {
// Tool name mapping for Claude OAuth (capitalizedName → originalName)
const toolNameMap = new Map();
// Cap max_tokens at the model's real output ceiling (e.g. Opus 4.8 = 128000),
// not the conservative 64000 default — otherwise a high-output model is
// pre-clamped here before prepareClaudeRequest's model-aware step runs.
const modelCeiling = getCapabilitiesForModel(null, model).maxOutput || undefined;
const result = {
model: model,
max_tokens: adjustMaxTokens(body),
max_tokens: adjustMaxTokens(body, modelCeiling),
stream: stream
};
@@ -148,7 +153,15 @@ Respond ONLY with the JSON object, no other text.`);
continue;
}
const toolData = toolType === OPENAI_BLOCK.FUNCTION && tool.function ? tool.function : tool;
// Function-shaped tools arrive in two flavors from real clients:
// (a) openai-spec: { type: "function", function: { name, ... } }
// (b) legacy/loose: { function: { name, ... } } (no parent `type`)
// Both must yield toolData.name = "echo". Treat the bare-function shape
// as a function tool too — Anthropic-compatible gateways (notably
// MiniMax M3 at api.minimaxi.com) reject payloads where this branch
// falls through with `toolData.name === undefined`, returning their
// upstream code (2013) "invalid tool type". See #2435.
const toolData = tool.function ?? tool;
const originalName = toolData.name;
// Claude OAuth requires prefixed tool names to avoid conflicts
@@ -1,7 +1,6 @@
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { DEFAULT_THINKING_AG_SIGNATURE, DEFAULT_THINKING_GEMINI_CLI_SIGNATURE } from "../../config/defaultThinkingSignature.js";
import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/appConstants.js";
import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.js";
function generateUUID() {
return crypto.randomUUID();
@@ -282,30 +281,16 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra
// Antigravity specific fields
if (isAntigravity) {
envelope.requestType = "agent";
// Inject required default system prompt for Antigravity
// Inject required default system prompt for Antigravity (double injection)
const systemParts = [
{ text: ANTIGRAVITY_DEFAULT_SYSTEM },
{ text: `Please ignore the following [ignore]${ANTIGRAVITY_DEFAULT_SYSTEM}[/ignore]` }
];
if (envelope.request.systemInstruction?.parts) {
envelope.request.systemInstruction.parts.unshift(...systemParts);
} else {
envelope.request.systemInstruction = { role: GEMINI_ROLE.USER, parts: systemParts };
// Keep safetySettings for Gemini CLI
envelope.request.safetySettings = geminiCLI.safetySettings;
}
// Add toolConfig for Antigravity
if (geminiCLI.tools?.length > 0) {
envelope.request.toolConfig = {
functionCallingConfig: { mode: "VALIDATED" }
};
}
} else {
// Keep safetySettings for Gemini CLI
envelope.request.safetySettings = geminiCLI.safetySettings;
}
return envelope;
}
@@ -414,12 +399,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu
}
}
// Add system instruction (Antigravity default - double injection + user system prompt)
const systemParts = [
{ text: ANTIGRAVITY_DEFAULT_SYSTEM },
{ text: `Please ignore the following [ignore]${ANTIGRAVITY_DEFAULT_SYSTEM}[/ignore]` }
];
const systemParts = [];
// Merge user system prompt from claudeRequest
if (claudeRequest.system) {
if (Array.isArray(claudeRequest.system)) {
@@ -431,10 +411,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu
}
}
// Merge existing systemInstruction parts (from contents conversion)
if (envelope.request.systemInstruction?.parts) {
envelope.request.systemInstruction.parts.unshift(...systemParts);
} else {
if (systemParts.length > 0) {
envelope.request.systemInstruction = { role: GEMINI_ROLE.USER, parts: systemParts };
}
@@ -463,4 +440,3 @@ export function openaiToAntigravityRequest(model, body, stream, credentials = nu
register(FORMATS.OPENAI, FORMATS.GEMINI, openaiToGeminiRequest, null);
register(FORMATS.OPENAI, FORMATS.GEMINI_CLI, (model, body, stream, credentials) => wrapInCloudCodeEnvelope(model, openaiToGeminiCLIRequest(model, body, stream), credentials), null);
register(FORMATS.OPENAI, FORMATS.ANTIGRAVITY, openaiToAntigravityRequest, null);
@@ -270,6 +270,7 @@ function convertMessages(messages, tools, model) {
let role = msg.role;
// Normalize: system/tool -> user
const wasSystem = role === ROLE.SYSTEM;
if (role === ROLE.SYSTEM || role === ROLE.TOOL) {
role = ROLE.USER;
}
@@ -338,7 +339,10 @@ function convertMessages(messages, tools, model) {
content: [{ text: toolContent }]
});
} else if (content) {
pendingUserContent.push(content);
// <instructions> tags: Claude models treat these as authoritative directives.
pendingUserContent.push(
wasSystem ? `<instructions>\n${content}\n</instructions>` : content
);
}
} else if (role === ROLE.ASSISTANT) {
// Extract text content and tool uses
+11 -10
View File
@@ -15,16 +15,19 @@ function getTimeString() {
* @param {string} options.provider - Provider name
* @param {string} options.model - Model name
*/
export function createStreamController({ onDisconnect, onError, log, provider, model } = {}) {
export function createStreamController({ onDisconnect, onError, log, provider, model, reqTag = "" } = {}) {
const abortController = new AbortController();
const startTime = Date.now();
let disconnected = false;
let abortTimeout = null;
const logStream = (status) => {
// Only abnormal terminations are logged; normal completion is covered by "📊 done".
// isError uses errorLine (always shown, ignores LOG_LEVEL) so failures survive quiet levels.
const logStream = (symbol, status, isError = false) => {
const duration = Date.now() - startTime;
const p = provider?.toUpperCase() || "UNKNOWN";
console.log(`[${getTimeString()}] 🌊 [STREAM] ${p} | ${model || "unknown"} | ${duration}ms | ${status}`);
const emit = isError ? log?.errorLine : log?.line;
if (emit) emit(reqTag, symbol, `${status} · ${provider}/${model} · ${duration}ms`);
else console.log(`[${getTimeString()}] ${symbol} ${provider}/${model} · ${status} · ${duration}ms`);
};
return {
@@ -38,7 +41,7 @@ export function createStreamController({ onDisconnect, onError, log, provider, m
if (disconnected) return;
disconnected = true;
logStream(`disconnect: ${reason}`);
logStream("⚡", `DISCONNECT: ${reason}`);
dbg("CTRL", `${provider}/${model} | disconnect=${reason} | dur=${Date.now() - startTime}ms`);
// Delay abort to allow cleanup
@@ -49,13 +52,11 @@ export function createStreamController({ onDisconnect, onError, log, provider, m
onDisconnect?.({ reason, duration: Date.now() - startTime });
},
// Call when stream completes normally
// Call when stream completes normally (no line here — "📊 done" is authoritative)
handleComplete: () => {
if (disconnected) return;
disconnected = true;
logStream("complete");
if (abortTimeout) {
clearTimeout(abortTimeout);
abortTimeout = null;
@@ -73,11 +74,11 @@ export function createStreamController({ onDisconnect, onError, log, provider, m
}
if (error.name === "AbortError") {
logStream("aborted");
logStream("⚡", "ABORTED");
return;
}
logStream(`error: ${error.message}`);
logStream("✗", `ERROR: ${error.message}${error.stack ? `\n ${error.stack}` : ""}`, true);
onError?.(error);
},
+7
View File
@@ -4,6 +4,9 @@
import { FORMATS } from "../translator/formats.js";
// Legacy per-chunk usage console line; off by default (superseded by "📊 done")
const DEBUG_USAGE = process.env.LOG_USAGE_VERBOSE === "1";
// ANSI color codes
export const COLORS = {
reset: "\x1b[0m",
@@ -401,6 +404,10 @@ export function estimateUsage(body, contentLength, targetFormat = FORMATS.OPENAI
export function logUsage(provider, usage, model = null, connectionId = null, apiKey = null) {
if (!usage || typeof usage !== "object") return;
// Console output moved to the unified "📊 done" line (streamingHandler). Kept as
// a no-op hook so callers stay unchanged; usage persistence happens via saveUsageStats.
if (!DEBUG_USAGE) return;
const p = provider?.toUpperCase() || "UNKNOWN";
// Support both formats:
+4 -4
View File
@@ -1,12 +1,13 @@
{
"name": "9router-app",
"version": "0.5.18",
"version": "0.5.30",
"description": "9Router web dashboard",
"private": true,
"scripts": {
"dev": "next dev --webpack --port 20127",
"dev": "next dev --port 20127",
"dev:webpack": "next dev --webpack --port 20127",
"build": "next build --webpack",
"start": "next start",
"start": "next start --port 20127",
"dev:bun": "bun --bun next dev --webpack --port 20127",
"build:bun": "bun --bun next build --webpack",
"start:bun": "bun ./.next/standalone/server.js",
@@ -24,7 +25,6 @@
"bcryptjs": "^3.0.3",
"confbox": "^0.2.4",
"express": "^5.2.1",
"fs": "^0.0.1-security",
"http-proxy-middleware": "^3.0.5",
"jose": "^6.1.3",
"marked": "^18.0.1",
+195
View File
@@ -0,0 +1,195 @@
{
"Cancel": "لغو",
"Delete": "حذف",
"Edit": "ویرایش",
"Save": "ذخیره",
"Close": "بستن",
"Add": "افزودن",
"Remove": "حذف",
"Settings": "تنظیمات",
"Profile": "پروفایل",
"Dashboard": "پیش‌خوان",
"Logout": "خروج",
"Login": "ورود",
"Providers": "ارائه‌دهندگان",
"Usage": "آمار مصرف",
"API Key": "کلید API",
"Connected": "متصل",
"Disconnected": "قطع شده",
"Active": "فعال",
"Inactive": "غیرفعال",
"Success": "موفق",
"Failed": "ناموفق",
"Error": "خطا",
"Warning": "هشدار",
"Info": "اطلاعات",
"Loading": "در حال بارگذاری",
"Search": "جستجو",
"Filter": "فیلتر",
"Sort": "مرتب‌سازی",
"Export": "خروجی",
"Import": "ورودی",
"Refresh": "تازه‌سازی",
"Back": "بازگشت",
"Next": "بعدی",
"Previous": "قبلی",
"Submit": "ارسال",
"Confirm": "تأیید",
"Yes": "بله",
"No": "خیر",
"OK": "تأیید",
"Apply": "اعمال",
"Reset": "بازنشانی",
"Clear": "پاک کردن",
"Select": "انتخاب",
"Upload": "آپلود",
"Download": "دانلود",
"Copy": "کپی",
"Paste": "چسباندن",
"Cut": "برش",
"Undo": "بازگشت",
"Redo": "انجام مجدد",
"Name": "نام",
"Description": "توضیحات",
"Status": "وضعیت",
"Type": "نوع",
"Date": "تاریخ",
"Time": "زمان",
"Created": "ایجاد شده",
"Updated": "بروزرسانی شده",
"Actions": "عملیات",
"Details": "جزئیات",
"View": "مشاهده",
"New": "جدید",
"Total": "مجموع",
"Count": "تعداد",
"Price": "قیمت",
"Cost": "هزینه",
"Free": "رایگان",
"Paid": "پولی",
"Enable": "فعال‌سازی",
"Disable": "غیرفعال‌سازی",
"Enabled": "فعال شده",
"Disabled": "غیرفعال شده",
"Online": "آنلاین",
"Offline": "آفلاین",
"Available": "موجود",
"Unavailable": "ناموجود",
"Required": "الزامی",
"Optional": "اختیاری",
"Default": "پیش‌فرض",
"Custom": "سفارشی",
"Advanced": "پیشرفته",
"Basic": "ساده",
"Help": "راهنما",
"Support": "پشتیبانی",
"Documentation": "مستندات",
"Version": "نسخه",
"Language": "زبان",
"Theme": "پوسته",
"Light": "روشن",
"Dark": "تاریک",
"Auto": "خودکار",
"Endpoint": "اندپوینت",
"Combos": "ترکیبات",
"Quota Tracker": "پیگیر سهمیه",
"MITM": "MITM",
"CLI Tools": "ابزارهای CLI",
"Console Log": "لاگ کنسول",
"System": "سیستم",
"Debug": "اشکال‌زدایی",
"Shutdown": "خاموش کردن",
"Close Proxy": "بستن پروکسی",
"Are you sure you want to close the proxy server?": "آیا مطمئن هستید که می‌خواهید سرور پروکسی را ببندید؟",
"Server Disconnected": "سرور قطع شد",
"The proxy server has been stopped.": "سرور پروکسی متوقف شده است.",
"Reload Page": "بارگذاری مجدد صفحه",
"Service is running in terminal. You can close this web page. Shutdown will stop the service.": "سرویس در ترمینال در حال اجراست. می‌توانید این صفحه وب را ببندید. خاموش کردن، سرویس را متوقف می‌کند.",
"Manage your AI provider connections": "مدیریت اتصالات ارائه‌دهندگان هوش مصنوعی خود",
"Model combos with fallback": "ترکیبات مدل با پشتیبان جایگزین",
"Monitor your API usage, token consumption, and request logs": "نظارت بر مصرف API، مصرف توکن و لاگ درخواست‌ها",
"Intercept CLI tool traffic and route through 9Router": "拦截 ترافیک ابزار CLI و مسیردهی از طریق 9Router",
"Configure CLI tools": "پیکربندی ابزارهای CLI",
"API endpoint configuration": "پیکربندی نقطه پایانی API",
"Manage your preferences": "مدیریت تنظیمات شخصی",
"Debug translation flow between formats": "اشکال‌زدایی جریان ترجمه بین فرمت‌ها",
"Live server console output": "خروجی کنسول سرور زنده",
"Create model combos with fallback support": "ایجاد ترکیبات مدل با پشتیبانی از پشتیبان جایگزین",
"Local Mode": "حالت محلی",
"Running on your machine": "در حال اجرا روی دستگاه شما",
"Database Location": "مکان پایگاه داده",
"Download Backup": "دانلود پشتیبان",
"Import Backup": "وارد کردن پشتیبان",
"Database backup downloaded": "پشتیبان پایگاه داده دانلود شد",
"Database imported successfully": "پایگاه داده با موفقیت وارد شد",
"Security": "امنیت",
"Require login": "نیاز به ورود",
"When ON, dashboard requires password. When OFF, access without login.": "در حالت روشن، داشبورد به رمز عبور نیاز دارد. در حالت خاموش، دسترسی بدون نیاز به ورود.",
"Current Password": "رمز عبور فعلی",
"Enter current password": "رمز عبور فعلی را وارد کنید",
"New Password": "رمز عبور جدید",
"Enter new password": "رمز عبور جدید را وارد کنید",
"Confirm New Password": "تأیید رمز عبور جدید",
"Confirm new password": "تأیید رمز عبور جدید",
"Update Password": "بروزرسانی رمز عبور",
"Set Password": "تنظیم رمز عبور",
"Password updated successfully": "رمز عبور با موفقیت بروزرسانی شد",
"Passwords do not match": "رمزهای عبور مطابقت ندارند",
"Routing Strategy": "استراتژی مسیردهی",
"Round Robin": "چرخشی",
"Cycle through accounts to distribute load": "چرخش بین حساب‌ها برای توزیع بار",
"Sticky Limit": "محدودیت چسبندگی",
"Calls per account before switching": "تعداد تماس به ازای هر حساب قبل از تغییر",
"Network": "شبکه",
"Outbound Proxy": "پروکسی خروجی",
"Enable proxy for OAuth + provider outbound requests.": "فعال‌سازی پروکسی برای درخواست‌های خروجی OAuth + ارائه‌دهنده.",
"Proxy URL": "آدرس پروکسی",
"Leave empty to inherit existing env proxy (if any).": "برای ارث‌بری از پروکسی موجود محیط، خالی بگذارید (در صورت وجود).",
"No Proxy": "بدون پروکسی",
"Comma-separated hostnames/domains to bypass the proxy.": "نام میزبان/دامنه‌ها با جداکننده ویرگول برای دور زدن پروکسی.",
"Test proxy URL": "آزمایش آدرس پروکسی",
"Proxy settings applied": "تنظیمات پروکسی اعمال شد",
"Proxy enabled": "پروکسی فعال شد",
"Proxy disabled": "پروکسی غیرفعال شد",
"Proxy test OK": "آزمایش پروکسی موفق",
"Proxy test failed": "آزمایش پروکسی ناموفق",
"Please enter a Proxy URL to test": "لطفاً یک آدرس پروکسی برای آزمایش وارد کنید",
"Observability": "مشاهده‌پذیری",
"Enable Observability": "فعال‌سازی مشاهده‌پذیری",
"Turn request detail recording on/off globally": "روشن/خاموش کردن ضبط جزئیات درخواست به صورت سراسری",
"Max Records": "حداکثر تعداد رکوردها",
"Maximum request detail records to keep (older records are auto-deleted)": "حداکثر تعداد رکوردهای جزئیات درخواست برای نگهداری (رکوردهای قدیمی‌تر به صورت خودکار حذف می‌شوند)",
"Batch Size": "اندازه دسته",
"Number of items to accumulate before writing to database (higher = better performance)": "تعداد موارد قبل از نوشتن در پایگاه داده (بیشتر = عملکرد بهتر)",
"Flush Interval (ms)": "فاصله تخلیه (میلی‌ثانیه)",
"Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "حداکثر زمان انتظار قبل از تخلیه بافر (از از دست رفتن داده در ترافیک کم جلوگیری می‌کند)",
"Max JSON Size (KB)": "حداکثر اندازه JSON (کیلوبایت)",
"Maximum size for each JSON field (request/response) before truncation": "حداکثر اندازه برای هر فیلد JSON (درخواست/پاسخ) قبل از برش",
"All data stored on your machine": "تمام داده‌ها روی دستگاه شما ذخیره می‌شوند",
"MITM Server": "سرور MITM",
"Running": "در حال اجرا",
"Stopped": "متوقف شده",
"Cert": "گواهی",
"Server": "سرور",
"Purpose:": "هدف:",
"Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "استفاده از Antigravity IDE و GitHub Copilot → با هر ارائه‌دهنده/مدلی از 9Router",
"How it works:": "نحوه عملکرد:",
"Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "درخواست Antigravity/Copilot IDE → تغییر مسیر DNS به localhost:443 → رهگیری پروکسی MITM → 9Router → پاسخ به Antigravity/Copilot",
"No API keys — create one in Keys page": "بدون کلید API — یکی در صفحه کلیدها ایجاد کنید",
"sk_9router (default)": "sk_9router (پیش‌فرض)",
"Server started": "سرور راه‌اندازی شد",
"Failed to start server": "خطا در راه‌اندازی سرور",
"Server stopped — all DNS cleared": "سرور متوقف شد — تمام DNS پاک شد",
"Failed to stop server": "خطا در توقف سرور",
"Sudo password is required": "گذرواژه sudo الزامی است",
"Stop Server": "توقف سرور",
"Start Server": "راه‌اندازی سرور",
"Enable DNS per tool below to activate interception": "DNS را برای هر ابزار در زیر فعال کنید تا رهگیری فعال شود",
"Sudo Password Required": "گذرواژه sudo الزامی است",
"Enter your sudo password to start/stop MITM server": "رمز عبور sudo خود را برای راه‌اندازی/توقف سرور MITM وارد کنید",
"Sudo Password": "گذرواژه sudo",
"Click to add, click again to remove. Changes are saved automatically.": "کلیک برای افزودن، کلیک مجدد برای حذف. تغییرات به صورت خودکار ذخیره می‌شوند.",
"⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ اطلاعیه ریسک: این ارائه‌دهنده از اشتراک/جلسه OAuth استفاده می‌کند که به طور رسمی برای استفاده پروکسی/روتر مجوز ندارد. حساب ممکن است محدود یا مسدود شود. با مسئولیت خود استفاده کنید.",
"⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM ترافیک HTTPS ابزارهای IDE (Antigravity, GitHub Copilot, Kiro) را از طریق CA محلی رهگیری می‌کند تا درخواست‌ها را به ارائه‌دهندگان شما مسیردهی کند. ممکن است شرایط خدمات را نقض کند → مسدود شدن حساب. با مسئولیت خود استفاده کنید.",
"Endpoint is exposed without an API key.": "Endpoint بدون کلید API در معرض دسترسی است."
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

@@ -41,6 +41,10 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [saving, setSaving] = useState(false);
const bulkPlaceholder = isCloudflareAi
? `name1|sk-key1|acc123456\nname2|sk-key2|def789012\nsk-key-only-auto-named`
: BULK_PLACEHOLDER;
const [mode, setMode] = useState("single"); // "single" | "bulk"
const [bulkText, setBulkText] = useState("");
const [bulkResult, setBulkResult] = useState(null); // { success, failed }
@@ -135,14 +139,31 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
let failed = 0;
for (let i = 0; i < lines.length; i++) {
const parts = lines[i].split("|");
const apiKey = parts.length >= 2 ? parts.slice(1).join("|").trim() : parts[0].trim();
const baseName = parts.length >= 2 ? parts[0].trim() : "Key";
const name = `${baseName} ${i + 1}`;
let apiKey;
let providerSpecificData;
if (isCloudflareAi && parts.length >= 3) {
// Format: name|apiKey|accountId
apiKey = parts.slice(1, -1).join("|").trim();
providerSpecificData = { accountId: parts[parts.length - 1].trim() };
} else {
apiKey = parts.length >= 2 ? parts.slice(1).join("|").trim() : parts[0].trim();
}
try {
const res = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, apiKey, name, priority: 1, testStatus: "unknown" }),
body: JSON.stringify({
provider,
apiKey,
name,
priority: 1,
testStatus: "unknown",
...(providerSpecificData ? { providerSpecificData } : {}),
}),
});
if (res.ok) success++;
else failed++;
@@ -168,10 +189,15 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
{mode === "bulk" && (
<div className="flex flex-col gap-3">
<p className="text-xs text-text-muted">One key per line. Format: <code>name|apiKey</code> or just <code>apiKey</code> (auto-named by index).</p>
<p className="text-xs text-text-muted">
{isCloudflareAi
? <>One key per line. Format: <code>name|apiKey|accountId</code> or just <code>apiKey</code> (auto-named by index).</>
: <>One key per line. Format: <code>name|apiKey</code> or just <code>apiKey</code> (auto-named by index).</>
}
</p>
<textarea
className="w-full rounded border border-accent/30 bg-sidebar p-2 text-sm font-mono resize-y min-h-[140px] focus:outline-none focus:ring-1 focus:ring-primary"
placeholder={BULK_PLACEHOLDER}
placeholder={bulkPlaceholder}
value={bulkText}
onChange={(e) => setBulkText(e.target.value)}
/>
@@ -1,7 +1,8 @@
import PropTypes from "prop-types";
import { CapacityBadges } from "@/shared/components";
export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting, onDisable, caps }) {
export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting, onDisable, caps, thinkingSuffix }) {
const displayModel = thinkingSuffix ? `${fullModel}(${thinkingSuffix})` : fullModel;
const borderColor = testStatus === "ok"
? "border-green-500/40"
: testStatus === "error"
@@ -24,7 +25,7 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test
{testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"}
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<code className="max-w-[72vw] truncate rounded bg-sidebar px-1.5 py-0.5 font-mono text-xs text-text-muted sm:max-w-[360px]">{fullModel}</code>
<code className="max-w-[72vw] truncate rounded bg-sidebar px-1.5 py-0.5 font-mono text-xs text-text-muted sm:max-w-[360px]">{displayModel}</code>
<span className="flex min-w-0 items-center text-[9px] gap-1 pl-1">
{model.name && <span className="truncate text-[9px] italic text-text-muted/70">{model.name}</span>}
<CapacityBadges caps={caps} colorOverride="text-text-muted/70" size={12} />
@@ -48,7 +49,7 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test
)}
<div className="relative shrink-0 group/btn">
<button
onClick={() => onCopy(fullModel, `model-${model.id}`)}
onClick={() => onCopy(displayModel, `model-${model.id}`)}
className="rounded p-0.5 text-text-muted hover:bg-sidebar hover:text-primary"
>
<span className="material-symbols-outlined text-sm">
@@ -97,4 +98,5 @@ ModelRow.propTypes = {
isTesting: PropTypes.bool,
onDisable: PropTypes.func,
caps: PropTypes.object,
thinkingSuffix: PropTypes.string,
};
@@ -5,8 +5,9 @@ import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS, THINKING_CONFIG } from "@/shared/constants/providers";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS } from "@/shared/constants/providers";
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
import { getThinkingLevels } from "open-sse/providers/thinkingLevels.js";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useModelCaps } from "@/shared/hooks/useModelCaps";
import { translate } from "@/i18n/runtime";
@@ -147,11 +148,38 @@ export default function ProviderDetailPage() {
const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId);
const isCompatible = isOpenAICompatible || isAnthropicCompatible;
const hasDualAuthModes = !isCompatible && isOAuth && supportsApiKeyAuth;
const oauthConnectionLabel = providerId === "xai" ? "Grok Build OAuth" : "OAuth";
const oauthConnectionLabel =
providerId === "xai" ? "Grok Build OAuth"
: providerId === "grok-cli" ? "Grok CLI Device Login"
: "OAuth";
const apiKeyConnectionLabel = providerId === "xai" ? "xAI API Key" : "API Key";
const thinkingConfig = AI_PROVIDERS[providerId]?.thinkingConfig || THINKING_CONFIG.extended;
// Resolve suffix "(level)" for a model when a thinking level is picked and the model supports it.
const resolveThinkingSuffix = (modelId) => {
if (!thinkingMode || thinkingMode === "auto") return null;
const levels = getThinkingLevels(providerId, modelId);
return levels && levels.includes(thinkingMode) ? thinkingMode : null;
};
const providerStorageAlias = isCompatible ? providerId : providerAlias;
// Union of levels across this provider's reasoning models — drives the level picker options.
// Include custom models too (e.g. manually added gpt-5.6-sol → max).
const providerThinkingLevels = (() => {
const set = new Set();
const seen = new Set();
const addLevels = (modelId) => {
if (!modelId || seen.has(modelId)) return;
seen.add(modelId);
const lv = getThinkingLevels(providerId, modelId);
if (lv) lv.forEach((l) => { if (l !== "none") set.add(l); });
};
for (const m of models) addLevels(m.id);
for (const m of kiloFreeModels) addLevels(m.id);
for (const entry of customModels) {
if (entry.providerAlias !== providerStorageAlias) continue;
if ((entry.kind || entry.type || "llm") !== "llm") continue;
addLevels(entry.id);
}
return set.size ? ["auto", ...[...set]] : null;
})();
const providerDisplayAlias = isCompatible
? (providerNode?.prefix || providerId)
: providerAlias;
@@ -1065,6 +1093,7 @@ export default function ProviderDetailPage() {
isCustom
isFree={false}
caps={getCaps(`${providerId}/${model.id}`)}
thinkingSuffix={resolveThinkingSuffix(model.id)}
/>
))}
@@ -1090,6 +1119,7 @@ export default function ProviderDetailPage() {
isFree={model.isFree}
onDisable={() => handleDisableModel(model.id)}
caps={getCaps(`${providerId}/${model.id}`)}
thinkingSuffix={resolveThinkingSuffix(model.id)}
/>
);
})}
@@ -1395,21 +1425,6 @@ export default function ProviderDetailPage() {
)}
</>
)}
{/* Thinking config */}
{/* {thinkingConfig && (
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted font-medium">Thinking</span>
<select
value={thinkingMode}
onChange={(e) => handleThinkingModeChange(e.target.value)}
className="text-xs px-2 py-1 border border-border rounded-md bg-background focus:outline-none focus:border-primary"
>
{thinkingConfig.options.map((opt) => (
<option key={opt} value={opt}>{opt.charAt(0).toUpperCase() + opt.slice(1)}</option>
))}
</select>
</div>
)} */}
{/* Round Robin toggle */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-text-muted font-medium">Round Robin</span>
@@ -1580,9 +1595,23 @@ export default function ProviderDetailPage() {
{/* Models */}
<Card>
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold">
{"Available Models"}
</h2>
{providerThinkingLevels && (
<select
value={thinkingMode}
onChange={(e) => handleThinkingModeChange(e.target.value)}
title="Appends (level) suffix to copied model names"
className="rounded-md border border-border bg-background px-2 py-1 text-xs focus:border-primary focus:outline-none"
>
{providerThinkingLevels.map((opt) => (
<option key={opt} value={opt}>{`Thinking: ${opt.charAt(0).toUpperCase() + opt.slice(1)}`}</option>
))}
</select>
)}
</div>
{!isCompatible && (() => {
const allIds = [
...models,
@@ -0,0 +1,283 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { Card, Button } from "@/shared/components";
const fmtTokens = (n) => {
if (n >= 1000000) return `${(n / 1000000).toFixed(2)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
return String(n || 0);
};
const fmtUptime = (ms) => {
if (!ms || ms <= 0) return "—";
const m = Math.floor(ms / 60000);
const h = Math.floor(m / 60);
return h > 0 ? `${h}h${String(m % 60).padStart(2, "0")}m` : `${m}m`;
};
const WINDOW_TABS = [
{ id: "today", label: "Today" },
{ id: "yesterday", label: "Yesterday" },
{ id: "last7d", label: "7 days" },
{ id: "last30d", label: "30 days" },
{ id: "all", label: "All time" },
];
const REASON_LABELS = {
applied: "Prompt exceeded threshold",
below_threshold: "Below size threshold",
not_profitable: "Compression not profitable",
below_min_chars: "Below minimum chars",
below_min_tokens: "Below minimum tokens",
unsupported_model: "Model not in allowlist",
unsupported_format: "Non-Claude request format",
timeout: "Compression timed out",
transform_error: "Transform error",
passthrough: "Passthrough",
disabled: "Disabled",
not_installed: "Not installed",
};
function SummaryCard({ label, value, sub, tone }) {
return (
<Card className="p-4">
<p className="text-xs text-text-muted uppercase tracking-wide">{label}</p>
<p className={`text-xl font-semibold mt-1 ${tone || ""}`}>{value}</p>
{sub && <p className="text-xs text-text-muted mt-0.5">{sub}</p>}
</Card>
);
}
export default function PxpipeClient() {
const [status, setStatus] = useState(null);
const [health, setHealth] = useState(null);
const [stats, setStats] = useState(null);
const [logs, setLogs] = useState(null);
const [windowId, setWindowId] = useState("last7d");
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
try {
const [statusRes, statsRes, logsRes] = await Promise.all([
fetch("/api/pxpipe/status", { headers: { "Cache-Control": "no-store" } }),
fetch("/api/pxpipe/stats"),
fetch("/api/pxpipe/logs?limit=50"),
]);
setStatus(await statusRes.json());
setStats(await statsRes.json());
setLogs(await logsRes.json());
const healthRes = await fetch("/api/pxpipe/health", { method: "POST" });
setHealth(await healthRes.json());
} catch {
/* sections render placeholders */
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const w = stats?.windows?.[windowId];
const statusLabel = !status
? "—"
: !status.installed
? "Not installed"
: health?.healthy
? "Healthy"
: status.running
? "Running"
: "Stopped";
return (
<div className="space-y-6 p-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
<span className="material-symbols-outlined text-primary">image</span>
PXPIPE Dashboard
</h2>
<div className="flex items-center gap-2">
<a href="/dashboard/token-saver" className="text-xs text-primary underline hover:opacity-80">
Token Saver settings
</a>
<Button size="sm" variant="ghost" onClick={refresh} disabled={loading}>
{loading ? "Refreshing…" : "Refresh"}
</Button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<SummaryCard
label="Status"
value={statusLabel}
tone={health?.healthy ? "text-success" : status?.installed ? "text-warning" : "text-text-muted"}
sub={status?.enabled ? "Enabled in pipeline" : "Disabled in pipeline"}
/>
<SummaryCard label="Version" value={status?.version ? `v${status.version}` : "—"} sub="pxpipe-proxy" />
<SummaryCard label="Uptime" value={fmtUptime(status?.uptimeMs)} sub="module loaded" />
<SummaryCard label="Requests" value={w ? w.requests.toLocaleString() : "—"} />
<SummaryCard label="Compressed" value={w ? w.compressed.toLocaleString() : "—"} tone="text-success" />
<SummaryCard label="Bypassed" value={w ? w.bypassed.toLocaleString() : "—"} />
</div>
<Card className="p-4">
<div className="flex items-center justify-between flex-wrap gap-3 mb-4">
<h3 className="font-medium">Token savings (estimated)</h3>
<div className="flex items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1">
{WINDOW_TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setWindowId(tab.id)}
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
windowId === tab.id
? "bg-primary text-white shadow-sm"
: "text-text-muted hover:text-text hover:bg-bg-hover"
}`}
>
{tab.label}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
<div>
<p className="text-xs text-text-muted">Original tokens</p>
<p className="text-lg font-semibold">{w ? fmtTokens(w.tokensBeforeEst) : "—"}</p>
</div>
<div>
<p className="text-xs text-text-muted">After PXPIPE</p>
<p className="text-lg font-semibold">{w ? fmtTokens(w.tokensAfterEst) : "—"}</p>
</div>
<div>
<p className="text-xs text-text-muted">Saved</p>
<p className="text-lg font-semibold text-success">{w ? fmtTokens(w.tokensSavedEst) : "—"}</p>
</div>
<div>
<p className="text-xs text-text-muted">Reduction</p>
<p className="text-lg font-semibold text-success">{w ? `${w.savedPct}%` : "—"}</p>
</div>
</div>
<p className="text-xs text-text-muted mt-3">
Estimates from body size before/after imaging; billed usage per request
(recorded on the Usage page) remains the ground truth. Images generated:{" "}
{w ? w.imagesGenerated.toLocaleString() : "—"} · avg compression time:{" "}
{w ? `${w.avgCompressionMs}ms` : "—"} · errors: {w ? w.errors : "—"}
</p>
</Card>
<Card className="p-4">
<h3 className="font-medium mb-3">Tokens saved last 30 days</h3>
{stats?.timeline?.some((d) => d.tokensSavedEst > 0) ? (
<ResponsiveContainer width="100%" height={220}>
<AreaChart data={stats.timeline} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gradPxpipe" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.25} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.2} />
<XAxis dataKey="date" tick={{ fontSize: 11 }} tickFormatter={(d) => d.slice(5)} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={fmtTokens} width={48} />
<Tooltip formatter={(v) => [fmtTokens(v), "Tokens saved"]} labelFormatter={(d) => d} />
<Area type="monotone" dataKey="tokensSavedEst" stroke="#10b981" fill="url(#gradPxpipe)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-32 flex items-center justify-center text-text-muted text-sm">
No savings recorded yet enable PXPIPE in the Token Saver and route a large Claude-format request.
</div>
)}
</Card>
<Card className="p-4">
<h3 className="font-medium mb-3">History</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-text-muted border-b border-border">
<th className="py-2 pr-3">Time</th>
<th className="py-2 pr-3">Model</th>
<th className="py-2 pr-3 text-right">Original</th>
<th className="py-2 pr-3 text-right">Compressed</th>
<th className="py-2 pr-3 text-right">Saved</th>
<th className="py-2 pr-3 text-right">%</th>
<th className="py-2 pr-3 text-right">Duration</th>
<th className="py-2">Status</th>
</tr>
</thead>
<tbody>
{(stats?.recent || []).slice(0, 50).map((ev, i) => (
<tr key={`${ev.ts}-${i}`} className="border-b border-border/50">
<td className="py-1.5 pr-3 whitespace-nowrap text-text-muted">
{new Date(ev.ts).toLocaleString()}
</td>
<td className="py-1.5 pr-3 font-mono text-xs">{ev.provider ? `${ev.provider}/${ev.model}` : ev.model || "—"}</td>
<td className="py-1.5 pr-3 text-right font-mono text-xs">
{ev.applied ? fmtTokens(ev.tokensBeforeEst) : "—"}
</td>
<td className="py-1.5 pr-3 text-right font-mono text-xs">
{ev.applied ? fmtTokens(ev.tokensAfterEst) : "—"}
</td>
<td className="py-1.5 pr-3 text-right font-mono text-xs text-success">
{ev.applied ? fmtTokens(ev.tokensSavedEst) : "—"}
</td>
<td className="py-1.5 pr-3 text-right font-mono text-xs">
{ev.applied ? `${ev.savedPct}%` : "—"}
</td>
<td className="py-1.5 pr-3 text-right font-mono text-xs">
{ev.durationMs != null ? `${ev.durationMs}ms` : "—"}
</td>
<td className="py-1.5">
<span
className={`text-xs px-2 py-0.5 rounded ${
ev.applied
? "bg-success/15 text-success"
: ev.reason === "transform_error" || ev.reason === "timeout"
? "bg-danger/15 text-danger"
: "bg-warning/15 text-warning"
}`}
title={ev.detail || ""}
>
{ev.applied ? "Compressed" : REASON_LABELS[ev.reason] || ev.reason}
</span>
</td>
</tr>
))}
{(!stats?.recent || stats.recent.length === 0) && (
<tr>
<td colSpan={8} className="py-6 text-center text-text-muted text-sm">
No PXPIPE activity yet
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
<Card className="p-4" id="logs">
<h3 className="font-medium mb-3">PXPIPE Logs</h3>
{logs?.installLog ? (
<pre className="rounded bg-black/5 dark:bg-white/5 p-3 text-xs font-mono overflow-x-auto max-h-64 overflow-y-auto whitespace-pre-wrap">
{logs.installLog}
</pre>
) : (
<p className="text-sm text-text-muted">No install log yet.</p>
)}
</Card>
</div>
);
}
@@ -0,0 +1,5 @@
import PxpipeClient from "./PxpipeClient";
export default function PxpipePage() {
return <PxpipeClient />;
}
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, Button, Input, Modal, Toggle } from "@/shared/components";
import { useState, useEffect, useCallback, useRef } from "react";
import { Card, Button, Input, Modal, Toggle, ConfirmModal } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { getCurrentLocale, onLocaleChange } from "@/i18n/runtime";
import {
@@ -24,10 +24,39 @@ export default function TokenSaverClient() {
useState(false);
const [headroomActionLoading, setHeadroomActionLoading] = useState(false);
const [headroomActionError, setHeadroomActionError] = useState("");
const [headroomExtras, setHeadroomExtras] = useState({
version: null,
extras: { code: false, ml: false },
available: ["code", "ml"],
loading: false,
});
const [pendingExtras, setPendingExtras] = useState([]);
const [extrasActionLoading, setExtrasActionLoading] = useState(false);
const [extrasActionError, setExtrasActionError] = useState("");
const [removingExtra, setRemovingExtra] = useState(null);
const [installLog, setInstallLog] = useState("");
const [extrasConfirm, setExtrasConfirm] = useState(null);
const [codeAware, setCodeAware] = useState(false);
const [kompress, setKompress] = useState(true);
const [restartingProxy, setRestartingProxy] = useState(false);
const logPollRef = useRef(null);
const [cavemanEnabled, setCavemanEnabled] = useState(false);
const [cavemanLevel, setCavemanLevel] = useState("full");
const [ponytailEnabled, setPonytailEnabled] = useState(false);
const [ponytailLevel, setPonytailLevel] = useState("full");
const [pxpipeEnabled, setPxpipeEnabled] = useState(false);
const [pxpipeMinChars, setPxpipeMinChars] = useState(25000);
const [pxpipeStatus, setPxpipeStatus] = useState({
installed: false,
installing: false,
running: false,
version: null,
loading: true,
});
const [pxpipeHealth, setPxpipeHealth] = useState(null);
const [showPxpipeModal, setShowPxpipeModal] = useState(false);
const [pxpipeActionLoading, setPxpipeActionLoading] = useState(false);
const [pxpipeActionError, setPxpipeActionError] = useState("");
const [locale, setLocale] = useState("en");
const { copied, copy } = useCopyToClipboard();
@@ -102,6 +131,39 @@ export default function TokenSaverClient() {
});
const data = await res.json();
setHeadroomStatus({ ...data, loading: false });
if (!data?.installed) {
setHeadroomExtras({
version: null,
extras: { code: false, ml: false },
available: ["code", "ml"],
loading: false,
});
setPendingExtras([]);
return;
}
try {
const er = await fetch("/api/headroom/extras", {
headers: { "Cache-Control": "no-store" },
});
if (!er.ok) throw new Error("extras status failed");
const ed = await er.json();
setHeadroomExtras((s) => ({
...s,
version: ed.version ?? null,
extras: ed.extras || { code: false, ml: false },
available: ed.available || ["code", "ml"],
loading: false,
}));
setPendingExtras([]);
} catch {
setHeadroomExtras({
version: null,
extras: { code: false, ml: false },
available: ["code", "ml"],
loading: false,
});
setPendingExtras([]);
}
} catch {
setHeadroomStatus({
installed: false,
@@ -109,6 +171,13 @@ export default function TokenSaverClient() {
python: null,
loading: false,
});
setHeadroomExtras({
version: null,
extras: { code: false, ml: false },
available: ["code", "ml"],
loading: false,
});
setPendingExtras([]);
}
}, []);
@@ -137,6 +206,138 @@ export default function TokenSaverClient() {
}
}, [refreshHeadroomStatus]);
const togglePendingExtra = (extra) => {
setPendingExtras((cur) =>
cur.includes(extra) ? cur.filter((e) => e !== extra) : [...cur, extra]
);
};
// Poll the install log tail while a pip install/uninstall is running.
const startLogPolling = useCallback(() => {
setInstallLog("");
if (logPollRef.current) clearInterval(logPollRef.current);
const tick = async () => {
try {
const r = await fetch("/api/headroom/extras?log=1", {
headers: { "Cache-Control": "no-store" },
});
const d = await r.json().catch(() => ({}));
if (typeof d.log === "string") setInstallLog(d.log);
} catch { /* ignore transient poll errors */ }
};
tick();
logPollRef.current = setInterval(tick, 1500);
}, []);
const stopLogPolling = useCallback(() => {
if (logPollRef.current) {
clearInterval(logPollRef.current);
logPollRef.current = null;
}
}, []);
useEffect(() => () => stopLogPolling(), [stopLogPolling]);
const installExtrasConfirmed = useCallback(async () => {
if (pendingExtras.length === 0) return;
setExtrasActionLoading(true);
setExtrasActionError("");
startLogPolling();
try {
const res = await fetch("/api/headroom/extras", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ extras: pendingExtras }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Install failed");
setHeadroomExtras((s) => ({
...s,
version: data.version ?? s.version,
extras: data.extras || s.extras,
}));
setPendingExtras([]);
} catch (e) {
setExtrasActionError(e.message);
} finally {
stopLogPolling();
setExtrasActionLoading(false);
}
}, [pendingExtras, startLogPolling, stopLogPolling]);
const removeExtraConfirmed = useCallback(async (extra) => {
setRemovingExtra(extra);
setExtrasActionError("");
startLogPolling();
try {
const res = await fetch("/api/headroom/extras", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ extras: [extra] }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Remove failed");
setHeadroomExtras((s) => ({
...s,
version: data.version ?? s.version,
extras: data.extras || s.extras,
}));
} catch (e) {
setExtrasActionError(e.message);
} finally {
stopLogPolling();
setRemovingExtra(null);
}
}, [startLogPolling, stopLogPolling]);
const handleInstallExtras = useCallback(() => {
if (pendingExtras.length === 0) return;
// Warn about the heavy ~1GB torch download before installing [ml].
if (pendingExtras.includes("ml")) {
setExtrasConfirm({
title: "Install [ml]",
message: "[ml] downloads ~1 GB (torch + huggingface-hub). Continue?",
confirmText: "Install",
variant: "primary",
onConfirm: installExtrasConfirmed,
});
return;
}
installExtrasConfirmed();
}, [pendingExtras, installExtrasConfirmed]);
const handleRemoveExtra = useCallback((extra) => {
setExtrasConfirm({
title: `Remove [${extra}]`,
message: `Remove [${extra}] and its packages?`,
confirmText: "Remove",
variant: "danger",
onConfirm: () => removeExtraConfirmed(extra),
});
}, [removeExtraConfirmed]);
// Toggle an extra's active state (persist setting), then restart the proxy so
// the new --code-aware / --disable-kompress flags take effect.
const toggleExtraActive = useCallback(async (extra, value) => {
setExtrasActionError("");
if (extra === "code") setCodeAware(value);
if (extra === "ml") setKompress(value);
const key = extra === "code" ? "headroomCodeAware" : "headroomKompress";
await patchSetting({ [key]: value });
if (!headroomStatus.running) return;
setRestartingProxy(true);
try {
const res = await fetch("/api/headroom/restart", { method: "POST" });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Restart failed");
await refreshHeadroomStatus();
} catch (e) {
setExtrasActionError(e.message);
} finally {
setRestartingProxy(false);
}
}, [headroomStatus.running, refreshHeadroomStatus]);
const handleCavemanLevel = (level) => {
setCavemanLevel(level);
patchSetting({ cavemanLevel: level });
@@ -152,6 +353,59 @@ export default function TokenSaverClient() {
patchSetting({ ponytailLevel: level });
};
const refreshPxpipeStatus = useCallback(async () => {
setPxpipeStatus((s) => ({ ...s, loading: true }));
try {
const res = await fetch("/api/pxpipe/status", {
headers: { "Cache-Control": "no-store" },
});
const data = await res.json();
setPxpipeStatus({ ...data, loading: false });
if (typeof data.minChars === "number") setPxpipeMinChars(data.minChars);
} catch {
setPxpipeStatus({ installed: false, installing: false, running: false, version: null, loading: false });
}
}, []);
const runPxpipeHealth = useCallback(async () => {
try {
const res = await fetch("/api/pxpipe/health", { method: "POST" });
setPxpipeHealth(await res.json());
} catch (e) {
setPxpipeHealth({ healthy: false, checks: [], error: e.message });
}
}, []);
const pxpipeAction = useCallback(
async (endpoint) => {
setPxpipeActionError("");
setPxpipeActionLoading(true);
try {
const res = await fetch(`/api/pxpipe/${endpoint}`, { method: "POST" });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `PXPIPE ${endpoint} failed`);
await refreshPxpipeStatus();
await runPxpipeHealth();
} catch (e) {
setPxpipeActionError(e.message);
} finally {
setPxpipeActionLoading(false);
}
},
[refreshPxpipeStatus, runPxpipeHealth]
);
const handlePxpipeEnabled = (value) => {
setPxpipeEnabled(value);
patchSetting({ pxpipeEnabled: value });
};
const handlePxpipeMinCharsBlur = () => {
const next = Math.max(0, Number(pxpipeMinChars) || 25000);
setPxpipeMinChars(next);
patchSetting({ pxpipeMinChars: next });
};
useEffect(() => {
const loadSettings = async () => {
try {
@@ -161,16 +415,22 @@ export default function TokenSaverClient() {
setRtkEnabledState(data.rtkEnabled !== false);
setHeadroomEnabled(!!data.headroomEnabled);
setHeadroomUrl(data.headroomUrl || "http://localhost:8787");
setCodeAware(data.headroomCodeAware === true);
setKompress(data.headroomKompress !== false);
setCavemanEnabled(!!data.cavemanEnabled);
setCavemanLevel(data.cavemanLevel || "full");
setPonytailEnabled(!!data.ponytailEnabled);
setPonytailLevel(data.ponytailLevel || "full");
setPxpipeEnabled(!!data.pxpipeEnabled);
if (typeof data.pxpipeMinChars === "number") setPxpipeMinChars(data.pxpipeMinChars);
refreshHeadroomStatus();
// PRD: run the PXPIPE health check automatically when the page opens
refreshPxpipeStatus().then(runPxpipeHealth);
}
} catch {}
};
loadSettings();
}, [refreshHeadroomStatus]);
}, [refreshHeadroomStatus, refreshPxpipeStatus, runPxpipeHealth]);
const headroomRunning = !!headroomStatus.running;
const headroomStatusLabel = headroomStatus.loading
@@ -187,6 +447,23 @@ export default function TokenSaverClient() {
const headroomManaged =
headroomLocalUrl && !!headroomStatus.managedPid;
const pxpipeHealthy = pxpipeHealth?.healthy === true;
const pxpipeStatusLabel = pxpipeStatus.loading
? "Checking…"
: pxpipeStatus.installing
? "Installing…"
: !pxpipeStatus.installed
? "Not installed"
: pxpipeHealthy
? "Healthy"
: pxpipeStatus.running
? "Running"
: "Stopped";
const pxpipeChipClass =
pxpipeHealthy || pxpipeStatus.running
? "bg-success/15 text-success"
: "bg-warning/15 text-warning";
return (
<div className="space-y-6 p-6">
<Card id="rtk">
@@ -220,7 +497,7 @@ export default function TokenSaverClient() {
onChange={() => handleRtkEnabled(!rtkEnabled)}
/>
</div>
<div className="flex items-center justify-between py-4 border-b border-border gap-4 flex-wrap">
<div className="flex items-center justify-between py-4 gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3 flex-wrap">
<p className="font-medium">
@@ -257,7 +534,105 @@ export default function TokenSaverClient() {
onChange={() => handleHeadroomEnabled(!headroomEnabled)}
/>
</div>
<div className="flex items-center justify-between pt-4 gap-4 flex-wrap">
{headroomStatus.installed && (
<div className="mb-3 ml-1 pl-3 pb-4 border-l-2 border-border">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-text-muted">
Compression extras
{headroomExtras.version ? ` · v${headroomExtras.version}` : ""}:
</span>
{headroomExtras.available.map((extra) => {
const installed = !!headroomExtras.extras[extra];
const pending = pendingExtras.includes(extra);
const extraTitle =
extra === "code"
? "tree-sitter AST compression for code responses"
: "Kompress-v2 HF model for prose/agentic traces (~+1GB)";
if (installed) {
const active = extra === "code" ? codeAware : kompress;
return (
<div
key={extra}
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded border border-success/40 bg-success/5 text-text"
title={extraTitle}
>
<Toggle
size="sm"
checked={active}
disabled={restartingProxy}
onChange={() => toggleExtraActive(extra, !active)}
/>
<span className="font-medium">[{extra}]</span>
<button
type="button"
onClick={() => handleRemoveExtra(extra)}
disabled={removingExtra === extra}
className="ml-1 text-error underline hover:opacity-80 disabled:opacity-50"
title={`Uninstall [${extra}]`}
>
{removingExtra === extra ? "Uninstalling…" : "Uninstall"}
</button>
</div>
);
}
return (
<label
key={extra}
className={`flex items-center gap-1.5 text-xs px-2 py-1 rounded border cursor-pointer transition-colors ${
pending
? "border-primary bg-primary/10 text-primary"
: "border-border text-text-muted hover:bg-surface-2"
}`}
title={extraTitle}
>
<input
type="checkbox"
className="w-3 h-3"
checked={pending}
onChange={() => togglePendingExtra(extra)}
/>
<span className="font-medium">[{extra}]</span>
<span className="opacity-70">not installed</span>
</label>
);
})}
{pendingExtras.length > 0 && (
<button
onClick={handleInstallExtras}
disabled={extrasActionLoading}
className="text-xs px-2.5 py-1 rounded bg-primary text-white hover:opacity-90 disabled:opacity-50"
>
{extrasActionLoading
? "Installing…"
: `Install [proxy,${pendingExtras.join(",")}]`}
</button>
)}
</div>
{extrasActionError && (
<p className="text-xs text-error mt-1">{extrasActionError}</p>
)}
{restartingProxy && (
<p className="text-xs text-text-muted mt-1">Restarting proxy</p>
)}
{(extrasActionLoading || removingExtra) && installLog && (
<pre className="mt-2 max-h-32 overflow-auto rounded bg-surface-2 p-2 text-[10px] leading-tight text-text-muted whitespace-pre-wrap">
{installLog}
</pre>
)}
<p className="text-xs text-text-muted mt-1">
Installing adds the package; use <code>on</code>/<code>off</code>{" "}
to activate it (restarts the proxy). Default install is{" "}
<code>[proxy]</code> only (SmartCrusher for JSON). Adding{" "}
<code>[code]</code> enables AST compression
(Python/JS/TS/Go/Rust/Java/C/C++/Perl). Adding <code>[ml]</code>{" "}
enables the Kompress-v2 HF model for prose/agentic traces but
adds ~1 GB (torch + huggingface-hub).
</p>
</div>
)}
<div className="flex items-center justify-between pt-4 border-t border-border gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="font-medium">
Compress LLM output{" "}
@@ -358,6 +733,52 @@ export default function TokenSaverClient() {
/>
</div>
</div>
{/* PXPIPE hidden from UI — experimental, not exposed to users yet */}
{false && (
<div className="flex items-center justify-between pt-4 mt-4 border-t border-border gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-3 flex-wrap">
<p className="font-medium">
Compress prompts as images{" "}
<a
href="https://github.com/teamchong/pxpipe"
target="_blank"
rel="noreferrer"
className="text-xs font-normal text-primary underline hover:opacity-80"
>
(PXPIPE)
</a>
</p>
<span className={`text-xs px-2 py-0.5 rounded ${pxpipeChipClass}`}>
{pxpipeStatusLabel}
</span>
<button
type="button"
onClick={() => setShowPxpipeModal(true)}
className="text-xs text-primary underline hover:opacity-80"
>
{pxpipeStatus.installed ? "Manage" : "Setup"}
</button>
<a
href="/dashboard/pxpipe"
className="text-xs text-primary underline hover:opacity-80"
>
Dashboard
</a>
</div>
<p className="text-sm text-text-muted mt-1">
Transforms large textual context into optimized images before
sending to the LLM. Ideal for huge prompts, tool outputs and long
conversations.
</p>
</div>
<Toggle
checked={pxpipeEnabled}
disabled={!pxpipeStatus.installed}
onChange={() => handlePxpipeEnabled(!pxpipeEnabled)}
/>
</div>
)}
</Card>
<Modal
@@ -374,6 +795,16 @@ export default function TokenSaverClient() {
{headroomStatusLabel}
</span>
</div>
{headroomRunning && (
<a
href="/api/headroom/proxy/dashboard"
target="_blank"
rel="noreferrer"
className="w-full rounded border border-border px-4 py-2 text-center text-sm hover:bg-surface-2"
>
Open Headroom Dashboard
</a>
)}
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Proxy URL</p>
<Input
@@ -457,6 +888,128 @@ export default function TokenSaverClient() {
</div>
</div>
</Modal>
<Modal
isOpen={false}
title={pxpipeStatus.installed ? "PXPIPE" : "Setup PXPIPE"}
onClose={() => setShowPxpipeModal(false)}
>
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">
Compress prompts using multimodal encoding. Runs in-process no
extra server or environment variables required.
</p>
<div className="flex items-center justify-between text-sm">
<span>Status</span>
<span className={pxpipeHealthy || pxpipeStatus.running ? "text-success" : "text-warning"}>
{pxpipeStatusLabel}
{pxpipeStatus.version ? ` · v${pxpipeStatus.version}` : ""}
</span>
</div>
{pxpipeHealth?.checks?.length > 0 && (
<div className="flex flex-col gap-1 rounded border border-border p-3">
<p className="text-sm font-medium mb-1">Health check</p>
{pxpipeHealth.checks.map((check) => (
<div key={check.id} className="flex items-center justify-between text-xs">
<span className={check.ok ? "text-success" : "text-warning"}>
{check.ok ? "●" : "○"} {check.label}
</span>
{check.detail && (
<span className="text-text-muted font-mono truncate max-w-[50%]">{check.detail}</span>
)}
</div>
))}
{pxpipeHealth.error && (
<p className="text-xs text-warning mt-1">{pxpipeHealth.error}</p>
)}
</div>
)}
{!pxpipeStatus.installed ? (
<div className="flex flex-col gap-2">
<p className="text-sm text-warning">PXPIPE is not installed.</p>
<Button
onClick={() => pxpipeAction("install")}
fullWidth
disabled={pxpipeActionLoading || pxpipeStatus.installing}
>
{pxpipeActionLoading || pxpipeStatus.installing ? "Installing…" : "Install"}
</Button>
<p className="text-xs text-text-muted">
Installs the npm package <code className="font-mono">pxpipe-proxy</code> into
the 9Router data directory. May take a few minutes.
</p>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
{pxpipeStatus.running ? (
<>
<Button onClick={() => pxpipeAction("restart")} variant="ghost" disabled={pxpipeActionLoading}>
Restart
</Button>
<Button onClick={() => pxpipeAction("stop")} variant="ghost" disabled={pxpipeActionLoading}>
Stop
</Button>
</>
) : (
<Button onClick={() => pxpipeAction("start")} disabled={pxpipeActionLoading}>
{pxpipeActionLoading ? "Starting…" : "Start"}
</Button>
)}
<Button onClick={() => pxpipeAction("install")} variant="ghost" disabled={pxpipeActionLoading}>
Repair
</Button>
<a
href="/dashboard/pxpipe#logs"
className="col-span-2 rounded border border-border px-4 py-2 text-center text-sm hover:bg-surface-2"
>
Open Logs
</a>
</div>
)}
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Minimum prompt size (chars)</p>
<Input
value={String(pxpipeMinChars)}
onChange={(e) => setPxpipeMinChars(e.target.value)}
onBlur={handlePxpipeMinCharsBlur}
placeholder="25000"
className="font-mono text-sm"
/>
<p className="text-xs text-text-muted">
Requests smaller than this bypass PXPIPE and are sent as-is.
</p>
</div>
{pxpipeActionError && (
<p className="text-sm text-warning">{pxpipeActionError}</p>
)}
<div className="flex gap-2">
<Button
onClick={() => refreshPxpipeStatus().then(runPxpipeHealth)}
variant="ghost"
fullWidth
>
Recheck
</Button>
<Button onClick={() => setShowPxpipeModal(false)} fullWidth>
Done
</Button>
</div>
</div>
</Modal>
<ConfirmModal
isOpen={!!extrasConfirm}
onClose={() => setExtrasConfirm(null)}
onConfirm={() => {
const fn = extrasConfirm?.onConfirm;
setExtrasConfirm(null);
fn?.();
}}
title={extrasConfirm?.title}
message={extrasConfirm?.message}
confirmText={extrasConfirm?.confirmText}
variant={extrasConfirm?.variant}
/>
</div>
);
}
@@ -8,7 +8,7 @@ const fmtCost = (n) => `$${(n || 0).toFixed(2)}`;
export default function OverviewCards({ stats }) {
return (
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-4 sm:gap-4">
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 sm:gap-4">
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
<span className="text-text-muted text-sm uppercase font-semibold">Total Requests</span>
<span className="truncate text-2xl font-bold">{fmt(stats.totalRequests)}</span>
@@ -17,12 +17,10 @@ export default function OverviewCards({ stats }) {
<span className="text-text-muted text-sm uppercase font-semibold">Total Input Tokens</span>
<span className="truncate text-2xl font-bold text-primary">{fmt(stats.totalPromptTokens)}</span>
</Card>
{/* Temporarily hidden: Cached Tokens card
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
<span className="text-text-muted text-sm uppercase font-semibold">Cached Tokens</span>
<span className="truncate text-2xl font-bold text-info">{fmt(stats.totalCachedTokens)}</span>
</Card>
*/}
<Card className="flex min-w-0 flex-col gap-1 px-4 py-3">
<span className="text-text-muted text-sm uppercase font-semibold">Output Tokens</span>
<span className="truncate text-2xl font-bold text-success">{fmt(stats.totalCompletionTokens)}</span>
@@ -475,6 +475,23 @@ export function parseQuotaData(provider, data) {
}
break;
case "grok-cli":
// Grok Build credits (on-demand window + prepaid balance).
// Do NOT forward absolute `remaining` — getRemainingPercentage treats
// it as a 0100 percentage (same as Qoder). Use remainingPercentage.
if (data.quotas) {
Object.entries(data.quotas).forEach(([name, quota]) => {
normalizedQuotas.push({
name,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: quota.remainingPercentage,
});
});
}
break;
default:
// Generic fallback for unknown providers
if (data.quotas) {
@@ -413,6 +413,48 @@ export default function RequestDetailsTab() {
</div>
</div>
{selectedDetail.pxpipe && (
<div className="rounded-lg border border-black/5 dark:border-white/5 p-4">
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-[18px] text-text-muted">image</span>
<span className="font-semibold text-sm text-text-main">PXPIPE</span>
<span className={cn(
"text-xs px-2 py-0.5 rounded",
selectedDetail.pxpipe.applied
? "bg-green-500/15 text-green-600"
: "bg-amber-500/15 text-amber-600"
)}>
{selectedDetail.pxpipe.applied ? "Activated" : "Skipped"}
</span>
</div>
{selectedDetail.pxpipe.applied ? (
<div className="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
<div>
<span className="text-text-muted block text-xs">Original (est.)</span>
<span className="font-mono">{(selectedDetail.pxpipe.tokensBeforeEst || 0).toLocaleString()} tokens</span>
</div>
<div>
<span className="text-text-muted block text-xs">Compressed (est.)</span>
<span className="font-mono">{(selectedDetail.pxpipe.tokensAfterEst || 0).toLocaleString()} tokens</span>
</div>
<div>
<span className="text-text-muted block text-xs">Saved</span>
<span className="font-mono text-green-600">{selectedDetail.pxpipe.savedPct || 0}%</span>
</div>
<div>
<span className="text-text-muted block text-xs">Images</span>
<span className="font-mono">{selectedDetail.pxpipe.imageCount || 0} ({selectedDetail.pxpipe.durationMs || 0}ms)</span>
</div>
</div>
) : (
<p className="text-sm text-text-muted">
Reason: <span className="font-mono">{selectedDetail.pxpipe.reason}</span>
{selectedDetail.pxpipe.detail ? `${selectedDetail.pxpipe.detail}` : ""}
</p>
)}
</div>
)}
<div className="space-y-4">
<CollapsibleSection title="1. Client Request (Input)" defaultOpen={true} icon="input">
<pre className="max-h-[300px] max-w-full overflow-auto rounded-lg border border-black/5 bg-black/5 p-3 font-mono text-xs text-text-main dark:border-white/5 dark:bg-white/5 sm:p-4">
+46
View File
@@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import { findPython310, getInstalledHeadroomExtras, HEADROOM_COMPRESSION_EXTRAS } from "@/lib/headroom/detect";
import { installHeadroomExtras, uninstallHeadroomExtras, getInstallLogTail } from "@/lib/headroom/process";
export const dynamic = "force-dynamic";
export async function GET(req) {
try {
// `?log=1` returns the live install/uninstall log tail for progress polling.
if (new URL(req.url).searchParams.get("log") === "1") {
return NextResponse.json({ log: getInstallLogTail() });
}
const python = findPython310();
const status = getInstalledHeadroomExtras(python);
return NextResponse.json({
available: HEADROOM_COMPRESSION_EXTRAS,
...status,
});
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export async function POST(req) {
try {
const body = await req.json().catch(() => ({}));
const requested = Array.isArray(body?.extras) ? body.extras : [];
const result = await installHeadroomExtras(requested);
return NextResponse.json(result);
} catch (error) {
const status = error.code === "NOT_INSTALLED" || error.code === "NO_PYTHON" ? 400 : 500;
return NextResponse.json({ error: error.message, code: error.code || null }, { status });
}
}
export async function DELETE(req) {
try {
const body = await req.json().catch(() => ({}));
const requested = Array.isArray(body?.extras) ? body.extras : [];
const result = await uninstallHeadroomExtras(requested);
return NextResponse.json(result);
} catch (error) {
const status = error.code === "NO_PYTHON" || error.code === "INVALID_EXTRAS" ? 400 : 500;
return NextResponse.json({ error: error.message, code: error.code || null }, { status });
}
}
@@ -0,0 +1,104 @@
import { NextResponse } from "next/server";
import { getSettings } from "@/lib/localDb";
import { DEFAULT_HEADROOM_URL } from "@/lib/headroom/detect";
export const dynamic = "force-dynamic";
const HOP_BY_HOP_HEADERS = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
]);
const DASHBOARD_PREFIX = "/api/headroom/proxy";
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
async function getTargetBase() {
const settings = await getSettings();
const url = settings.headroomUrl || DEFAULT_HEADROOM_URL;
const target = new URL(url);
if (!["http:", "https:"].includes(target.protocol)) {
throw new Error("Headroom URL must use http or https");
}
return target;
}
function buildTargetUrl(base, path, search) {
const target = new URL(base);
target.pathname = `/${path.join("/")}`;
target.search = search;
return target;
}
function forwardedHeaders(request, target) {
const headers = new Headers(request.headers);
for (const header of headers.keys()) {
if (HOP_BY_HOP_HEADERS.has(header.toLowerCase())) headers.delete(header);
}
headers.delete("host");
// Never leak viewer credentials to a non-loopback Headroom host
if (!LOOPBACK_HOSTS.has(target.hostname.replace(/^\[|\]$/g, "").toLowerCase())) {
headers.delete("cookie");
headers.delete("authorization");
}
return headers;
}
function rewriteDashboardHtml(html) {
return html.replace(
/fetch\('(?=\/(?:stats|health|stats-history|transformations\/feed))/g,
`fetch('${DASHBOARD_PREFIX}`,
);
}
async function proxy(request, { params }) {
try {
const base = await getTargetBase();
const { search } = new URL(request.url);
const path = (await params).path || [];
const target = buildTargetUrl(base, path, search);
const method = request.method;
const hasBody = !["GET", "HEAD"].includes(method);
const response = await fetch(target, {
method,
headers: forwardedHeaders(request, target),
body: hasBody ? request.body : undefined,
duplex: hasBody ? "half" : undefined,
redirect: "manual",
});
const headers = new Headers(response.headers);
for (const header of headers.keys()) {
if (HOP_BY_HOP_HEADERS.has(header.toLowerCase())) headers.delete(header);
}
if (path.join("/") === "dashboard") {
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("text/html")) {
headers.delete("content-length");
return new NextResponse(rewriteDashboardHtml(await response.text()), {
status: response.status,
headers,
});
}
}
return new NextResponse(response.body, { status: response.status, headers });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
export const GET = proxy;
export const POST = proxy;
export const PUT = proxy;
export const PATCH = proxy;
export const DELETE = proxy;
export const HEAD = proxy;
export const OPTIONS = proxy;
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { getSettings } from "@/lib/localDb";
import { restartHeadroomProxy } from "@/lib/headroom/process";
import { DEFAULT_HEADROOM_URL, isLoopbackHeadroomUrl } from "@/lib/headroom/detect";
export const dynamic = "force-dynamic";
function parsePortFromUrl(url) {
try {
const u = new URL(url);
const p = parseInt(u.port, 10);
if (p > 0 && p < 65536) return p;
} catch { /* ignore, fall through to default */ }
return null;
}
export async function POST() {
try {
const settings = await getSettings();
const url = settings.headroomUrl || DEFAULT_HEADROOM_URL;
if (!isLoopbackHeadroomUrl(url)) {
return NextResponse.json({ error: "External Headroom proxies must be started outside 9Router", code: "EXTERNAL_PROXY" }, { status: 400 });
}
const port = parsePortFromUrl(url) || 8787;
const result = await restartHeadroomProxy({
port,
codeAware: settings.headroomCodeAware === true,
kompress: settings.headroomKompress !== false,
});
return NextResponse.json({ success: true, ...result });
} catch (error) {
const status = error.code === "NOT_INSTALLED" ? 400 : 500;
return NextResponse.json({ error: error.message, code: error.code || null }, { status });
}
}
+5 -1
View File
@@ -22,7 +22,11 @@ export async function POST() {
return NextResponse.json({ error: "External Headroom proxies must be started outside 9Router", code: "EXTERNAL_PROXY" }, { status: 400 });
}
const port = parsePortFromUrl(url) || 8787;
const result = await startHeadroomProxy({ port });
const result = await startHeadroomProxy({
port,
codeAware: settings.headroomCodeAware === true,
kompress: settings.headroomKompress !== false,
});
return NextResponse.json({ success: true, ...result });
} catch (error) {
const status = error.code === "NOT_INSTALLED" ? 400 : 500;
+10 -2
View File
@@ -150,8 +150,16 @@ export async function GET(request, { params }) {
}
: undefined;
// Providers that don't use PKCE for device code
const noPkceDeviceProviders = ["github", "kiro", "kimi-coding", "kilocode", "codebuddy-cn", "qoder"];
// Providers that don't use PKCE for device code (Grok CLI HAR: plain device_code, no challenge)
const noPkceDeviceProviders = [
"github",
"kiro",
"kimi-coding",
"kilocode",
"codebuddy-cn",
"qoder",
"grok-cli",
];
let deviceData;
if (noPkceDeviceProviders.includes(provider)) {
deviceData = await requestDeviceCode(provider, undefined, deviceOptions);
@@ -236,6 +236,7 @@ const PROVIDER_MODELS_CONFIG = {
xai: createOpenAIModelsConfig("https://api.x.ai/v1/models"),
mistral: createOpenAIModelsConfig("https://api.mistral.ai/v1/models"),
perplexity: createOpenAIModelsConfig("https://api.perplexity.ai/v1/models"),
"perplexity-agent": createOpenAIModelsConfig("https://api.perplexity.ai/v1/models"),
together: createOpenAIModelsConfig("https://api.together.xyz/v1/models"),
fireworks: createOpenAIModelsConfig("https://api.fireworks.ai/inference/v1/models"),
cerebras: createOpenAIModelsConfig("https://api.cerebras.ai/v1/models"),
+88 -10
View File
@@ -103,8 +103,61 @@ const OAUTH_TEST_CONFIG = {
},
refreshable: false,
},
// Grok CLI / Grok Build — probe /v1/user (no inference quota). Headers mirror official CLI.
"grok-cli": {
url: PROVIDERS["grok-cli"]?.userUrl || "https://cli-chat-proxy.grok.com/v1/user",
method: "GET",
authHeader: "Authorization",
authPrefix: "Bearer ",
extraHeaders: {
Accept: "application/json",
...(PROVIDERS["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",
}),
},
refreshable: true,
// Subscription spending-limit is not an auth failure — token is fine, credits aren't.
// Accept 402 so the connection stays "active" with a warning (same idea as Codex 400).
acceptStatuses: [402],
softFailMessage: {
402: "Connected, but Grok Build credits are exhausted (spending limit). Add credits or upgrade SuperGrok.",
},
},
};
/**
* Classify an OAuth probe response as success / soft-success / hard-fail.
* Soft success (e.g. 402 spending-limit on Grok CLI) means auth works but the
* account cannot spend keep connection active and surface a warning.
* Exported for unit tests.
*/
export function classifyOAuthProbeResult(res, config, bodyText = "") {
if (!res) return { valid: false, error: "No response", soft: false };
const status = res.status;
const accepted = res.ok || (config?.acceptStatuses && config.acceptStatuses.includes(status));
if (!accepted) {
if (status === 401) return { valid: false, error: "Token invalid or revoked", soft: false };
if (status === 403) return { valid: false, error: "Access denied", soft: false };
return { valid: false, error: `API returned ${status}`, soft: false };
}
// Soft success only when the provider configured an explicit message for this
// status (e.g. Grok CLI 402 spending-limit). Codex-style acceptStatuses:[400]
// stays silent success — 400 there only proves auth, not a user-facing warning.
if (!res.ok && config?.acceptStatuses?.includes(status)) {
const softMap = config.softFailMessage || {};
if (softMap[status]) {
return { valid: true, error: softMap[status], soft: true };
}
return { valid: true, error: null, soft: false };
}
return { valid: true, error: null, soft: false };
}
async function probeClineAccessToken(accessToken) {
const res = await fetch("https://api.cline.bot/api/v1/users/me", {
method: "GET",
@@ -186,7 +239,7 @@ async function refreshOAuthToken(connection) {
return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken };
}
if (provider === "codex") {
if (provider === "codex" || provider === "grok-cli" || provider === "xai") {
return await refreshProviderCredentials(provider, connection, console);
}
@@ -362,9 +415,19 @@ async function testOAuthConnection(connection, effectiveProxy = null) {
const fetchOpts = { method: config.method, headers };
if (config.body) fetchOpts.body = config.body;
const res = await fetchWithConnectionProxy(testUrl, fetchOpts, effectiveProxy);
const bodyText = !res.ok ? await res.text().catch(() => "") : "";
const accepted = res.ok || (config.acceptStatuses && config.acceptStatuses.includes(res.status));
if (accepted) return { valid: true, error: null, refreshed, newTokens };
const classified = classifyOAuthProbeResult(res, config, bodyText);
if (classified.valid) {
return {
valid: true,
// soft success surfaces warning text without marking connection error
error: classified.soft ? classified.error : null,
warning: classified.soft ? classified.error : null,
refreshed,
newTokens,
};
}
if (res.status === 401 && config.refreshable && !refreshed && connection.refreshToken) {
const tokens = await refreshOAuthToken(connection);
@@ -376,15 +439,22 @@ async function testOAuthConnection(connection, effectiveProxy = null) {
const retryOpts = { method: config.method, headers: retryHeaders };
if (config.body) retryOpts.body = config.body;
const retryRes = await fetchWithConnectionProxy(retryUrl, retryOpts, effectiveProxy);
const retryAccepted = retryRes.ok || (config.acceptStatuses && config.acceptStatuses.includes(retryRes.status));
if (retryAccepted) return { valid: true, error: null, refreshed: true, newTokens: tokens };
const retryBody = !retryRes.ok ? await retryRes.text().catch(() => "") : "";
const retryClassified = classifyOAuthProbeResult(retryRes, config, retryBody);
if (retryClassified.valid) {
return {
valid: true,
error: retryClassified.soft ? retryClassified.error : null,
warning: retryClassified.soft ? retryClassified.error : null,
refreshed: true,
newTokens: tokens,
};
}
}
return { valid: false, error: "Token invalid or revoked", refreshed: false };
}
if (res.status === 401) return { valid: false, error: "Token invalid or revoked", refreshed };
if (res.status === 403) return { valid: false, error: "Access denied", refreshed };
return { valid: false, error: `API returned ${res.status}`, refreshed };
return { valid: false, error: classified.error, refreshed };
} catch (err) {
return { valid: false, error: err.message, refreshed };
}
@@ -752,10 +822,18 @@ export async function testSingleConnection(id) {
const latencyMs = Date.now() - start;
// Soft success (e.g. Grok CLI 402 spending-limit): credentials are good, account is
// out of credits. Keep testStatus active; surface the message as lastError so the
// dashboard can show a warning without marking the connection broken.
const softWarning = result.valid && (result.warning || result.error);
const updateData = {
testStatus: result.valid ? "active" : "error",
lastError: result.valid ? null : result.error,
lastErrorAt: result.valid ? null : new Date().toISOString(),
lastError: result.valid ? (softWarning || null) : result.error,
lastErrorAt: result.valid
? softWarning
? new Date().toISOString()
: null
: new Date().toISOString(),
};
if (result.refreshed && result.newTokens) {
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { runHealthCheck } from "@/lib/pxpipe/service.js";
export const dynamic = "force-dynamic";
export async function POST() {
try {
const result = await runHealthCheck();
return NextResponse.json(result);
} catch (error) {
return NextResponse.json({ healthy: false, checks: [], error: error.message }, { status: 500 });
}
}
// GET mirrors POST so the card can probe on page load without a mutation call.
export const GET = POST;
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { installPxpipe } from "@/lib/pxpipe/install.js";
import { unloadPxpipe } from "@/lib/pxpipe/loader.js";
import { runHealthCheck } from "@/lib/pxpipe/service.js";
export const dynamic = "force-dynamic";
// npm install can legitimately take minutes on a cold cache.
export const maxDuration = 300;
// Install (or repair — same operation, reinstalls @latest) then re-run the health check.
export async function POST() {
try {
const info = await installPxpipe();
unloadPxpipe(); // drop any previously-loaded version so health loads the fresh one
const health = await runHealthCheck();
return NextResponse.json({ ...info, health });
} catch (error) {
return NextResponse.json({ error: error.message, code: error.code || null }, { status: 500 });
}
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { getInstallLogTail } from "@/lib/pxpipe/install.js";
import { readPxpipeEvents } from "@/lib/pxpipe/events.js";
export const dynamic = "force-dynamic";
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const limit = Math.min(Number(searchParams.get("limit")) || 100, 500);
return NextResponse.json({
installLog: getInstallLogTail(),
events: readPxpipeEvents({ limit }).reverse(),
});
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { unloadPxpipe, loadPxpipe } from "@/lib/pxpipe/loader.js";
import { getPxpipeStatus } from "@/lib/pxpipe/service.js";
export const dynamic = "force-dynamic";
// Reload the in-process module (picks up an upgraded install without a server restart).
export async function POST() {
try {
unloadPxpipe();
await loadPxpipe();
return NextResponse.json(getPxpipeStatus());
} catch (error) {
return NextResponse.json({ error: error.message, code: error.code || null }, { status: 500 });
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { getSettings } from "@/lib/localDb";
import { getInstallInfo, installPxpipe } from "@/lib/pxpipe/install.js";
import { loadPxpipe } from "@/lib/pxpipe/loader.js";
import { getPxpipeStatus } from "@/lib/pxpipe/service.js";
export const dynamic = "force-dynamic";
export const maxDuration = 300;
// "Start" in library mode = warm the in-process transform module.
// Auto-installs first when the package is missing and pxpipeAutoInstall is on.
export async function POST() {
try {
if (!getInstallInfo().installed) {
const settings = await getSettings();
if (!settings.pxpipeAutoInstall) {
return NextResponse.json({ error: "PXPIPE is not installed", code: "NOT_INSTALLED" }, { status: 409 });
}
await installPxpipe();
}
await loadPxpipe();
return NextResponse.json(getPxpipeStatus());
} catch (error) {
return NextResponse.json({ error: error.message, code: error.code || null }, { status: 500 });
}
}
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { getPxpipeStats } from "@/lib/pxpipe/events.js";
export const dynamic = "force-dynamic";
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const recentLimit = Math.min(Number(searchParams.get("limit")) || 100, 500);
return NextResponse.json(getPxpipeStats({ recentLimit }));
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { getSettings } from "@/lib/localDb";
import { getPxpipeStatus } from "@/lib/pxpipe/service.js";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const settings = await getSettings();
const status = getPxpipeStatus();
return NextResponse.json({
...status,
enabled: !!settings.pxpipeEnabled,
autoInstall: !!settings.pxpipeAutoInstall,
minChars: settings.pxpipeMinChars,
timeoutMs: settings.pxpipeTimeoutMs,
});
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { unloadPxpipe } from "@/lib/pxpipe/loader.js";
import { getPxpipeStatus } from "@/lib/pxpipe/service.js";
export const dynamic = "force-dynamic";
// "Stop" in library mode = drop the in-process module; requests fail open to
// uncompressed passthrough until it is started again.
export async function POST() {
try {
const wasLoaded = unloadPxpipe();
return NextResponse.json({ stopped: wasLoaded, ...getPxpipeStatus() });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
+13 -1
View File
@@ -1,11 +1,23 @@
import { NextResponse } from "next/server";
import { getTunnelStatus, getTailscaleStatus, getDownloadStatus } from "@/lib/tunnel";
const STATUS_CACHE_TTL_MS = 3000; // coalesce rapid polls; underlying probes already cache 10s
// Survive hot reload; one cache per process. Only tunnel/tailscale probes are cached —
// download progress stays live so the enable/download UI updates smoothly.
const statusCache = (global.__tunnelStatusCache ??= { value: null, fetchedAt: 0 });
export async function GET() {
try {
let probes = statusCache.value;
if (!probes || Date.now() - statusCache.fetchedAt >= STATUS_CACHE_TTL_MS) {
const [tunnel, tailscale] = await Promise.all([getTunnelStatus(), getTailscaleStatus()]);
probes = { tunnel, tailscale };
statusCache.value = probes;
statusCache.fetchedAt = Date.now();
}
const download = getDownloadStatus();
return NextResponse.json({ tunnel, tailscale, download });
return NextResponse.json({ ...probes, download });
} catch (error) {
console.error("Tunnel status error:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
+4 -5
View File
@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { getRequestDetails } from "@/lib/requestDetailsDb";
import { getDistinctProviders } from "@/lib/requestDetailsDb";
import { getProviderNodes } from "@/lib/localDb";
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
@@ -9,10 +9,9 @@ import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
*/
export async function GET() {
try {
const { details } = await getRequestDetails({ pageSize: 9999 });
// Extract unique providers
const providerIds = [...new Set(details.map(r => r.provider).filter(Boolean))].sort();
// Query DISTINCT provider column directly — avoids parsing every row's
// full JSON blob (can be hundreds of MB), which previously caused OOM.
const providerIds = await getDistinctProviders();
const providerNodes = await getProviderNodes();
const nodeMap = {};
+4 -2
View File
@@ -9,8 +9,10 @@ export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page")) || 1;
const pageSize = parseInt(searchParams.get("pageSize")) || 20;
const pageRaw = parseInt(searchParams.get("page"));
const page = Number.isNaN(pageRaw) ? 1 : pageRaw;
const pageSizeRaw = parseInt(searchParams.get("pageSize"));
const pageSize = Number.isNaN(pageSizeRaw) ? 20 : pageSizeRaw;
const provider = searchParams.get("provider");
const model = searchParams.get("model");
const connectionId = searchParams.get("connectionId");
+59 -17
View File
@@ -11,6 +11,64 @@ export async function OPTIONS() {
return new Response(null, { headers: CORS_HEADERS });
}
function countValueChars(value) {
if (value == null) return 0;
if (typeof value === "string") return value.length;
if (typeof value === "number" || typeof value === "boolean") {
return String(value).length;
}
if (Array.isArray(value)) {
return value.reduce((total, item) => total + countValueChars(item), 0);
}
if (typeof value === "object") {
return Object.entries(value).reduce((total, [key, item]) => {
return total + key.length + countValueChars(item);
}, 0);
}
return 0;
}
function countContentBlockChars(block) {
if (block == null) return 0;
if (typeof block === "string") return block.length;
if (typeof block !== "object") return countValueChars(block);
switch (block.type) {
case "text":
return countValueChars(block.text);
case "tool_use":
return countValueChars(block.name) + countValueChars(block.input);
case "tool_result":
return countValueChars(block.content);
case "thinking":
return countValueChars(block.thinking);
default:
return countValueChars(block);
}
}
function countMessageChars(message) {
if (!message || typeof message !== "object") return 0;
const content = message.content;
if (typeof content === "string") return content.length;
if (Array.isArray(content)) {
return content.reduce((total, block) => total + countContentBlockChars(block), 0);
}
return countValueChars(content);
}
export function estimateAnthropicInputTokens(body = {}) {
const messages = Array.isArray(body.messages) ? body.messages : [];
let totalChars = countValueChars(body.system) + countValueChars(body.tools);
for (const msg of messages) {
totalChars += countMessageChars(msg);
}
return Math.ceil(totalChars / 4);
}
/**
* POST /v1/messages/count_tokens - Mock token count response
*/
@@ -25,23 +83,7 @@ export async function POST(request) {
});
}
// Estimate token count based on content length
const messages = body.messages || [];
let totalChars = 0;
for (const msg of messages) {
if (typeof msg.content === "string") {
totalChars += msg.content.length;
} else if (Array.isArray(msg.content)) {
for (const part of msg.content) {
if (part.type === "text" && part.text) {
totalChars += part.text.length;
}
}
}
}
// Rough estimate: ~4 chars per token
const inputTokens = Math.ceil(totalChars / 4);
const inputTokens = estimateAnthropicInputTokens(body);
return new Response(JSON.stringify({
input_tokens: inputTokens
+17 -1
View File
@@ -2,6 +2,10 @@ import https from "https";
import pkg from "../../../../package.json" with { type: "json" };
const NPM_PACKAGE_NAME = "9router";
const VERSION_CACHE_TTL_MS = 3600000; // cache npm latest lookup for 1h
// Survive hot reload; one cache per process
const versionCache = (global.__npmVersionCache ??= { value: null, fetchedAt: 0 });
// Fetch latest version from npm registry
function fetchLatestVersion() {
@@ -36,8 +40,20 @@ function compareVersions(a, b) {
return 0;
}
async function getLatestVersionCached() {
if (versionCache.value && Date.now() - versionCache.fetchedAt < VERSION_CACHE_TTL_MS) {
return versionCache.value;
}
const latest = await fetchLatestVersion();
if (latest) {
versionCache.value = latest;
versionCache.fetchedAt = Date.now();
}
return latest;
}
export async function GET() {
const latestVersion = await fetchLatestVersion();
const latestVersion = await getLatestVersionCached();
const currentVersion = pkg.version;
const hasUpdate = latestVersion ? compareVersions(latestVersion, currentVersion) > 0 : false;
+2 -1
View File
@@ -1,4 +1,5 @@
@import "tailwindcss";
/* source() sets scan base to src/ for both webpack + Turbopack; auto-detection still skips binaries + gitignore */
@import "tailwindcss" source("../../");
@custom-variant dark (&:where(.dark, .dark *));
+1
View File
@@ -81,6 +81,7 @@ const LOCAL_ONLY_PATHS = [
"/api/auth/reset-password",
"/api/headroom/start",
"/api/headroom/stop",
"/api/headroom/proxy",
];
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
+69 -30
View File
@@ -1,41 +1,77 @@
export const LOCALES = ["en", "vi", "zh-CN", "zh-TW", "ja", "pt-BR", "pt-PT", "ko", "es", "de", "fr", "he", "ar", "ru", "pl", "cs", "nl", "tr", "uk", "tl", "id", "th", "hi", "bn", "ur", "ro", "sv", "it", "el", "hu", "fi", "da", "no"];
export const LOCALES = [
"en",
"vi",
"zh-CN",
"zh-TW",
"ja",
"pt-BR",
"pt-PT",
"ko",
"es",
"de",
"fr",
"he",
"ar",
"ru",
"pl",
"cs",
"nl",
"tr",
"uk",
"tl",
"id",
"th",
"hi",
"bn",
"ur",
"ro",
"sv",
"it",
"el",
"hu",
"fi",
"da",
"no",
"fa",
];
export const DEFAULT_LOCALE = "en";
export const LOCALE_COOKIE = "locale";
export const LOCALE_NAMES = {
"en": "English",
"vi": "Tiếng Việt",
en: "English",
vi: "Tiếng Việt",
"zh-CN": "简体中文",
"zh-TW": "繁體中文",
"ja": "日本語",
ja: "日本語",
"pt-BR": "Português (Brasil)",
"pt-PT": "Português (Portugal)",
"ko": "한국어",
"es": "Español",
"de": "Deutsch",
"fr": "Français",
"he": "עברית",
"ar": "العربية",
"ru": "Русский",
"pl": "Polski",
"cs": "Čeština",
"nl": "Nederlands",
"tr": "Türkçe",
"uk": "Українська",
"tl": "Tagalog",
"id": "Indonesia",
"th": "ไทย",
"hi": "हिन्दी",
"bn": "বাংলা",
"ur": "اردو",
"ro": "Română",
"sv": "Svenska",
"it": "Italiano",
"el": "Ελληνικά",
"hu": "Magyar",
"fi": "Suomi",
"da": "Dansk",
"no": "Norsk"
ko: "한국어",
es: "Español",
de: "Deutsch",
fr: "Français",
he: "עברית",
ar: "العربية",
ru: "Русский",
pl: "Polski",
cs: "Čeština",
nl: "Nederlands",
tr: "Türkçe",
uk: "Українська",
tl: "Tagalog",
id: "Indonesia",
th: "ไทย",
hi: "हिन्दी",
bn: "বাংলা",
ur: "اردو",
ro: "Română",
sv: "Svenska",
it: "Italiano",
el: "Ελληνικά",
hu: "Magyar",
fi: "Suomi",
da: "Dansk",
no: "Norsk",
fa: "فارسی",
};
export function normalizeLocale(locale) {
@@ -138,6 +174,9 @@ export function normalizeLocale(locale) {
if (locale === "no") {
return "no";
}
if (locale === "fa") {
return "fa";
}
return DEFAULT_LOCALE;
}
+41 -1
View File
@@ -1,9 +1,20 @@
// DB safety backups — taken ONLY before a schema change (see migrate.js).
//
// ⚠️ AGENT/DEV NOTES:
// - Backups are a best-effort safety net before schema migrations. There is NO
// automated restore path; recovery is manual (copy a backup file back).
// - Backups intentionally EXCLUDE the `requestDetails` table (observability log,
// auto-pruned, non-critical) so a multi-hundred-MB DB backs up as a few MB.
// - Only the newest KEEP_BACKUPS are kept; older ones are pruned automatically.
import fs from "node:fs";
import path from "node:path";
import { BACKUPS_DIR, ensureDirs } from "./paths.js";
import { timestampSlug, getAppVersion } from "./version.js";
const KEEP_BACKUPS = 5;
const KEEP_BACKUPS = 3;
// Tables excluded from safety backups (large, non-critical, reproducible).
const BACKUP_EXCLUDE_TABLES = ["requestDetails"];
export function makeBackupDir(label) {
ensureDirs();
@@ -22,6 +33,35 @@ export function backupFile(srcPath, destDir, destName = null) {
return dest;
}
// Lightweight DB backup via ATTACH: create an empty sqlite file, copy every
// table EXCEPT the excluded ones into it. Avoids duplicating the huge
// observability log, so the backup stays small regardless of DB size.
export function backupDbLite(adapter, destDir, destName = "data.sqlite") {
const dest = path.join(destDir, destName);
try { fs.rmSync(dest, { force: true }); } catch {}
const escaped = dest.replace(/'/g, "''");
adapter.exec(`ATTACH DATABASE '${escaped}' AS bak`);
try {
const excluded = new Set(BACKUP_EXCLUDE_TABLES);
const tables = adapter
.all(`SELECT name, sql FROM main.sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'`)
.filter((t) => !excluded.has(t.name));
adapter.transaction(() => {
for (const t of tables) {
// Recreate table structure in backup DB, then copy rows.
const createSql = t.sql.replace(/CREATE TABLE\s+/i, "CREATE TABLE bak.");
adapter.exec(createSql);
adapter.exec(`INSERT INTO bak.${t.name} SELECT * FROM main.${t.name}`);
}
});
} finally {
try { adapter.exec("DETACH DATABASE bak"); } catch {}
}
return dest;
}
export function pruneOldBackups() {
if (!fs.existsSync(BACKUPS_DIR)) return;
const entries = fs.readdirSync(BACKUPS_DIR, { withFileTypes: true })
+1 -1
View File
@@ -64,7 +64,7 @@ export {
// Request details
export {
saveRequestDetail, getRequestDetails, getRequestDetailById,
saveRequestDetail, getRequestDetails, getRequestDetailById, getDistinctProviders,
} from "./repos/requestDetailsRepo.js";
// Export/import full DB
+33 -22
View File
@@ -1,10 +1,10 @@
import fs from "node:fs";
import path from "node:path";
import { LEGACY_FILES, DB_DIR, DATA_FILE } from "./paths.js";
import { TABLES, buildCreateTableSql } from "./schema.js";
import { LEGACY_FILES, DB_DIR } from "./paths.js";
import { TABLES, buildCreateTableSql, SCHEMA_VERSION } from "./schema.js";
import { MIGRATIONS, latestVersion } from "./migrations/index.js";
import { getMetaSync, setMetaSync } from "./helpers/metaStore.js";
import { makeBackupDir, backupFile, pruneOldBackups } from "./backup.js";
import { makeBackupDir, backupFile, backupDbLite, pruneOldBackups } from "./backup.js";
import { getAppVersion } from "./version.js";
import { stringifyJson } from "./helpers/jsonCol.js";
@@ -221,12 +221,37 @@ export async function runMigrationOnce(adapter) {
// a brand-new DB as non-fresh once schemaVersion is written).
const fresh = isFreshDb(adapter);
// Prune stale backups every boot so old oversized backups shrink to KEEP.
pruneOldBackups();
// Bootstrap _meta so we can read the stored backup schema version below
// (runVersionedMigrations also ensures this, but we need it earlier here).
adapter.exec(buildCreateTableSql("_meta", TABLES._meta));
// Detect a pending schema change via the central SCHEMA_VERSION const.
// A lightweight backup is taken BEFORE any schema mutation below.
const storedSchemaVer = parseInt(getMetaSync(adapter, "backupSchemaVersion", "0"), 10) || 0;
const schemaChanging = !fresh && storedSchemaVer < SCHEMA_VERSION;
if (schemaChanging) {
try {
const backupDir = makeBackupDir(`schema-${storedSchemaVer}-to-${SCHEMA_VERSION}`);
backupDbLite(adapter, backupDir);
pruneOldBackups();
console.log(`[DB][migrate] pre-schema backup ${storedSchemaVer}${SCHEMA_VERSION}: ${backupDir}`);
} catch (e) {
console.warn(`[DB][migrate] pre-schema backup failed (continuing): ${e.message}`);
}
}
// 1. Always run versioned migrations chain (skip-version safe)
const migInfo = runVersionedMigrations(adapter);
// 2. Additive sync (auto add missing columns/indexes declared in TABLES)
syncSchemaFromTables(adapter);
// Stamp the schema version we just reached so future boots skip re-backup.
setMetaSync(adapter, "backupSchemaVersion", SCHEMA_VERSION);
// 3. One-time legacy JSON import (only if DB was fresh on entry)
const alreadyImported = fs.existsSync(MIGRATED_MARKER);
const legacyMain = readJsonSafe(LEGACY_FILES.main);
@@ -247,6 +272,7 @@ export async function runMigrationOnce(adapter) {
importLegacyDisabled(adapter, legacyDisabled);
importLegacyDetails(adapter, legacyDetails);
setMetaSync(adapter, "appVersion", getAppVersion());
setMetaSync(adapter, "backupSchemaVersion", SCHEMA_VERSION);
setMetaSync(adapter, "migratedAt", new Date().toISOString());
});
} catch (err) {
@@ -263,24 +289,9 @@ export async function runMigrationOnce(adapter) {
return;
}
if (fresh) {
setMetaSync(adapter, "appVersion", getAppVersion());
return;
}
// 4. App version bump → backup data.sqlite (safety net before user-side upgrade)
const oldVer = getMetaSync(adapter, "appVersion", null);
// Track app version for informational purposes only. App version bumps no
// longer trigger a DB backup — only real schema changes (SCHEMA_VERSION) do.
const newVer = getAppVersion();
if (oldVer && oldVer !== newVer) {
const backupDir = makeBackupDir(`upgrade-${oldVer}-to-${newVer}`);
try { backupFile(DATA_FILE, backupDir); } catch {}
setMetaSync(adapter, "appVersion", newVer);
pruneOldBackups();
console.log(`[DB][migrate] App ${oldVer}${newVer} | schema ${migInfo.from}${migInfo.to} | backup: ${backupDir}`);
} else if (migInfo.applied > 0) {
// Schema upgrade without app version bump — still backup
const backupDir = makeBackupDir(`schema-${migInfo.from}-to-${migInfo.to}`);
try { backupFile(DATA_FILE, backupDir); } catch {}
pruneOldBackups();
}
const oldVer = getMetaSync(adapter, "appVersion", null);
if (oldVer !== newVer) setMetaSync(adapter, "appVersion", newVer);
}

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