Merge branch 'master' into dev

# Conflicts:
#	src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js
#	src/app/(dashboard)/dashboard/cli-tools/components/index.js
#	src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js
#	src/app/api/cli-tools/all-statuses/route.js
#	src/app/api/settings/route.js
#	src/app/api/v1/models/route.js
#	src/shared/constants/cliTools.js
This commit is contained in:
2026-07-17 11:15:18 +07:00
85 changed files with 9230 additions and 669 deletions
+8
View File
@@ -78,4 +78,12 @@ gitbook/README.md
open-sse.old/
.graphifyignore
graphify-out/*
# Local-only working dirs (notes, vendored repos, scripts, skills)
.claude/
.docs/
.repo/
.script/
.codegraph/
.PR/
.next-analyze/*
+30
View File
@@ -1,3 +1,33 @@
# v0.5.35 (2026-07-16)
## Features
- **xAI**: Grok Imagine video generation (`/v1/videos`) + CLI
- **CLI tools**: Grok Build setup — writes `[model.9router]` to `~/.grok/config.toml`
- **GitHub Copilot**: route Claude models through Copilot's native `/v1/messages`
- **Kiro**: add GPT-5.6 model family (#2596)
- **RTK**: `X-9Router-Token-Saver` header to bypass token savers per request
- **Providers**: quota visibility settings
- **Translator**: drop temperature for all Claude models
- **i18n**: Thai (th) + Persian (fa) translations / README
## Fixes
- **Providers**: bulk-add API keys no longer overwrite existing keys (gap-fill `Key N`)
- **Anthropic**: lowercase `anthropic-version` header to prevent duplication on `/v1/messages`
- **Alicode-intl**: use DashScope compatible-mode endpoint so standard keys work
- **Grok CLI**: align Grok Build with current subscription protocol (#2590)
- **Grok CLI**: surface `expiresAt` so proactive token refresh fires (#2546)
- **Kiro**: improve direct session cache reuse
- **Models**: populate capabilities for live-catalog LLM models
- **Models**: list compatible provider models in `/v1/models`
- **Thinking**: send explicit `thinking:{type:adaptive}` alongside `output_config.effort`
- **Translator**: strip `client_metadata` when converting openai-responses → openai
## Improvements
- **Perf**: skip inactive background services on startup
## Docs
- README: Persian YouTube tutorial
# v0.5.30 (2026-07-10)
## Features
+10 -1
View File
@@ -17,7 +17,7 @@
[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com)
[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md)
[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md)
</div>
@@ -207,6 +207,13 @@ Default URLs:
<b>🇮🇩 Indonesia</b><br/>
<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>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=GyX-DLvePW8">
<img src="https://img.youtube.com/vi/GyX-DLvePW8/hqdefault.jpg" alt="این شکلی از هر API ای استفاده کن برای هوش مصنوعی" width="300"/>
</a><br/>
<b>🇮🇷 Persian-فارسی</b><br/>
<sub dir="rtl">این شکلی از هر API ای استفاده کن برای هوش مصنوعی<br/>by <a href="https://www.youtube.com/@Matin_SenPai">Matin SenPai</a></sub>
</td>
</tr>
</table>
@@ -448,6 +455,8 @@ Default URLs:
| 📊 **Usage Analytics** | Track tokens, cost, trends over time | Optimize spending |
| 🌐 **Deploy Anywhere** | Localhost, VPS, Docker, Cloudflare Workers | Flexible deployment options |
Set `X-9Router-Token-Saver: off` to bypass all token savers for one chat request.
<details>
<summary><b>📖 Feature Details</b></summary>
+18
View File
@@ -30,6 +30,19 @@ const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRunt
const { ensureTrayRuntime } = require("./hooks/trayRuntime");
const args = process.argv.slice(2);
// Subcommands (`9router xai video …`) run against an already-running gateway
// and bypass the launcher flow (no runtime self-heal, no server spawn).
if (args[0] === "xai" && args[1] === "video") {
const { run } = require("./src/cli/commands/xaiVideo");
run(args.slice(2))
.then((code) => process.exit(code))
.catch((err) => {
console.error(`${err?.message || err}`);
process.exit(1);
});
return;
}
// Self-heal SQLite runtime deps (sql.js + better-sqlite3) into ~/.9router/runtime
// so the server can resolve them via NODE_PATH. Best-effort — sql.js is required,
// better-sqlite3 is optional. Logs to stderr only on failure.
@@ -97,6 +110,11 @@ Options:
-t, --tray Run in system tray mode (background)
-h, --help Show this help message
-v, --version Show version
Commands:
xai video --prompt "..." --output video.mp4
Generate a Grok Imagine video via the running gateway
(see: ${APP_NAME} xai video --help)
`);
process.exit(0);
} else if (args[i] === "--version" || args[i] === "-v") {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "9router",
"version": "0.5.30",
"version": "0.5.35",
"description": "9Router CLI - Start and manage 9Router server",
"bin": {
"9router": "./cli.js"
+300
View File
@@ -0,0 +1,300 @@
/**
* `9router xai video` — generate a Grok Imagine video through the local
* 9router gateway and save the result as an MP4 file.
*
* Flow: POST /v1/videos/generations → poll GET /v1/videos/{request_id}
* until done/failed/timeout → download video.url → atomic rename.
*
* No OAuth tokens or Authorization headers are ever printed.
*/
const http = require("http");
const https = require("https");
const fs = require("fs");
const path = require("path");
const DEFAULT_PORT = 20128;
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_MODEL = "xai/grok-imagine-video";
const DEFAULT_TIMEOUT_SEC = 600;
const DEFAULT_POLL_INTERVAL_MS = 5000;
const TERMINAL_STATUSES = new Set(["done", "failed", "completed", "error", "expired", "cancelled"]);
const FAILED_STATUSES = new Set(["failed", "error", "expired", "cancelled"]);
const HELP = `
Usage: 9router xai video --prompt "..." [options]
Generate a Grok Imagine video via your local 9router gateway
(requires a connected xAI account — Grok Build OAuth or API key).
Options:
--prompt <text> Video description (required)
--output <file> Output MP4 path (default: video.mp4)
--model <id> Model (default: ${DEFAULT_MODEL})
--duration <seconds> Video duration
--aspect-ratio <ratio> e.g. 16:9, 9:16, 1:1
--resolution <res> 480p | 720p | 1080p
--image <path-or-url> Image input for image-to-video
--timeout <seconds> Max wait for the job (default: ${DEFAULT_TIMEOUT_SEC})
--port <port> Gateway port (default: ${DEFAULT_PORT})
--host <host> Gateway host (default: ${DEFAULT_HOST})
--api-key <key> 9router API key (or env NINE_ROUTER_API_KEY)
-h, --help Show this help
`;
function sanitizeText(text) {
return String(text ?? "").replace(/Bearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]");
}
function parseArgs(argv) {
const opts = {
model: DEFAULT_MODEL,
output: "video.mp4",
timeoutSec: DEFAULT_TIMEOUT_SEC,
port: DEFAULT_PORT,
host: DEFAULT_HOST,
apiKey: process.env.NINE_ROUTER_API_KEY || null,
pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
const next = () => argv[++i];
if (a === "--prompt") opts.prompt = next();
else if (a === "--output" || a === "-o") opts.output = next();
else if (a === "--model") opts.model = next();
else if (a === "--duration") opts.duration = parseInt(next(), 10);
else if (a === "--aspect-ratio") opts.aspectRatio = next();
else if (a === "--resolution") opts.resolution = next();
else if (a === "--image") opts.image = next();
else if (a === "--timeout") opts.timeoutSec = parseInt(next(), 10) || DEFAULT_TIMEOUT_SEC;
else if (a === "--port" || a === "-p") opts.port = parseInt(next(), 10) || DEFAULT_PORT;
else if (a === "--host" || a === "-H") opts.host = next() || DEFAULT_HOST;
else if (a === "--api-key") opts.apiKey = next();
else if (a === "--poll-interval-ms") opts.pollIntervalMs = parseInt(next(), 10) || DEFAULT_POLL_INTERVAL_MS;
else if (a === "-h" || a === "--help") opts.help = true;
else {
throw new Error(`Unknown option: ${a}`);
}
}
return opts;
}
/** Local file path → base64 data URL; URLs pass through untouched. */
function imageInputToUrl(input) {
if (/^(https?:|data:)/i.test(input)) return input;
const buf = fs.readFileSync(input);
const ext = path.extname(input).toLowerCase();
const mime = ext === ".png" ? "image/png" : ext === ".webp" ? "image/webp" : "image/jpeg";
return `data:${mime};base64,${buf.toString("base64")}`;
}
/** Minimal JSON request against the local gateway. Returns { status, headers, body }. */
function gatewayRequest({ host, port, apiKey, method, reqPath, body, signal }) {
return new Promise((resolve, reject) => {
const payload = body ? JSON.stringify(body) : null;
const headers = { Accept: "application/json" };
if (payload) {
headers["Content-Type"] = "application/json";
headers["Content-Length"] = Buffer.byteLength(payload);
}
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
const req = http.request({ hostname: host, port, path: reqPath, method, headers, signal }, (res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => {
let parsed = null;
try { parsed = data ? JSON.parse(data) : null; } catch { /* keep raw */ }
resolve({ status: res.statusCode, headers: res.headers, body: parsed, raw: data });
});
});
req.on("error", reject);
if (payload) req.write(payload);
req.end();
});
}
const sleep = (ms, signal) =>
new Promise((resolve, reject) => {
const t = setTimeout(resolve, ms);
signal?.addEventListener?.("abort", () => { clearTimeout(t); reject(new Error("aborted")); }, { once: true });
});
/**
* Poll GET /v1/videos/{id} until a terminal status or deadline.
* @returns {Promise<object>} final poll body (status done) — throws on failed/timeout.
*/
async function pollUntilDone({ host, port, apiKey, requestId, connectionId, timeoutSec, pollIntervalMs, signal, onProgress }) {
const deadline = Date.now() + timeoutSec * 1000;
while (true) {
if (signal?.aborted) throw new Error("aborted");
if (Date.now() > deadline) {
throw new Error(`Timed out after ${timeoutSec}s waiting for video job ${requestId}`);
}
const res = await gatewayRequestWithConnection({ host, port, apiKey, requestId, connectionId, signal });
if (res.status === 200 && res.body) {
const status = String(res.body.status || "").toLowerCase();
onProgress?.(status || "pending", res.body.progress);
if (FAILED_STATUSES.has(status)) {
const msg = res.body.error?.message || res.body.error || "video generation failed";
throw new Error(`Job ${requestId} failed: ${sanitizeText(typeof msg === "string" ? msg : JSON.stringify(msg))}`);
}
if (TERMINAL_STATUSES.has(status)) return res.body;
} else if (res.status >= 400 && res.status !== 429 && res.status !== 503) {
throw new Error(`Polling failed (HTTP ${res.status}): ${sanitizeText(res.raw?.slice(0, 300))}`);
}
await sleep(pollIntervalMs, signal);
}
}
function gatewayRequestWithConnection({ host, port, apiKey, requestId, connectionId, signal }) {
return new Promise((resolve, reject) => {
const headers = { Accept: "application/json" };
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
if (connectionId) headers["x-connection-id"] = connectionId;
const req = http.request(
{ hostname: host, port, path: `/v1/videos/${encodeURIComponent(requestId)}`, method: "GET", headers, signal },
(res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => {
let parsed = null;
try { parsed = data ? JSON.parse(data) : null; } catch { /* keep raw */ }
resolve({ status: res.statusCode, body: parsed, raw: data });
});
}
);
req.on("error", reject);
req.end();
});
}
/**
* Download a URL to `outputPath` via a `.part` temp file with atomic rename.
* The temp file is removed on any failure.
*/
async function downloadToFile(url, outputPath, { signal } = {}) {
const partPath = `${outputPath}.part`;
await new Promise((resolve, reject) => {
const cleanupAnd = (fn) => (err) => {
try { fs.unlinkSync(partPath); } catch { /* not created yet */ }
fn(err);
};
const get = (target, redirectsLeft) => {
const mod = target.startsWith("https:") ? https : http;
const req = mod.get(target, { signal }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0) {
res.resume();
return get(new URL(res.headers.location, target).toString(), redirectsLeft - 1);
}
if (res.statusCode !== 200) {
res.resume();
return cleanupAnd(reject)(new Error(`Download failed: HTTP ${res.statusCode}`));
}
const out = fs.createWriteStream(partPath);
res.pipe(out);
out.on("finish", () => out.close(resolve));
out.on("error", cleanupAnd(reject));
res.on("error", cleanupAnd(reject));
});
req.on("error", cleanupAnd(reject));
};
get(url, 5);
});
fs.renameSync(partPath, outputPath);
}
async function run(argv) {
let opts;
try {
opts = parseArgs(argv);
} catch (err) {
console.error(`${err.message}`);
console.log(HELP);
return 1;
}
if (opts.help) {
console.log(HELP);
return 0;
}
if (!opts.prompt) {
console.error("❌ --prompt is required");
console.log(HELP);
return 1;
}
const controller = new AbortController();
const partPath = `${opts.output}.part`;
const onSigint = () => {
controller.abort();
try { fs.unlinkSync(partPath); } catch { /* absent */ }
console.error("\n✋ Cancelled");
process.exit(130);
};
process.on("SIGINT", onSigint);
try {
const body = { model: opts.model, prompt: opts.prompt };
if (opts.duration) body.duration = opts.duration;
if (opts.aspectRatio) body.aspect_ratio = opts.aspectRatio;
if (opts.resolution) body.resolution = opts.resolution;
if (opts.image) body.image = { url: imageInputToUrl(opts.image) };
console.log(`🎬 Requesting video (${opts.model})…`);
const create = await gatewayRequest({
host: opts.host, port: opts.port, apiKey: opts.apiKey,
method: "POST", reqPath: "/v1/videos/generations", body, signal: controller.signal,
});
if (create.status !== 200 || !create.body?.request_id) {
const detail = create.body?.error?.message || create.body?.error || create.raw || `HTTP ${create.status}`;
console.error(`❌ Create failed: ${sanitizeText(typeof detail === "string" ? detail : JSON.stringify(detail)).slice(0, 500)}`);
if (create.status === 400 && /No credentials/i.test(String(detail))) {
console.error(" Connect an xAI account first: dashboard → Providers → xAI (Grok).");
}
return 1;
}
const requestId = create.body.request_id;
const connectionId = create.headers["x-9router-connection-id"] || null;
console.log(`📋 Job accepted: ${requestId}`);
let lastLine = "";
const result = await pollUntilDone({
host: opts.host, port: opts.port, apiKey: opts.apiKey,
requestId, connectionId,
timeoutSec: opts.timeoutSec, pollIntervalMs: opts.pollIntervalMs,
signal: controller.signal,
onProgress: (status, progress) => {
const line = `${status}${Number.isFinite(progress) ? ` ${progress}%` : ""}`;
if (line !== lastLine) {
lastLine = line;
if (process.stdout.isTTY) process.stdout.write(`\r\x1b[K${line}`);
else console.log(line);
}
},
});
if (process.stdout.isTTY) process.stdout.write("\n");
const videoUrl = result.video?.url || result.video?.file_output?.public_url;
if (!videoUrl) {
console.error("❌ Job finished but no video URL was returned");
return 1;
}
console.log("⬇️ Downloading…");
await downloadToFile(videoUrl, opts.output, { signal: controller.signal });
console.log(`✅ Saved ${opts.output}`);
return 0;
} catch (err) {
if (process.stdout.isTTY) process.stdout.write("\n");
console.error(`${sanitizeText(err?.message || String(err))}`);
return 1;
} finally {
process.removeListener("SIGINT", onSigint);
}
}
module.exports = { run, parseArgs, pollUntilDone, downloadToFile, imageInputToUrl, sanitizeText };
+1442
View File
File diff suppressed because it is too large Load Diff
+723
View File
@@ -0,0 +1,723 @@
นี่คือเอกสารแปลภาษาไทยของไฟล์ Markdown ต้นฉบับ โดยรักษาโครงสร้างและซินแท็กซ์ทางเทคนิคทั้งหมดไว้เหมือนเดิม
<div align="center">
<img src="../images/9router.png?1" alt="แดชบอร์ด 9Router" width="800"/>
# 9Router - Free AI Router
**ไม่ต้องหยุดเขียนโค้ด ประหยัดโทเค็น 20-40% ด้วย RTK + สลับอัตโนมัติไปยังโมเดล AI ฟรีและราคาถูก**
**ผู้ให้บริการ AI ฟรีสำหรับ OpenClaw**
<p align="center">
<img src="../public/providers/openclaw.png" alt="OpenClaw" width="80"/>
</p>
[![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router)
[![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router)
[![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE)
[🚀 เริ่มต้นใช้งาน](#-quick-start) • [💡 ฟีเจอร์](#-key-features) • [📖 การตั้งค่า](#-setup-guide) • [🌐 เว็บไซต์](https://9router.com)
</div>
---
## 🤔 ทำไมต้อง 9Router?
**หยุดเสียเงินและเจอขีดจำกัด:**
- ❌ โควตาสมาชิกหมดอายุโดยไม่ได้ใช้ทุกเดือน
- ❌ Rate Limit หยุดคุณระหว่างเขียนโค้ด
- ❌ ค่า API แพง ($20-50/เดือน ต่อผู้ให้บริการแต่ละราย)
- ❌ ต้องสลับผู้ให้บริการด้วยตนเอง
**9Router แก้ปัญหาเหล่านี้:**
-**ประหยัดโทเค็น RTK** - บีบอัดผลลัพธ์จากเครื่องมือ (`git diff`, `grep`, `ls`...) ก่อนส่งให้ LLM
-**เพิ่มประสิทธิภาพสมาชิก** - ติดตามโควตา ใช้ทุกบิตก่อนรีเซ็ต
-**สลับอัตโนมัติ** - สมาชิก → ถูก → ฟรี, ไม่มีเวลาหยุดทำงาน
-**รองรับหลายบัญชี** - Round-robin ระหว่างบัญชีของผู้ให้บริการแต่ละราย
-**ใช้งานได้ทุกที่** - ใช้ได้กับ Claude Code, Codex, Cursor, Cline, เครื่องมือ CLI ใดก็ได้
---
## 🔄 วิธีการทำงาน
```
┌─────────────┐
│ Your CLI │ (Claude Code, Codex, OpenClaw, Cursor, Cline...)
│ Tool │
└──────┬──────┘
│ http://localhost:20128/v1
┌─────────────────────────────────────────────┐
│ 9Router (Smart Router) │
│ • RTK Token Saver (ตัดโทเค็น tool_result) │
│ • แปลงรูปแบบ (OpenAI ↔ Claude) │
│ • ติดตามโควตา │
│ • รีเฟรชโทเค็นอัตโนมัติ │
└──────┬──────────────────────────────────────┘
├─→ [Tier 1: สมาชิก] Claude Code, Codex, GitHub Copilot
│ ↓ โควตาหมด
├─→ [Tier 2: ถูก] GLM ($0.6/1M), MiniMax ($0.2/1M)
│ ↓ งบหมด
└─→ [Tier 3: ฟรี] Kiro, OpenCode Free, Vertex ($300 เครดิตฟรี)
ผลลัพธ์: ไม่ต้องหยุดเขียนโค้ด ค่าใช้จ่ายน้อยที่สุด + ประหยัดโทเค็น 20-40% ด้วย RTK
```
---
## ⚡ เริ่มต้นใช้งาน
**1. ติดตั้งแบบ Global:**
```bash
npm install -g 9router
9router
```
🎉 เปิดแดชบอร์ดที่ `http://localhost:20128`
**2. เชื่อมต่อผู้ให้บริการฟรี (ไม่ต้องสมัคร):**
แดชบอร์ด → Providers → เชื่อมต่อ **Kiro AI** (Claude ฟรีไม่จำกัด) หรือ **OpenCode Free** (ไม่ต้องยืนยันตัวตน) → เสร็จ!
**3. ใช้ในเครื่องมือ CLI ของคุณ:**
```
ตั้งค่า Claude Code/Codex/OpenClaw/Cursor/Cline:
Endpoint: http://localhost:20128/v1
API Key: [คัดลอกจากแดชบอร์ด]
Model: kr/claude-sonnet-4.5
```
**เสร็จแล้ว!** เริ่มเขียนโค้ดด้วยโมเดล AI ฟรี
**วิธีอื่น: รันจากซอร์สโค้ด (เก็บรักษาไว้ใน repo นี้):**
Repo นี้เป็น private package (`9router-app`) ดังนั้นการรันจากซอร์ส/Docker คือเส้นทางพัฒนาท้องถิ่นที่คาดไว้
```bash
cp .env.example .env
npm install
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
```
โหมด Production:
```bash
npm run build
PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start
```
URL ค่าเริ่มต้น:
- แดชบอร์ด: `http://localhost:20128/dashboard`
- OpenAI-compatible API: `http://localhost:20128/v1`
---
## 🛠️ เครื่องมือ CLI ที่รองรับ
9Router ทำงานได้อย่างราบรื่นกับเครื่องมือเขียนโค้ด AI ทุกประเภท:
<div align="center">
<table>
<tr>
<td align="center" width="120">
<img src="../public/providers/claude.png" width="60" alt="Claude Code"/><br/>
<b>Claude-Code</b>
</td>
<td align="center" width="120">
<img src="../public/providers/openclaw.png" width="60" alt="OpenClaw"/><br/>
<b>OpenClaw</b>
</td>
<td align="center" width="120">
<img src="../public/providers/codex.png" width="60" alt="Codex"/><br/>
<b>Codex</b>
</td>
<td align="center" width="120">
<img src="../public/providers/opencode.png" width="60" alt="OpenCode"/><br/>
<b>OpenCode</b>
</td>
<td align="center" width="120">
<img src="../public/providers/cursor.png" width="60" alt="Cursor"/><br/>
<b>Cursor</b>
</td>
<td align="center" width="120">
<img src="../public/providers/antigravity.png" width="60" alt="Antigravity"/><br/>
<b>Antigravity</b>
</td>
</tr>
<tr>
<td align="center" width="120">
<img src="../public/providers/cline.png" width="60" alt="Cline"/><br/>
<b>Cline</b>
</td>
<td align="center" width="120">
<img src="../public/providers/continue.png" width="60" alt="Continue"/><br/>
<b>Continue</b>
</td>
<td align="center" width="120">
<img src="../public/providers/droid.png" width="60" alt="Droid"/><br/>
<b>Droid</b>
</td>
<td align="center" width="120">
<img src="../public/providers/roo.png" width="60" alt="Roo"/><br/>
<b>Roo</b>
</td>
<td align="center" width="120">
<img src="../public/providers/copilot.png" width="60" alt="Copilot"/><br/>
<b>Copilot</b>
</td>
<td align="center" width="120">
<img src="../public/providers/kilocode.png" width="60" alt="Kilo Code"/><br/>
<b>Kilo Code</b>
</td>
</tr>
</table>
</div>
---
## ผู้ให้บริการที่รองรับ
### 🔐 ผู้ให้บริการ OAuth
<div align="center">
<table>
<tr>
<td align="center" width="120">
<img src="../public/providers/claude.png" width="60" alt="Claude Code"/><br/>
<b>Claude-Code</b>
</td>
<td align="center" width="120">
<img src="../public/providers/antigravity.png" width="60" alt="Antigravity"/><br/>
<b>Antigravity</b>
</td>
<td align="center" width="120">
<img src="../public/providers/codex.png" width="60" alt="Codex"/><br/>
<b>Codex</b>
</td>
<td align="center" width="120">
<img src="../public/providers/github.png" width="60" alt="GitHub"/><br/>
<b>GitHub</b>
</td>
<td align="center" width="120">
<img src="../public/providers/cursor.png" width="60" alt="Cursor"/><br/>
<b>Cursor</b>
</td>
</tr>
</table>
</div>
### 🆓 ผู้ให้บริการฟรี
<div align="center">
<table>
<tr>
<td align="center" width="150">
<img src="../public/providers/kiro.png" width="70" alt="Kiro"/><br/>
<b>Kiro AI</b><br/>
<sub>Claude 4.5 + GLM-5 + MiniMax • ไม่จำกัด ฟรี</sub>
</td>
<td align="center" width="150">
<img src="../public/providers/opencode.png" width="70" alt="OpenCode"/><br/>
<b>OpenCode Free</b><br/>
<sub>ไม่ต้องยืนยันตัวตน • ดึงโมเดลอัตโนมัติ • ไม่จำกัด ฟรี</sub>
</td>
<td align="center" width="150">
<img src="../public/providers/gemini.png" width="70" alt="Vertex AI"/><br/>
<b>Vertex AI</b><br/>
<sub>Gemini 3 Pro + GLM-5 + DeepSeek • เครดิตฟรี $300</sub>
</td>
</tr>
</table>
</div>
> **หมายเหตุ:** iFlow, Qwen และ Gemini CLI หยุดให้บริการในปี 2026 แล้ว ใช้ Kiro / OpenCode Free / Vertex แทน
### 🔑 ผู้ให้บริการ API Key (40+)
<div align="center">
<table>
<tr>
<td align="center" width="100">
<img src="../public/providers/openrouter.png" width="50" alt="OpenRouter"/><br/>
<sub>OpenRouter</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/glm.png" width="50" alt="GLM"/><br/>
<sub>GLM</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/kimi.png" width="50" alt="Kimi"/><br/>
<sub>Kimi</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/minimax.png" width="50" alt="MiniMax"/><br/>
<sub>MiniMax</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/openai.png" width="50" alt="OpenAI"/><br/>
<sub>OpenAI</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/anthropic.png" width="50" alt="Anthropic"/><br/>
<sub>Anthropic</sub>
</td>
</tr>
<tr>
<td align="center" width="100">
<img src="../public/providers/gemini.png" width="50" alt="Gemini"/><br/>
<sub>Gemini</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/deepseek.png" width="50" alt="DeepSeek"/><br/>
<sub>DeepSeek</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/groq.png" width="50" alt="Groq"/><br/>
<sub>Groq</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/xai.png" width="50" alt="xAI"/><br/>
<sub>xAI</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/mistral.png" width="50" alt="Mistral"/><br/>
<sub>Mistral</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/perplexity.png" width="50" alt="Perplexity"/><br/>
<sub>Perplexity</sub>
</td>
</tr>
<tr>
<td align="center" width="100">
<img src="../public/providers/together.png" width="50" alt="Together"/><br/>
<sub>Together AI</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/fireworks.png" width="50" alt="Fireworks"/><br/>
<sub>Fireworks</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/cerebras.png" width="50" alt="Cerebras"/><br/>
<sub>Cerebras</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/cohere.png" width="50" alt="Cohere"/><br/>
<sub>Cohere</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/nvidia.png" width="50" alt="NVIDIA"/><br/>
<sub>NVIDIA</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/siliconflow.png" width="50" alt="SiliconFlow"/><br/>
<sub>SiliconFlow</sub>
</td>
</tr>
</table>
<p><i>...และผู้ให้บริการอีกกว่า 20 ราย รวมถึง Nebius, Chutes, Hyperbolic และ OpenAI/Anthropic compatible endpoints แบบกำหนดเอง</i></p>
</div>
---
## 💡 ฟีเจอร์หลัก
| ฟีเจอร์ | ทำอะไร | ทำไมถึงสำคัญ |
|---------|--------------|----------------|
| 🚀 **RTK Token Saver** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | บีบอัดผลลัพธ์จากเครื่องมือ (`git diff`, `grep`, `ls`, `tree`...) ก่อนส่งให้ LLM | ประหยัด **โทเค็น input 20-40%** ต่อคำขอ |
| 🧠 **Headroom Token Saver** ([Headroom](https://github.com/chopratejas/headroom)) | พร็อกซี `/v1/compress` ภายนอกก่อนเลือกผู้ให้บริการ | ประหยัดโทเค็นบริบทมากขึ้นโดยไม่ต้องเปลี่ยน client |
| 🪨 **Caveman Mode** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | ฉีด caveman-speak prompt → LLM ตอบสั้นกระชับ เนื้อหาทางเทคนิคยังครบถ้วน | ประหยัด **โทเค็น output สูงสุด 65%** |
| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | ฉีด prompt "lazy senior dev" → LLM เขียนโค้ดน้อยที่สุด YAGNI-first (Lite/Full/Ultra) | **โทเค็น output น้อยลง, ไม่ต้อง refactor มาก** |
| 🎯 **Smart 3-Tier Fallback** | เลือกเส้นทางอัตโนมัติ: สมาชิก → ถูก → ฟรี | ไม่ต้องหยุดเขียนโค้ด, ไม่มีเวลาหยุดทำงาน |
| 📊 **ติดตามโควตาแบบ Real-Time** | นับโทเค็นแบบ live + นับถอยหลังรีเซ็ต | เพิ่มประสิทธิภาพมูลค่าสมาชิก |
| 🔄 **แปลงรูปแบบ** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | ใช้ได้กับเครื่องมือ CLI ทุกประเภท |
| 👥 **รองรับหลายบัญชี** | หลายบัญชีต่อผู้ให้บริการ | Load balancing + สำรองข้อมูล |
| 🔄 **รีเฟรชโทเค็นอัตโนมัติ** | OAuth token รีเฟรชอัตโนมัติ | ไม่ต้องล็อกอินซ้ำด้วยตนเอง |
| 🎨 **Combo กำหนดเอง** | สร้างการผสมผสานโมเดลไม่จำกัด | ปรับแต่ง fallback ตามความต้องการ |
| 📝 **บันทึก Request** | โหมด debug พร้อม log request/response ครบถ้วน | แก้ไขปัญหาได้ง่าย |
| 💾 **ซิงค์คลาวด์** | ซิงค์การตั้งค่าระหว่างอุปกรณ์ | การตั้งค่าเดียวกันทุกที่ |
| 📊 **วิเคราะห์การใช้งาน** | ติดตามโทเค็น, ค่าใช้จ่าย, แนวโน้มตามเวลา | ปรับแต่งค่าใช้จ่าย |
| 🌐 **Deploy ได้ทุกที่** | Localhost, VPS, Docker, Cloudflare Workers | ตัวเลือก deploy ที่ยืดหยุ่น |
<details>
<summary><b>📖 รายละเอียดฟีเจอร์</b></summary>
### 🚀 RTK Token Saver
ผลลัพธ์จากเครื่องมือ (`git diff`, `grep`, `find`, `ls`, `tree`, log dumps...) มักกินงบประมาณ prompt 30-50% RTK ตรวจสอบและบีบอัดอย่างชาญฉลาดแบบ lossless **ก่อน**คำขอถึง LLM:
- **ตัวกรอง:** `git-diff`, `git-status`, `grep`, `find`, `ls`, `tree`, `dedup-log`, `smart-truncate`, `read-numbered`, `search-list`
- **ตรวจจับอัตโนมัติ:** ไม่ต้องตั้งค่า — RTK .peek 1KB แรกของแต่ละ `tool_result` และเลือกตัวกรองที่ถูกต้อง
- **ปลอดภัยโดยการออกแบบ:** ถ้าตัวกรองล้มเหลว, ขว้าง error, หรือทำให้ผลลัพธ์ใหญ่ขึ้น RTK จะเก็บข้อความต้นฉบับไว้โดยเงียบๆ ไม่มี error ทำให้คำขอของคุณล้มเหลว
- **ใช้ได้ทุกที่:** ใช้ได้กับทุกรูปแบบ (OpenAI, Claude, Gemini, Cursor, Kiro, OpenAI Responses) เพราะทำงาน **ก่อน**การแปลงรูปแบบใดๆ
- **เปิดใช้งานเป็นค่าเริ่มต้น:** ปิด/เปิดได้ตลอดเวลาใน แดชบอร์ด → ตั้งค่า Endpoint
```
ไม่ใช้ RTK: ส่ง 47K โทเค็นให้ LLM
ใช้ RTK: ส่ง 28K โทเค็นให้ LLM (ประหยัด 40% · บริบทเดียวกัน · คำตอบเดียวกัน)
```
### 🧠 Headroom Token Saver
Headroom เป็นตัวเลือกและทำงานแยกกัน 9Router เรียก endpoint `/v1/compress` ของ Headroom จากนั้นยังคงเลือกเส้นทาง, fallback, auth และติดตามการใช้งานตามปกติ:
```
Client → 9Router → Headroom /v1/compress → 9Router → provider
```
ตั้งค่าท้องถิ่น:
```bash
pip install "headroom-ai[proxy]"
headroom proxy --port 8787
```
เปิดใช้งานใน แดชบอร์ด → Endpoint → Token Saver → Headroom URL ค่าเริ่มต้น: `http://localhost:8787`
ตัวอย่าง Docker:
```bash
# Headroom service ใน Docker network เดียวกัน
http://host.docker.internal:8787
```
ถ้า Headroom ดับหรือคืน error, 9Router จะ fail open และส่งคำขอต้นฉบับ
### 🐴 Ponytail (Lazy Senior Dev)
Ponytail ฉีด prompt *"lazy senior dev"* เข้าไปในทุกคำขอ ทำให้ LLM เขียนโค้ดน้อยที่สุดแบบ YAGNI-first — ลบมากกว่าเพิ่ม, stdlib มากกว่า dep ใหม่, one-liner มากกว่า abstraction
- **Lite** — สร้างตามที่ขอ, บอกชื่อทางเลือกที่ lazy กว่า
- **Full** — บังคับ YAGNI ladder: stdlib → native → existing deps → one-liner → minimal code
- **Ultra** — YAGNI extremist: ลบก่อน, ส่ง one-liner, ตั้งคำถามกับ requirement ที่เหลือในคำตอบเดียวกัน
```
ไม่ใช้ Ponytail: โค้ดเยอะ, abstraction เยอะ, "เผื่อไว้" scaffolding
ใช้ Ponytail: diff สั้นที่สุดที่ทำงานได้, ไม่เพิ่ม abstraction ที่ไม่ได้ขอ, โทเค็นน้อยลง
```
ไม่มีวันแลก: input validation, error handling ที่ป้องกัน data loss, security, accessibility หรือสิ่งที่ขอมาอย่างชัดเจน เปิดใช้งานใน แดชบอร์ด → Endpoint → Ponytail ใช้คู่กับ Caveman (ความกระชับ output) และ RTK (การบีบอัด input) ได้
### 🎯 Smart 3-Tier Fallback
สร้าง combo พร้อม fallback อัตโนมัติ:
```
Combo: "my-coding-stack"
1. cc/claude-opus-4-6 (สมาชิกของคุณ)
2. glm/glm-4.7 (สำรองราคาถูก, $0.6/1M)
3. if/kimi-k2-thinking (fallback ฟรี)
→ สลับอัตโนมัติเมื่อโควตาหมดหรือเกิด error
```
### 📊 ติดตามโควตาแบบ Real-Time
- การใช้โทเค็นต่อผู้ให้บริการ
- นับถอยหลังรีเซ็ต (5 ชั่วโมง, รายวัน, รายสัปดาห์)
- ประมาณการค่าใช้จ่ายสำหรับชั้นแบบเสียค่าใช้จ่าย
- รายงานค่าใช้จ่ายรายเดือน
### 🔄 แปลงรูปแบบ
แปลงรูปแบบได้อย่างราบรื่น:
- **OpenAI** ↔ **Claude****Gemini****Cursor****Kiro****Vertex****Antigravity****Ollama****OpenAI Responses**
- เครื่องมือ CLI ของคุณส่งรูปแบบ OpenAI → 9Router แปลง → ผู้ให้บริการได้รับรูปแบบต้นฉบับ
- ใช้ได้กับเครื่องมือใดก็ได้ที่รองรับ custom OpenAI endpoints
### 👥 รองรับหลายบัญชี
- เพิ่มหลายบัญชีสำหรับผู้ให้บริการแต่ละราย
- เลือกเส้นทาง round-robin หรือตามลำดับความสำคัญอัตโนมัติ
- Fallback ไปยังบัญชีถัดไปเมื่อบัญชีหนึ่งชนโควตา
### 🔄 รีเฟรชโทเค็นอัตโนมัติ
- OAuth token รีเฟรชอัตโนมัติก่อนหมดอายุ
- ไม่ต้องยืนยันตัวตนใหม่ด้วยตนเอง
- ประสบการณ์ที่ราบรื่นบนผู้ให้บริการทุกราย
### 🎨 Combo กำหนดเอง
- สร้างการผสมผสานโมเดลไม่จำกัด
- ผสมชั้นสมาชิก, ราคาถูกและฟรี
- ตั้งชื่อ combo เพื่อเข้าถึงง่าย
- แชร์ combo ระหว่างอุปกรณ์ด้วยการซิงค์คลาวด์
### 📝 บันทึก Request
- เปิดโหมด debug เพื่อดู log request/response ครบถ้วน
- ติดตาม API calls, headers และ payloads
- แก้ไขปัญหาการเชื่อมต่อ
- Export log เพื่อวิเคราะห์
### 💾 ซิงค์คลาวด์
- ซิงค์ผู้ให้บริการ, combo และการตั้งค่าระหว่างอุปกรณ์
- ซิงค์เบื้องหลังอัตโนมัติ
- จัดเก็บข้อมูลแบบเข้ารหัสปลอดภัย
- เข้าถึงการตั้งค่าของคุณจากทุกที่
### 📊 วิเคราะห์การใช้งาน
- ติดตามการใช้โทเค็นตามผู้ให้บริการและโมเดล
- ประมาณการค่าใช้จ่ายและแนวโน้มค่าใช้จ่าย
- รายงานและข้อมูลเชิงลึกรายเดือน
- ปรับแต่งค่าใช้จ่าย AI ของคุณ
### 🌐 Deploy ได้ทุกที่
- 💻 **Localhost** - ค่าเริ่มต้น, ทำงานออฟไลน์
- ☁️ **VPS/Cloud** - แชร์ระหว่างอุปกรณ์
- 🐳 **Docker** - Deploy ด้วยคำสั่งเดียว
- 🚀 **Cloudflare Workers** - เครือข่าย edge ทั่วโลก
</details>
---
## 💰 สรุปราคา
| ประเภท | ผู้ให้บริการ | ค่าใช้จ่าย | รีเซ็ตโควตา | ดีที่สุดสำหรับ |
|------|----------|------|-------------|----------|
| **💳 สมาชิก** | Claude Code (Pro) | $20/เดือน | 5 ชม. + รายสัปดาห์ | มีสมาชิกอยู่แล้ว |
| | Codex (Plus/Pro) | $20-200/เดือน | 5 ชม. + รายสัปดาห์ | ผู้ใช้ OpenAI |
| | GitHub Copilot | $10-19/เดือน | รายเดือน | ผู้ใช้ GitHub |
| **💰 ราคาถูก** | GLM-4.7 | $0.6/1M | ทุกวัน 10:00 AM | สำรองงบ |
| | MiniMax M2.1 | $0.2/1M | 5 ชั่วโมง | ถูกที่สุด |
| | Kimi K2 | $9/เดือน คงที่ | 10M โทเค็น/เดือน | ค่าใช้จ่ายที่คาดเดาได้ |
| **🆓 ฟรี** | Kiro | $0 | ไม่จำกัด | Claude ฟรี |
| | OpenCode Free | $0 | ไม่จำกัด | ไม่ต้องยืนยันตัวตน |
| | Vertex AI | $0 | $300 เครดิตฟรี | Gemini 3 Pro |
**💡 เคล็ดลับ:** เริ่มจาก combo Kiro (Claude ฟรีไม่จำกัด) + OpenCode Free (ไม่ต้องยืนยันตัวตน) = ค่าใช้จ่าย $0!
---
## 🎯 กรณีการใช้งาน
### กรณีที่ 1: "ฉันมีสมาชิก Claude Pro"
**ปัญหา:** โควตาหมดอายุโดยไม่ได้ใช้, Rate Limit ตอนเขียนโค้ดหนัก
**วิธีแก้:**
```
Combo: "maximize-claude"
1. cc/claude-opus-4-6 (ใช้สมาชิกเต็มที่)
2. glm/glm-4.7 (สำรองราคาถูกเมื่อโควตาหมด)
3. kr/claude-sonnet-4.5 (fallback ฉุกเฉินฟรี)
ค่าใช้จ่ายรายเดือน: $20 (สมาชิก) + ~$5 (สำรอง) = $25 รวม
เทียบกับ $20 + ชนโควตา = ผิดหวัง
```
### กรณีที่ 2: "ฉันต้องการค่าใช้จ่ายเป็นศูนย์"
**ปัญหา:** ไม่มีงบจ่ายสมาชิก, ต้องการ AI เขียนโค้ดที่เชื่อถือได้
**วิธีแก้:**
```
Combo: "free-forever"
1. kr/claude-sonnet-4.5 (Claude ฟรีไม่จำกัด)
2. oc/* (OpenCode Free ไม่ต้องยืนยันตัวตน)
3. vertex/gemini-3.1-pro-preview (Vertex $300 เครดิตฟรี)
ค่าใช้จ่ายรายเดือน: $0
คุณภาพ: โมเดลพร้อมใช้งาน production
```
### กรณีที่ 3: "ฉันต้องเขียนโค้ด 24/7 ไม่มีสะดุด"
**ปัญหา:** Deadline, ไม่สามารถหยุดทำงานได้
**วิธีแก้:**
```
Combo: "always-on"
1. cc/claude-opus-4-6 (คุณภาพดีที่สุด)
2. cx/gpt-5.5 (สมาชิกที่สอง)
3. glm/glm-5.1 (ราคาถูก, รีเซ็ตทุกวัน)
4. minimax/MiniMax-M2.7 (ถูกที่สุด, รีเซ็ต 5 ชม.)
5. kr/claude-sonnet-4.5 (ฟรีไม่จำกัด)
ผลลัพธ์: 5 ชั้น fallback = ไม่มีเวลาหยุดทำงาน
ค่าใช้จ่ายเดือน: $20-200 (สมาชิก) + $10-20 (สำรอง)
```
### กรณีที่ 4: "ฉันต้องการ AI ฟรีใน OpenClaw"
**ปัญหา:** ต้องการ AI assistant ในแอปพลิเคชันแชท (WhatsApp, Telegram, Slack...), ฟรีทั้งหมด
**วิธีแก้:**
```
Combo: "openclaw-free"
1. kr/claude-sonnet-4.5 (Claude ฟรีไม่จำกัด)
2. kr/glm-5 (GLM ฟรีไม่จำกัด)
3. kr/MiniMax-M2.5 (MiniMax ฟรีไม่จำกัด)
ค่าใช้จ่ายรายเดือน: $0
เข้าถึงผ่าน: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
```
---
## ❓ คำถามที่พบบ่อย
<details>
<summary><b>💳 9Router เก็บเงินฉันหรือไม่?</b></summary>
**ไม่.** 9Router เป็นซอฟต์แวร์ฟรีแบบ open source ที่ทำงานบนเครื่องของคุณเอง มันไม่มีวันเรียกเก็บเงินจากคุณ
**คุณจ่ายเงินเฉพาะ:**
-**ผู้ให้บริการสมาชิก** (Claude Code $20/เดือน, Codex $20-200/เดือน) → จ่ายตรงให้พวกเขาบนเว็บไซต์ของพวกเขา
-**ผู้ให้บริการราคาถูก** (GLM, MiniMax) → จ่ายตรงให้พวกเขา, 9Router แค่เลือกเส้นทางคำขอของคุณ
-**ตัว 9Router เอง****ไม่มีวันเรียกเก็บเงินใดๆ ทั้งสิ้น**
9Router เป็น proxy/router ท้องถิ่น มันไม่มีบัตรเครดิตของคุณ, ไม่สามารถส่งใบแจ้งหนี้ได้ และไม่มีระบบชำระเงิน เป็นซอฟต์แวร์ฟรีทั้งหมด
</details>
<details>
<summary><b>🆓 ผู้ให้บริการฟรีไม่จำกัดจริงหรือ?</b></summary>
**จริง!** ผู้ให้บริการที่ระบุว่าฟรี (Kiro, OpenCode Free, Vertex) ไม่จำกัดจริงๆ **ไม่มีค่าใช้จ่ายแอบแฝง**
นี่คือบริการฟรีที่บริษัทต่างๆ ให้บริการ:
- **Kiro**: Claude ฟรีไม่จำกัดผ่าน AWS Builder ID
- **OpenCode Free**: ไม่ต้องยืนยันตัวตน, ดึงโมเดลอัตโนมัติ
- **Vertex AI**: $300 เครดิตฟรีสำหรับ Gemini 3 Pro
9Router แค่เลือกเส้นทางคำขอของคุณไปหาพวกเขา — ไม่มี "กับดัก" หรือการเรียกเก็บเงินในอนาคต เป็นบริการที่ฟรีจริงๆ และ 9Router ทำให้ใช้งานง่ายด้วยการรองรับ fallback
</details>
<details>
<summary><b>💰 ทำอย่างไรเพื่อลดค่าใช้จ่าย AI จริงของฉัน?</b></summary>
**กลยุทธ์ Free First:**
1. **เริ่มจาก combo ฟรี 100%:**
```
1. kr/claude-sonnet-4.5 (Claude ฟรีไม่จำกัด)
2. oc/* (OpenCode Free ไม่ต้องยืนยันตัวตน)
3. vertex/gemini-3.1-pro-preview ($300 เครดิตฟรี)
```
**ค่าใช้จ่าย: $0/เดือน**
2. **เพิ่มสำรองราคาถูก** เมื่อจำเป็นเท่านั้น:
```
4. glm/glm-5.1 ($0.6/1M โทเค็น)
```
**ค่าใช้จ่ายเพิ่มเติม:** จ่ายเฉพาะที่ใช้
3. **ใช้ผู้ให้บริการสมาชิก** ก็ต่อเมื่อมีอยู่แล้ว:
- 9Router ช่วยเพิ่มประสิทธิภาพมูลค่าของพวกเขาผ่านการติดตามโควตา
**ผลลัพธ์:** ผู้ใช้ส่วนใหญ่สามารถทำงานที่ $0/เดือน โดยใช้เฉพาะชั้นฟรี!
</details>
---
## 🐛 การแก้ไขปัญหา
**"Language model did not provide messages"**
- โควตาผู้ให้บริการหมด → ตรวจสอบตัวติดตามโควตาในแดชบอร์ด
- วิธีแก้: ใช้ combo fallback หรือสลับไปชั้นที่ถูกกว่า
**Rate Limiting**
- สมาชิกหมดโควตา → Fallback ไป GLM/MiniMax
- เพิ่ม combo: `cc/claude-opus-4-6 → glm/glm-5.1 → kr/claude-sonnet-4.5`
**OAuth Token หมดอายุ**
- รีเฟรชอัตโนมัติโดย 9Router
- ถ้าปัญหายังคงอยู่: แดชบอร์ด → ผู้ให้บริการ → เชื่อมต่อใหม่
**ค่าใช้จ่ายสูง**
- เปิดใช้ RTK ใน แดชบอร์ด → ตั้งค่า Endpoint (เปิดเป็นค่าเริ่มต้น, ประหยัด 20-40% โทเค็น)
- ตรวจสอบสถิติการใช้งานในแดชบอร์ด
- สลับโมเดลหลักไป GLM/MiniMax
- ใช้ชั้นฟรี (Kiro, OpenCode Free, Vertex) สำหรับงานที่ไม่สำคัญ
**แดชบอร์ดเปิดผิดพอร์ต**
- ตั้ง `PORT=20128` และ `NEXT_PUBLIC_BASE_URL=http://localhost:20128`
**ล็อกอินครั้งแรกไม่ทำงาน**
- ตรวจสอบ `INITIAL_PASSWORD` ใน `.env`
- ถ้ายังไม่ตั้งค่า รหัสผ่านสำรองคือ `123456`
**ไม่มี request log ใต้ `logs/`**
- ตั้ง `ENABLE_REQUEST_LOGS=true`
---
## 🛠️ Tech Stack
- **Runtime**: Node.js 20+
- **Framework**: Next.js 16
- **UI**: React 19 + Tailwind CSS 4
- **Database**: SQLite (better-sqlite3 / node:sqlite / sql.js fallback)
- **Streaming**: Server-Sent Events (SSE)
- **Auth**: OAuth 2.0 (PKCE) + JWT + API Keys
---
## 📝 API Reference
### Chat Completions
```bash
POST http://localhost:20128/v1/chat/completions
Authorization: Bearer your-api-key
Content-Type: application/json
{
"model": "cc/claude-opus-4-6",
"messages": [
{"role": "user", "content": "เขียนฟังก์ชันเพื่อ..."}
],
"stream": true
}
```
### List Models
```bash
GET http://localhost:20128/v1/models
Authorization: Bearer your-api-key
→ คืนค่าโมเดลทั้งหมด + combo ในรูปแบบ OpenAI
```
---
## 📧 สนับสนุน
- **เว็บไซต์**: [9router.com](https://9router.com)
- **GitHub**: [github.com/decolua/9router](https://github.com/decolua/9router)
- **Issues**: [github.com/decolua/9router/issues](https://github.com/decolua/9router/issues)
---
## 👥 ผู้มีส่วนร่วม
ขอขอบคุณผู้มีส่วนร่วมทุกคนที่ช่วยทำให้ 9Router ดียิ่งขึ้น!
[![Contributors](https://contrib.rocks/image?repo=decolua/9router&max=150&columns=15&anon=1)](https://github.com/decolua/9router/graphs/contributors)
---
## 📄 ลิขสิทธิ์
MIT License - ดู [LICENSE](../LICENSE) สำหรับรายละเอียด
---
<div align="center">
<sub>สร้างด้วย ❤️ สำหรับนักพัฒนาที่เขียนโค้ด 24/7</sub>
</div>
+10
View File
@@ -0,0 +1,10 @@
export const GROK_CLI_VERSION = "0.2.99";
export const GROK_CLI_MODEL = "grok-build";
export const GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
export const GROK_CLI_CLIENT_IDENTIFIER = "grok-shell";
export const GROK_CLI_USER_AGENT = `grok-shell/${GROK_CLI_VERSION} (linux; x86_64)`;
export function supportsGrokCliReasoningEffort(model) {
// ponytail: unknown models omit effort until live metadata reaches dispatch.
return /^grok-4\.5(?:$|-)/.test(String(model || ""));
}
+44
View File
@@ -131,6 +131,50 @@ export function resolveKiroThinkingBudget(body, headers, model) {
return null;
}
export function extractKiroEffortLevel(body) {
const effort =
body?.output_config?.effort ??
body?.reasoning_effort ??
(typeof body?.reasoning === "object" ? body.reasoning?.effort : null);
if (typeof effort !== "string") return null;
const normalized = effort.toLowerCase();
if (normalized === "none" || normalized === "off" || normalized === "disabled") return null;
if (normalized === "xhigh" || normalized === "max") return "high";
if (["low", "medium", "high"].includes(normalized)) return normalized;
return null;
}
export function buildKiroAdditionalModelRequestFields(body) {
const effort = extractKiroEffortLevel(body);
if (!effort) return undefined;
// Mirrors Kiro CLI/KAS buildEffortRequestFields("output_config").
return {
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort },
};
}
export function supportsKiroAdditionalModelRequestFields(model) {
if (typeof model !== "string") return false;
const normalized = model.toLowerCase().replace(/-/g, ".");
if (!normalized.includes("claude")) return false;
const match = normalized.match(/(?:^|[/.])claude(?:[/.][a-z]+)*[/.](\d+)(?:[/.](\d+))?(?:[/.]|$)/);
if (!match) return false;
const [, majorText, minorText] = match;
const major = Number(majorText);
const minor = minorText === undefined ? null : Number(minorText);
const dateSuffixMinor = minor !== null && minor >= 1000;
// Kiro rejected additionalModelRequestFields on legacy 4.5 models in live smoke.
// Default future Claude/Kiro models to supported so new model releases do not
// need a code allowlist update.
return !(major < 4 || (major === 4 && (minor === null || minor <= 5 || dateSuffixMinor)));
}
export function buildKiroAdditionalModelRequestFieldsForModel(body, model) {
if (!supportsKiroAdditionalModelRequestFields(model)) return undefined;
return buildKiroAdditionalModelRequestFields(body);
}
/**
* Detect whether an inbound request is asking for reasoning / thinking output.
* Thin wrapper over resolveKiroThinkingBudget (single source of truth).
+2
View File
@@ -65,6 +65,8 @@ export const GEMINI_NATIVE_TTS_FETCH_TIMEOUT_MS = envMs("GEMINI_NATIVE_TTS_FETCH
export const DEFAULT_MAX_TOKENS = 64000;
export const DEFAULT_MIN_TOKENS = 32000;
export const TOKEN_SAVER_HEADER = "x-9router-token-saver";
// Retry config for 429 responses (legacy - kept for backward compatibility)
export const RETRY_CONFIG = {
maxAttempts: 2,
+123 -34
View File
@@ -4,11 +4,13 @@ import { OAUTH_ENDPOINTS, GITHUB_COPILOT } from "../config/appConstants.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { openaiToOpenAIResponsesRequest } from "../translator/request/openai-responses.js";
import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js";
import { initState } from "../translator/index.js";
import { initState, translateRequest, translateResponse } from "../translator/index.js";
import { FORMATS } from "../translator/formats.js";
import { parseSSELine, formatSSE } from "../utils/streamHelpers.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { stripUnsupportedParams } from "../translator/concerns/paramSupport.js";
import { SSE_DONE } from "../utils/sseConstants.js";
import { ANTHROPIC_API_VERSION } from "../providers/shared.js";
import crypto from "crypto";
export class GithubExecutor extends BaseExecutor {
@@ -17,6 +19,16 @@ export class GithubExecutor extends BaseExecutor {
this.knownCodexModels = new Set();
}
// Claude models get routed to Copilot's Anthropic-native /v1/messages shim (see
// executeWithMessagesEndpoint below) — the only Copilot endpoint that surfaces
// prompt-cache token counts. gpt/gemini/grok models stay on /chat/completions
// (or /responses). Name-pattern check, not a registry field: Copilot's live model
// catalog (services/copilotModels.js) regularly exposes claude-* variants ahead
// of the static registry (registry/github.js).
isClaudeModel(model) {
return /claude/i.test(model || "");
}
buildUrl(model, stream, urlIndex = 0) {
return this.config.baseUrl;
}
@@ -35,47 +47,20 @@ export class GithubExecutor extends BaseExecutor {
"x-request-id": crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`,
"x-vscode-user-agent-library-version": "electron-fetch",
"X-Initiator": "user",
// Harmless no-op on /chat/completions and /responses; required by /v1/messages.
"anthropic-version": ANTHROPIC_API_VERSION,
"Accept": stream ? "text/event-stream" : "application/json"
};
}
// Sanitize messages for GitHub Copilot /chat/completions endpoint.
// Sanitize messages for GitHub Copilot /chat/completions endpoint (gpt/gemini/grok models —
// claude models never reach this, see execute() below).
// The endpoint only accepts 'text' and 'image_url' content part types.
// Tool-related content (tool_use, tool_result, thinking) must be serialized as text.
sanitizeMessagesForChatCompletions(body) {
if (!body?.messages) return body;
const sanitized = { ...body };
// Handle response_format for Claude models via GitHub
// GitHub's internal translation doesn't respect response_format, so we inject it as a system prompt
// AND prepend a reminder to the last user message for maximum effectiveness
if (body.response_format && body.model?.includes('claude')) {
const responseFormat = body.response_format;
let systemInstruction = '';
if (responseFormat.type === 'json_schema' && responseFormat.json_schema?.schema) {
systemInstruction = 'CRITICAL: You must ONLY output raw JSON. Never use markdown code blocks. Never use backticks. Never wrap JSON in triple backticks. Output ONLY the raw JSON object.';
} else if (responseFormat.type === 'json_object') {
systemInstruction = 'CRITICAL: You must ONLY output raw JSON. Never use markdown code blocks. Never use backticks.';
}
if (systemInstruction) {
// Add to system message
const systemIdx = body.messages.findIndex(m => m.role === 'system');
if (systemIdx >= 0) {
body.messages[systemIdx].content = systemInstruction + '\n\n' + body.messages[systemIdx].content;
} else {
body.messages.unshift({ role: 'system', content: systemInstruction });
}
// Also prepend to the last user message as a reminder
const lastUserIdx = body.messages.map((m, i) => m.role === 'user' ? i : -1).filter(i => i >= 0).pop();
if (lastUserIdx >= 0) {
const userMsg = body.messages[lastUserIdx];
const userContent = typeof userMsg.content === 'string' ? userMsg.content : JSON.stringify(userMsg.content);
userMsg.content = 'Respond with ONLY raw JSON (no markdown, no backticks, no code blocks): ' + userContent;
}
}
}
sanitized.messages = body.messages.map(msg => {
// assistant messages with only tool_calls have content: null — leave as-is
if (!msg.content) return msg;
@@ -138,6 +123,15 @@ export class GithubExecutor extends BaseExecutor {
async execute(options) {
const { model, log } = options;
// Claude models: route to Copilot's Anthropic-native /v1/messages shim — the only
// Copilot endpoint that surfaces prompt-cache token counts for Claude. Detected by
// model NAME (not a registry field): Copilot's live model catalog regularly exposes
// claude-* variants the static registry hasn't caught up with yet (see registry/github.js).
if (this.isClaudeModel(model)) {
log?.debug("GITHUB", `Using /v1/messages route for ${model}`);
return this.executeWithMessagesEndpoint(options);
}
// Only use /responses for models that are explicitly known to need it (e.g. gpt codex models)
// and that the /responses endpoint actually serves (excludes Gemini/Claude, see #1062).
if (this.knownCodexModels.has(model) && this.supportsResponsesEndpoint(model)) {
@@ -145,8 +139,8 @@ export class GithubExecutor extends BaseExecutor {
return this.executeWithResponsesEndpoint(options);
}
// Sanitize messages before sending to /chat/completions
// This handles Claude models on GitHub Copilot which reject non-text/image_url content types
// Sanitize messages before sending to /chat/completions (gpt/gemini/grok — the
// endpoint rejects non-text/image_url content parts).
const sanitizedOptions = {
...options,
body: this.sanitizeMessagesForChatCompletions(options.body)
@@ -251,6 +245,101 @@ export class GithubExecutor extends BaseExecutor {
};
}
// Claude models arrive here OpenAI-shape (chatCore.js targets "openai" for github —
// see the note in execute() above), so we translate to Anthropic-native ourselves.
// This is what makes prepareClaudeRequest() (translator/formats/claude.js) inject
// cache_control — /chat/completions never gets there, so it never sees cache tokens.
async executeWithMessagesEndpoint({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
const url = this.config.messagesUrl;
const headers = this.buildHeaders(credentials, stream);
// Force stream:true upstream regardless of client preference, same as
// executeWithResponsesEndpoint below — chatCore.js's non-streaming handler already
// knows how to buffer an SSE response into a single JSON reply when the client
// asked for stream:false.
const transformedBody = translateRequest(FORMATS.OPENAI, FORMATS.CLAUDE, model, body, true, credentials, "github");
// _toolNameMap is internal bookkeeping (see openai-to-claude.js) — chatCore.js
// normally strips it before dispatch and threads it into the response state to
// restore original tool names; we must do the same here, or Anthropic's strict
// schema rejects the extra field with a 400.
const toolNameMap = transformedBody._toolNameMap;
delete transformedBody._toolNameMap;
log?.debug("GITHUB", "Sending translated request to /v1/messages");
const response = await proxyAwareFetch(url, {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal
}, proxyOptions);
if (!response.ok) {
return { response, url, headers, transformedBody };
}
const state = initState(FORMATS.CLAUDE);
state.model = model;
if (toolNameMap) state.toolNameMap = toolNameMap;
const decoder = new TextDecoder();
let buffer = "";
const emitAll = (controller, chunks) => {
for (const c of chunks) {
controller.enqueue(new TextEncoder().encode(formatSSE(c, "openai")));
}
};
const transformStream = new TransformStream({
async transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const parsed = parseSSELine(trimmed);
if (!parsed) continue;
if (parsed.done && stream === true) {
controller.enqueue(new TextEncoder().encode(SSE_DONE));
continue;
}
emitAll(controller, translateResponse(FORMATS.CLAUDE, FORMATS.OPENAI, parsed, state));
}
},
flush(controller) {
if (buffer.trim()) {
const parsed = parseSSELine(buffer.trim());
if (parsed && !parsed.done) {
emitAll(controller, translateResponse(FORMATS.CLAUDE, FORMATS.OPENAI, parsed, state));
}
}
}
});
if (!response.body) {
return { response: new Response("", { status: response.status, headers: response.headers }), url, headers, transformedBody };
}
const convertedStream = response.body.pipeThrough(transformStream);
return {
response: new Response(convertedStream, {
status: response.status,
statusText: response.statusText,
headers: response.headers
}),
url,
headers,
transformedBody
};
}
async refreshCopilotToken(githubAccessToken, log, proxyOptions = null) {
try {
const response = await proxyAwareFetch("https://api.github.com/copilot_internal/v2/token", {
+194 -39
View File
@@ -7,6 +7,12 @@ import {
} from "../services/oauthCredentialManager.js";
import { normalizeResponsesInput } from "../translator/formats/responsesApi.js";
import { getModelUpstreamId } from "../config/providerModels.js";
import {
GROK_CLI_CLIENT_IDENTIFIER,
GROK_CLI_VERSION,
supportsGrokCliReasoningEffort,
} from "../config/grokCli.js";
import { MEMORY_CONFIG } from "../config/runtimeConfig.js";
import { resolveSessionId } from "../utils/sessionManager.js";
import { getConsistentMachineId } from "../shared/machineId.js";
@@ -45,10 +51,18 @@ const RESPONSES_API_ALLOWLIST = new Set([
"prompt_cache_key",
]);
const EFFORT_LEVELS = ["low", "medium", "high"];
const EFFORT_LEVELS = ["low", "medium", "high", "xhigh"];
const GROK_CLI_TURN_STORE_MAX = 5000;
const GROK_CLI_NATIVE_ITEM_ID = /^(?:rs|msg|fc)_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const GROK_CLI_FREEFORM_TOOL_PARAMETERS = {
type: "object",
properties: { input: { type: "string" } },
required: ["input"],
};
// Per-session last turn index so multi-turn headers never go backwards within this process
const sessionTurnStore = new Map();
let requestTurnStore = new WeakMap();
/**
* Count user turns in a Responses `input` array.
@@ -72,18 +86,138 @@ export function countGrokCliUserTurns(input) {
* 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) {
export function resolveGrokCliTurnIdx(sessionId, input, requestKey = null) {
const fromInput = countGrokCliUserTurns(input);
if (!sessionId) return fromInput;
const prev = sessionTurnStore.get(sessionId) || 0;
const turn = Math.max(fromInput, prev);
sessionTurnStore.set(sessionId, turn);
if (requestKey && requestTurnStore.has(requestKey)) {
return requestTurnStore.get(requestKey);
}
const now = Date.now();
const existing = sessionTurnStore.get(sessionId);
const prev = existing && now - existing.lastUsed <= MEMORY_CONFIG.sessionTtlMs
? existing.turn
: 0;
if (existing) sessionTurnStore.delete(sessionId);
// A new delta-style request advances the turn; retries reuse requestKey.
const turn = prev > 0 ? Math.max(fromInput, prev + (requestKey ? 1 : 0)) : fromInput;
while (sessionTurnStore.size >= GROK_CLI_TURN_STORE_MAX) {
sessionTurnStore.delete(sessionTurnStore.keys().next().value);
}
sessionTurnStore.set(sessionId, { turn, lastUsed: now });
if (requestKey) requestTurnStore.set(requestKey, turn);
return turn;
}
/** Test helper — clear in-memory turn counters */
export function _resetGrokCliTurnStore() {
sessionTurnStore.clear();
requestTurnStore = new WeakMap();
}
export function _getGrokCliTurnStoreSize() {
return sessionTurnStore.size;
}
export function normalizeGrokCliEffort(value) {
const effort = typeof value === "string" ? value.trim().toLowerCase() : "";
if (effort === "max") return "xhigh";
if (EFFORT_LEVELS.includes(effort)) return effort;
return "high";
}
export { supportsGrokCliReasoningEffort } from "../config/grokCli.js";
export function resolveGrokCliSessionId(credentials, body) {
// ponytail: clients without stable thread metadata share one connection session;
// split further when their wire format exposes a durable conversation id.
const explicitSessionBody = {
prompt_cache_key: body?.prompt_cache_key,
session_id: body?.session_id,
conversation_id: body?.conversation_id,
metadata: body?.metadata,
};
return resolveSessionId({
headers: credentials?.rawHeaders,
body: explicitSessionBody,
connectionId: credentials?.connectionId || credentials?.id,
workspaceId: credentials?.providerSpecificData?.workspaceId,
scope: "grok-cli",
});
}
function stringifyGrokCliToolOutput(output) {
if (typeof output === "string") return output;
if (output === undefined) return "";
return JSON.stringify(output);
}
function isNativeGrokCliItemId(id) {
return typeof id === "string" && GROK_CLI_NATIVE_ITEM_ID.test(id);
}
function normalizeGrokCliInputItem(item) {
if (!item || typeof item !== "object" || Array.isArray(item)) return item;
const { internal_chat_message_metadata_passthrough: _metadata, ...clean } = item;
if (item.type === "reasoning") {
if (!isNativeGrokCliItemId(item.id) || typeof item.encrypted_content !== "string") return null;
return clean;
}
if (item.type === "custom_tool_call") {
const callId = item.call_id || item.id;
const name = typeof item.name === "string" ? item.name.trim() : "";
if (!callId || !name) return null;
return {
type: "function_call",
call_id: callId,
name,
arguments: JSON.stringify({ input: stringifyGrokCliToolOutput(item.input ?? item.arguments) }),
};
}
if (item.type === "custom_tool_call_output" || item.type === "function_call_output") {
const callId = item.call_id || item.id;
if (!callId) return null;
return {
type: "function_call_output",
call_id: callId,
output: stringifyGrokCliToolOutput(item.output),
};
}
if (item.type === "function_call") {
const callId = item.call_id || item.id;
const name = typeof item.name === "string" ? item.name.trim() : "";
if (!callId || !name) return null;
return {
type: "function_call",
...(isNativeGrokCliItemId(item.id) ? { id: item.id } : {}),
call_id: callId,
name,
arguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments ?? {}),
...(typeof item.status === "string" ? { status: item.status } : {}),
};
}
return clean;
}
export function normalizeGrokCliInput(body) {
if (!Array.isArray(body?.input)) return body;
const normalized = body.input.map(normalizeGrokCliInputItem).filter(Boolean);
const callIds = new Set(
normalized
.filter((item) => item?.type === "function_call" && item.call_id)
.map((item) => item.call_id)
);
body.input = normalized.filter(
(item) => item?.type !== "function_call_output" || callIds.has(item.call_id)
);
return body;
}
function stripStoredItemReferences(body) {
@@ -92,7 +226,11 @@ function stripStoredItemReferences(body) {
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;
if (
typeof item.id === "string" &&
SERVER_ID_PATTERN.test(item.id) &&
!isNativeGrokCliItemId(item.id)
) delete item.id;
}
return true;
});
@@ -103,15 +241,23 @@ function stripStoredItemReferences(body) {
* Keep hosted tools (web_search / x_search) passthrough.
*/
function normalizeGrokCliTools(body) {
if (!Array.isArray(body.tools)) return;
if (!Array.isArray(body.tools) || body.tools.length === 0) {
delete body.tools;
delete body.tool_choice;
return;
}
const validNames = new Set();
const hostedTypes = 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;
if (HOSTED_TOOL_TYPES.has(type)) {
hostedTypes.add(type);
return true;
}
// Nested function shape without type
if (!type && tool.function) {
// fall through to function flatten below
@@ -143,8 +289,9 @@ function normalizeGrokCliTools(body) {
: typeof fn?.description === "string"
? fn.description
: "";
const parameters =
tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters)
const parameters = type === "custom"
? GROK_CLI_FREEFORM_TOOL_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
@@ -155,14 +302,25 @@ function normalizeGrokCliTools(body) {
tool.name = name.slice(0, 128);
if (description) tool.description = description;
tool.parameters = parameters;
validNames.add(name);
validNames.add(tool.name);
return true;
});
if (body.tools.length === 0) {
delete body.tools;
delete body.tool_choice;
return;
}
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;
const choiceType = typeof body.tool_choice.type === "string" ? body.tool_choice.type : "";
if (choiceType === "function" || choiceType === "custom") {
const rawName = body.tool_choice.name ?? body.tool_choice.function?.name;
const name = typeof rawName === "string" ? rawName.trim().slice(0, 128) : "";
if (!name || !validNames.has(name)) delete body.tool_choice;
else body.tool_choice = { type: "function", name };
} else if (!hostedTypes.has(choiceType)) {
delete body.tool_choice;
}
}
}
@@ -192,9 +350,9 @@ export class GrokCliExecutor extends BaseExecutor {
return this.config.baseUrl;
}
async refreshCredentials(credentials, log) {
async refreshCredentials(credentials, log, proxyOptions = null) {
if (!credentials?.refreshToken) return null;
return refreshProviderCredentials("grok-cli", credentials, log);
return refreshProviderCredentials("grok-cli", credentials, log, proxyOptions);
}
needsRefresh(credentials) {
@@ -210,13 +368,10 @@ export class GrokCliExecutor extends BaseExecutor {
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";
this.config.clientIdentifier || headers["x-grok-client-identifier"] || GROK_CLI_CLIENT_IDENTIFIER;
headers["x-grok-client-version"] =
this.config.clientVersion || headers["x-grok-client-version"] || "0.2.93";
headers["x-authenticateresponse"] = "authenticate-response";
this.config.clientVersion || headers["x-grok-client-version"] || GROK_CLI_VERSION;
const sessionId = this._currentSessionId || credentials?.connectionId || crypto.randomUUID();
const reqId = this._currentReqId || crypto.randomUUID();
@@ -231,10 +386,6 @@ export class GrokCliExecutor extends BaseExecutor {
// 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 || {};
@@ -267,13 +418,8 @@ export class GrokCliExecutor extends BaseExecutor {
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",
});
const requestKey = body;
this._currentSessionId = resolveGrokCliSessionId(credentials, body);
this._currentReqId = crypto.randomUUID();
this._agentId =
credentials?.providerSpecificData?.deviceId ||
@@ -302,11 +448,12 @@ export class GrokCliExecutor extends BaseExecutor {
// Keep role:"system" as-is — official grok-pager HAR sends system, not developer
// (Codex converts system→developer; Grok CLI does not).
normalizeGrokCliInput(body);
stripStoredItemReferences(body);
normalizeGrokCliTools(body);
// Turn index after input is finalized (user-message count, monotonic per session)
this._currentTurnIdx = resolveGrokCliTurnIdx(this._currentSessionId, body.input);
this._currentTurnIdx = resolveGrokCliTurnIdx(this._currentSessionId, body.input, requestKey);
body.stream = true;
body.store = false;
@@ -325,20 +472,28 @@ export class GrokCliExecutor extends BaseExecutor {
body.model = resolvedModel;
this._currentModel = resolvedModel;
// Reasoning effort priority: explicit > reasoning_effort > model suffix > default high
// Reasoning effort priority: explicit > reasoning_effort > model suffix > default high.
// grok-build and Composer reject reasoningEffort but still accept summary/encrypted continuity.
const supportsReasoningEffort = supportsGrokCliReasoningEffort(resolvedModel);
if (!body.reasoning || typeof body.reasoning !== "object") {
const effort = body.reasoning_effort || modelEffort || "high";
body.reasoning = { effort, summary: "concise" };
body.reasoning = { summary: "concise" };
if (supportsReasoningEffort) {
body.reasoning.effort = normalizeGrokCliEffort(body.reasoning_effort || modelEffort);
}
} else {
if (!body.reasoning.effort) {
body.reasoning.effort = body.reasoning_effort || modelEffort || "high";
if (supportsReasoningEffort) {
body.reasoning.effort = normalizeGrokCliEffort(
body.reasoning.effort || body.reasoning_effort || modelEffort,
);
} else {
delete body.reasoning.effort;
}
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") {
if (body.reasoning && body.reasoning.effort !== "none") {
const include = Array.isArray(body.include) ? body.include : [];
if (!include.includes("reasoning.encrypted_content")) {
include.push("reasoning.encrypted_content");
+22 -23
View File
@@ -9,10 +9,11 @@ import { createRequestLogger } from "../utils/requestLogger.js";
import { getModelTargetFormat, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
import { PROVIDERS } from "../config/providers.js";
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { HTTP_STATUS, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.js";
import { handleBypassRequest } from "../utils/bypassHandler.js";
import { trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
import { getExecutor } from "../executors/index.js";
import { supportsGrokCliReasoningEffort } from "../config/grokCli.js";
import { buildRequestDetail, extractRequestConfig } from "./chatCore/requestDetail.js";
import { handleForcedSSEToJson } from "./chatCore/sseToJsonHandler.js";
import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js";
@@ -21,8 +22,8 @@ import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.j
import { dedupeTools } from "../utils/toolDeduper.js";
import { injectCaveman } from "../rtk/caveman.js";
import { injectPonytail } from "../rtk/ponytail.js";
import { compressMessages } from "../rtk/index.js";
import { compressWithHeadroom, formatHeadroomSizeLog, isHeadroomPhantomSavings } from "../rtk/headroom.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";
@@ -168,7 +169,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
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 showThinking = provider !== "grok-cli" || supportsGrokCliReasoningEffort(model);
const think = showThinking ? log.fmtThink?.(extractThinking(translatedBody)) : null;
const acc = credentials?.connectionName || credentials?.connectionId?.slice(0, 8) || "-";
const parts = [
`POST ${clientModel}${provider}/${model}`,
@@ -188,40 +190,37 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
delete translatedBody.tools;
}
// Token-saver summary parts, printed as one "⚙" line at the end (only active ones)
const xf = [];
// Per-request opt-out: client can bypass all token savers via header
const tokenSaverEnabled = clientRawRequest?.headers?.[TOKEN_SAVER_HEADER]?.toLowerCase() !== "off";
// RTK: compress tool_result content
const rtkStats = compressMessages(translatedBody, rtkEnabled);
if (rtkStats?.hits?.length) {
const saved = rtkStats.bytesBefore - rtkStats.bytesAfter;
const pct = rtkStats.bytesBefore > 0 ? ((saved / rtkStats.bytesBefore) * 100).toFixed(0) : "0";
xf.push(`RTK ${saved}B(${pct}%)`);
}
const rtkStats = compressMessages(translatedBody, tokenSaverEnabled && rtkEnabled);
const rtkLine = formatRtkLog(rtkStats);
if (rtkLine) console.log(rtkLine);
// Headroom: optional external proxy compression; fail open if proxy is absent.
const headroomDiagnostics = {};
const headroomStats = await compressWithHeadroom(translatedBody, { enabled: headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, diagnostics: headroomDiagnostics });
if (headroomStats) {
const before = headroomStats.tokens_before || 0;
const delta = headroomStats.tokens_saved || 0;
const pct = before > 0 ? ((delta / before) * 100).toFixed(1) : "0";
xf.push(`HEADROOM ${delta}tok(${pct}%)`);
const headroomStats = await compressWithHeadroom(translatedBody, { enabled: tokenSaverEnabled && headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, diagnostics: headroomDiagnostics });
const headroomLine = formatHeadroomLog(headroomStats);
const headroomSizeLine = formatHeadroomSizeLog(headroomDiagnostics);
if (headroomLine) {
log?.info?.("HEADROOM", `${headroomLine}${headroomSizeLine ? ` | ${headroomSizeLine}` : ""}`);
if (isHeadroomPhantomSavings(headroomStats, headroomDiagnostics)) {
log?.warn?.("HEADROOM", `reported token delta, but outbound JSON shrank <5%; provider may bill near-original payload | ${formatHeadroomSizeLog(headroomDiagnostics)}`);
}
} else if (headroomEnabled) {
log?.warn?.("HEADROOM", `skipped: ${headroomDiagnostics.reason || "compression unavailable"}${headroomDiagnostics.endpoint ? ` (${headroomDiagnostics.endpoint})` : ""}`);
}
} else if (tokenSaverEnabled && headroomEnabled) log?.warn?.("HEADROOM", `skipped: ${headroomDiagnostics.reason || "compression unavailable"}${headroomDiagnostics.endpoint ? ` (${headroomDiagnostics.endpoint})` : ""}`);
// Token-saver flags accumulator for the single "⚙" log line below.
const xf = [];
// Caveman: inject terse-style system prompt
if (cavemanEnabled && cavemanLevel) {
if (tokenSaverEnabled && cavemanEnabled && cavemanLevel) {
injectCaveman(translatedBody, finalFormat, cavemanLevel);
xf.push(`CAVEMAN:${cavemanLevel}`);
}
// Ponytail: inject lazy-senior-dev system prompt
if (ponytailEnabled && ponytailLevel) {
if (tokenSaverEnabled && ponytailEnabled && ponytailLevel) {
injectPonytail(translatedBody, finalFormat, ponytailLevel);
xf.push(`PONYTAIL:${ponytailLevel}`);
}
+166
View File
@@ -0,0 +1,166 @@
import { createErrorResult } from "../utils/error.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { refreshTokenByProvider } from "../services/tokenRefresh.js";
import { PROVIDER_MEDIA } from "../providers/index.js";
// Upstream fetch deadline for video job submission/polling (the job itself is
// async upstream — this only bounds the HTTP round-trip, not video rendering).
const VIDEO_FETCH_TIMEOUT_MS = Number(process.env.VIDEO_FETCH_TIMEOUT_MS || 120000);
// POST /videos/* creates a billable upstream job. A network error after the
// request left the socket may still have created the job, so creation is NEVER
// auto-retried (the only re-send is the auth retry after a 401/403 refresh,
// which upstream rejects before job creation).
export const VIDEO_ACTIONS = new Set(["generations", "edits", "extensions"]);
export function getVideoConfig(provider) {
return PROVIDER_MEDIA[provider]?.videoConfig || null;
}
/** Strip bearer tokens / obvious secrets from text destined for clients or logs. */
export function sanitizeSecrets(text, credentials = null) {
if (!text) return text;
let out = String(text).replace(/Bearer\s+[A-Za-z0-9._~+/=-]{8,}/gi, "Bearer [redacted]");
for (const key of ["accessToken", "refreshToken", "apiKey"]) {
const secret = credentials?.[key];
if (typeof secret === "string" && secret.length >= 8) {
out = out.split(secret).join("[redacted]");
}
}
return out;
}
function buildUpstreamUrl(config, action, requestId) {
const base = config.baseUrl.replace(/\/$/, "");
return requestId ? `${base}/${encodeURIComponent(requestId)}` : `${base}/${action}`;
}
function buildHeaders({ token, contentType, idempotencyKey }) {
const headers = { Accept: "application/json" };
if (token) headers.Authorization = `Bearer ${token}`;
if (contentType) headers["Content-Type"] = contentType;
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
return headers;
}
function combineSignals(signal, timeoutMs) {
const timeoutSignal = typeof AbortSignal?.timeout === "function" ? AbortSignal.timeout(timeoutMs) : null;
if (signal && timeoutSignal && typeof AbortSignal.any === "function") {
return AbortSignal.any([signal, timeoutSignal]);
}
return signal || timeoutSignal || undefined;
}
/**
* Transparent proxy for async video jobs (xAI Grok Imagine shape).
*
* - Forwards the raw body byte-for-byte (JSON or multipart) — no reshaping.
* - Passes upstream JSON (request_id, status, video.url, error) back verbatim.
* - 401/403 with a refresh token: refresh ONCE, retry ONCE. No other retry.
* - Upstream error text is sanitized before it reaches the client.
*
* @param {object} options
* @param {string} options.provider - Provider id (must have registry videoConfig)
* @param {"generations"|"edits"|"extensions"|null} options.action - Creation action (POST)
* @param {string|null} [options.requestId] - Poll target (GET /videos/{id})
* @param {Buffer|string|null} [options.rawBody] - Exact body to forward
* @param {string|null} [options.contentType] - Original Content-Type header
* @param {string|null} [options.idempotencyKey] - Forwarded Idempotency-Key
* @param {object} options.credentials - { accessToken?, apiKey?, refreshToken?, authType? }
* @param {AbortSignal} [options.signal] - Client cancellation signal
* @param {number} [options.timeoutMs]
* @param {object} [options.log]
* @param {function} [options.onCredentialsRefreshed]
* @returns {Promise<{ success: boolean, response: Response, status?: number, error?: string }>}
*/
export async function handleVideoProxyCore({
provider,
action = null,
requestId = null,
rawBody = null,
contentType = null,
idempotencyKey = null,
credentials,
signal,
timeoutMs = VIDEO_FETCH_TIMEOUT_MS,
log,
onCredentialsRefreshed,
}) {
const config = getVideoConfig(provider);
if (!config) {
return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Provider '${provider}' does not support video generation`);
}
if (!requestId && !VIDEO_ACTIONS.has(action)) {
return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Unknown video action: ${action}`);
}
const method = requestId ? "GET" : "POST";
const url = buildUpstreamUrl(config, action, requestId);
const fetchSignal = combineSignals(signal, timeoutMs);
const doFetch = (token) =>
fetch(url, {
method,
headers: buildHeaders({ token, contentType: method === "POST" ? contentType : null, idempotencyKey: method === "POST" ? idempotencyKey : null }),
body: method === "POST" ? rawBody : undefined,
signal: fetchSignal,
});
let upstream;
try {
upstream = await doFetch(credentials?.accessToken || credentials?.apiKey);
} catch (error) {
if (error?.name === "AbortError" || error?.name === "TimeoutError") {
return createErrorResult(HTTP_STATUS.REQUEST_TIMEOUT, `[${provider}] video ${method} aborted: ${error.message}`);
}
// Never re-send a creation POST on network error — the job may already exist upstream.
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, sanitizeSecrets(`[${provider}] video upstream fetch failed: ${error.message}`, credentials));
}
// 401/403 → refresh once → retry once (OAuth accounts only; API keys can't refresh)
if (
(upstream.status === HTTP_STATUS.UNAUTHORIZED || upstream.status === HTTP_STATUS.FORBIDDEN) &&
credentials?.refreshToken
) {
let refreshed = null;
try {
refreshed = await refreshTokenByProvider(provider, credentials, log);
} catch (error) {
log?.warn?.("TOKEN", `${provider} | video refresh error: ${sanitizeSecrets(error.message, credentials)}`);
}
if (refreshed?.accessToken) {
log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed for video ${method}`);
Object.assign(credentials, refreshed);
if (onCredentialsRefreshed) await onCredentialsRefreshed(refreshed);
try {
await upstream.body?.cancel?.();
} catch { /* noop */ }
try {
upstream = await doFetch(credentials.accessToken || credentials.apiKey);
} catch (error) {
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, sanitizeSecrets(`[${provider}] video retry after refresh failed: ${error.message}`, credentials));
}
} else {
log?.warn?.("TOKEN", `${provider.toUpperCase()} | video refresh failed — account needs re-auth`);
}
}
const bodyText = await upstream.text().catch(() => "");
if (!upstream.ok) {
const message = sanitizeSecrets(bodyText || `HTTP ${upstream.status}`, credentials);
return createErrorResult(upstream.status, `[${provider}] ${message.slice(0, 2000)}`);
}
// Success: pass the upstream JSON through untouched (request_id / status / video.url).
return {
success: true,
response: new Response(bodyText, {
status: upstream.status,
headers: {
"Content-Type": upstream.headers.get("content-type") || "application/json",
"Access-Control-Allow-Origin": "*",
},
}),
};
}
+24 -4
View File
@@ -98,6 +98,8 @@ export const MODEL_CAPABILITIES = {
"coder-model": { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 },
};
const KIRO_GPT_5_6_CAPABILITIES = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 };
/**
* Provider-specific capability overrides. Keyed by provider alias/id.
*/
@@ -111,6 +113,20 @@ export const PROVIDER_CAPABILITIES = {
"deepseek-ai/deepseek-v4-pro": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 65536 },
"deepseek-ai/deepseek-v4-flash": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 65536 },
},
"kiro": {
"gpt-5.6-sol": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-terra": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-luna": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-sol-thinking": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-terra-thinking": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-luna-thinking": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-sol-agentic": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-terra-agentic": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-luna-agentic": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-sol-thinking-agentic": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-terra-thinking-agentic": KIRO_GPT_5_6_CAPABILITIES,
"gpt-5.6-luna-thinking-agentic": KIRO_GPT_5_6_CAPABILITIES,
},
// CodeBuddy.cn — authoritative per-model metadata from the gateway's model
// config (contextWindow=maxInputTokens, maxOutput=maxOutputTokens, vision=
// supportsImages). Every model reasons via OpenAI-style reasoning_effort
@@ -271,13 +287,17 @@ export const PATTERN_CAPABILITIES = [
export function getCapabilitiesForModel(provider, model) {
if (!model) return { ...DEFAULT_CAPABILITIES };
// Canonical exact lookup strips vendor prefix: "anthropic/claude-opus-4.7" -> "claude-opus-4.7".
const baseModel = model.includes("/") ? model.split("/").pop() : model;
// 1. Provider-specific override
if (provider && PROVIDER_CAPABILITIES[provider]?.[model]) {
return { ...DEFAULT_CAPABILITIES, ...PROVIDER_CAPABILITIES[provider][model] };
if (provider) {
const providerCaps = PROVIDER_CAPABILITIES[provider];
if (providerCaps?.[model]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[model] };
if (providerCaps?.[baseModel]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[baseModel] };
}
// 2. Canonical exact (strip vendor prefix: "anthropic/claude-opus-4.7" -> "claude-opus-4.7")
const baseModel = model.includes("/") ? model.split("/").pop() : model;
// 2. Canonical exact
if (MODEL_CAPABILITIES[baseModel]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[baseModel] };
if (MODEL_CAPABILITIES[model]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[model] };
+1 -1
View File
@@ -14,7 +14,7 @@ export default {
},
category: "apikey",
transport: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions",
baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
headers: {},
quirks: { preserveCacheControl: true },
},
+1 -3
View File
@@ -1,5 +1,3 @@
import { CLAUDE_API_HEADERS } from "../shared.js";
export default {
id: "anthropic",
priority: 30,
@@ -19,7 +17,7 @@ export default {
baseUrl: "https://api.anthropic.com/v1/messages",
format: "claude",
headers: {
"Anthropic-Version": "2023-06-01",
"anthropic-version": "2023-06-01",
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
},
},
+9
View File
@@ -18,6 +18,7 @@ export default {
transport: {
baseUrl: "https://api.githubcopilot.com/chat/completions",
responsesUrl: "https://api.githubcopilot.com/responses",
messagesUrl: "https://api.githubcopilot.com/v1/messages",
headers: {
"copilot-integration-id": "vscode-chat",
"editor-version": "vscode/1.110.0",
@@ -46,6 +47,14 @@ export default {
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
{ id: "gpt-5.4", name: "GPT-5.4" },
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini" },
// Note: routing to Copilot's Anthropic-native /v1/messages shim (see
// executors/github.js) is decided by model-NAME pattern at request time, not by
// a static targetFormat field here — Copilot's live model catalog (see
// services/copilotModels.js) regularly exposes claude-* models this static list
// hasn't caught up with yet (e.g. claude-opus-4.8), and a static per-entry
// targetFormat would silently miss those while also double-translating requests
// for models that ARE listed here (chatCore.js would pre-translate to Claude
// shape, then the executor would translate again). Keep these as plain entries.
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
{ id: "claude-opus-4.5", name: "Claude Opus 4.5" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
+28 -18
View File
@@ -1,13 +1,21 @@
/**
* Grok CLI / Grok Build (cli-chat-proxy.grok.com)
*
* Source of truth: HAR capture of official grok-shell/grok-pager 0.2.93
* Source of truth: wire capture of official @xai-official/grok 0.2.99
* talking to https://cli-chat-proxy.grok.com (OpenAI Responses API).
*
* Distinct from:
* - `xai` → api.x.ai (API key / Grok Build OAuth PKCE)
* - `xai` → api.x.ai (API key / xAI API OAuth PKCE)
* - `grok-web` → grok.com web SSO cookie
*/
import {
GROK_CLI_BASE_URL,
GROK_CLI_CLIENT_IDENTIFIER,
GROK_CLI_MODEL,
GROK_CLI_USER_AGENT,
GROK_CLI_VERSION,
} from "../../config/grokCli.js";
export default {
id: "grok-cli",
priority: 275,
@@ -29,32 +37,28 @@ export default {
authModes: ["oauth"],
hasOAuth: true,
thinkingConfig: {
options: ["low", "medium", "high"],
options: ["low", "medium", "high", "xhigh"],
defaultMode: "high",
},
transport: {
baseUrl: "https://cli-chat-proxy.grok.com/v1/responses",
baseUrl: `${GROK_CLI_BASE_URL}/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",
modelsUrl: `${GROK_CLI_BASE_URL}/models`,
userUrl: `${GROK_CLI_BASE_URL}/user`,
billingUrl: `${GROK_CLI_BASE_URL}/billing`,
clientVersion: GROK_CLI_VERSION,
clientIdentifier: GROK_CLI_CLIENT_IDENTIFIER,
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",
"User-Agent": GROK_CLI_USER_AGENT,
"x-grok-client-identifier": GROK_CLI_CLIENT_IDENTIFIER,
"x-grok-client-version": GROK_CLI_VERSION,
},
// 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",
url: `${GROK_CLI_BASE_URL}/billing?format=credits`,
userUrl: `${GROK_CLI_BASE_URL}/user?include=subscription`,
},
retry: {
429: { attempts: 2, delayMs: 2000 },
@@ -63,6 +67,12 @@ export default {
},
},
models: [
{
id: GROK_CLI_MODEL,
name: "Grok Build",
contextLength: 500000,
maxOutputTokens: 64000,
},
{ 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" },
+12
View File
@@ -65,18 +65,30 @@ export default {
{ 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" },
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol", contextLength: 272000, rateMultiplier: 2.4, upstreamModelId: "gpt-5.6-sol", description: "Experimental preview of OpenAI GPT 5.6 Sol with 272k context window" },
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra", contextLength: 272000, rateMultiplier: 1.2, upstreamModelId: "gpt-5.6-terra", description: "Experimental preview of OpenAI GPT 5.6 Terra with 272k context window" },
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna", contextLength: 272000, rateMultiplier: 0.6, upstreamModelId: "gpt-5.6-luna", description: "Experimental preview of OpenAI GPT 5.6 Luna with 272k context window" },
// 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)" },
{ id: "gpt-5.6-sol-thinking", name: "GPT 5.6 Sol (Thinking)", contextLength: 272000, rateMultiplier: 2.4, upstreamModelId: "gpt-5.6-sol", description: "Experimental preview of OpenAI GPT 5.6 Sol with 272k context window" },
{ id: "gpt-5.6-terra-thinking", name: "GPT 5.6 Terra (Thinking)", contextLength: 272000, rateMultiplier: 1.2, upstreamModelId: "gpt-5.6-terra", description: "Experimental preview of OpenAI GPT 5.6 Terra with 272k context window" },
{ id: "gpt-5.6-luna-thinking", name: "GPT 5.6 Luna (Thinking)", contextLength: 272000, rateMultiplier: 0.6, upstreamModelId: "gpt-5.6-luna", description: "Experimental preview of OpenAI GPT 5.6 Luna with 272k context window" },
// 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)" },
{ id: "gpt-5.6-sol-agentic", name: "GPT 5.6 Sol (Agentic)", contextLength: 272000, rateMultiplier: 2.4, upstreamModelId: "gpt-5.6-sol", description: "Experimental preview of OpenAI GPT 5.6 Sol with 272k context window" },
{ id: "gpt-5.6-terra-agentic", name: "GPT 5.6 Terra (Agentic)", contextLength: 272000, rateMultiplier: 1.2, upstreamModelId: "gpt-5.6-terra", description: "Experimental preview of OpenAI GPT 5.6 Terra with 272k context window" },
{ id: "gpt-5.6-luna-agentic", name: "GPT 5.6 Luna (Agentic)", contextLength: 272000, rateMultiplier: 0.6, upstreamModelId: "gpt-5.6-luna", description: "Experimental preview of OpenAI GPT 5.6 Luna with 272k context window" },
// 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)" },
{ id: "gpt-5.6-sol-thinking-agentic", name: "GPT 5.6 Sol (Thinking + Agentic)", contextLength: 272000, rateMultiplier: 2.4, upstreamModelId: "gpt-5.6-sol", description: "Experimental preview of OpenAI GPT 5.6 Sol with 272k context window" },
{ id: "gpt-5.6-terra-thinking-agentic", name: "GPT 5.6 Terra (Thinking + Agentic)", contextLength: 272000, rateMultiplier: 1.2, upstreamModelId: "gpt-5.6-terra", description: "Experimental preview of OpenAI GPT 5.6 Terra with 272k context window" },
{ id: "gpt-5.6-luna-thinking-agentic", name: "GPT 5.6 Luna (Thinking + Agentic)", contextLength: 272000, rateMultiplier: 0.6, upstreamModelId: "gpt-5.6-luna", description: "Experimental preview of OpenAI GPT 5.6 Luna with 272k context window" },
],
oauth: {
ssoOidcEndpoint: "https://oidc.us-east-1.amazonaws.com",
+5 -1
View File
@@ -32,9 +32,13 @@ export default {
{ id: "grok-code-fast-1", name: "Grok Code Fast" },
{ id: "grok-3", name: "Grok 3" },
{ id: "grok-2-image-1212", name: "Grok 2 Image", params: ["n","response_format"], kind: "image" },
{ id: "grok-imagine-video", name: "Grok Imagine Video", params: ["duration","aspect_ratio","resolution"], kind: "video" },
],
serviceKinds: ["llm","imageToText","webSearch","image"],
serviceKinds: ["llm","imageToText","webSearch","image","video"],
imageConfig: { baseUrl: "https://api.x.ai/v1/images/generations", bodyFields: ["model","prompt","n","response_format"] },
// Async video jobs (POST returns { request_id }, GET polls until done/failed).
// Docs: https://docs.x.ai/developers/rest-api-reference/inference/videos
videoConfig: { baseUrl: "https://api.x.ai/v1/videos" },
searchViaChat: {
defaultModel: "grok-4.20-reasoning",
endpoint: "https://api.x.ai/v1/responses",
+127
View File
@@ -0,0 +1,127 @@
import {
GROK_CLI_BASE_URL,
GROK_CLI_CLIENT_IDENTIFIER,
GROK_CLI_MODEL,
GROK_CLI_USER_AGENT,
GROK_CLI_VERSION,
} from "../config/grokCli.js";
import { refreshProviderCredentials } from "./oauthCredentialManager.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
const MODELS_URL = `${GROK_CLI_BASE_URL}/models`;
function modelEntries(data) {
const value = Array.isArray(data) ? data : data?.data ?? data?.models ?? data?.results ?? [];
if (Array.isArray(value)) return value.map((item) => [null, item]);
if (value && typeof value === "object") return Object.entries(value);
return [];
}
export function parseGrokCliModels(data) {
const seen = new Set();
const models = [];
for (const [key, raw] of modelEntries(data)) {
const item = typeof raw === "string" ? { id: raw } : raw;
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const id = String(
item.id ?? item.model_id ?? item.modelId ?? item.model ?? item.slug ?? key ?? item.name ?? "",
).trim();
if (!id || seen.has(id)) continue;
seen.add(id);
const model = {
...item,
id,
name: item.display_name ?? item.displayName ?? item.name ?? id,
};
const contextLength = Number(
item.context_length ?? item.contextLength ?? item.context_window ?? item.contextWindow,
);
const maxOutputTokens = Number(item.max_output_tokens ?? item.maxOutputTokens);
if (Number.isFinite(contextLength) && contextLength > 0) model.contextLength = contextLength;
if (Number.isFinite(maxOutputTokens) && maxOutputTokens > 0) {
model.maxOutputTokens = maxOutputTokens;
}
if (id === GROK_CLI_MODEL) {
model.contextLength ||= 500000;
model.maxOutputTokens ||= 64000;
}
models.push(model);
}
return models;
}
function buildHeaders(accessToken, providerSpecificData = {}) {
const headers = {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"User-Agent": GROK_CLI_USER_AGENT,
"x-xai-token-auth": "xai-grok-cli",
"x-grok-client-version": GROK_CLI_VERSION,
"x-grok-client-identifier": GROK_CLI_CLIENT_IDENTIFIER,
"x-grok-client-mode": "headless",
};
const email = providerSpecificData?.email;
const userId = providerSpecificData?.userId || providerSpecificData?.principalId;
if (email) headers["x-email"] = email;
if (userId) headers["x-userid"] = userId;
return headers;
}
export async function resolveGrokCliModels(credentials, options = {}) {
const {
fetchFn = proxyAwareFetch,
log = console,
proxyOptions = null,
onCredentialsRefreshed,
} = options;
let accessToken = credentials?.accessToken;
if (!accessToken) return { models: [], warning: "Grok CLI access token is missing." };
const request = (token) => fetchFn(
MODELS_URL,
{
method: "GET",
headers: buildHeaders(token, credentials?.providerSpecificData),
},
proxyOptions,
);
try {
let response = await request(accessToken);
if ((response.status === 401 || response.status === 403) && credentials?.refreshToken) {
const refreshed = await refreshProviderCredentials(
"grok-cli",
credentials,
log,
proxyOptions,
);
if (refreshed?.accessToken) {
accessToken = refreshed.accessToken;
try {
await onCredentialsRefreshed?.(refreshed);
} catch (error) {
log?.warn?.("Grok CLI credential persistence failed", error);
}
response = await request(accessToken);
}
}
if (!response.ok) {
const detail = await response.text().catch(() => "");
return {
models: [],
warning: `Grok CLI model discovery failed (${response.status})${detail ? `: ${detail.slice(0, 160)}` : ""}`,
};
}
const models = parseGrokCliModels(await response.json());
return models.length
? { models }
: { models: [], warning: "Grok CLI returned no selectable models." };
} catch (error) {
return { models: [], warning: `Grok CLI model discovery failed: ${error.message}` };
}
}
+7 -1
View File
@@ -17,6 +17,10 @@ for (const entry of REGISTRY) {
for (const a of entry.aliases || []) ALIAS_TO_PROVIDER_ID[a] = entry.id;
}
const BUILTIN_MODEL_ALIASES = {
"grok-build": "gcli/grok-build",
};
/**
* Resolve provider alias to provider ID
*/
@@ -104,7 +108,9 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
: aliasesOrGetter;
// Resolve alias
const resolved = resolveModelAliasFromMap(parsed.model, aliases);
const resolved =
resolveModelAliasFromMap(parsed.model, aliases) ||
resolveModelAliasFromMap(parsed.model, BUILTIN_MODEL_ALIASES);
if (resolved) {
return resolved;
}
+61 -7
View File
@@ -24,6 +24,11 @@
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
import { U, parseResetTime, toFiniteNumber } from "./shared.js";
import {
GROK_CLI_CLIENT_IDENTIFIER,
GROK_CLI_USER_AGENT,
GROK_CLI_VERSION,
} from "../../config/grokCli.js";
const USAGE = U("grok-cli");
const BILLING_URL = USAGE.url || "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
@@ -43,10 +48,11 @@ function buildGrokCliHeaders(accessToken, providerSpecificData = {}) {
const headers = {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
"User-Agent": GROK_CLI_USER_AGENT,
"x-xai-token-auth": "xai-grok-cli",
"x-grok-client-identifier": "grok-pager",
"x-grok-client-version": "0.2.93",
"x-grok-client-identifier": GROK_CLI_CLIENT_IDENTIFIER,
"x-grok-client-version": GROK_CLI_VERSION,
"x-grok-client-mode": "headless",
};
const email = psd.email;
const userId = psd.userId || psd.principalId;
@@ -55,8 +61,18 @@ function buildGrokCliHeaders(accessToken, providerSpecificData = {}) {
return headers;
}
function subscriptionTier(user, config) {
const rawTier =
user?.subscriptionTier ??
user?.subscription_tier ??
user?.subscription?.tier ??
config?.subscriptionTier ??
config?.subscription_tier;
return typeof rawTier === "string" ? rawTier.trim() : "";
}
function resolvePlan(user, config) {
const tier = typeof user?.subscriptionTier === "string" ? user.subscriptionTier.trim() : "";
const tier = subscriptionTier(user, config);
if (tier) {
return tier
.replace(/[_-]+/g, " ")
@@ -105,11 +121,42 @@ export function parseGrokCliBilling(billing, user = null) {
const periodEnd =
parseResetTime(config.billingPeriodEnd) ||
parseResetTime(config.billing_period_end) ||
parseResetTime(config.currentPeriod?.end) ||
parseResetTime(config.resetAt || config.resetsAt || config.periodEnd) ||
parseResetTime(root.billingPeriodEnd) ||
parseResetTime(root.billing_period_end) ||
parseResetTime(root.resetAt || root.resetsAt || root.periodEnd) ||
null;
const quotas = {};
const tier = subscriptionTier(user, config);
const subscriptionAccess = Boolean(tier) && !/^(free|none|null)$/i.test(tier);
// Current Grok Build responses expose included monthly usage at top level.
const monthlyLimit = unwrapVal(
config.monthlyLimit ?? config.monthly_limit ?? root.monthlyLimit ?? root.monthly_limit,
NaN,
);
const includedUsed = unwrapVal(
config.includedUsed ?? config.included_used ?? root.includedUsed ?? root.included_used,
NaN,
);
const totalUsed = unwrapVal(
config.totalUsed ?? config.total_used ?? root.totalUsed ?? root.total_used,
NaN,
);
if (Number.isFinite(monthlyLimit) && monthlyLimit > 0) {
quotas["Monthly included"] = makeQuota({
used: Number.isFinite(includedUsed)
? includedUsed
: Number.isFinite(totalUsed)
? totalUsed
: 0,
total: monthlyLimit,
resetAt: periodEnd,
});
}
// Primary: on-demand spending window (subscription / promo credits)
const onDemandCap = unwrapVal(config.onDemandCap ?? root.onDemandCap, NaN);
@@ -121,7 +168,12 @@ export function parseGrokCliBilling(billing, user = null) {
total: onDemandCap,
resetAt: periodEnd,
});
} else if (Number.isFinite(onDemandCap) && onDemandCap === 0 && Number.isFinite(onDemandUsed)) {
} else if (
!subscriptionAccess &&
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"] = {
@@ -199,6 +251,7 @@ export function parseGrokCliBilling(billing, user = null) {
quotas,
periodEnd,
exhausted,
subscriptionAccess,
rawConfig: config,
};
}
@@ -255,8 +308,9 @@ export async function getGrokCliUsage(accessToken, providerSpecificData = null,
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.",
message: parsed.subscriptionAccess
? "Subscription access is active; Grok does not expose a numeric included quota."
: "Grok Build connected, but no credit allotment was returned. Free promo may be exhausted.",
quotas: {},
};
}
+2 -2
View File
@@ -6,8 +6,8 @@ import { getCapabilitiesForModel } from "../../providers/capabilities.js";
// Each rule: optional provider, regex match on model, list of params to drop.
// A param is removed only when it is present (!== undefined).
const STRIP_RULES = [
// claude-opus-4 series: temperature is deprecated (Anthropic 400). #1748
{ match: /claude-opus-4/i, drop: ["temperature"] },
// All Claude models: temperature deprecated/rejected upstream (Anthropic 400). #1748
{ match: /claude/i, drop: ["temperature"] },
// GitHub Copilot gpt-5.4: temperature unsupported.
{ provider: "github", match: /gpt-5\.4/i, drop: ["temperature"] },
// GitHub Copilot Claude (except opus/sonnet 4.6): thinking + reasoning_effort rejected. #713
@@ -229,6 +229,12 @@ function applyFormat(fmt, body, cfg, caps) {
}
case "claude-adaptive": {
if (none && canDisable) { body.thinking = { type: "disabled" }; break; }
// output_config.effort alone does NOT turn thinking on: Anthropic requires
// an explicit thinking:{type:"adaptive"} on Opus 4.6/4.7/4.8 and Sonnet 4.6
// ("thinking is off unless you explicitly set it"), and Anthropic-compatible
// shims (e.g. GitHub Copilot /v1/messages) default thinking off even for
// Sonnet 5. Send both fields — the documented adaptive-thinking shape.
body.thinking = { type: "adaptive" };
const level = toLevel(eff);
body.output_config = { effort: level === "xhigh" ? "high" : level };
break;
+9 -1
View File
@@ -103,8 +103,16 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
}
}
// Normalize thinking to the target provider-native format (config-driven, capability-aware)
// Normalize thinking to the target provider-native format (config-driven, capability-aware).
// Kiro's GenerateAssistantResponse request does not accept the generic top-level
// `thinking` field; its translators map thinking intent to KAS-compatible
// systemPrompt/additionalModelRequestFields instead.
const kiroThinkingMappedByTranslator =
targetFormat === FORMATS.KIRO &&
(sourceFormat === FORMATS.OPENAI || sourceFormat === FORMATS.CLAUDE);
if (!kiroThinkingMappedByTranslator) {
applyThinking(targetFormat, model, result, provider, thinkingIntent);
}
// Always normalize to clean OpenAI format when target is OpenAI
// This handles hybrid requests (e.g., OpenAI messages + Claude tools)
+65 -38
View File
@@ -24,13 +24,15 @@
*/
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { v4 as uuidv4 } from "uuid";
import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js";
import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js";
import {
resolveKiroModel,
resolveKiroThinkingBudget,
buildThinkingSystemPrefix,
KIRO_AGENTIC_SYSTEM_PROMPT,
resolveDefaultProfileArn,
buildKiroAdditionalModelRequestFieldsForModel,
} from "../../config/kiroConstants.js";
import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
import { ROLE, CLAUDE_BLOCK } from "../schema/index.js";
@@ -363,6 +365,18 @@ function reconcileOrphanedToolResults(history, currentMessage) {
}
}
function extractClaudeSystemText(system) {
if (!system) return "";
if (typeof system === "string") return system;
if (Array.isArray(system)) {
return system.map((s) => {
if (typeof s === "string") return s;
return s?.text || "";
}).filter(Boolean).join("\n");
}
return "";
}
/**
* Build a Kiro payload directly from a Claude Messages API request body.
*/
@@ -402,62 +416,75 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
? (credentials?.providerSpecificData?.profileArn || "")
: (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod));
let finalContent = currentMessage?.userInputMessage?.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") {
systemText = body.system;
} else if (Array.isArray(body.system)) {
systemText = body.system.map((s) => s.text || "").join("\n");
}
if (systemText) {
systemInstruction = systemText;
finalContent = `<instructions>\n${systemText}\n</instructions>\n\n${finalContent}`;
}
}
// Prefix order: thinking_mode tag, timestamp marker, then agentic prompt.
// Kiro CLI/KAS sends system prompt as top-level `systemPrompt`. Keep a
// content fallback too because the CodeWhisperer surface does not always
// enforce top-level systemPrompt for direct calls.
const timestamp = new Date().toISOString();
const prefixParts = [];
if (thinkingBudget !== null) prefixParts.push(buildThinkingSystemPrefix(thinkingBudget));
prefixParts.push(`[Context: Current time is ${timestamp}]`);
if (agentic) prefixParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
finalContent = `${prefixParts.join("\n\n")}\n\n${finalContent}`;
const systemPromptParts = [];
if (thinkingBudget !== null) systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget));
if (agentic) systemPromptParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
const systemInstruction = extractClaudeSystemText(body.system);
if (systemInstruction) systemPromptParts.push(systemInstruction);
const systemPrompt = systemPromptParts.filter(Boolean).join("\n\n");
const currentTimeContext = `[Context: Current time is ${timestamp}]`;
const contentPrefix = [systemPrompt, currentTimeContext].filter(Boolean).join("\n\n");
const sessionIdentity = resolveSessionIdentity({
headers: credentials?.rawHeaders,
body,
connectionId: credentials?.connectionId,
scope: "kiro",
});
const conversationId = sessionIdentity.sessionId;
const continuationId = resolveContinuationId({
sessionId: conversationId,
connectionId: credentials?.connectionId,
scope: "kiro",
ephemeral: sessionIdentity.ephemeral,
});
const replay = applyKiroSessionReplay({
conversationId,
connectionId: credentials?.connectionId,
modelId: upstreamModel,
systemPrompt,
contentPrefix,
currentContentPrefix: currentTimeContext,
history,
currentMessage,
});
const replayCurrent = replay.currentMessage?.userInputMessage || {};
const userInputMessage = {
content: finalContent,
content: replayCurrent.content || "",
modelId: upstreamModel,
origin: "AI_EDITOR",
...(currentMessage?.userInputMessage?.userInputMessageContext && {
userInputMessageContext:
currentMessage.userInputMessage.userInputMessageContext,
...(replayCurrent.userInputMessageContext && {
userInputMessageContext: replayCurrent.userInputMessageContext,
}),
...(currentMessage?.userInputMessage?.images && {
images: currentMessage.userInputMessage.images,
...(replayCurrent.images && {
images: replayCurrent.images,
}),
};
if (systemInstruction) {
userInputMessage.systemInstruction = systemInstruction;
}
const payload = {
conversationState: {
chatTriggerType: "MANUAL",
conversationId: uuidv4(),
conversationId,
agentContinuationId: continuationId,
agentTaskType: "vibe",
currentMessage: {
userInputMessage,
},
history,
history: replay.history,
},
agentMode: "vibe",
};
if (profileArn) payload.profileArn = profileArn;
if (systemPrompt) payload.systemPrompt = systemPrompt;
const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel);
if (additionalModelRequestFields) {
payload.additionalModelRequestFields = additionalModelRequestFields;
}
if (maxTokens || temperature !== undefined || topP !== undefined) {
payload.inferenceConfig = {};
@@ -201,6 +201,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials)
delete result.prompt_cache_key;
delete result.store;
delete result.reasoning;
delete result.client_metadata;
return result;
}
+49 -20
View File
@@ -5,13 +5,15 @@
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { v4 as uuidv4 } from "uuid";
import { resolveSessionId } from "../../utils/sessionManager.js";
import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js";
import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js";
import {
resolveKiroModel,
resolveKiroThinkingBudget,
buildThinkingSystemPrefix,
KIRO_AGENTIC_SYSTEM_PROMPT,
resolveDefaultProfileArn
resolveDefaultProfileArn,
buildKiroAdditionalModelRequestFieldsForModel
} from "../../config/kiroConstants.js";
import { parseDataUri } from "../concerns/image.js";
import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
@@ -546,47 +548,74 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
? (credentials?.providerSpecificData?.profileArn || "")
: (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod));
let finalContent = currentMessage?.userInputMessage?.content || "";
const timestamp = new Date().toISOString();
// Build the system-prompt prefix that goes ABOVE the user message body.
// Order: thinking_mode tag first (so Kiro sees it before any user text),
// then context/timestamp marker, then optional agentic chunked-write prompt.
const prefixParts = [];
// Kiro CLI/KAS sends these as top-level systemPrompt. Keep a content fallback
// too because the CodeWhisperer surface does not always enforce top-level
// systemPrompt for direct calls.
const systemPromptParts = [];
if (thinkingBudget !== null) {
prefixParts.push(buildThinkingSystemPrefix(thinkingBudget));
systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget));
}
prefixParts.push(`[Context: Current time is ${timestamp}]`);
if (agentic) {
prefixParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
systemPromptParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
}
finalContent = `${prefixParts.join("\n\n")}\n\n${finalContent}`;
const systemPrompt = systemPromptParts.filter(Boolean).join("\n\n");
const currentTimeContext = `[Context: Current time is ${timestamp}]`;
const contentPrefix = [systemPrompt, currentTimeContext].filter(Boolean).join("\n\n");
const sessionIdentity = resolveSessionIdentity({ headers: credentials?.rawHeaders, body, connectionId: credentials?.connectionId, scope: "kiro" });
const conversationId = sessionIdentity.sessionId;
const continuationId = resolveContinuationId({
sessionId: conversationId,
connectionId: credentials?.connectionId,
scope: "kiro",
ephemeral: sessionIdentity.ephemeral,
});
const replay = applyKiroSessionReplay({
conversationId,
connectionId: credentials?.connectionId,
modelId: upstreamModel,
systemPrompt,
contentPrefix,
currentContentPrefix: currentTimeContext,
history,
currentMessage,
});
const replayCurrent = replay.currentMessage?.userInputMessage || {};
const payload = {
conversationState: {
chatTriggerType: "MANUAL",
conversationId: resolveSessionId({ headers: credentials?.rawHeaders, body, connectionId: credentials?.connectionId, scope: "kiro" }),
conversationId,
agentContinuationId: continuationId,
agentTaskType: "vibe",
currentMessage: {
userInputMessage: {
content: finalContent,
content: replayCurrent.content || "",
modelId: upstreamModel,
origin: "AI_EDITOR",
...(currentMessage?.userInputMessage?.images?.length > 0 && {
images: currentMessage.userInputMessage.images
...(replayCurrent.images?.length > 0 && {
images: replayCurrent.images
}),
...(currentMessage?.userInputMessage?.userInputMessageContext && {
userInputMessageContext: currentMessage.userInputMessage.userInputMessageContext
...(replayCurrent.userInputMessageContext && {
userInputMessageContext: replayCurrent.userInputMessageContext
})
}
},
history: history
}
history: replay.history
},
agentMode: "vibe",
};
if (profileArn) {
payload.profileArn = profileArn;
}
if (systemPrompt) payload.systemPrompt = systemPrompt;
const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel);
if (additionalModelRequestFields) {
payload.additionalModelRequestFields = additionalModelRequestFields;
}
if (maxTokens || temperature !== undefined || topP !== undefined) {
payload.inferenceConfig = {};
+125
View File
@@ -0,0 +1,125 @@
import { MEMORY_CONFIG } from "../config/runtimeConfig.js";
const sessionStartStore = new Map();
const MAX_SESSION_STARTS = 5000;
function clone(value) {
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function sessionKey(connectionId, conversationId) {
return `${connectionId || ""}:${conversationId || ""}`;
}
function ensureUserMessageModelId(message, modelId) {
if (message?.userInputMessage && !message.userInputMessage.modelId && modelId) {
message.userInputMessage.modelId = modelId;
}
return message;
}
function ensureHistoryModelIds(history, modelId) {
for (const item of history || []) {
ensureUserMessageModelId(item, modelId);
}
return history;
}
function prefixUserMessage(message, contentPrefix, modelId) {
const out = clone(message) || { userInputMessage: { content: "" } };
if (!out.userInputMessage) out.userInputMessage = { content: "" };
ensureUserMessageModelId(out, modelId);
if (contentPrefix) {
const content = out.userInputMessage.content || "";
out.userInputMessage.content = content
? `${contentPrefix}\n\n${content}`
: contentPrefix;
}
return out;
}
function findFirstUserIndex(history) {
return history.findIndex((item) => item?.userInputMessage);
}
function rememberSessionStart(key, entry) {
if (sessionStartStore.size >= MAX_SESSION_STARTS) {
sessionStartStore.delete(sessionStartStore.keys().next().value);
}
sessionStartStore.set(key, { ...entry, lastUsed: Date.now() });
}
/**
* Preserve Kiro cacheability by freezing the first user message (`msg0`) for a
* session, replaying that exact message as the first history user on later
* turns, and injecting volatile current-time context only into the current turn.
*/
export function applyKiroSessionReplay({
conversationId,
connectionId,
modelId,
systemPrompt = "",
contentPrefix = "",
currentContentPrefix = "",
history = [],
currentMessage,
} = {}) {
const key = sessionKey(connectionId, conversationId);
const existing = conversationId ? sessionStartStore.get(key) : null;
const baseHistory = clone(history) || [];
const baseCurrent = clone(currentMessage) || { userInputMessage: { content: "" } };
if (existing && existing.modelId === modelId && existing.systemPrompt === systemPrompt) {
existing.lastUsed = Date.now();
const firstUserIndex = findFirstUserIndex(baseHistory);
const sessionStart = ensureUserMessageModelId(clone(existing.sessionStart), modelId);
if (firstUserIndex >= 0) {
baseHistory[firstUserIndex] = sessionStart;
} else {
baseHistory.unshift(sessionStart);
}
return {
history: ensureHistoryModelIds(baseHistory, modelId),
currentMessage: prefixUserMessage(baseCurrent, currentContentPrefix, modelId),
replayed: true,
};
}
const firstUserIndex = findFirstUserIndex(baseHistory);
let sessionStart;
let nextCurrent = ensureUserMessageModelId(baseCurrent, modelId);
if (firstUserIndex >= 0) {
sessionStart = prefixUserMessage(baseHistory[firstUserIndex], contentPrefix, modelId);
baseHistory[firstUserIndex] = clone(sessionStart);
nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId);
} else {
sessionStart = prefixUserMessage(baseCurrent, contentPrefix, modelId);
nextCurrent = clone(sessionStart);
}
if (conversationId) {
rememberSessionStart(key, {
sessionStart: clone(sessionStart),
modelId,
systemPrompt,
});
}
return {
history: ensureHistoryModelIds(baseHistory, modelId),
currentMessage: nextCurrent,
replayed: false,
};
}
export function clearKiroSessionReplayStore() {
sessionStartStore.clear();
}
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, entry] of sessionStartStore) {
if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) sessionStartStore.delete(key);
}
}, MEMORY_CONFIG.sessionCleanupIntervalMs);
if (cleanup.unref) cleanup.unref();
+49 -13
View File
@@ -13,6 +13,7 @@ import { MEMORY_CONFIG } from "../config/runtimeConfig.js";
// Runtime storage: Key = connectionId, Value = { sessionId, lastUsed }
const runtimeSessionStore = new Map();
const continuationStore = new Map();
// Periodically evict entries that haven't been used within TTL
const cleanupInterval = setInterval(() => {
@@ -80,6 +81,7 @@ export function generateBinaryStyleId() {
export function clearSessionStore() {
runtimeSessionStore.clear();
assistantSessionStore.clear();
continuationStore.clear();
}
// Conversation-stable session store: Key = hash(scope+assistant text), Value = { sessionId, lastUsed }
@@ -87,9 +89,10 @@ const assistantSessionStore = new Map();
const ASSISTANT_MIN_LEN = 50;
const ASSISTANT_CAP_LEN = 50;
const MAX_ASSISTANT_SESSIONS = 5000;
const MAX_CONTINUATION_SESSIONS = 5000;
// Client headers/body fields that carry an upstream session id (priority order)
const SESSION_HEADER_KEYS = ["x-session-id", "session-id", "session_id", "x-amp-thread-id", "x-client-request-id"];
const SESSION_HEADER_KEYS = ["x-session-id", "session-id", "session_id", "x-amp-thread-id"];
const CLAUDE_CODE_SESSION_RE = /_session_([a-f0-9-]+)$/;
function sha16(text) {
@@ -131,7 +134,7 @@ function extractAntigravitySession(body) {
return m ? normalizeSessionId(m[1]) : null;
}
function extractClientSessionId(headers, body) {
function extractClientSessionId(headers, body, scope = "") {
const claude = extractClaudeCodeSession(body?.metadata?.user_id);
if (claude) return `claude:${claude}`;
const antigravity = extractAntigravitySession(body);
@@ -140,18 +143,25 @@ function extractClientSessionId(headers, body) {
const v = headerValue(headers, key);
if (v) return v;
}
const requestId = scope === "kiro" ? null : headerValue(headers, "x-client-request-id");
if (requestId) return requestId;
const fromBody =
normalizeSessionId(body?.prompt_cache_key) ||
normalizeSessionId(body?.session_id) ||
normalizeSessionId(body?.conversation_id) ||
normalizeSessionId(body?.metadata?.user_id);
(scope === "kiro" ? null : normalizeSessionId(body?.metadata?.user_id));
return fromBody || null;
}
function requestMessages(body) {
if (Array.isArray(body?.messages)) return body.messages;
if (Array.isArray(body?.input)) return body.input;
return [];
}
// Accumulate assistant text from OpenAI/Responses-style input/messages (cap-limited)
function accumulateAssistantText(body) {
const items = Array.isArray(body?.input) ? body.input
: Array.isArray(body?.messages) ? body.messages : null;
const items = requestMessages(body);
if (!items) return "";
let text = "";
for (const item of items) {
@@ -193,16 +203,39 @@ function assistantTextSessionId(scope, body) {
* @param {string} [opts.connectionId] - Connection identifier (fallback scope)
* @param {string} [opts.workspaceId] - Provider workspace id (account-wide fallback)
* @param {string} [opts.scope] - Provider scope to isolate cache keys across providers
* @returns {string} A stable session id
* @returns {{sessionId: string, ephemeral: boolean}} A session id plus whether it is one-shot
*/
export function resolveSessionId({ headers, body, connectionId, workspaceId, scope = "" } = {}) {
const client = extractClientSessionId(headers, body);
if (client) return client;
const fromAssistant = assistantTextSessionId(`${scope}:${connectionId || ""}`, body);
if (fromAssistant) return fromAssistant;
export function resolveSessionIdentity({ headers, body, connectionId, workspaceId, scope = "" } = {}) {
const client = extractClientSessionId(headers, body, scope);
if (client) return { sessionId: client, ephemeral: false };
const fromAssistant = scope === "kiro" ? null : assistantTextSessionId(`${scope}:${connectionId || ""}`, body);
if (fromAssistant) return { sessionId: fromAssistant, ephemeral: false };
const ws = normalizeSessionId(workspaceId);
if (ws) return ws;
return deriveSessionId(connectionId);
if (ws) return { sessionId: ws, ephemeral: false };
if (scope === "kiro") return { sessionId: generateBinaryStyleId(), ephemeral: true };
return { sessionId: deriveSessionId(connectionId), ephemeral: false };
}
export function resolveSessionId(opts = {}) {
return resolveSessionIdentity(opts).sessionId;
}
export function resolveContinuationId({ sessionId, connectionId, scope = "", ephemeral = false } = {}) {
if (ephemeral) return crypto.randomUUID();
const key = `${scope}:${connectionId || ""}:${sessionId || ""}`;
const existing = continuationStore.get(key);
if (existing) {
existing.lastUsed = Date.now();
continuationStore.delete(key);
continuationStore.set(key, existing);
return existing.continuationId;
}
const continuationId = crypto.randomUUID();
if (continuationStore.size >= MAX_CONTINUATION_SESSIONS) {
continuationStore.delete(continuationStore.keys().next().value);
}
continuationStore.set(key, { continuationId, lastUsed: Date.now() });
return continuationId;
}
// Capture session id from request body + credentials (envelope still intact here)
@@ -227,5 +260,8 @@ const assistantCleanup = setInterval(() => {
for (const [key, entry] of assistantSessionStore) {
if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) assistantSessionStore.delete(key);
}
for (const [key, entry] of continuationStore) {
if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) continuationStore.delete(key);
}
}, MEMORY_CONFIG.sessionCleanupIntervalMs);
if (assistantCleanup.unref) assistantCleanup.unref();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "9router-app",
"version": "0.5.30",
"version": "0.5.35",
"description": "9Router web dashboard",
"private": true,
"scripts": {
+1376 -180
View File
File diff suppressed because it is too large Load Diff
+1384 -188
View File
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
---
name: 9router-video
description: Generate videos via 9Router /v1/videos/generations using xAI Grok Imagine (grok-imagine-video). Async job flow - submit, poll request_id until done, download MP4. Use when the user wants to create, generate, or render a video, text-to-video (txt2vid), or image-to-video.
---
# 9Router — Video Generation (xAI Grok Imagine)
Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup.
Requires a connected **xAI account** in the 9Router dashboard — either **Grok Build OAuth** (SuperGrok / X Premium+ subscription sign-in) or a direct **xAI API key** from console.x.ai. The two are separate auth types with separate billing; the dashboard shows which one each connection uses.
## Endpoints (async job flow)
Video generation is **asynchronous**: the POST returns a `request_id` immediately, then you poll until the job is `done` or `failed`.
| Endpoint | Purpose |
|---|---|
| `POST /v1/videos/generations` | text-to-video / image-to-video |
| `POST /v1/videos/edits` | edit an existing video |
| `POST /v1/videos/extensions` | extend an existing video |
| `GET /v1/videos/{request_id}` | poll job status |
Request fields (passed through to xAI unchanged — see https://docs.x.ai/developers/rest-api-reference/inference/videos):
| Field | Required | Notes |
|---|---|---|
| `model` | no | `xai/grok-imagine-video` (prefix is stripped before upstream) |
| `prompt` | yes for T2V | video description |
| `duration` | no | seconds |
| `aspect_ratio` | no | `16:9`, `9:16`, `1:1`, `4:3`, `3:4`, `3:2`, `2:3` |
| `resolution` | no | `480p`, `720p`, `1080p` |
| `image` | no | `{ "url": "https://… or data:image/…;base64,…" }` for image-to-video |
| `video` | edits/extensions | `{ "url": "…mp4" }` or `{ "file_id": "…" }` |
## Examples
Submit a job:
```bash
curl -X POST "$NINEROUTER_URL/v1/videos/generations" \
-H "Authorization: Bearer $NINEROUTER_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"xai/grok-imagine-video","prompt":"A cinematic tracking shot through a neon city at night","duration":8,"aspect_ratio":"16:9","resolution":"720p"}'
# → {"request_id":"abc123"} (response header x-9router-connection-id: <id>)
```
Poll until done (echo the connection header back so the same account polls the job):
```bash
curl "$NINEROUTER_URL/v1/videos/abc123" \
-H "Authorization: Bearer $NINEROUTER_KEY" \
-H "x-connection-id: <id from create response>"
# → {"status":"pending","progress":42}
# → {"status":"done","video":{"url":"https://…mp4","duration":8},"model":"grok-imagine-video"}
# → {"status":"failed","error":{"code":"…","message":"…"}}
```
Download: fetch `video.url` from the `done` response.
## CLI one-shot
```bash
9router xai video \
--prompt "A cinematic tracking shot through a neon city at night" \
--output video.mp4
# options: --model --duration --aspect-ratio --resolution --image --timeout --port --api-key
```
Submits, polls with progress, downloads to `video.mp4.part`, atomically renames on success. Ctrl+C cancels cleanly; non-zero exit on failure.
## Notes & limits
- Jobs are **account-bound** upstream: poll with the same connection that created the job (`x-connection-id` header, value from the create response's `x-9router-connection-id`).
- Creation POSTs are **never auto-retried** (a retry could create and bill two videos). Only a 401→token-refresh→single-retry is performed, which upstream rejects before job creation.
- Video models are tagged `kind: "video"` and are excluded from chat model lists and chat fallback combos.
- Grok Build **subscription OAuth** tokens are sent to the same `api.x.ai/v1/videos` endpoints as API keys; whether a given subscription tier includes video-generation quota is controlled by xAI and is not verified by 9Router — a `403`/`permission_denied` from upstream means the connected account has no video access.
+1
View File
@@ -11,6 +11,7 @@ Drop-in skills for any AI agent (Claude, Cursor, ChatGPT, custom SDK). Just **co
| **Entry / Setup** (start here) | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md |
| Chat / code-gen | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-chat/SKILL.md |
| Image generation | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-image/SKILL.md |
| Video generation (xAI Grok Imagine) | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-video/SKILL.md |
| Text-to-speech | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-tts/SKILL.md |
| Speech-to-text | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-stt/SKILL.md |
| Embeddings | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-embeddings/SKILL.md |
@@ -155,6 +155,11 @@ function buildConfigs(toolId, { baseUrl, apiKey, models, claudeModels = {}, clau
})),
}),
}];
case "grok-build":
return [{
filename: "~/.grok/config.toml",
content: `[models]\ndefault = "9router"\n\n[model.9router]\nmodel = "${model}"\nbase_url = "${endpoint}"\nname = "9Router"\ndescription = "Routed via 9Router gateway"\napi_backend = "chat_completions"\napi_key = "${apiKey}"\n`,
}];
default:
return [{ filename: "config.json", content: toJson({ baseUrl: endpoint, apiKey, model }) }];
}
@@ -4,10 +4,11 @@ import { useState } from "react";
import PropTypes from "prop-types";
import { Button, Badge, Input, Modal, Select } from "@/shared/components";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { planBulkAdd } from "@/shared/utils/bulkAdd";
const BULK_PLACEHOLDER = `name1|sk-key1\nname2|sk-key2\nsk-key-only-auto-named`;
export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, authType, authHint, website, proxyPools, error, onSave, onBulkDone, onClose }) {
export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, authType, authHint, website, proxyPools, error, existingNames, onSave, onBulkDone, onClose }) {
const NONE_PROXY_POOL_VALUE = "__none__";
const isOllamaLocal = provider === "ollama-local";
const isCookie = authType === "cookie";
@@ -131,39 +132,30 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
};
const handleBulkSubmit = async () => {
const lines = bulkText.split("\n").map(l => l.trim()).filter(Boolean);
const lines = bulkText.split("\n");
if (!lines.length) return;
// Plan collision-free names against existing connections so a generated
// "Key N" never matches a saved name (which the backend would upsert /
// overwrite instead of inserting). See bulkAdd.js for the full rationale.
const plan = planBulkAdd(lines, existingNames, { isCloudflareAi });
if (!plan.length) return;
setSaving(true);
setBulkResult(null);
let success = 0;
let failed = 0;
const duplicateLines = [];
for (let i = 0; i < lines.length; i++) {
const parts = lines[i].split("|");
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();
}
for (const entry of plan) {
try {
const res = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider,
apiKey,
name,
apiKey: entry.apiKey,
name: entry.name,
priority: 1,
testStatus: "unknown",
...(providerSpecificData ? { providerSpecificData } : {}),
...(entry.providerSpecificData ? { providerSpecificData: entry.providerSpecificData } : {}),
}),
});
if (res.ok) {
@@ -171,8 +163,8 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
} else {
failed++;
const data = await res.json().catch(() => null);
if (res.status === 409) duplicateLines.push(i + 1);
if (data?.error) console.log(`Bulk API key add failed on line ${i + 1}:`, data.error);
if (res.status === 409) duplicateLines.push(entry.lineNumber);
if (data?.error) console.log(`Bulk API key add failed on line ${entry.lineNumber}:`, data.error);
}
} catch {
failed++;
@@ -423,6 +415,7 @@ AddApiKeyModal.propTypes = {
name: PropTypes.string,
})),
error: PropTypes.string,
existingNames: PropTypes.arrayOf(PropTypes.string),
onSave: PropTypes.func.isRequired,
onBulkDone: PropTypes.func,
onClose: PropTypes.func.isRequired,
@@ -1705,6 +1705,7 @@ export default function ProviderDetailPage() {
website={providerInfo?.website}
proxyPools={proxyPools}
error={addConnectionError}
existingNames={connections.map((c) => c.name).filter(Boolean)}
onSave={handleSaveApiKey}
onBulkDone={fetchConnections}
onClose={() => {
@@ -87,6 +87,7 @@ export default function QuotaTable({
compact = false,
sortMode = "default",
showSortLabel = false,
onHideQuota = null,
}) {
const [page, setPage] = useState(1);
@@ -130,6 +131,7 @@ export default function QuotaTable({
const resetPrimary = compact ? "text-[11px]" : "text-sm";
const resetSecondary = compact ? "text-[10px] leading-tight" : "text-xs";
const sortLabel = "Sorted by account remaining";
const hasHideAction = typeof onHideQuota === "function";
return (
<div className="space-y-2">
@@ -193,7 +195,7 @@ export default function QuotaTable({
</div>
</td>
<td className={`${cellPad} w-[25%]`}>
<td className={`${cellPad} ${hasHideAction ? "w-[20%]" : "w-[25%]"}`}>
{countdown !== "-" || resetDisplay ? (
compact ? (
<div
@@ -220,6 +222,22 @@ export default function QuotaTable({
<div className={`${resetPrimary} text-text-muted italic`}>N/A</div>
)}
</td>
{hasHideAction && (
<td className={`${cellPad} w-[5%] text-right`}>
<button
type="button"
onClick={() => onHideQuota(quota)}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-text-muted transition-colors hover:bg-black/5 hover:text-text-primary dark:hover:bg-white/5"
title="Hide this quota row"
aria-label={`Hide quota ${quota.name}`}
>
<span className="material-symbols-outlined text-[15px]">
visibility_off
</span>
</button>
</td>
)}
</tr>
);
})}
@@ -8,6 +8,9 @@ import Tooltip from "@/shared/components/Tooltip";
import {
parseQuotaData,
calculatePercentage,
filterQuotasByVisibility,
getHiddenQuotaRows,
getQuotaVisibilityKey,
getConnectionLabel,
getConnectionQuotaRemaining,
sortVisibleConnections,
@@ -144,6 +147,7 @@ export default function ProviderLimits() {
const [providerOptions, setProviderOptions] = useState([]);
const [accountFilter, setAccountFilter] = useState("all");
const [quotaSortMode, setQuotaSortMode] = useState("default");
const [quotaVisibility, setQuotaVisibility] = useState({});
const [expiringFirst, setExpiringFirst] = useState(false);
const [providerMenuOpen, setProviderMenuOpen] = useState(false);
const [bulkToggling, setBulkToggling] = useState(false);
@@ -532,10 +536,13 @@ export default function ProviderLimits() {
useEffect(() => {
fetch("/api/settings", { cache: "no-store" })
.then((r) => (r.ok ? r.json() : {}))
.then((s) => setAutoPingMaps({
.then((s) => {
setAutoPingMaps({
claude: s?.claudeAutoPing?.connections || {},
codex: s?.codexAutoPing?.connections || {},
}))
});
setQuotaVisibility(s?.quotaVisibility || {});
})
.catch(() => {});
}, []);
@@ -561,6 +568,57 @@ export default function ProviderLimits() {
}
}, [autoPingMaps]);
const updateQuotaVisibility = useCallback(async (nextVisibility, previousVisibility) => {
setQuotaVisibility(nextVisibility);
try {
const response = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quotaVisibility: nextVisibility }),
});
if (!response.ok) throw new Error("Failed to update quota visibility");
} catch (error) {
console.error("Error updating quota visibility:", error);
setQuotaVisibility(previousVisibility);
}
}, []);
const handleHideQuota = useCallback((provider, quota) => {
const key = getQuotaVisibilityKey(quota);
if (!provider || !key) return;
const previous = quotaVisibility;
const providerVisibility = previous[provider] || {};
const hidden = new Set(providerVisibility.hidden || []);
hidden.add(key);
const next = {
...previous,
[provider]: {
...providerVisibility,
hidden: [...hidden],
},
};
updateQuotaVisibility(next, previous);
}, [quotaVisibility, updateQuotaVisibility]);
const handleShowQuota = useCallback((provider, quota) => {
const key = getQuotaVisibilityKey(quota);
if (!provider || !key) return;
const previous = quotaVisibility;
const providerVisibility = previous[provider] || {};
const hidden = new Set(providerVisibility.hidden || []);
hidden.delete(key);
const next = {
...previous,
[provider]: {
...providerVisibility,
hidden: [...hidden],
},
};
updateQuotaVisibility(next, previous);
}, [quotaVisibility, updateQuotaVisibility]);
// Auto-refresh interval
useEffect(() => {
if (!hasHydratedAutoRefresh || !autoRefresh) {
@@ -944,6 +1002,9 @@ export default function ProviderLimits() {
const resetCreditCount = getCodexResetCreditCount(quota);
const isResettingLimit = resettingLimitId === conn.id;
const rowBusy = deletingId === conn.id || togglingId === conn.id || isResettingLimit;
const rawQuotas = quota?.quotas || [];
const visibleQuotas = filterQuotasByVisibility(conn.provider, rawQuotas, quotaVisibility);
const hiddenQuotaRows = getHiddenQuotaRows(conn.provider, rawQuotas, quotaVisibility);
return (
<Card
@@ -1165,14 +1226,34 @@ export default function ProviderLimits() {
</div>
) : (
<QuotaTable
quotas={quota?.quotas}
quotas={visibleQuotas}
compact
sortMode="default"
showSortLabel={
conn.provider === "codex" && quotaSortMode !== "default"
}
onHideQuota={(quotaRow) => handleHideQuota(conn.provider, quotaRow)}
/>
)}
{hiddenQuotaRows.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1 border-t border-black/5 pt-2 text-[10px] text-text-muted dark:border-white/5">
<span className="material-symbols-outlined text-[14px]">
visibility_off
</span>
<span>Hidden:</span>
{hiddenQuotaRows.map((quotaRow) => (
<button
key={getQuotaVisibilityKey(quotaRow)}
type="button"
onClick={() => handleShowQuota(conn.provider, quotaRow)}
className="rounded-md border border-black/10 px-1.5 py-0.5 transition-colors hover:bg-black/5 hover:text-text-primary dark:border-white/10 dark:hover:bg-white/5"
title="Show this quota row"
>
{quotaRow.name}
</button>
))}
</div>
)}
</div>
</Card>
);
@@ -300,6 +300,30 @@ export function getRemainingPercentage(quota) {
return calculatePercentage(quota?.used, quota?.total);
}
export function getQuotaVisibilityKey(quota) {
if (!quota || typeof quota !== "object") return "";
return String(quota.modelKey || quota.name || "").trim();
}
function getProviderHiddenQuotaSet(provider, quotaVisibility) {
const hidden = quotaVisibility?.[provider]?.hidden;
return new Set(Array.isArray(hidden) ? hidden.map(String) : []);
}
export function filterQuotasByVisibility(provider, quotas = [], quotaVisibility = {}) {
if (!Array.isArray(quotas) || quotas.length === 0) return [];
const hidden = getProviderHiddenQuotaSet(provider, quotaVisibility);
if (hidden.size === 0) return quotas;
return quotas.filter((quota) => !hidden.has(getQuotaVisibilityKey(quota)));
}
export function getHiddenQuotaRows(provider, quotas = [], quotaVisibility = {}) {
if (!Array.isArray(quotas) || quotas.length === 0) return [];
const hidden = getProviderHiddenQuotaSet(provider, quotaVisibility);
if (hidden.size === 0) return [];
return quotas.filter((quota) => hidden.has(getQuotaVisibilityKey(quota)));
}
/**
* Parse provider-specific quota structures into normalized array
* @param {string} provider - Provider name (github, antigravity, codex, kiro, claude)
@@ -9,6 +9,8 @@ import { getModelsByProviderId } from "open-sse/config/providerModels.js";
import { resolveKiroModels } from "open-sse/services/kiroModels.js";
import { resolveKimchiModels } from "open-sse/services/kimchiModels.js";
import { resolveQoderModels } from "open-sse/services/qoderModels.js";
import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels";
@@ -370,6 +372,35 @@ const PROVIDER_MODELS_CONFIG = {
errorLabel: "Failed to fetch Gemini CLI models"
})
},
"grok-cli": {
customResolver: async (connection) => {
const proxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {});
const result = await resolveGrokCliModels({
...connection,
connectionId: connection.id,
}, {
log: console,
proxyOptions: {
connectionProxyEnabled: proxy.connectionProxyEnabled === true,
connectionProxyUrl: proxy.connectionProxyUrl || "",
connectionNoProxy: proxy.connectionNoProxy || "",
vercelRelayUrl: proxy.vercelRelayUrl || "",
strictProxy: proxy.strictProxy === true,
},
onCredentialsRefreshed: async (refreshed) => {
await updateProviderCredentials(connection.id, {
...refreshed,
existingProviderSpecificData: connection.providerSpecificData || {},
});
},
});
if (result.models.length) return result;
return {
models: getStaticProviderModels("grok-cli"),
warning: result.warning || "Grok CLI returned no live models; using static catalog.",
};
},
},
"ollama-local": {
customResolver: async (connection) => {
const url = `${resolveOllamaLocalHost(connection)}/api/tags`;
+7 -5
View File
@@ -2,7 +2,6 @@ import { NextResponse } from "next/server";
import { getSettings, getCombos, updateSettings } from "@/lib/localDb";
import { applyOutboundProxyEnv } from "@/lib/network/outboundProxy";
import { resetComboRotation } from "open-sse/services/combo.js";
import { runQuotaAutoPingTick } from "@/shared/services/quotaAutoPing";
import { requireCurrentDashboardUser, requireUsageDashboardUser } from "@/lib/auth/currentUser";
import { updateUser } from "@/lib/db";
@@ -56,6 +55,7 @@ export async function GET() {
try {
const settings = await getSettings();
const { password, oidcClientSecret, ...safeSettings } = settings;
safeSettings.oidcConfigured = !!(safeSettings.oidcIssuerUrl && safeSettings.oidcClientId && oidcClientSecret);
const user = await requireUsageDashboardUser();
if (user.role !== "admin") {
for (const key of USER_RESTRICTED_SETTING_KEYS) delete safeSettings[key];
@@ -159,10 +159,12 @@ export async function PATCH(request) {
Object.prototype.hasOwnProperty.call(body, "claudeAutoPing") ||
Object.prototype.hasOwnProperty.call(body, "codexAutoPing")
) {
// Run once immediately after opt-in changes so users don't wait for the next scheduler tick.
runQuotaAutoPingTick().catch((error) => {
console.warn("[AutoPing] settings-triggered tick failed:", error.message);
});
// Keep the scheduler absent when no account opted in; load its provider graph only on demand.
import("@/shared/services/quotaAutoPing")
.then(({ configureQuotaAutoPing }) => {
configureQuotaAutoPing(settings);
})
.catch((error) => console.warn("[AutoPing] settings update failed:", error.message));
}
const { password, oidcClientSecret, ...safeSettings } = settings;
+5
View File
@@ -1,9 +1,14 @@
import { NextResponse } from "next/server";
import { disableTunnel } from "@/lib/tunnel";
import { getSettings } from "@/lib/localDb";
import { configureTunnelMonitoring } from "@/shared/services/initializeApp";
export async function POST() {
try {
const result = await disableTunnel();
getSettings()
.then(configureTunnelMonitoring)
.catch((error) => console.warn("Tunnel monitor update failed:", error.message));
return NextResponse.json(result);
} catch (error) {
console.error("Tunnel disable error:", error);
+5
View File
@@ -1,11 +1,16 @@
import { NextResponse } from "next/server";
import { enableTunnel } from "@/lib/tunnel";
import { getSettings } from "@/lib/localDb";
import { configureTunnelMonitoring } from "@/shared/services/initializeApp";
const DNS_WARMUP_DELAY_MS = 8000;
export async function POST() {
try {
const result = await enableTunnel();
getSettings()
.then(configureTunnelMonitoring)
.catch((error) => console.warn("Tunnel monitor start failed:", error.message));
// Wait for DNS warmup to propagate at Cloudflare edge after tunnel registered
await new Promise((r) => setTimeout(r, DNS_WARMUP_DELAY_MS));
return NextResponse.json(result);
@@ -1,9 +1,14 @@
import { NextResponse } from "next/server";
import { disableTailscale } from "@/lib/tunnel";
import { getSettings } from "@/lib/localDb";
import { configureTunnelMonitoring } from "@/shared/services/initializeApp";
export async function POST() {
try {
const result = await disableTailscale();
getSettings()
.then(configureTunnelMonitoring)
.catch((error) => console.warn("Tailscale monitor update failed:", error.message));
return NextResponse.json(result);
} catch (error) {
console.error("Tailscale disable error:", error);
@@ -1,9 +1,14 @@
import { NextResponse } from "next/server";
import { enableTailscale } from "@/lib/tunnel";
import { getSettings } from "@/lib/localDb";
import { configureTunnelMonitoring } from "@/shared/services/initializeApp";
export async function POST() {
try {
const result = await enableTailscale();
getSettings()
.then(configureTunnelMonitoring)
.catch((error) => console.warn("Tailscale monitor start failed:", error.message));
return NextResponse.json(result);
} catch (error) {
console.error("Tailscale enable error:", error.message);
+54 -9
View File
@@ -12,8 +12,10 @@ import { resolveKimchiModels } from "open-sse/services/kimchiModels.js";
import { resolveQoderModels } from "open-sse/services/qoderModels.js";
import { resolveCopilotModels } from "open-sse/services/copilotModels.js";
import { resolveClinepassModels } from "open-sse/services/clinepassModels.js";
import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js";
import { updateProviderCredentials } from "@/sse/services/tokenRefresh";
import { capabilitiesFromServiceKind } from "open-sse/providers/capabilities.js";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
// Per-provider live model resolvers. Each receives a connection record and
// returns { models: [{ id, name? }, ...] } | null on failure.
@@ -71,7 +73,30 @@ const LIVE_MODEL_RESOLVERS = {
apiKey: conn.apiKey,
});
return result?.models?.length ? { models: result.models } : null;
}
},
"grok-cli": async (conn) => {
const proxy = await resolveConnectionProxyConfig(conn.providerSpecificData || {});
const result = await resolveGrokCliModels({
...conn,
connectionId: conn.id,
}, {
log: console,
proxyOptions: {
connectionProxyEnabled: proxy.connectionProxyEnabled === true,
connectionProxyUrl: proxy.connectionProxyUrl || "",
connectionNoProxy: proxy.connectionNoProxy || "",
vercelRelayUrl: proxy.vercelRelayUrl || "",
strictProxy: proxy.strictProxy === true,
},
onCredentialsRefreshed: async (refreshed) => {
await updateProviderCredentials(conn.id, {
...refreshed,
existingProviderSpecificData: conn.providerSpecificData || {},
});
},
});
return result?.models?.length ? { models: result.models } : null;
},
};
const parseOpenAIStyleModels = (data) => {
@@ -79,8 +104,9 @@ const parseOpenAIStyleModels = (data) => {
return data?.data || data?.models || data?.results || [];
};
// Matches provider IDs that are upstream/cross-instance connections (contain a UUID suffix)
const UPSTREAM_CONNECTION_RE = /[-_][0-9a-f]{8,}$/i;
// Header sent by fetchCompatibleModelIds to detect cross-instance /models fetches
// and break recursive loops between 9router instances connected to each other.
const INTERNAL_MODELS_FETCH_HEADER = "x-9r-internal-models-fetch";
// LLM kind sentinel — combos/models with no explicit kind default to LLM
const LLM_KIND = "llm";
@@ -93,6 +119,7 @@ const MODEL_TYPE_TO_KIND = {
embedding: "embedding",
stt: "stt",
imageToText: "imageToText",
video: "video",
};
function modelKind(model) {
@@ -145,7 +172,7 @@ async function fetchCompatibleModelIds(connection) {
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(url, {
method: "GET",
headers,
headers: { ...headers, [INTERNAL_MODELS_FETCH_HEADER]: "1" },
cache: "no-store",
signal: controller.signal,
});
@@ -188,8 +215,17 @@ function comboMatchesKinds(combo, kindFilter) {
/**
* Build OpenAI-format models list filtered by service kinds.
* @param {string[]} kindFilter - List of service kinds to include (e.g. ["llm"], ["webSearch","webFetch"]).
* @param {string|object|null} ownerOrOptions - Legacy owner ID or build options.
*/
export async function buildModelsList(kindFilter, ownerId = undefined) {
export async function buildModelsList(kindFilter, ownerOrOptions = {}) {
const options = ownerOrOptions && typeof ownerOrOptions === "object"
? ownerOrOptions
: { ownerId: ownerOrOptions };
const ownerId = options.ownerId;
// When this header is present, the /v1/models request came from another
// 9router instance's fetchCompatibleModelIds — skip dynamic fetch to break
// cross-instance recursive loops.
const skipDynamicFetch = options.skipDynamicFetch === true;
let connections = [];
try {
connections = await getProviderConnections();
@@ -319,7 +355,7 @@ export async function buildModelsList(kindFilter, ownerId = undefined) {
)
: providerModels.map((model) => model.id);
if (isCompatibleProvider && rawModelIds.length === 0 && !UPSTREAM_CONNECTION_RE.test(providerId)) {
if (isCompatibleProvider && rawModelIds.length === 0 && !skipDynamicFetch) {
rawModelIds = await fetchCompatibleModelIds(conn);
}
@@ -421,7 +457,13 @@ export async function buildModelsList(kindFilter, ownerId = undefined) {
object: "model",
owned_by: outputAlias,
};
const caps = liveCapabilitiesById.get(modelId) || capabilitiesFromServiceKind(customKind || liveKind);
// Live-catalog resolvers (kiro/qoder/github/clinepass) mostly only return
// { id, name } — no per-model capability data. Fall back to the same
// pattern-matched capabilities the dashboard uses (useModelCaps.js) so
// dynamically-discovered LLM models still surface vision/reasoning/search/tools.
const caps = liveCapabilitiesById.get(modelId)
|| capabilitiesFromServiceKind(customKind || liveKind)
|| (kind === LLM_KIND ? getCapabilitiesForModel(providerId, modelId) : null);
if (caps) model.capabilities = caps;
models.push(model);
}
@@ -490,7 +532,10 @@ export async function getApiKeyOwnerId(request) {
*/
export async function GET(request) {
try {
const data = await buildModelsList([LLM_KIND], await getApiKeyOwnerId(request));
// Detect cross-instance recursive /models fetch (another 9router fetching our /models)
const skipDynamicFetch = request?.headers?.get(INTERNAL_MODELS_FETCH_HEADER) === "1";
const ownerId = await getApiKeyOwnerId(request);
const data = await buildModelsList([LLM_KIND], { ownerId, skipDynamicFetch });
return Response.json({ object: "list", data }, {
headers: { "Access-Control-Allow-Origin": "*" },
});
+17
View File
@@ -0,0 +1,17 @@
import { handleVideoGet } from "@/sse/handlers/videoGeneration.js";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
});
}
/** GET /v1/videos/{request_id} - poll async video job status (xAI Grok Imagine) */
export async function GET(request, { params }) {
const { id } = await params;
return await handleVideoGet(request, id);
}
+16
View File
@@ -0,0 +1,16 @@
import { handleVideoCreate } from "@/sse/handlers/videoGeneration.js";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
});
}
/** POST /v1/videos/edits - async video edit (xAI Grok Imagine) */
export async function POST(request) {
return await handleVideoCreate(request, "edits");
}
+16
View File
@@ -0,0 +1,16 @@
import { handleVideoCreate } from "@/sse/handlers/videoGeneration.js";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
});
}
/** POST /v1/videos/extensions - async video extension (xAI Grok Imagine) */
export async function POST(request) {
return await handleVideoCreate(request, "extensions");
}
@@ -0,0 +1,16 @@
import { handleVideoCreate } from "@/sse/handlers/videoGeneration.js";
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
});
}
/** POST /v1/videos/generations - async video generation (xAI Grok Imagine) */
export async function POST(request) {
return await handleVideoCreate(request, "generations");
}
+1
View File
@@ -13,6 +13,7 @@ const DEFAULT_SETTINGS = {
tailscaleUrl: "",
stickyRoundRobinLimit: 3,
providerStrategies: {},
quotaVisibility: {},
comboStrategy: "fallback",
comboStickyRoundRobinLimit: 1,
comboStrategies: {},
+10
View File
@@ -350,10 +350,20 @@ const PROVIDERS = {
.join(" ")
.trim() || null;
const expiresAt = tokens.expires_in
? new Date(Date.now() + tokens.expires_in * 1000).toISOString()
: null;
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || null,
expiresIn: tokens.expires_in,
// Surface an absolute expiry so the proactive refresh path
// (shouldRefreshCredentials / checkAndRefreshToken) can refresh the
// xAI token before it silently expires ~40-45 min after login.
// Without this, only the reactive 401 path in chatCore would refresh,
// causing intermittent "token expired" failures for Grok CLI.
expiresAt,
scope: tokens.scope,
// Top-level for dashboard connection cards
email: email || undefined,
+1 -1
View File
@@ -10,7 +10,7 @@ import { MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers";
import useUserStore from "@/store/userStore";
// const VISIBLE_MEDIA_KINDS = ["embedding", "image", "imageToText", "tts", "stt", "webSearch", "webFetch", "video", "music"];
const VISIBLE_MEDIA_KINDS = ["embedding", "image", "tts", "stt"];
const VISIBLE_MEDIA_KINDS = ["embedding", "image", "video", "tts", "stt"];
// Combined entry: webSearch + webFetch share one page at /dashboard/media-providers/web
const COMBINED_WEB_ITEM = { id: "web", label: "Web Fetch & Search", icon: "travel_explore", href: "/dashboard/media-providers/web" };
+17
View File
@@ -62,6 +62,9 @@ export const MITM_TOOLS = {
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5", alias: "claude-haiku-4.5" },
{ id: "deepseek-3.2", name: "DeepSeek 3.2", alias: "deepseek-3.2" },
{ id: "minimax-m2.1", name: "MiniMax M2.1", alias: "minimax-m2.1" },
{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol", alias: "gpt-5.6-sol", contextLength: 272000, rateMultiplier: 2.4 },
{ id: "gpt-5.6-terra", name: "GPT 5.6 Terra", alias: "gpt-5.6-terra", contextLength: 272000, rateMultiplier: 1.2 },
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna", alias: "gpt-5.6-luna", contextLength: 272000, rateMultiplier: 0.6 },
{ id: "simple-task", name: "Qwen3 Coder Next", alias: "simple-task" },
],
},
@@ -161,6 +164,20 @@ export const CLI_TOOLS = {
description: "GitHub Copilot in VS Code via custom models",
configType: "custom",
},
"grok-build": {
id: "grok-build",
name: "Grok Build",
image: "/providers/grok-cli.png",
color: "#1DA1F2",
description: "xAI Grok Build TUI coding agent",
configType: "custom",
docsUrl: "https://x.ai/cli",
defaultCommand: "grok",
notes: [
{ type: "info", text: "Grok Build uses ~/.grok/config.toml with a custom 9Router model." },
{ type: "warning", text: "Config path: Linux/macOS ~/.grok/config.toml • Windows %USERPROFILE%\\.grok\\config.toml" },
],
},
// HIDDEN: gemini-cli
// "gemini-cli": {
// id: "gemini-cli",
+1 -1
View File
@@ -77,7 +77,7 @@ export const MEDIA_PROVIDER_KINDS = [
{ id: "stt", label: "Speech To Text", icon: "mic", endpoint: { method: "POST", path: "/v1/audio/transcriptions" } },
{ id: "webSearch", label: "Web Search", icon: "travel_explore", endpoint: { method: "POST", path: "/v1/search" } },
{ id: "webFetch", label: "Web Fetch", icon: "language", endpoint: { method: "POST", path: "/v1/web/fetch" } },
{ id: "video", label: "Video", icon: "movie", endpoint: { method: "POST", path: "/v1/video/generations" } },
{ id: "video", label: "Video", icon: "movie", endpoint: { method: "POST", path: "/v1/videos/generations" } },
{ id: "music", label: "Music", icon: "music_note", endpoint: { method: "POST", path: "/v1/audio/music" } },
];
+43 -9
View File
@@ -14,7 +14,6 @@ import {
WATCHDOG_INTERVAL_MS, NETWORK_CHECK_INTERVAL_MS, VIRTUAL_IFACE_REGEX,
} from "@/lib/tunnel";
import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks, restoreToolDNS, removeAllDNSEntriesSync } from "@/mitm/manager";
import { startQuotaAutoPing } from "@/shared/services/quotaAutoPing";
import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache";
import { killAllBridges } from "@/lib/mcp/stdioSseBridge";
@@ -98,22 +97,32 @@ async function runHeavyStartup() {
safeRestartTailscale("startup").catch((e) => console.log("[InitApp] Tailscale resume failed:", e.message));
}
ensureCloudflared().catch(() => {});
if (settings.tunnelEnabled) ensureCloudflared().catch(() => {});
// Sync mitmAlias DB → JSON cache so standalone MITM server can read it
if (settings.mitmEnabled) {
// Sync mitmAlias DB → JSON cache so standalone MITM server can read it.
syncMitmAliasCache().catch(() => {});
autoStartMitm(settings);
}
startWatchdog();
startNetworkMonitor();
autoStartMitm();
startQuotaAutoPing();
configureTunnelMonitoring(settings);
if (hasQuotaAutoPingEnabled(settings)) {
import("@/shared/services/quotaAutoPing")
.then(({ startQuotaAutoPing }) => startQuotaAutoPing())
.catch((e) => console.log("[AutoPing] scheduler start failed:", e.message));
}
}
async function autoStartMitm() {
function hasQuotaAutoPingEnabled(settings) {
return [settings?.claudeAutoPing, settings?.codexAutoPing]
.some((config) => Object.values(config?.connections || {}).some(Boolean));
}
async function autoStartMitm(settings) {
if (g.mitmStartInProgress) return;
g.mitmStartInProgress = true;
try {
const settings = await getSettings();
if (!settings.mitmEnabled) return;
const mitmStatus = await getMitmStatus();
if (mitmStatus.running) return;
@@ -232,6 +241,12 @@ function startWatchdog() {
if (g.watchdogInterval.unref) g.watchdogInterval.unref();
}
function stopWatchdog() {
if (!g.watchdogInterval) return;
clearInterval(g.watchdogInterval);
g.watchdogInterval = null;
}
// ─── Network monitor: detect IPv4 fingerprint change + sleep/wake ────────────
function getNetworkFingerprint() {
@@ -293,4 +308,23 @@ function startNetworkMonitor() {
if (g.networkMonitorInterval.unref) g.networkMonitorInterval.unref();
}
function stopNetworkMonitor() {
if (!g.networkMonitorInterval) return;
clearInterval(g.networkMonitorInterval);
g.networkMonitorInterval = null;
g.lastNetworkFingerprint = null;
g.lastOnline = null;
}
export function configureTunnelMonitoring(settings) {
if (settings?.tunnelEnabled || settings?.tailscaleEnabled) {
startWatchdog();
startNetworkMonitor();
return;
}
stopWatchdog();
stopNetworkMonitor();
}
export default initializeApp;
+15
View File
@@ -296,3 +296,18 @@ export function startQuotaAutoPing() {
g.interval = setInterval(() => { runQuotaAutoPingTick().catch(() => {}); }, C.tickIntervalMs);
if (g.interval.unref) g.interval.unref();
}
export function stopQuotaAutoPing() {
if (!g.interval) return;
clearInterval(g.interval);
g.interval = null;
console.log("[AutoPing] scheduler stopped");
}
export function configureQuotaAutoPing(settings) {
const enabled = Object.values(C.providers).some((providerConfig) =>
Object.values(settings?.[providerConfig.settingsKey]?.connections || {}).some(Boolean)
);
if (enabled) startQuotaAutoPing();
else stopQuotaAutoPing();
}
+95
View File
@@ -0,0 +1,95 @@
// Bulk-add API-key planner.
//
// Background: the backend upserts apikey connections BY NAME
// (src/lib/db/repos/connectionsRepo.js ~L144: existing = all.find(c =>
// c.authType === "apikey" && c.name === data.name)). A colliding name
// overwrites an existing key instead of inserting a new one. Bulk-add used to
// derive "<base> <lineIndex>" from the paste position, blind to existing
// names, so re-adding keys often silently replaced earlier ones.
//
// This planner gap-fills the smallest free "<base> <n>" against both existing
// connection names and names already assigned earlier in the same batch, so a
// generated name is never reused and the backend always inserts.
//
// ponytail: only numeric-suffix collision is handled. A user who manually
// types an exact existing non-numbered custom name (no index) will still hit
// the backend upsert — but bulk auto-naming always appends " <n>", so this
// path is unreachable from the bulk modal. Upgrade path: a backend
// "skip-if-exists" flag on POST /api/providers if single-add ever needs it.
/**
* Parse one pipe-separated bulk line into { baseName, apiKey, providerSpecificData? }.
* @param {string} line
* @param {{isCloudflareAi?: boolean}} [opts]
* @returns {{baseName: string, apiKey: string, providerSpecificData?: object}|null}
*/
function parseLine(line, opts = {}) {
const { isCloudflareAi = false } = opts;
const parts = line.split("|");
if (isCloudflareAi && parts.length >= 3) {
// name|apiKey|accountId (apiKey may itself contain pipes)
const baseName = parts[0].trim();
const apiKey = parts.slice(1, -1).join("|").trim();
const accountId = parts[parts.length - 1].trim();
return {
baseName: baseName || "Key",
apiKey,
providerSpecificData: { accountId },
};
}
if (parts.length >= 2) {
// name|apiKey (apiKey may itself contain pipes)
const baseName = parts[0].trim();
const apiKey = parts.slice(1).join("|").trim();
return { baseName: baseName || "Key", apiKey };
}
// apiKey only — auto-named "Key N"
const apiKey = parts[0].trim();
return { baseName: "Key", apiKey };
}
/**
* Plan a bulk add: parse lines, assign collision-free "<base> <n>" names.
*
* @param {string[]} lines raw paste lines
* @param {string[]|null|undefined} existingNames connection names already saved
* @param {{isCloudflareAi?: boolean}} [opts]
* @returns {{name: string, apiKey: string, skipped: boolean, lineNumber: number, providerSpecificData?: object}[]}
*/
export function planBulkAdd(lines, existingNames, opts = {}) {
const { isCloudflareAi = false } = opts;
const safeExisting = Array.isArray(existingNames) ? existingNames : [];
const used = new Set(safeExisting.map((n) => (typeof n === "string" ? n.toLowerCase() : "")));
const out = [];
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
const raw = lines[lineIndex];
const line = typeof raw === "string" ? raw.trim() : "";
if (!line) continue;
const parsed = parseLine(line, { isCloudflareAi });
if (!parsed || !parsed.apiKey) continue;
const base = parsed.baseName;
// Gap-fill from 1: smallest free "<base> <n>" not in `used`.
// O(batch * existing) — fine for bulk add (tens to low hundreds of keys).
let idx = 1;
let name;
for (;;) {
name = `${base} ${idx}`;
if (!used.has(name.toLowerCase())) break;
idx += 1;
}
used.add(name.toLowerCase());
const entry = { name, apiKey: parsed.apiKey, skipped: false, lineNumber: lineIndex + 1 };
if (parsed.providerSpecificData) entry.providerSpecificData = parsed.providerSpecificData;
out.push(entry);
}
return out;
}
+223
View File
@@ -0,0 +1,223 @@
import {
getProviderCredentials,
markAccountUnavailable,
clearAccountError,
extractApiKey,
isValidApiKey,
} from "../services/auth.js";
import { getSettings } from "@/lib/localDb";
import { getModelInfo } from "../services/model.js";
import { handleVideoProxyCore, getVideoConfig, sanitizeSecrets } from "open-sse/handlers/videoCore.js";
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
import * as log from "../utils/logger.js";
// Video generation is xAI-only today; requests without a provider prefix
// (bare model id, or multipart bodies we deliberately don't parse) land here.
const DEFAULT_VIDEO_PROVIDER = "xai";
// Creation POSTs are billable jobs — only rotate to another account for
// errors that upstream rejects BEFORE creating a job (auth/quota). A 5xx may
// have created the job, so it is returned to the caller instead of re-sent.
const CREATE_ROTATION_STATUSES = new Set([
HTTP_STATUS.UNAUTHORIZED,
HTTP_STATUS.FORBIDDEN,
HTTP_STATUS.RATE_LIMITED,
]);
async function requireValidApiKey(request) {
const apiKey = extractApiKey(request);
const settings = await getSettings();
if (settings.requireApiKey) {
if (!apiKey) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
const valid = await isValidApiKey(apiKey);
if (!valid) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
}
return null;
}
/**
* Read the request body once, byte-preserving.
* JSON bodies are additionally parsed so the `model` provider prefix can be
* resolved (and stripped) everything else is forwarded exactly as received.
*/
async function readForwardableBody(request) {
const contentType = request.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const raw = await request.text();
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
return { error: errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body") };
}
return { raw, parsed, contentType };
}
// Multipart (or any other content type): forward the exact bytes — parsing
// and re-encoding FormData would change the multipart boundary.
const buf = Buffer.from(await request.arrayBuffer());
return { raw: buf, parsed: null, contentType };
}
async function resolveVideoProvider(parsedBody) {
if (!parsedBody?.model) return { provider: DEFAULT_VIDEO_PROVIDER, model: null };
const modelStr = String(parsedBody.model);
const modelInfo = await getModelInfo(modelStr);
if (!modelInfo.provider) {
return { error: errorResponse(HTTP_STATUS.BAD_REQUEST, "Combos are not supported for video generation") };
}
if (!getVideoConfig(modelInfo.provider)) {
// Bare model ids (no explicit "provider/" prefix) fall back to the default
// video provider — the prefix-less inference targets chat providers only.
if (!modelStr.includes("/")) {
return { provider: DEFAULT_VIDEO_PROVIDER, model: modelStr };
}
return { error: errorResponse(HTTP_STATUS.BAD_REQUEST, `Provider '${modelInfo.provider}' does not support video generation`) };
}
return { provider: modelInfo.provider, model: modelInfo.model };
}
function withConnectionHeader(response, connectionId) {
if (!connectionId) return response;
const headers = new Headers(response.headers);
// Video jobs are account-bound upstream — clients echo this back as
// `x-connection-id` on GET polls so the same account is used.
headers.set("x-9router-connection-id", String(connectionId));
return new Response(response.body, { status: response.status, headers });
}
/**
* POST /v1/videos/{generations|edits|extensions} async job creation proxy.
*/
export async function handleVideoCreate(request, action) {
const authError = await requireValidApiKey(request);
if (authError) return authError;
const bodyInfo = await readForwardableBody(request);
if (bodyInfo.error) return bodyInfo.error;
const resolved = await resolveVideoProvider(bodyInfo.parsed);
if (resolved.error) return resolved.error;
const { provider, model } = resolved;
// Strip the provider prefix (e.g. "xai/grok-imagine-video") before forwarding;
// otherwise forward the original bytes untouched.
let forwardBody = bodyInfo.raw;
if (bodyInfo.parsed && model && bodyInfo.parsed.model !== model) {
forwardBody = JSON.stringify({ ...bodyInfo.parsed, model });
}
const preferredConnectionId = request.headers.get("x-connection-id") || null;
const idempotencyKey = request.headers.get("idempotency-key") || null;
const excludeConnectionIds = new Set();
let lastError = null;
let lastStatus = null;
while (true) {
const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { preferredConnectionId });
if (!credentials || credentials.allRateLimited) {
if (credentials?.allRateLimited) {
const errorMsg = lastError || credentials.lastError || "Unavailable";
const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
return unavailableResponse(status, `[${provider}/${model || "video"}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman);
}
if (excludeConnectionIds.size === 0) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
}
return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable");
}
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
const result = await handleVideoProxyCore({
provider,
action,
rawBody: forwardBody,
contentType: bodyInfo.contentType || null,
idempotencyKey,
credentials: refreshedCredentials,
signal: request.signal,
log,
onCredentialsRefreshed: async (newCreds) => {
await updateProviderCredentials(credentials.connectionId, {
accessToken: newCreds.accessToken,
refreshToken: newCreds.refreshToken,
providerSpecificData: newCreds.providerSpecificData,
testStatus: "active",
});
},
});
if (result.success) {
await clearAccountError(credentials.connectionId, credentials, model);
log.info("VIDEO", `${provider.toUpperCase()} | ${action} accepted (connection ${credentials.connectionId})`);
return withConnectionHeader(result.response, credentials.connectionId);
}
// Record the failure (dashboard shows lastError/errorCode → user sees re-auth is needed)
const { shouldFallback } = await markAccountUnavailable(
credentials.connectionId, result.status, sanitizeSecrets(result.error, refreshedCredentials), provider, model
);
if (shouldFallback && CREATE_ROTATION_STATUSES.has(result.status)) {
excludeConnectionIds.add(credentials.connectionId);
lastError = result.error;
lastStatus = result.status;
continue;
}
return result.response;
}
}
/**
* GET /v1/videos/{request_id} poll job status.
* Jobs are account-bound upstream, so no cross-account rotation here: the
* caller pins the creating account via `x-connection-id` (returned on create).
*/
export async function handleVideoGet(request, requestId) {
const authError = await requireValidApiKey(request);
if (authError) return authError;
if (!requestId) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing video request id");
const provider = DEFAULT_VIDEO_PROVIDER;
const preferredConnectionId = request.headers.get("x-connection-id") || null;
const credentials = await getProviderCredentials(provider, null, null, { preferredConnectionId });
if (!credentials || credentials.allRateLimited) {
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`);
}
const refreshedCredentials = await checkAndRefreshToken(provider, credentials);
const result = await handleVideoProxyCore({
provider,
requestId,
credentials: refreshedCredentials,
signal: request.signal,
log,
onCredentialsRefreshed: async (newCreds) => {
await updateProviderCredentials(credentials.connectionId, {
accessToken: newCreds.accessToken,
refreshToken: newCreds.refreshToken,
providerSpecificData: newCreds.providerSpecificData,
testStatus: "active",
});
},
});
if (result.success) {
await clearAccountError(credentials.connectionId, credentials, null);
return withConnectionHeader(result.response, credentials.connectionId);
}
await markAccountUnavailable(
credentials.connectionId, result.status, sanitizeSecrets(result.error, refreshedCredentials), provider, null
);
return result.response;
}
@@ -118,6 +118,9 @@ exports[`GOLDEN request: OpenAI → Claude > reasoning_effort → adaptive outpu
"type": "text",
},
],
"thinking": {
"type": "adaptive",
},
}
`;
+68 -7
View File
@@ -6,8 +6,8 @@ import "./registerAll.js";
import { translateRequest, translateResponse } from "../../open-sse/translator/index.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
const C2K = (body) =>
translateRequest(FORMATS.CLAUDE, FORMATS.KIRO, "claude-sonnet-4.5", body, true, null, "kiro");
const C2K = (body, credentials = null, model = "claude-sonnet-4.5") =>
translateRequest(FORMATS.CLAUDE, FORMATS.KIRO, model, body, true, credentials, "kiro");
describe("Claude → Kiro (direct route)", () => {
it("produces a Kiro conversationState payload", () => {
@@ -16,6 +16,27 @@ describe("Claude → Kiro (direct route)", () => {
expect(out.conversationState.currentMessage.userInputMessage.content).toContain("hello");
});
it("keeps conversationId stable from client session headers and replays frozen msg0", () => {
const credentials = {
rawHeaders: { "x-session-id": "hermes-session-123-claude-replay" },
connectionId: "kiro-account-1",
};
const first = C2K({ messages: [{ role: "user", content: "first" }] }, credentials);
const second = C2K({ messages: [{ role: "user", content: "second" }] }, credentials);
expect(first.conversationState.conversationId).toBe("hermes-session-123-claude-replay");
expect(second.conversationState.conversationId).toBe("hermes-session-123-claude-replay");
expect(first.conversationState.agentContinuationId).toBeTruthy();
expect(second.conversationState.agentContinuationId).toBe(first.conversationState.agentContinuationId);
expect(first.conversationState.agentTaskType).toBe("vibe");
expect(second.conversationState.history[0].userInputMessage.content).toBe(
first.conversationState.currentMessage.userInputMessage.content
);
expect(second.conversationState.history[0].userInputMessage.modelId).toBe("claude-sonnet-4.5");
expect(second.conversationState.currentMessage.userInputMessage.content).toContain("Current time");
expect(second.conversationState.currentMessage.userInputMessage.content).toContain("second");
});
it("guard 1: with no tools, a dangling tool_result is flattened to text (no structured ref)", () => {
// Client omitted `tools` but kept a tool_result after compaction.
const out = C2K({
@@ -60,20 +81,60 @@ describe("Claude → Kiro (direct route)", () => {
null,
"kiro"
);
expect(out.conversationState.currentMessage.userInputMessage.content).toContain(
expect(out.systemPrompt).toContain(
"<thinking_mode>enabled</thinking_mode>"
);
expect(out.agentMode).toBe("vibe");
});
it("maps output_config.effort high to Kiro max_thinking_length 24576", () => {
it("does not send additionalModelRequestFields for Kiro models without effort support", () => {
const out = C2K({
output_config: { effort: "high" },
messages: [{ role: "user", content: "think with adaptive effort" }],
});
expect(out.conversationState.currentMessage.userInputMessage.content).toContain(
"<max_thinking_length>24576</max_thinking_length>"
);
expect(out.additionalModelRequestFields).toBeUndefined();
expect(out.thinking).toBeUndefined();
expect(out.systemPrompt).toContain("<max_thinking_length>24576</max_thinking_length>");
});
it("maps output_config.effort high to Kiro CLI-style additionalModelRequestFields for effort models", () => {
const out = C2K({
output_config: { effort: "high" },
messages: [{ role: "user", content: "think with adaptive effort" }],
}, null, "claude-sonnet-5");
expect(out.additionalModelRequestFields).toEqual({
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: "high" },
});
expect(out.thinking).toBeUndefined();
expect(out.systemPrompt).toContain("<max_thinking_length>24576</max_thinking_length>");
});
it("sends Claude system as top-level systemPrompt and keeps a user-content fallback", () => {
const out = C2K({
system: "system-only instruction",
messages: [{ role: "user", content: "hello" }],
});
expect(out.systemPrompt).toContain("system-only instruction");
expect(out.conversationState.currentMessage.userInputMessage.content).toContain("system-only instruction");
});
it("keeps top-level systemPrompt stable across turns", () => {
const first = C2K({
system: "stable instruction",
messages: [{ role: "user", content: "first" }],
});
const second = C2K({
system: "stable instruction",
messages: [{ role: "user", content: "second" }],
});
expect(first.systemPrompt).toBe(second.systemPrompt);
expect(first.systemPrompt).not.toContain("Current time");
expect(first.conversationState.currentMessage.userInputMessage.content).toContain("Current time");
});
});
+6 -2
View File
@@ -55,10 +55,14 @@ describe("extractThinking", () => {
});
describe("applyThinking per provider format", () => {
it("claude 4.6+ → adaptive output_config (no budget_tokens)", () => {
it("claude 4.6+ → adaptive thinking + output_config (no budget_tokens)", () => {
const out = apply("claude", "claude-opus-4.7", { reasoning_effort: "high" }, "claude");
expect(out.output_config).toEqual({ effort: "high" });
expect(out.thinking).toBeUndefined();
// Anthropic: on Opus 4.6/4.7/4.8 and Sonnet 4.6 thinking stays OFF unless
// thinking:{type:"adaptive"} is sent explicitly; output_config alone is not
// enough (and Anthropic-compatible shims like Copilot default off even on
// Sonnet 5). Both fields together are the documented adaptive shape.
expect(out.thinking).toEqual({ type: "adaptive" });
});
it("claude haiku → enabled+budget", () => {
const out = apply("claude", "claude-haiku-4.5", { reasoning_effort: "high" }, "claude");
@@ -0,0 +1,24 @@
// #2591 — Alibaba Intl (alicode-intl) must use the OpenAI-compatible-mode
// DashScope endpoint so standard DashScope API keys work. The previous
// coding-intl host only accepted Alibaba Coding Plan keys and rejected
// ordinary DashScope keys with "Invalid API key".
import { describe, it, expect } from "vitest";
import alicodeIntl from "../../open-sse/providers/registry/alicode-intl.js";
describe("alicode-intl endpoint (issue #2591)", () => {
it("routes to the compatible-mode DashScope endpoint", () => {
expect(alicodeIntl.id).toBe("alicode-intl");
expect(alicodeIntl.transport.baseUrl).toBe(
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions"
);
});
it("does not use the coding-intl host that rejects standard keys", () => {
expect(alicodeIntl.transport.baseUrl).not.toContain("coding-intl.dashscope.aliyuncs.com");
});
it("keeps the chat/completions path and preserveCacheControl quirk", () => {
expect(alicodeIntl.transport.baseUrl).toContain("/v1/chat/completions");
expect(alicodeIntl.transport.quirks.preserveCacheControl).toBe(true);
});
});
+113
View File
@@ -0,0 +1,113 @@
// Guards the bulk-add API-key naming bug: auto-generated "Key N" names used to be
// derived from the paste-line index, blind to existing connection names. The
// backend upserts apikey connections by name (connectionsRepo), so a colliding
// generated name OVERWROTE an existing key instead of adding a new one.
// Fix: planBulkAdd gap-fills the smallest free "<base> <n>" against existing
// names (and earlier entries in the same batch) so a name is never reused.
import { describe, it, expect } from "vitest";
import { planBulkAdd } from "../../src/shared/utils/bulkAdd.js";
describe("planBulkAdd: auto-named gap-fill (the replace bug)", () => {
it("uses Key 1..N by paste index when nothing exists", () => {
const out = planBulkAdd(["sk-a", "sk-b", "sk-c"], []);
expect(out.map(o => o.name)).toEqual(["Key 1", "Key 2", "Key 3"]);
expect(out.every(o => o.skipped === false)).toBe(true);
});
it("gap-fills around existing names — never reuses an existing name", () => {
// Key 3 and Key 5 already exist; user adds 4 keys.
// Free slots: 1, 2, 4, 6 -> assign those, never 3 or 5.
const out = planBulkAdd(["sk-a", "sk-b", "sk-c", "sk-d"], ["Key 3", "Key 5"]);
expect(out.map(o => o.name)).toEqual(["Key 1", "Key 2", "Key 4", "Key 6"]);
});
it("continues past the highest existing index when low slots are taken", () => {
const out = planBulkAdd(["sk-a", "sk-b"], ["Key 1", "Key 2"]);
expect(out.map(o => o.name)).toEqual(["Key 3", "Key 4"]);
});
it("skips blank/whitespace-only lines but keeps indexing contiguous", () => {
const out = planBulkAdd(["sk-a", " ", "", "sk-b"], []);
expect(out.map(o => o.name)).toEqual(["Key 1", "Key 2"]);
expect(out.map(o => o.apiKey)).toEqual(["sk-a", "sk-b"]);
});
it("within-batch names are unique even for the same free slot", () => {
const out = planBulkAdd(["sk-a", "sk-b", "sk-c"], ["Key 1"]);
// Key 1 taken; batch gets 2, 3, 4 — no internal dup.
const names = out.map(o => o.name);
expect(new Set(names).size).toBe(names.length);
expect(names).toEqual(["Key 2", "Key 3", "Key 4"]);
});
});
describe("planBulkAdd: custom name|apiKey", () => {
it("uses the literal base name with a gap-filled index", () => {
const out = planBulkAdd(["Prod|sk-1", "Prod|sk-2"], []);
expect(out.map(o => o.name)).toEqual(["Prod 1", "Prod 2"]);
expect(out.map(o => o.apiKey)).toEqual(["sk-1", "sk-2"]);
});
it("custom name avoids an existing same-base name", () => {
// "Prod 1" exists -> first new "Prod|.." line becomes "Prod 2".
const out = planBulkAdd(["Prod|sk-new"], ["Prod 1"]);
expect(out[0].name).toBe("Prod 2");
});
it("apiKey containing pipes is preserved (parts after first rejoined)", () => {
const out = planBulkAdd(["Prod|sk|with|pipes"], []);
expect(out[0].apiKey).toBe("sk|with|pipes");
expect(out[0].name).toBe("Prod 1");
});
});
describe("planBulkAdd: cloudflare-ai (name|apiKey|accountId)", () => {
it("parses 3-part lines into name + apiKey + accountId", () => {
const out = planBulkAdd(
["main|sk-key1|acc123", "main|sk-key2|def789"],
[],
{ isCloudflareAi: true }
);
expect(out.map(o => o.name)).toEqual(["main 1", "main 2"]);
expect(out[0].apiKey).toBe("sk-key1");
expect(out[0].providerSpecificData).toEqual({ accountId: "acc123" });
expect(out[1].providerSpecificData).toEqual({ accountId: "def789" });
});
it("2-part cloudflare line is name|apiKey (no accountId)", () => {
const out = planBulkAdd(["main|sk-key1"], [], { isCloudflareAi: true });
expect(out[0].name).toBe("main 1");
expect(out[0].apiKey).toBe("sk-key1");
expect(out[0].providerSpecificData).toBeUndefined();
});
it("1-part cloudflare line is auto-named Key N", () => {
const out = planBulkAdd(["sk-key1"], [], { isCloudflareAi: true });
expect(out[0].name).toBe("Key 1");
expect(out[0].apiKey).toBe("sk-key1");
});
});
describe("planBulkAdd: robustness", () => {
it("returns [] for no input", () => {
expect(planBulkAdd([], [])).toEqual([]);
expect(planBulkAdd(["", " "], [])).toEqual([]);
});
it("trims names and apiKeys", () => {
const out = planBulkAdd([" Prod | sk-1 "], []);
expect(out[0].name).toBe("Prod 1");
expect(out[0].apiKey).toBe("sk-1");
});
it("falls back to base 'Key' when name part is empty", () => {
const out = planBulkAdd(["|sk-1"], []);
expect(out[0].name).toBe("Key 1");
expect(out[0].apiKey).toBe("sk-1");
});
it("coerces non-array existingNames gracefully", () => {
const out = planBulkAdd(["sk-a"], null);
expect(out[0].name).toBe("Key 1");
});
});
+17
View File
@@ -11,6 +11,15 @@ describe("getCapabilitiesForModel", () => {
search: true,
};
const kiroGpt56Expected = {
contextWindow: 272000,
maxOutput: 128000,
thinkingFormat: "openai",
reasoning: true,
vision: true,
search: true,
};
it("reports Kiro Claude Opus 4.8 as a 1M context model", () => {
expect(getCapabilitiesForModel("kiro", "claude-opus-4.8").contextWindow).toBe(1000000);
expect(getCapabilitiesForModel("kiro", "anthropic/claude-opus-4.8").contextWindow).toBe(1000000);
@@ -26,4 +35,12 @@ describe("getCapabilitiesForModel", () => {
expect(getCapabilitiesForModel("kiro", "claude-sonnet-5-agentic")).toMatchObject(claudeSonnet5Expected);
expect(getCapabilitiesForModel("kiro", "claude-sonnet-5-thinking-agentic")).toMatchObject(claudeSonnet5Expected);
});
it("reports Kiro GPT 5.6 models with the Kiro 272k context window", () => {
expect(getCapabilitiesForModel("kiro", "gpt-5.6-sol")).toMatchObject(kiroGpt56Expected);
expect(getCapabilitiesForModel("kiro", "openai/gpt-5.6-sol")).toMatchObject(kiroGpt56Expected);
expect(getCapabilitiesForModel("kiro", "gpt-5.6-terra-thinking")).toMatchObject(kiroGpt56Expected);
expect(getCapabilitiesForModel("kiro", "gpt-5.6-luna-agentic")).toMatchObject(kiroGpt56Expected);
expect(getCapabilitiesForModel("kiro", "gpt-5.6-sol-thinking-agentic")).toMatchObject(kiroGpt56Expected);
});
});
+273
View File
@@ -0,0 +1,273 @@
/**
* Tests for the `9router xai video` CLI command (cli/src/cli/commands/xaiVideo.js)
*
* Uses a real local HTTP server standing in for the 9router gateway + video CDN.
* No real credentials or upstream calls.
*
* Covers:
* - arg parsing (defaults, flags, unknown flag rejection)
* - full happy path: create poll (pending done) MP4 download atomic rename
* - x-connection-id pinning from the create response header
* - failed job non-zero exit, no output file, no stray .part
* - poll timeout non-zero exit
* - download failure cleans up the .part file
* - no Authorization/token material in output
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import http from "node:http";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { run, parseArgs, downloadToFile, sanitizeText, imageInputToUrl } = require("../../cli/src/cli/commands/xaiVideo.js");
const MP4_BYTES = Buffer.from("FAKE-MP4-DATA-0123456789");
function startServer(handler) {
return new Promise((resolve) => {
const server = http.createServer(handler);
server.listen(0, "127.0.0.1", () => resolve({ server, port: server.address().port }));
});
}
const closeServer = (server) => new Promise((r) => server.close(r));
let tmpDir;
let server;
beforeEach(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "xai-video-test-"));
});
afterEach(async () => {
if (server) {
await closeServer(server);
server = null;
}
fs.rmSync(tmpDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe("parseArgs", () => {
it("applies defaults", () => {
const opts = parseArgs(["--prompt", "hi"]);
expect(opts.prompt).toBe("hi");
expect(opts.model).toBe("xai/grok-imagine-video");
expect(opts.output).toBe("video.mp4");
expect(opts.port).toBe(20128);
});
it("parses all documented flags", () => {
const opts = parseArgs([
"--prompt", "p", "--output", "o.mp4", "--model", "m",
"--duration", "10", "--aspect-ratio", "16:9", "--resolution", "720p",
"--image", "https://x/img.png", "--timeout", "30", "--port", "1234", "--api-key", "k",
]);
expect(opts).toMatchObject({
prompt: "p", output: "o.mp4", model: "m", duration: 10,
aspectRatio: "16:9", resolution: "720p", image: "https://x/img.png",
timeoutSec: 30, port: 1234, apiKey: "k",
});
});
it("rejects unknown flags", () => {
expect(() => parseArgs(["--bogus"])).toThrow(/Unknown option/);
});
});
describe("imageInputToUrl", () => {
it("passes URLs and data URLs through", () => {
expect(imageInputToUrl("https://example.com/a.png")).toBe("https://example.com/a.png");
expect(imageInputToUrl("data:image/png;base64,AAA")).toBe("data:image/png;base64,AAA");
});
it("converts a local file to a base64 data URL", () => {
const p = path.join(tmpDir, "in.png");
fs.writeFileSync(p, Buffer.from([1, 2, 3]));
expect(imageInputToUrl(p)).toBe(`data:image/png;base64,${Buffer.from([1, 2, 3]).toString("base64")}`);
});
});
describe("sanitizeText", () => {
it("redacts bearer tokens from error output", () => {
expect(sanitizeText("boom Bearer abcdefghijklmnop!")).toBe("boom Bearer [redacted]!");
});
});
describe("run (against a mock gateway)", () => {
it("creates, polls to done, downloads the MP4, and exits 0", async () => {
let pollCount = 0;
const seen = { createAuth: null, pollConnectionIds: [] };
({ server } = await startServer((req, res) => {
if (req.method === "POST" && req.url === "/v1/videos/generations") {
seen.createAuth = req.headers.authorization || null;
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
seen.createBody = JSON.parse(body);
res.writeHead(200, { "Content-Type": "application/json", "x-9router-connection-id": "conn-42" });
res.end(JSON.stringify({ request_id: "job-1" }));
});
return;
}
if (req.method === "GET" && req.url === "/v1/videos/job-1") {
seen.pollConnectionIds.push(req.headers["x-connection-id"] || null);
pollCount++;
const port = server.address().port;
const payload = pollCount < 3
? { status: "pending", progress: pollCount * 30 }
: { status: "done", video: { url: `http://127.0.0.1:${port}/files/out.mp4`, duration: 8 } };
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(payload));
return;
}
if (req.method === "GET" && req.url === "/files/out.mp4") {
res.writeHead(200, { "Content-Type": "video/mp4" });
res.end(MP4_BYTES);
return;
}
res.writeHead(404).end();
}));
const output = path.join(tmpDir, "result.mp4");
const logs = [];
vi.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" ")));
vi.spyOn(console, "error").mockImplementation((...a) => logs.push(a.join(" ")));
const code = await run([
"--prompt", "a neon city",
"--output", output,
"--port", String(server.address().port),
"--api-key", "local-key-secret",
"--timeout", "10",
"--poll-interval-ms", "20",
]);
expect(code).toBe(0);
expect(fs.readFileSync(output)).toEqual(MP4_BYTES);
expect(fs.existsSync(`${output}.part`)).toBe(false);
// Model prefix forwarded as-is to the gateway (gateway strips it)
expect(seen.createBody.model).toBe("xai/grok-imagine-video");
expect(seen.createBody.prompt).toBe("a neon city");
// Polls pinned to the connection that created the job
expect(seen.pollConnectionIds.every((id) => id === "conn-42")).toBe(true);
// No token material in user-facing output
expect(logs.join("\n")).not.toContain("local-key-secret");
expect(logs.join("\n")).not.toContain("Authorization");
});
it("exits non-zero when the job fails, without leaving files", async () => {
({ server } = await startServer((req, res) => {
if (req.method === "POST") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ request_id: "job-f" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "failed", error: { code: "invalid_argument", message: "bad prompt" } }));
}));
const output = path.join(tmpDir, "nope.mp4");
const errors = [];
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation((...a) => errors.push(a.join(" ")));
const code = await run([
"--prompt", "x", "--output", output,
"--port", String(server.address().port),
"--timeout", "10", "--poll-interval-ms", "10",
]);
expect(code).toBe(1);
expect(errors.join("\n")).toContain("bad prompt");
expect(fs.existsSync(output)).toBe(false);
expect(fs.existsSync(`${output}.part`)).toBe(false);
});
it("exits non-zero when polling exceeds the timeout", async () => {
({ server } = await startServer((req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(req.method === "POST" ? JSON.stringify({ request_id: "job-slow" }) : JSON.stringify({ status: "pending", progress: 1 }));
}));
vi.spyOn(console, "log").mockImplementation(() => {});
const errors = [];
vi.spyOn(console, "error").mockImplementation((...a) => errors.push(a.join(" ")));
const code = await run([
"--prompt", "x", "--output", path.join(tmpDir, "slow.mp4"),
"--port", String(server.address().port),
"--timeout", "1", "--poll-interval-ms", "50",
]);
expect(code).toBe(1);
expect(errors.join("\n")).toMatch(/Timed out/i);
}, 15000);
it("reports a helpful error when no xAI account is connected", async () => {
({ server } = await startServer((req, res) => {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: { message: "No credentials for provider: xai", type: "invalid_request_error" } }));
}));
vi.spyOn(console, "log").mockImplementation(() => {});
const errors = [];
vi.spyOn(console, "error").mockImplementation((...a) => errors.push(a.join(" ")));
const code = await run([
"--prompt", "x", "--output", path.join(tmpDir, "n.mp4"),
"--port", String(server.address().port),
]);
expect(code).toBe(1);
expect(errors.join("\n")).toContain("No credentials");
expect(errors.join("\n")).toContain("Connect an xAI account");
});
});
describe("downloadToFile", () => {
it("downloads via .part and renames atomically", async () => {
({ server } = await startServer((req, res) => {
res.writeHead(200, { "Content-Type": "video/mp4" });
res.end(MP4_BYTES);
}));
const out = path.join(tmpDir, "dl.mp4");
await downloadToFile(`http://127.0.0.1:${server.address().port}/f.mp4`, out);
expect(fs.readFileSync(out)).toEqual(MP4_BYTES);
expect(fs.existsSync(`${out}.part`)).toBe(false);
});
it("follows redirects", async () => {
({ server } = await startServer((req, res) => {
if (req.url === "/start") {
res.writeHead(302, { Location: `/final` });
res.end();
return;
}
res.writeHead(200);
res.end(MP4_BYTES);
}));
const out = path.join(tmpDir, "redir.mp4");
await downloadToFile(`http://127.0.0.1:${server.address().port}/start`, out);
expect(fs.readFileSync(out)).toEqual(MP4_BYTES);
});
it("removes the .part file when the download fails", async () => {
({ server } = await startServer((req, res) => {
res.writeHead(500);
res.end("nope");
}));
const out = path.join(tmpDir, "fail.mp4");
await expect(downloadToFile(`http://127.0.0.1:${server.address().port}/f.mp4`, out)).rejects.toThrow(/HTTP 500/);
expect(fs.existsSync(out)).toBe(false);
expect(fs.existsSync(`${out}.part`)).toBe(false);
});
});
+216 -9
View File
@@ -4,11 +4,14 @@ import {
countGrokCliUserTurns,
resolveGrokCliTurnIdx,
_resetGrokCliTurnStore,
_getGrokCliTurnStoreSize,
normalizeGrokCliEffort,
supportsGrokCliReasoningEffort,
} from "../../open-sse/executors/grok-cli.js";
import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.js";
import { PROVIDERS, PROVIDER_OAUTH, PROVIDER_MODELS } from "../../open-sse/providers/index.js";
import { getModelUpstreamId } from "../../open-sse/config/providerModels.js";
import { resolveProviderAlias } from "../../open-sse/services/model.js";
import { getModelInfoCore, resolveProviderAlias } from "../../open-sse/services/model.js";
import { OAUTH_PROVIDERS } from "../../src/shared/constants/providers.js";
describe("grok-cli registry", () => {
@@ -27,7 +30,7 @@ describe("grok-cli registry", () => {
expect(oauth.scope).toContain("conversations:write");
expect(oauth.referrer).toBe("grok-build");
expect(PROVIDER_MODELS.gcli?.some((m) => m.id === "grok-4.5")).toBe(true);
expect(PROVIDER_MODELS.gcli?.some((m) => m.id === "grok-build")).toBe(true);
});
it("is listed as oauth provider for dashboard", () => {
@@ -42,6 +45,13 @@ describe("grok-cli registry", () => {
expect(resolveProviderAlias("grok-cli")).toBe("grok-cli");
});
it("routes bare grok-build to the subscription provider", async () => {
await expect(getModelInfoCore("grok-build", {})).resolves.toEqual({
provider: "grok-cli",
model: "grok-build",
});
});
it("maps effort virtual models to upstream grok-4.5", () => {
expect(getModelUpstreamId("gcli", "grok-4.5-high")).toBe("grok-4.5");
expect(getModelUpstreamId("gcli", "grok-4.5-medium")).toBe("grok-4.5");
@@ -86,19 +96,19 @@ describe("GrokCliExecutor", () => {
expect(headers.Authorization).toBe("Bearer tok_test");
expect(headers.Accept).toBe("text/event-stream");
expect(headers["x-xai-token-auth"]).toBe("xai-grok-cli");
expect(headers["x-grok-client-identifier"]).toBe("grok-pager");
expect(headers["x-grok-client-version"]).toBe("0.2.93");
expect(headers["x-xai-token-auth"]).toBeUndefined();
expect(headers["x-grok-client-identifier"]).toBe("grok-shell");
expect(headers["x-grok-client-version"]).toBe("0.2.99");
expect(headers["x-grok-session-id"]).toBe("sess-abc");
expect(headers["x-grok-conv-id"]).toBe("sess-abc");
expect(headers["x-grok-req-id"]).toBe("req-xyz");
expect(headers["x-grok-turn-idx"]).toBe("3");
expect(headers["x-grok-agent-id"]).toBe("agent-1");
expect(headers["x-grok-model-override"]).toBe("grok-4.5");
expect(headers["x-compaction-at"]).toBe("400000");
expect(headers["x-compaction-at"]).toBeUndefined();
expect(headers["x-email"]).toBe("u@example.com");
expect(headers["x-userid"]).toBe("uid-1");
expect(headers["x-authenticateresponse"]).toBe("authenticate-response");
expect(headers["x-authenticateresponse"]).toBeUndefined();
});
it("buildHeaders falls back to top-level email/userId (OAuth mapTokens shape)", () => {
@@ -190,6 +200,164 @@ describe("GrokCliExecutor", () => {
expect(out.reasoning.effort).toBe("medium");
});
it("normalizes Codex cross-provider tool and reasoning history", () => {
const out = executor.transformRequest("grok-4.5", {
model: "grok-4.5",
input: [
{ type: "message", role: "user", content: "continue" },
{
type: "reasoning",
id: "rs_07fe505b3114f180016a5698411c448191bdcdcba678464461",
encrypted_content: "openai-ciphertext",
summary: [],
internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
},
{
type: "custom_tool_call",
id: "ctc_openai",
call_id: "call-custom",
name: "exec",
input: "run this",
internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
},
{
type: "custom_tool_call_output",
call_id: "call-custom",
output: [{ type: "input_text", text: "first" }, { type: "input_text", text: "second" }],
},
{
type: "function_call_output",
call_id: "call-function",
output: [{ type: "input_text", text: "function result" }],
},
],
tools: [{ type: "custom", name: "exec", description: "Run command" }],
}, true, { connectionId: "cross-provider" });
expect(out.input.some((item) => item.type === "reasoning")).toBe(false);
expect(out.input[1]).toEqual({
type: "function_call",
call_id: "call-custom",
name: "exec",
arguments: JSON.stringify({ input: "run this" }),
});
expect(out.input[2]).toEqual({
type: "function_call_output",
call_id: "call-custom",
output: JSON.stringify([{ type: "input_text", text: "first" }, { type: "input_text", text: "second" }]),
});
expect(out.input.some((item) => item.call_id === "call-function")).toBe(false);
expect(out.tools[0].parameters).toEqual({
type: "object",
properties: { input: { type: "string" } },
required: ["input"],
});
});
it("stringifies structured outputs and removes orphaned output items", () => {
const out = executor.transformRequest("grok-4.5", {
model: "grok-4.5",
input: [
{ type: "function_call", call_id: "call-array", name: "array_tool", arguments: "{}" },
{ type: "function_call_output", call_id: "call-array", output: [1, 2] },
{ type: "function_call", call_id: "call-null", name: "null_tool", arguments: "{}" },
{ type: "function_call_output", call_id: "call-null", output: null },
{ type: "custom_tool_call", call_id: "call-invalid", input: "missing name" },
{ type: "custom_tool_call_output", call_id: "call-invalid", output: "orphan" },
],
}, true, { connectionId: "structured-output" });
const outputs = out.input.filter((item) => item.type === "function_call_output");
expect(outputs).toEqual([
{ type: "function_call_output", call_id: "call-array", output: "[1,2]" },
{ type: "function_call_output", call_id: "call-null", output: "null" },
]);
expect(out.input.some((item) => item.call_id === "call-invalid")).toBe(false);
});
it("preserves native Grok encrypted reasoning and item ids", () => {
const reasoningId = "rs_3e3f6187-892a-96db-893b-904eff019e19";
const messageId = "msg_3e3f6187-892a-96db-893b-904eff019e19";
const functionId = "fc_3e3f6187-892a-96db-893b-904eff019e19";
const out = executor.transformRequest("grok-4.5", {
model: "grok-4.5",
input: [
{
type: "reasoning",
id: reasoningId,
status: "completed",
encrypted_content: "grok-ciphertext",
summary: [],
internal_chat_message_metadata_passthrough: { turn_id: "turn-2" },
},
{ type: "message", id: messageId, role: "assistant", content: "done" },
{ type: "function_call", id: functionId, call_id: "native-call", name: "wait", arguments: "{}" },
{ type: "function_call_output", call_id: "native-call", output: "done" },
{ type: "message", role: "user", content: "next" },
],
}, true, { connectionId: "native-grok" });
expect(out.input[0]).toMatchObject({
type: "reasoning",
id: reasoningId,
encrypted_content: "grok-ciphertext",
});
expect(out.input[0].internal_chat_message_metadata_passthrough).toBeUndefined();
expect(out.input[1].id).toBe(messageId);
expect(out.input[2].id).toBe(functionId);
});
it("normalizes official effort aliases", () => {
expect(normalizeGrokCliEffort("none")).toBe("high");
expect(normalizeGrokCliEffort("minimal")).toBe("high");
expect(normalizeGrokCliEffort("max")).toBe("xhigh");
expect(normalizeGrokCliEffort("xhigh")).toBe("xhigh");
expect(normalizeGrokCliEffort("ultra")).toBe("high");
const out = executor.transformRequest("grok-4.5", {
model: "grok-4.5",
input: "hi",
reasoning: { effort: "max", summary: "detailed" },
}, true, { connectionId: "effort-conn" });
expect(out.reasoning).toEqual({ effort: "xhigh", summary: "detailed" });
});
it("omits reasoning effort for models that reject it", () => {
expect(supportsGrokCliReasoningEffort("grok-4.5")).toBe(true);
expect(supportsGrokCliReasoningEffort("grok-build")).toBe(false);
expect(supportsGrokCliReasoningEffort("grok-composer-2.5-fast")).toBe(false);
for (const model of ["grok-build", "grok-composer-2.5-fast"]) {
const out = executor.transformRequest(model, {
model,
input: "hi",
reasoning: { effort: "max" },
}, true, { connectionId: `effort-${model}` });
expect(out.reasoning).toEqual({ summary: "concise" });
expect(out.include).toContain("reasoning.encrypted_content");
}
});
it("drops stale tool_choice and normalizes converted custom choices", () => {
const noTools = executor.transformRequest("grok-build", {
model: "grok-build",
input: "hi",
tool_choice: "auto",
}, true, { connectionId: "tools-none" });
expect(noTools.tool_choice).toBeUndefined();
const custom = executor.transformRequest("grok-build", {
model: "grok-build",
input: "hi",
tools: [{ type: "custom", name: "apply_patch", description: "Patch files" }],
tool_choice: { type: "custom", name: "apply_patch" },
}, true, { connectionId: "tools-custom" });
expect(custom.tools).toEqual([
expect.objectContaining({ type: "function", name: "apply_patch" }),
]);
expect(custom.tool_choice).toEqual({ type: "function", name: "apply_patch" });
});
it("increments x-grok-turn-idx from user-message count and stays monotonic", () => {
const creds = {
connectionId: "turn-conn",
@@ -238,7 +406,7 @@ describe("GrokCliExecutor", () => {
headers = executor.buildHeaders({ accessToken: "t" }, true);
expect(headers["x-grok-turn-idx"]).toBe("2");
// Same session, payload that only has 1 user msg (delta-style client) must not go backwards
// Same session, a new delta-style request advances without relying on full history.
executor.transformRequest(
"grok-4.5",
{
@@ -248,7 +416,7 @@ describe("GrokCliExecutor", () => {
true,
creds
);
expect(executor._currentTurnIdx).toBe(2);
expect(executor._currentTurnIdx).toBe(3);
});
it("countGrokCliUserTurns / resolveGrokCliTurnIdx helpers", () => {
@@ -273,6 +441,45 @@ describe("GrokCliExecutor", () => {
expect(resolveGrokCliTurnIdx("s1", [{ role: "user", type: "message", content: "a" }])).toBe(2);
});
it("keeps fallback session stable when assistant history appears", () => {
const creds = { connectionId: "fallback-conn", rawHeaders: {} };
executor.transformRequest("grok-build", {
model: "grok-build",
input: [{ type: "message", role: "user", content: "first" }],
}, true, creds);
const firstSession = executor._currentSessionId;
executor.transformRequest("grok-build", {
model: "grok-build",
input: [
{ type: "message", role: "user", content: "first" },
{ type: "message", role: "assistant", content: "x".repeat(100) },
{ type: "message", role: "user", content: "second" },
],
}, true, creds);
expect(executor._currentSessionId).toBe(firstSession);
expect(executor._currentTurnIdx).toBe(2);
});
it("does not advance turn index when retrying the same request body", () => {
const body = {
model: "grok-build",
input: [{ type: "message", role: "user", content: "retry me" }],
};
const creds = { connectionId: "retry-conn" };
executor.transformRequest("grok-build", body, true, creds);
const firstTurn = executor._currentTurnIdx;
executor.transformRequest("grok-build", body, true, creds);
expect(executor._currentTurnIdx).toBe(firstTurn);
});
it("bounds per-session turn state", () => {
for (let i = 0; i < 5100; i += 1) {
resolveGrokCliTurnIdx(`session-${i}`, [{ role: "user", content: "hi" }]);
}
expect(_getGrokCliTurnStoreSize()).toBe(5000);
});
it("parseError surfaces 402 spending-limit", () => {
const err = executor.parseError(
{ status: 402 },
@@ -0,0 +1,57 @@
/**
* Regression test for issue #2546: Grok CLI (xAI) token refresh not used,
* session dies 40-45 min after login.
*
* Root cause: grok-cli mapTokens stored `expiresIn` but never `expiresAt`.
* shouldRefreshCredentials() only reads expiresAt/tokenExpiresAt, so the
* proactive refresh path never fired and only the reactive 401 path could
* refresh causing intermittent "token expired" failures.
*
* This test exercises the proactive-refresh decision path for grok-cli with
* an absolute expiresAt. (The mapTokens unit portion cannot run in this
* checkout because src/lib/oauth/providers.js self-imports the bare
* "open-sse/index.js" specifier which vitest here does not resolve a
* pre-existing harness gap unrelated to this fix.)
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
const originalFetch = global.fetch;
describe("Grok CLI (xAI) token expiry propagation (#2546)", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
global.fetch = originalFetch;
});
afterEach(() => {
global.fetch = originalFetch;
});
it("proactive refresh fires for a near-expiry grok-cli token (expiresAt present)", async () => {
const { shouldRefreshCredentials } = await import(
"../../open-sse/services/oauthCredentialManager.js"
);
const soon = new Date(Date.now() + 60 * 1000).toISOString();
const creds = {
connectionId: "grok-1",
refreshToken: "rt",
expiresIn: 60,
expiresAt: soon,
};
expect(shouldRefreshCredentials("grok-cli", creds)).toBe(true);
});
it("proactive refresh does NOT fire for a far-future grok-cli token", async () => {
const { shouldRefreshCredentials } = await import(
"../../open-sse/services/oauthCredentialManager.js"
);
const farFuture = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
const creds = {
connectionId: "grok-2",
refreshToken: "rt",
expiresIn: 86400,
expiresAt: farFuture,
};
expect(shouldRefreshCredentials("grok-cli", creds)).toBe(false);
});
});
+80
View File
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../open-sse/services/oauthCredentialManager.js", () => ({
refreshProviderCredentials: vi.fn(),
}));
import { refreshProviderCredentials } from "../../open-sse/services/oauthCredentialManager.js";
import {
parseGrokCliModels,
resolveGrokCliModels,
} from "../../open-sse/services/grokCliModels.js";
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("Grok CLI live models", () => {
beforeEach(() => vi.clearAllMocks());
it("normalizes official model metadata", () => {
expect(parseGrokCliModels({
models: [{
model_id: "grok-build",
display_name: "Grok Build",
context_window: 500000,
max_output_tokens: 64000,
supported_in_api: false,
}],
})).toEqual([
expect.objectContaining({
id: "grok-build",
name: "Grok Build",
contextLength: 500000,
maxOutputTokens: 64000,
supported_in_api: false,
}),
]);
});
it("refreshes and retries through selected proxy", async () => {
const fetchFn = vi.fn()
.mockResolvedValueOnce(jsonResponse({ error: "expired" }, 401))
.mockResolvedValueOnce(jsonResponse({ data: [{ id: "grok-build" }] }));
const onCredentialsRefreshed = vi.fn();
const proxyOptions = {
connectionProxyEnabled: true,
connectionProxyUrl: "http://proxy.test:8080",
strictProxy: true,
};
refreshProviderCredentials.mockResolvedValue({ accessToken: "new-token" });
const result = await resolveGrokCliModels({
accessToken: "old-token",
refreshToken: "refresh-token",
providerSpecificData: { email: "user@example.com" },
}, { fetchFn, proxyOptions, onCredentialsRefreshed });
expect(result.models).toEqual([
expect.objectContaining({
id: "grok-build",
contextLength: 500000,
maxOutputTokens: 64000,
}),
]);
expect(refreshProviderCredentials).toHaveBeenCalledWith(
"grok-cli",
expect.any(Object),
expect.anything(),
proxyOptions,
);
expect(onCredentialsRefreshed).toHaveBeenCalledWith({ accessToken: "new-token" });
expect(fetchFn).toHaveBeenCalledTimes(2);
expect(fetchFn.mock.calls[0][2]).toBe(proxyOptions);
expect(fetchFn.mock.calls[1][1].headers.Authorization).toBe("Bearer new-token");
expect(fetchFn.mock.calls[1][1].headers["x-grok-client-version"]).toBe("0.2.99");
});
});
+49
View File
@@ -101,6 +101,35 @@ describe("parseGrokCliBilling", () => {
});
expect(parsed.plan).toBe("Super Grok");
});
it("does not report paid subscription access as depleted on-demand credit", () => {
const parsed = parseGrokCliBilling(EXHAUSTED_BILLING, {
...USER_PROFILE,
subscriptionTier: "XPremiumPlus",
});
expect(parsed.plan).toBe("XPremiumPlus");
expect(parsed.subscriptionAccess).toBe(true);
expect(parsed.quotas).toEqual({});
expect(parsed.exhausted).toBe(false);
});
it("maps current monthly fields and snake-case subscription tier", () => {
const parsed = parseGrokCliBilling({
monthlyLimit: { val: 1000 },
includedUsed: { val: 275 },
totalUsed: { val: 300 },
resetAt: "2026-08-01T00:00:00Z",
}, {
subscription_tier: "premium_plus",
});
expect(parsed.plan).toBe("Premium Plus");
expect(parsed.quotas["Monthly included"]).toMatchObject({
used: 275,
total: 1000,
remainingPercentage: 72.5,
resetAt: "2026-08-01T00:00:00.000Z",
});
});
});
describe("getUsageForProvider(grok-cli)", () => {
@@ -140,6 +169,8 @@ describe("getUsageForProvider(grok-cli)", () => {
expect(billingCall[0]).toContain("/v1/billing");
expect(billingCall[1].headers.Authorization).toBe("Bearer test-token");
expect(billingCall[1].headers["x-xai-token-auth"]).toBe("xai-grok-cli");
expect(billingCall[1].headers["x-grok-client-version"]).toBe("0.2.99");
expect(billingCall[1].headers["x-grok-client-identifier"]).toBe("grok-shell");
expect(billingCall[1].headers["x-userid"]).toBe(
"d84768dd-224d-4052-ba49-0d336fa9160c",
);
@@ -174,6 +205,24 @@ describe("getUsageForProvider(grok-cli)", () => {
expect(usage.quotas["On-demand"].remainingPercentage).toBe(0);
expect(usage.quotas["On-demand"].total).toBe(1);
});
it("reports active paid access when provider exposes no numeric quota", async () => {
proxyAwareFetch
.mockResolvedValueOnce(jsonResponse(EXHAUSTED_BILLING))
.mockResolvedValueOnce(jsonResponse({
...USER_PROFILE,
subscriptionTier: "XPremiumPlus",
}));
const usage = await getUsageForProvider({
provider: "grok-cli",
accessToken: "test-token",
});
expect(usage.plan).toBe("XPremiumPlus");
expect(usage.message).toMatch(/active.*numeric included quota/i);
expect(usage.quotas).toEqual({});
});
});
describe("parseQuotaData(grok-cli)", () => {
+44
View File
@@ -250,4 +250,48 @@ describe("handleChatCore Headroom diagnostics", () => {
expect.stringContaining("reported token delta, but outbound JSON shrank <5%; provider may bill near-original payload")
);
});
it("bypasses token savers when requested by the client", async () => {
const log = { debug: vi.fn(), info: vi.fn(), warn: vi.fn() };
const pxpipeTransform = vi.fn();
const messages = [{ role: "user", content: "Write polished prose." }];
global.fetch = vi.fn(async (url) => {
throw new Error(`unexpected fetch: ${url}`);
});
await handleChatCore({
body: { model: "gpt-4o", stream: false, messages },
modelInfo: { provider: "openai", model: "gpt-4o" },
credentials: { apiKey: "test-key", providerSpecificData: {} },
log,
connectionId: "test-conn",
headroomEnabled: true,
headroomUrl: "http://localhost:8787",
headroomCompressUserMessages: true,
rtkEnabled: true,
cavemanEnabled: true,
cavemanLevel: "full",
ponytailEnabled: true,
ponytailLevel: "full",
pxpipeEnabled: true,
pxpipeTransform,
clientRawRequest: {
endpoint: "/v1/chat/completions",
body: {},
headers: {
accept: "application/json",
"x-9router-token-saver": "off",
},
},
});
expect(global.fetch).not.toHaveBeenCalled();
expect(pxpipeTransform).not.toHaveBeenCalled();
expect(executeMock).toHaveBeenCalledWith(expect.objectContaining({
body: expect.objectContaining({
messages: [{ role: "user", content: "Write polished prose." }],
}),
}));
});
});
+50
View File
@@ -25,6 +25,13 @@ describe("Kiro MITM model slots", () => {
expect(simpleTask).toBeTruthy();
expect(simpleTask.alias).toBe("simple-task");
});
it("offers mappable slots for GPT-5.6 family models", () => {
const models = new Map(kiro.defaultModels.map((m) => [m.id, m]));
expect(models.get("gpt-5.6-sol")).toMatchObject({ alias: "gpt-5.6-sol", contextLength: 272000, rateMultiplier: 2.4 });
expect(models.get("gpt-5.6-terra")).toMatchObject({ alias: "gpt-5.6-terra", contextLength: 272000, rateMultiplier: 1.2 });
expect(models.get("gpt-5.6-luna")).toMatchObject({ alias: "gpt-5.6-luna", contextLength: 272000, rateMultiplier: 0.6 });
});
});
describe("Kiro static provider models", () => {
@@ -37,4 +44,47 @@ describe("Kiro static provider models", () => {
"claude-sonnet-5-thinking-agentic",
]));
});
it("includes GPT-5.6 family and synthetic Kiro variants", () => {
const models = new Map((PROVIDER_MODELS.kr || []).map((model) => [model.id, model]));
const ids = [...models.keys()];
expect(ids).toEqual(expect.arrayContaining([
"gpt-5.6-sol",
"gpt-5.6-sol-thinking",
"gpt-5.6-sol-agentic",
"gpt-5.6-sol-thinking-agentic",
"gpt-5.6-terra",
"gpt-5.6-terra-thinking",
"gpt-5.6-terra-agentic",
"gpt-5.6-terra-thinking-agentic",
"gpt-5.6-luna",
"gpt-5.6-luna-thinking",
"gpt-5.6-luna-agentic",
"gpt-5.6-luna-thinking-agentic",
]));
for (const [id, rateMultiplier] of [
["gpt-5.6-sol", 2.4],
["gpt-5.6-sol-thinking", 2.4],
["gpt-5.6-sol-agentic", 2.4],
["gpt-5.6-sol-thinking-agentic", 2.4],
["gpt-5.6-terra", 1.2],
["gpt-5.6-terra-thinking", 1.2],
["gpt-5.6-terra-agentic", 1.2],
["gpt-5.6-terra-thinking-agentic", 1.2],
["gpt-5.6-luna", 0.6],
["gpt-5.6-luna-thinking", 0.6],
["gpt-5.6-luna-agentic", 0.6],
["gpt-5.6-luna-thinking-agentic", 0.6],
]) {
const model = models.get(id);
const upstreamModelId = id.replace(/-(thinking-agentic|thinking|agentic)$/, "");
expect(model).toMatchObject({
contextLength: 272000,
rateMultiplier,
upstreamModelId,
});
expect(model.description).toContain("272k context window");
}
});
});
+57
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { KiroExecutor } from "../../open-sse/executors/kiro.js";
import "../translator/registerAll.js";
function createMockFrame(eventType, payloadObj) {
const payloadStr = JSON.stringify(payloadObj);
@@ -47,6 +48,13 @@ async function readAllSSE(stream) {
return result;
}
async function readNextWithTimeout(reader) {
return Promise.race([
reader.read(),
new Promise((_, reject) => setTimeout(() => reject(new Error("timed out waiting for SSE chunk")), 100)),
]);
}
describe("KiroExecutor thinking tag stripping", () => {
it("strips <thinking> tags from assistantResponseEvent", async () => {
const executor = new KiroExecutor();
@@ -121,4 +129,53 @@ describe("KiroExecutor thinking tag stripping", () => {
const contentChunks = objects.filter(obj => obj.choices[0].delta.content !== undefined);
expect(contentChunks.length).toBe(0);
});
it("emits a terminal chunk at messageStop before the upstream stream closes", async () => {
const executor = new KiroExecutor();
const f1 = createMockFrame("assistantResponseEvent", { content: "OK" });
const f2 = createMockFrame("messageStopEvent", {});
const readableStream = new ReadableStream({
start(controller) {
controller.enqueue(f1);
controller.enqueue(f2);
}
});
const transformedResponse = executor.transformEventStreamToSSE({ body: readableStream }, "claude-test");
const reader = transformedResponse.body.getReader();
const decoder = new TextDecoder();
let output = "";
for (let i = 0; i < 4 && !output.includes("\"finish_reason\":\"stop\""); i++) {
const { value } = await readNextWithTimeout(reader);
output += decoder.decode(value, { stream: true });
}
await reader.cancel();
expect(output).toContain("\"finish_reason\":\"stop\"");
});
it("uses tool_calls finish reason for tool streams without messageStop", async () => {
const executor = new KiroExecutor();
const f1 = createMockFrame("toolUseEvent", { toolUseId: "tool-1", name: "read_file", input: { path: "a.txt" } });
const readableStream = new ReadableStream({
start(controller) {
controller.enqueue(f1);
controller.close();
}
});
const transformedResponse = executor.transformEventStreamToSSE({ body: readableStream }, "claude-test");
const output = await readAllSSE(transformedResponse.body);
const objects = output
.split("\n")
.filter(line => line.startsWith("data: ") && !line.includes("[DONE]"))
.map(line => JSON.parse(line.slice(6)));
const finalChunk = objects.at(-1);
expect(finalChunk.choices[0].finish_reason).toBe("tool_calls");
});
});
@@ -138,21 +138,21 @@ describe("openai ↔ responses multi-turn reasoning", () => {
});
describe("GrokCliExecutor multi-turn input", () => {
it("keeps reasoning items (incl. encrypted_content) and strips only server message ids", () => {
it("keeps native Grok reasoning and item ids", () => {
_resetGrokCliTurnStore();
const executor = new GrokCliExecutor();
const body = {
model: "grok-4.5",
input: [
{ type: "message", role: "system", content: "You are Grok" },
{ type: "message", role: "user", content: "hi", id: "msg_server_prev" },
{ type: "message", role: "user", content: "hi", id: "msg_3e3f6187-892a-96db-893b-904eff019e19" },
{
type: "reasoning",
id: "rs_server_prev",
id: "rs_3e3f6187-892a-96db-893b-904eff019e19",
summary: [{ type: "summary_text", text: "prior plan" }],
encrypted_content: "enc_from_cli",
},
{ type: "message", role: "assistant", content: "hello", id: "msg_server_asst" },
{ type: "message", role: "assistant", content: "hello", id: "msg_4e3f6187-892a-96db-893b-904eff019e19" },
{ type: "message", role: "user", content: "again" },
],
include: ["reasoning.encrypted_content"],
@@ -166,14 +166,13 @@ describe("GrokCliExecutor multi-turn input", () => {
expect(reasoning).toHaveLength(1);
expect(reasoning[0].encrypted_content).toBe("enc_from_cli");
expect(reasoning[0].summary?.[0]?.text).toBe("prior plan");
// server id stripped from reasoning item, content kept
expect(reasoning[0].id).toBeUndefined();
expect(reasoning[0].id).toBe("rs_3e3f6187-892a-96db-893b-904eff019e19");
// system preserved (not developer)
expect(out.input[0].role).toBe("system");
// message server ids stripped
// Native Grok IDs are required for encrypted continuity.
for (const item of out.input) {
if (item.type === "message") expect(item.id).toBeUndefined();
if (item.type === "message" && item.id) expect(item.id).toMatch(/^msg_[0-9a-f-]{36}$/);
}
expect(out.include).toContain("reasoning.encrypted_content");
expect(out.store).toBe(false);
+153 -8
View File
@@ -11,6 +11,7 @@ import { openaiToKiroRequest } from "../../open-sse/translator/request/openai-to
const contentOf = (result) =>
result.conversationState.currentMessage.userInputMessage.content;
const systemPromptOf = (result) => result.systemPrompt || "";
describe("openaiToKiroRequest", () => {
describe("basic message conversion", () => {
@@ -293,7 +294,11 @@ describe("openaiToKiroRequest", () => {
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
expect(contentOf(result)).toContain("<max_thinking_length>1024</max_thinking_length>");
expect(systemPromptOf(result)).toContain("<max_thinking_length>1024</max_thinking_length>");
expect(result.additionalModelRequestFields).toEqual({
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: "low" },
});
});
it("maps reasoning_effort high to max_thinking_length 24576", () => {
@@ -304,7 +309,97 @@ describe("openaiToKiroRequest", () => {
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
expect(contentOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
expect(result.additionalModelRequestFields).toEqual({
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: "high" },
});
});
it("does not send additionalModelRequestFields for legacy Kiro model ids", () => {
const body = {
reasoning_effort: "high",
messages: [{ role: "user", content: "Legacy model id should not get adaptive fields" }]
};
const result = openaiToKiroRequest("claude-sonnet-4.5", body, true, {});
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
expect(result.additionalModelRequestFields).toBeUndefined();
});
it("does not send additionalModelRequestFields for date-suffixed Claude 4 model ids", () => {
const body = {
reasoning_effort: "high",
messages: [{ role: "user", content: "Date-suffixed Claude 4 should stay legacy" }]
};
const result = openaiToKiroRequest("claude-sonnet-4-20250514", body, true, {});
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
expect(result.additionalModelRequestFields).toBeUndefined();
});
it("does not send additionalModelRequestFields for pre-4 legacy Kiro model ids", () => {
const body = {
reasoning_effort: "high",
messages: [{ role: "user", content: "Older model id should not get adaptive fields" }]
};
const result = openaiToKiroRequest("claude-sonnet-3.7", body, true, {});
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
expect(result.additionalModelRequestFields).toBeUndefined();
});
it("does not send additionalModelRequestFields for prefixed pre-4 legacy Kiro model ids", () => {
const body = {
reasoning_effort: "high",
messages: [{ role: "user", content: "Prefixed older model id should not get adaptive fields" }]
};
const result = openaiToKiroRequest("kiro/claude-3-7-sonnet-20250219", body, true, {});
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
expect(result.additionalModelRequestFields).toBeUndefined();
});
it("does not send Claude-specific additionalModelRequestFields for prefixed non-Claude aliases", () => {
const body = {
reasoning_effort: "high",
messages: [{ role: "user", content: "Prefixed non-Claude alias should not get adaptive fields" }]
};
const result = openaiToKiroRequest("kiro/gpt-4o", body, true, {});
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
expect(result.additionalModelRequestFields).toBeUndefined();
});
it("does not send Claude-specific additionalModelRequestFields for non-Claude aliases", () => {
const body = {
reasoning_effort: "high",
messages: [{ role: "user", content: "Non-Claude aliases should not get Claude adaptive fields" }]
};
const result = openaiToKiroRequest("gpt-4o", body, true, {});
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
expect(result.additionalModelRequestFields).toBeUndefined();
});
it("defaults future Kiro model ids to additionalModelRequestFields support", () => {
const body = {
reasoning_effort: "high",
messages: [{ role: "user", content: "Future model id should get adaptive fields" }]
};
const result = openaiToKiroRequest("claude-sonnet-4.60", body, true, {});
expect(result.additionalModelRequestFields).toEqual({
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: "high" },
});
});
it("clamps reasoning_effort max to Kiro max_thinking_length 32000", () => {
@@ -315,7 +410,8 @@ describe("openaiToKiroRequest", () => {
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
expect(contentOf(result)).toContain("<max_thinking_length>32000</max_thinking_length>");
expect(systemPromptOf(result)).toContain("<max_thinking_length>32000</max_thinking_length>");
expect(result.additionalModelRequestFields?.output_config?.effort).toBe("high");
});
it("clamps OpenAI Responses reasoning.effort xhigh to max_thinking_length 32000", () => {
@@ -326,7 +422,8 @@ describe("openaiToKiroRequest", () => {
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
expect(contentOf(result)).toContain("<max_thinking_length>32000</max_thinking_length>");
expect(systemPromptOf(result)).toContain("<max_thinking_length>32000</max_thinking_length>");
expect(result.additionalModelRequestFields?.output_config?.effort).toBe("high");
});
it("uses Claude thinking.budget_tokens as max_thinking_length", () => {
@@ -337,7 +434,7 @@ describe("openaiToKiroRequest", () => {
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
expect(contentOf(result)).toContain("<max_thinking_length>4096</max_thinking_length>");
expect(systemPromptOf(result)).toContain("<max_thinking_length>4096</max_thinking_length>");
});
it("uses the default budget for synthetic -thinking models with no explicit config", () => {
@@ -347,7 +444,54 @@ describe("openaiToKiroRequest", () => {
const result = openaiToKiroRequest("claude-sonnet-4.6-thinking", body, true, {});
expect(contentOf(result)).toContain("<max_thinking_length>16000</max_thinking_length>");
expect(systemPromptOf(result)).toContain("<max_thinking_length>16000</max_thinking_length>");
});
it("keeps top-level systemPrompt stable across turns", () => {
const first = openaiToKiroRequest(
"claude-sonnet-4.6-thinking",
{ messages: [{ role: "user", content: "first" }] },
true,
{}
);
const second = openaiToKiroRequest(
"claude-sonnet-4.6-thinking",
{ messages: [{ role: "user", content: "second" }] },
true,
{}
);
expect(first.systemPrompt).toBe(second.systemPrompt);
expect(first.systemPrompt).not.toContain("Current time");
expect(first.conversationState.currentMessage.userInputMessage.content).toContain("Current time");
});
it("replays frozen msg0 for explicit Kiro sessions while keeping current time fresh", () => {
const credentials = {
connectionId: "kiro-account-openai-replay",
rawHeaders: { "x-session-id": "hermes-session-openai-replay" },
};
const first = openaiToKiroRequest(
"claude-sonnet-4.6",
{ messages: [{ role: "user", content: "first turn" }] },
true,
credentials
);
const second = openaiToKiroRequest(
"claude-sonnet-4.6",
{ messages: [{ role: "user", content: "second turn" }] },
true,
credentials
);
expect(second.conversationState.conversationId).toBe("hermes-session-openai-replay");
expect(second.conversationState.agentContinuationId).toBe(first.conversationState.agentContinuationId);
expect(second.conversationState.history[0].userInputMessage.content).toBe(
first.conversationState.currentMessage.userInputMessage.content
);
expect(second.conversationState.history[0].userInputMessage.modelId).toBe("claude-sonnet-4.6");
expect(second.conversationState.currentMessage.userInputMessage.content).toContain("Current time");
expect(second.conversationState.currentMessage.userInputMessage.content).toContain("second turn");
});
it("does not inject thinking prefix for reasoning_effort none", () => {
@@ -358,8 +502,9 @@ describe("openaiToKiroRequest", () => {
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
expect(contentOf(result)).not.toContain("<thinking_mode>enabled</thinking_mode>");
expect(contentOf(result)).not.toContain("<max_thinking_length>");
expect(systemPromptOf(result)).not.toContain("<thinking_mode>enabled</thinking_mode>");
expect(systemPromptOf(result)).not.toContain("<max_thinking_length>");
expect(result.additionalModelRequestFields).toBeUndefined();
});
});
});
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import {
filterQuotasByVisibility,
getHiddenQuotaRows,
parseQuotaData,
} from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
describe("provider quota visibility", () => {
const data = {
quotas: {
"gemini-pro-agent": {
displayName: "Gemini 3.1 Pro (High)",
used: 200,
total: 1000,
resetAt: "2026-07-04T00:00:00Z",
},
"claude-opus-4-6-thinking": {
displayName: "Claude Opus 4.6 (Thinking)",
used: 100,
total: 1000,
resetAt: "2026-07-04T00:00:00Z",
},
},
};
it("keeps Antigravity modelKey so hidden settings use stable quota ids", () => {
const quotas = parseQuotaData("antigravity", data);
expect(quotas.map((q) => q.modelKey)).toEqual([
"gemini-pro-agent",
"claude-opus-4-6-thinking",
]);
});
it("shows all quotas by default and hides configured provider rows", () => {
const quotas = parseQuotaData("antigravity", data);
expect(filterQuotasByVisibility("antigravity", quotas, {})).toHaveLength(2);
const visibility = {
antigravity: { hidden: ["claude-opus-4-6-thinking"] },
};
const visible = filterQuotasByVisibility("antigravity", quotas, visibility);
const hidden = getHiddenQuotaRows("antigravity", quotas, visibility);
expect(visible.map((q) => q.modelKey)).toEqual(["gemini-pro-agent"]);
expect(hidden.map((q) => q.modelKey)).toEqual(["claude-opus-4-6-thinking"]);
});
it("does not apply one provider hidden list to another provider", () => {
const quotas = parseQuotaData("antigravity", data);
const visibility = {
codex: { hidden: ["gemini-pro-agent"] },
};
expect(filterQuotasByVisibility("antigravity", quotas, visibility)).toHaveLength(2);
});
});
+22 -1
View File
@@ -72,6 +72,7 @@ vi.mock("open-sse/executors/index.js", () => ({
describe("quota auto-ping", () => {
let runQuotaAutoPingTick;
let configureQuotaAutoPing;
let deps;
let state;
let getCodexUsage;
@@ -83,11 +84,12 @@ describe("quota auto-ping", () => {
vi.resetModules();
vi.clearAllMocks();
vi.useRealTimers();
delete global.__quotaAutoPing;
({ getCodexUsage } = await import("open-sse/services/usage/codex.js"));
({ getClaudeUsage } = await import("open-sse/services/usage/claude.js"));
({ getExecutor } = await import("open-sse/executors/index.js"));
({ runQuotaAutoPingTick } = await import("../../src/shared/services/quotaAutoPing.js"));
({ runQuotaAutoPingTick, configureQuotaAutoPing } = await import("../../src/shared/services/quotaAutoPing.js"));
deps = {
getSettings: vi.fn(),
@@ -117,6 +119,25 @@ describe("quota auto-ping", () => {
expect(deps.proxyAwareFetch).not.toHaveBeenCalled();
});
it("starts the scheduler only when an account opts in", () => {
vi.useFakeTimers();
configureQuotaAutoPing({ codexAutoPing: { connections: {} } });
expect(vi.getTimerCount()).toBe(0);
configureQuotaAutoPing({ codexAutoPing: { connections: { "codex-1": true } } });
expect(vi.getTimerCount()).toBe(1);
});
it("stops the scheduler when the last account opts out", () => {
vi.useFakeTimers();
configureQuotaAutoPing({ claudeAutoPing: { connections: { "claude-1": true } } });
configureQuotaAutoPing({ claudeAutoPing: { connections: { "claude-1": false } } });
expect(vi.getTimerCount()).toBe(0);
});
it("does not ping Codex on the first resetAt observation", async () => {
deps.getSettings.mockResolvedValue({ codexAutoPing: { connections: { "codex-1": true } } });
deps.getProviderConnections.mockImplementation(async ({ provider }) => (
+166 -2
View File
@@ -1,13 +1,15 @@
// A2: locks resolveSessionId priority/stickiness (codex/kiro/antigravity centralization).
import { describe, it, expect, beforeEach } from "vitest";
import { resolveSessionId, deriveSessionId, clearSessionStore } from "../../open-sse/utils/sessionManager.js";
import { resolveContinuationId, resolveSessionId, resolveSessionIdentity, deriveSessionId, clearSessionStore } from "../../open-sse/utils/sessionManager.js";
// Assistant text must reach ASSISTANT_MIN_LEN (80) to use assistant anchor; else first user message.
const longAssistant = "x".repeat(80);
const bodyWithAssistant = { messages: [{ role: "assistant", content: longAssistant }] };
const bodyWithUserOnly = { messages: [{ role: "user", content: "hello from first user message anchor" }] };
beforeEach(() => clearSessionStore());
beforeEach(() => {
clearSessionStore();
});
describe("resolveSessionId", () => {
it("stickiness: same body+connectionId+scope -> same id", () => {
@@ -55,8 +57,170 @@ describe("resolveSessionId", () => {
expect(got).toBe("client-sess-123");
});
it("does not treat request-scoped x-client-request-id as a session override", () => {
const first = resolveSessionId({
headers: { "x-client-request-id": "req-1" },
body: bodyWithUserOnly,
connectionId: "conn1",
scope: "kiro",
});
const second = resolveSessionId({
headers: { "x-client-request-id": "req-2" },
body: bodyWithUserOnly,
connectionId: "conn1",
scope: "kiro",
});
expect(first).not.toBe("req-1");
expect(second).not.toBe("req-2");
expect(first).not.toBe(second);
});
it("does not treat request-scoped previous_response_id as a Kiro session override", () => {
const first = resolveSessionId({
body: { ...bodyWithUserOnly, previous_response_id: "resp-1" },
connectionId: "conn1",
scope: "kiro",
});
const second = resolveSessionId({
body: { ...bodyWithUserOnly, previous_response_id: "resp-2" },
connectionId: "conn1",
scope: "kiro",
});
expect(first).not.toBe("resp-1");
expect(second).not.toBe("resp-2");
expect(first).not.toBe(second);
});
it("does not treat raw metadata.user_id as a Kiro conversation session", () => {
const first = resolveSessionId({
body: {
metadata: { user_id: "user-123" },
messages: [{ role: "user", content: "new chat about invoices" }],
},
connectionId: "conn1",
scope: "kiro",
});
const second = resolveSessionId({
body: {
metadata: { user_id: "user-123" },
messages: [{ role: "user", content: "unrelated new chat about refunds" }],
},
connectionId: "conn1",
scope: "kiro",
});
expect(first).not.toBe("user-123");
expect(second).not.toBe("user-123");
expect(first).not.toBe(second);
});
it("keeps Claude Code session_id metadata as a Kiro conversation session", () => {
const body = {
metadata: { user_id: JSON.stringify({ session_id: "claude-code-session-123" }) },
messages: [{ role: "user", content: "same Claude Code session" }],
};
expect(resolveSessionId({ body, connectionId: "conn1", scope: "kiro" })).toBe("claude:claude-code-session-123");
});
it("keeps raw metadata.user_id as a non-Kiro session fallback", () => {
const got = resolveSessionId({
body: {
metadata: { user_id: "user-123" },
messages: [{ role: "user", content: "non-Kiro provider" }],
},
connectionId: "conn1",
scope: "codex",
});
expect(got).toBe("user-123");
});
it("keeps x-client-request-id as a session override outside Kiro scope", () => {
const got = resolveSessionId({
headers: { "x-client-request-id": "req-1" },
body: bodyWithAssistant,
connectionId: "conn1",
scope: "codex",
});
expect(got).toBe("req-1");
});
it("workspaceId path: empty body + workspaceId set -> normalized workspaceId", () => {
const got = resolveSessionId({ body: {}, connectionId: "conn1", workspaceId: "ws-abc" });
expect(got).toBe("ws-abc");
});
it("uses fresh Kiro sessions for unrelated headerless requests on the same connection", () => {
const a = resolveSessionId({ body: bodyWithUserOnly, connectionId: "conn1", scope: "kiro" });
const b = resolveSessionId({ body: bodyWithUserOnly, connectionId: "conn1", scope: "kiro" });
expect(a).not.toBe(b);
});
it("marks generated headerless Kiro sessions as ephemeral", () => {
const generated = resolveSessionIdentity({ body: bodyWithUserOnly, connectionId: "conn1", scope: "kiro" });
const explicit = resolveSessionIdentity({
headers: { "x-session-id": "client-sess-123" },
body: bodyWithUserOnly,
connectionId: "conn1",
scope: "kiro",
});
expect(generated.ephemeral).toBe(true);
expect(explicit).toEqual({ sessionId: "client-sess-123", ephemeral: false });
});
it("does not switch Kiro headerless requests to assistant-text session ids mid-conversation", () => {
const withAssistant = { messages: [{ role: "user", content: "same user" }, { role: "assistant", content: "y".repeat(80) }] };
const a = resolveSessionId({ body: withAssistant, connectionId: "conn1", scope: "kiro" });
const b = resolveSessionId({ body: withAssistant, connectionId: "conn1", scope: "kiro" });
expect(a).not.toBe(b);
});
});
describe("resolveContinuationId", () => {
it("keeps continuation id stable for the same Kiro session", () => {
const opts = { sessionId: "kiro-session-1", connectionId: "conn1", scope: "kiro" };
expect(resolveContinuationId(opts)).toBe(resolveContinuationId(opts));
});
it("uses a different continuation id for a different Kiro session", () => {
const a = resolveContinuationId({ sessionId: "kiro-session-1", connectionId: "conn1", scope: "kiro" });
const b = resolveContinuationId({ sessionId: "kiro-session-2", connectionId: "conn1", scope: "kiro" });
expect(a).not.toBe(b);
});
it("does not evict a recently used continuation id when the store exceeds its cap", () => {
const first = resolveContinuationId({ sessionId: "kiro-session-0", connectionId: "conn1", scope: "kiro" });
for (let i = 1; i < 5000; i++) {
resolveContinuationId({ sessionId: `kiro-session-${i}`, connectionId: "conn1", scope: "kiro" });
}
expect(resolveContinuationId({ sessionId: "kiro-session-0", connectionId: "conn1", scope: "kiro" })).toBe(first);
resolveContinuationId({ sessionId: "kiro-session-5000", connectionId: "conn1", scope: "kiro" });
expect(resolveContinuationId({ sessionId: "kiro-session-0", connectionId: "conn1", scope: "kiro" })).toBe(first);
});
it("evicts old continuation ids when the store exceeds its cap", () => {
const first = resolveContinuationId({ sessionId: "kiro-session-0", connectionId: "conn1", scope: "kiro" });
for (let i = 1; i <= 5000; i++) {
resolveContinuationId({ sessionId: `kiro-session-${i}`, connectionId: "conn1", scope: "kiro" });
}
const afterEviction = resolveContinuationId({ sessionId: "kiro-session-0", connectionId: "conn1", scope: "kiro" });
expect(afterEviction).not.toBe(first);
});
it("does not let ephemeral Kiro continuations evict explicit session continuations", () => {
const stable = resolveContinuationId({ sessionId: "explicit-session", connectionId: "conn1", scope: "kiro" });
for (let i = 0; i <= 5000; i++) {
resolveContinuationId({ sessionId: `ephemeral-session-${i}`, connectionId: "conn1", scope: "kiro", ephemeral: true });
}
expect(resolveContinuationId({ sessionId: "explicit-session", connectionId: "conn1", scope: "kiro" })).toBe(stable);
});
});
+301
View File
@@ -0,0 +1,301 @@
/**
* Unit tests for the xAI video proxy core (open-sse/handlers/videoCore.js)
*
* Covers:
* - registry wiring (videoConfig, grok-imagine-video kind)
* - byte-exact body forwarding (JSON + multipart)
* - request_id / polling-status passthrough (pending, processing, done, failed)
* - 401 refresh once retry once; refresh failure no retry loop
* - no auto-retry of creation POSTs on network error
* - upstream error propagation with secret sanitization
* - abort/cancellation
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("open-sse/services/tokenRefresh.js", () => ({
refreshTokenByProvider: vi.fn(),
}));
import { handleVideoProxyCore, getVideoConfig, sanitizeSecrets, VIDEO_ACTIONS } from "open-sse/handlers/videoCore.js";
import { refreshTokenByProvider } from "open-sse/services/tokenRefresh.js";
import { PROVIDER_MEDIA, PROVIDER_MODELS } from "open-sse/providers/index.js";
const originalFetch = global.fetch;
const jsonResponse = (body, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
describe("registry wiring", () => {
it("exposes videoConfig for xai", () => {
expect(getVideoConfig("xai")).toEqual({ baseUrl: "https://api.x.ai/v1/videos" });
expect(PROVIDER_MEDIA.xai.serviceKinds).toContain("video");
});
it("registers grok-imagine-video with kind video (kept out of LLM lists)", () => {
const model = PROVIDER_MODELS.xai.find((m) => m.id === "grok-imagine-video");
expect(model).toBeTruthy();
expect(model.kind || model.type).toBe("video");
});
it("supports exactly the three creation actions", () => {
expect([...VIDEO_ACTIONS].sort()).toEqual(["edits", "extensions", "generations"]);
});
});
describe("handleVideoProxyCore", () => {
beforeEach(() => {
global.fetch = vi.fn();
refreshTokenByProvider.mockReset();
});
afterEach(() => {
global.fetch = originalFetch;
});
it("rejects providers without videoConfig", async () => {
const result = await handleVideoProxyCore({
provider: "openai",
action: "generations",
rawBody: "{}",
credentials: { apiKey: "k" },
});
expect(result.success).toBe(false);
expect(result.status).toBe(400);
expect(result.error).toContain("does not support video generation");
});
it("forwards a creation POST byte-for-byte and passes request_id through", async () => {
global.fetch.mockResolvedValueOnce(jsonResponse({ request_id: "req-123" }));
const raw = '{"model":"grok-imagine-video","prompt":"neon city","duration":8}';
const result = await handleVideoProxyCore({
provider: "xai",
action: "generations",
rawBody: raw,
contentType: "application/json",
idempotencyKey: "idem-1",
credentials: { accessToken: "tok-A", refreshToken: "ref-A" },
});
expect(result.success).toBe(true);
const [url, init] = global.fetch.mock.calls[0];
expect(url).toBe("https://api.x.ai/v1/videos/generations");
expect(init.method).toBe("POST");
expect(init.body).toBe(raw); // byte-exact, no reshaping
expect(init.headers.Authorization).toBe("Bearer tok-A");
expect(init.headers["Content-Type"]).toBe("application/json");
expect(init.headers["Idempotency-Key"]).toBe("idem-1");
expect(await result.response.json()).toEqual({ request_id: "req-123" });
});
it("forwards multipart bodies untouched with the original boundary header", async () => {
global.fetch.mockResolvedValueOnce(jsonResponse({ request_id: "req-mp" }));
const boundary = "----vitestBoundary42";
const multipartBody = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="prompt"\r\n\r\nextend it\r\n--${boundary}--\r\n`
);
const result = await handleVideoProxyCore({
provider: "xai",
action: "extensions",
rawBody: multipartBody,
contentType: `multipart/form-data; boundary=${boundary}`,
credentials: { apiKey: "xai-key" },
});
expect(result.success).toBe(true);
const [url, init] = global.fetch.mock.calls[0];
expect(url).toBe("https://api.x.ai/v1/videos/extensions");
expect(init.body).toBe(multipartBody); // same Buffer, no re-encode
expect(init.headers["Content-Type"]).toBe(`multipart/form-data; boundary=${boundary}`);
});
it.each([
["pending", { status: "pending", progress: 10 }],
["processing", { status: "processing", progress: 55 }],
["done", { status: "done", video: { url: "https://cdn.x.ai/v.mp4", duration: 8 } }],
])("passes %s polling payload through verbatim", async (_label, payload) => {
global.fetch.mockResolvedValueOnce(jsonResponse(payload));
const result = await handleVideoProxyCore({
provider: "xai",
requestId: "req-123",
credentials: { accessToken: "tok" },
});
expect(result.success).toBe(true);
const [url, init] = global.fetch.mock.calls[0];
expect(url).toBe("https://api.x.ai/v1/videos/req-123");
expect(init.method).toBe("GET");
expect(await result.response.json()).toEqual(payload);
});
it("passes a failed job (HTTP 200, status failed) through without translating", async () => {
const payload = { status: "failed", error: { code: "internal_error", message: "render crashed" } };
global.fetch.mockResolvedValueOnce(jsonResponse(payload));
const result = await handleVideoProxyCore({
provider: "xai",
requestId: "req-bad",
credentials: { accessToken: "tok" },
});
expect(result.success).toBe(true);
expect(await result.response.json()).toEqual(payload);
});
it("url-encodes the request id when polling", async () => {
global.fetch.mockResolvedValueOnce(jsonResponse({ status: "pending" }));
await handleVideoProxyCore({
provider: "xai",
requestId: "id with/slash",
credentials: { accessToken: "tok" },
});
expect(global.fetch.mock.calls[0][0]).toBe("https://api.x.ai/v1/videos/id%20with%2Fslash");
});
it("401 → refreshes once and retries once with the new token", async () => {
global.fetch
.mockResolvedValueOnce(jsonResponse({ error: "expired" }, 401))
.mockResolvedValueOnce(jsonResponse({ request_id: "req-after-refresh" }));
refreshTokenByProvider.mockResolvedValueOnce({ accessToken: "tok-NEW", refreshToken: "ref-NEW" });
const credentials = { accessToken: "tok-OLD", refreshToken: "ref-OLD" };
const onCredentialsRefreshed = vi.fn();
const result = await handleVideoProxyCore({
provider: "xai",
action: "generations",
rawBody: '{"prompt":"x"}',
contentType: "application/json",
credentials,
onCredentialsRefreshed,
});
expect(result.success).toBe(true);
expect(refreshTokenByProvider).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(global.fetch.mock.calls[1][1].headers.Authorization).toBe("Bearer tok-NEW");
expect(onCredentialsRefreshed).toHaveBeenCalledWith(expect.objectContaining({ accessToken: "tok-NEW" }));
expect(await result.response.json()).toEqual({ request_id: "req-after-refresh" });
});
it("401 twice → still only one refresh and one retry (no loop)", async () => {
global.fetch
.mockResolvedValueOnce(jsonResponse({ error: "expired" }, 401))
.mockResolvedValueOnce(jsonResponse({ error: "still expired" }, 401));
refreshTokenByProvider.mockResolvedValueOnce({ accessToken: "tok-NEW" });
const result = await handleVideoProxyCore({
provider: "xai",
action: "generations",
rawBody: "{}",
credentials: { accessToken: "tok-OLD", refreshToken: "ref" },
});
expect(result.success).toBe(false);
expect(result.status).toBe(401);
expect(refreshTokenByProvider).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it("failed refresh → 401 propagates with a single upstream call (account flagged for re-auth upstream)", async () => {
global.fetch.mockResolvedValueOnce(jsonResponse({ error: "expired" }, 401));
refreshTokenByProvider.mockResolvedValueOnce(null);
const result = await handleVideoProxyCore({
provider: "xai",
action: "generations",
rawBody: "{}",
credentials: { accessToken: "tok-OLD", refreshToken: "ref" },
});
expect(result.success).toBe(false);
expect(result.status).toBe(401);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it("API-key accounts (no refreshToken) never attempt refresh on 401", async () => {
global.fetch.mockResolvedValueOnce(jsonResponse({ error: "bad key" }, 401));
const result = await handleVideoProxyCore({
provider: "xai",
action: "generations",
rawBody: "{}",
credentials: { apiKey: "xai-key" },
});
expect(result.success).toBe(false);
expect(refreshTokenByProvider).not.toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it("never re-sends a creation POST after a network error", async () => {
global.fetch.mockRejectedValueOnce(new Error("socket hang up"));
const result = await handleVideoProxyCore({
provider: "xai",
action: "generations",
rawBody: "{}",
credentials: { accessToken: "tok", refreshToken: "ref" },
});
expect(result.success).toBe(false);
expect(result.status).toBe(502);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
it("sanitizes bearer tokens and credential values out of upstream errors", async () => {
global.fetch.mockResolvedValueOnce(
jsonResponse({ error: "denied for Bearer sk-secret-token-value-123456 (token tok-SECRETSECRET)" }, 403)
);
const result = await handleVideoProxyCore({
provider: "xai",
action: "generations",
rawBody: "{}",
credentials: { apiKey: "tok-SECRETSECRET" },
});
expect(result.success).toBe(false);
expect(result.error).not.toContain("sk-secret-token-value-123456");
expect(result.error).not.toContain("tok-SECRETSECRET");
expect(result.error).toContain("[redacted]");
});
it("maps client aborts to 408 without retrying", async () => {
const abortError = new Error("This operation was aborted");
abortError.name = "AbortError";
global.fetch.mockRejectedValueOnce(abortError);
const result = await handleVideoProxyCore({
provider: "xai",
action: "generations",
rawBody: "{}",
credentials: { accessToken: "tok" },
signal: new AbortController().signal,
});
expect(result.success).toBe(false);
expect(result.status).toBe(408);
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});
describe("sanitizeSecrets", () => {
it("redacts bearer tokens", () => {
expect(sanitizeSecrets("Authorization: Bearer abc.def-ghi_jkl")).not.toContain("abc.def-ghi_jkl");
});
it("redacts explicit credential values", () => {
const creds = { accessToken: "supersecretaccess", refreshToken: "supersecretrefresh" };
const out = sanitizeSecrets("leak supersecretaccess and supersecretrefresh", creds);
expect(out).toBe("leak [redacted] and [redacted]");
});
it("leaves normal text untouched", () => {
expect(sanitizeSecrets("video render failed: invalid_argument")).toBe("video render failed: invalid_argument");
});
});
+221
View File
@@ -0,0 +1,221 @@
/**
* Unit tests for the app-side video handler (src/sse/handlers/videoGeneration.js)
*
* Covers:
* - `xai/` model prefix stripping before the body is forwarded upstream
* - byte-exact forwarding when no prefix rewrite is needed
* - multi-account selection (preferred connection id, rotation on 401)
* - NO rotation on 5xx creation errors (a job may already exist upstream)
* - connection id surfaced via x-9router-connection-id
* - GET polling pinned to x-connection-id, no rotation
* - refresh failure recorded via markAccountUnavailable (dashboard re-auth signal)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const authMocks = vi.hoisted(() => ({
getProviderCredentials: vi.fn(),
markAccountUnavailable: vi.fn(async () => ({ shouldFallback: true, cooldownMs: 0 })),
clearAccountError: vi.fn(async () => {}),
extractApiKey: vi.fn(() => null),
isValidApiKey: vi.fn(async () => true),
}));
const tokenMocks = vi.hoisted(() => ({
checkAndRefreshToken: vi.fn(async (_p, creds) => creds),
updateProviderCredentials: vi.fn(async () => {}),
}));
vi.mock("@/sse/services/auth.js", () => authMocks);
vi.mock("@/sse/services/tokenRefresh.js", () => tokenMocks);
vi.mock("@/lib/localDb", () => ({
getSettings: vi.fn(async () => ({ requireApiKey: false })),
getComboByName: vi.fn(async () => null),
getModelAliases: vi.fn(async () => ({})),
getProviderNodes: vi.fn(async () => []),
}));
vi.mock("@/sse/utils/logger.js", () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }));
import { handleVideoCreate, handleVideoGet } from "@/sse/handlers/videoGeneration.js";
const originalFetch = global.fetch;
const jsonResponse = (body, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
const makeRequest = (body, { headers = {}, contentType = "application/json" } = {}) =>
new Request("http://localhost/v1/videos/generations", {
method: "POST",
headers: { "Content-Type": contentType, ...headers },
body: typeof body === "string" ? body : JSON.stringify(body),
});
const account = (overrides = {}) => ({
connectionId: "conn-1",
accessToken: "tok-1",
refreshToken: "ref-1",
authType: "oauth",
...overrides,
});
beforeEach(() => {
global.fetch = vi.fn();
authMocks.getProviderCredentials.mockReset();
authMocks.markAccountUnavailable.mockClear();
authMocks.clearAccountError.mockClear();
tokenMocks.checkAndRefreshToken.mockClear();
});
afterEach(() => {
global.fetch = originalFetch;
});
describe("handleVideoCreate", () => {
it("strips the xai/ prefix from model before forwarding", async () => {
authMocks.getProviderCredentials.mockResolvedValueOnce(account());
global.fetch.mockResolvedValueOnce(jsonResponse({ request_id: "r1" }));
const res = await handleVideoCreate(
makeRequest({ model: "xai/grok-imagine-video", prompt: "a cat" }),
"generations"
);
expect(res.status).toBe(200);
const forwarded = JSON.parse(global.fetch.mock.calls[0][1].body);
expect(forwarded.model).toBe("grok-imagine-video");
expect(forwarded.prompt).toBe("a cat");
});
it("forwards the original raw JSON bytes when no rewrite is needed", async () => {
authMocks.getProviderCredentials.mockResolvedValueOnce(account());
global.fetch.mockResolvedValueOnce(jsonResponse({ request_id: "r1" }));
// Odd spacing survives only if we forward the raw string untouched
const raw = '{ "model" : "grok-imagine-video", "prompt" : "spaced" }';
await handleVideoCreate(makeRequest(raw), "generations");
expect(global.fetch.mock.calls[0][1].body).toBe(raw);
});
it("rejects providers without video support", async () => {
const res = await handleVideoCreate(
makeRequest({ model: "openai/sora-alike", prompt: "x" }),
"generations"
);
expect(res.status).toBe(400);
expect(await res.text()).toContain("does not support video generation");
expect(global.fetch).not.toHaveBeenCalled();
});
it("returns the serving connection id in x-9router-connection-id", async () => {
authMocks.getProviderCredentials.mockResolvedValueOnce(account({ connectionId: "conn-77" }));
global.fetch.mockResolvedValueOnce(jsonResponse({ request_id: "r1" }));
const res = await handleVideoCreate(makeRequest({ prompt: "x" }), "generations");
expect(res.headers.get("x-9router-connection-id")).toBe("conn-77");
expect(await res.json()).toEqual({ request_id: "r1" });
});
it("honors preferred x-connection-id when selecting the account", async () => {
authMocks.getProviderCredentials.mockResolvedValueOnce(account());
global.fetch.mockResolvedValueOnce(jsonResponse({ request_id: "r1" }));
await handleVideoCreate(
makeRequest({ prompt: "x" }, { headers: { "x-connection-id": "conn-9" } }),
"generations"
);
expect(authMocks.getProviderCredentials).toHaveBeenCalledWith(
"xai", expect.anything(), null, expect.objectContaining({ preferredConnectionId: "conn-9" })
);
});
it("rotates to the next account on 401 (auth errors cannot have created a job)", async () => {
authMocks.getProviderCredentials
.mockResolvedValueOnce(account({ connectionId: "conn-1", refreshToken: null }))
.mockResolvedValueOnce(account({ connectionId: "conn-2", accessToken: "tok-2", refreshToken: null }));
global.fetch
.mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401))
.mockResolvedValueOnce(jsonResponse({ request_id: "r2" }));
const res = await handleVideoCreate(makeRequest({ prompt: "x" }), "generations");
expect(res.status).toBe(200);
expect(res.headers.get("x-9router-connection-id")).toBe("conn-2");
expect(authMocks.markAccountUnavailable).toHaveBeenCalledWith(
"conn-1", 401, expect.any(String), "xai", null
);
});
it("does NOT rotate accounts on a 500 creation error (job may exist upstream)", async () => {
authMocks.getProviderCredentials.mockResolvedValueOnce(account({ refreshToken: null }));
global.fetch.mockResolvedValueOnce(jsonResponse({ error: "boom" }, 500));
const res = await handleVideoCreate(makeRequest({ prompt: "x" }), "generations");
expect(res.status).toBe(500);
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(authMocks.getProviderCredentials).toHaveBeenCalledTimes(1);
});
it("forwards multipart bodies byte-exact with default xai provider", async () => {
authMocks.getProviderCredentials.mockResolvedValueOnce(account());
global.fetch.mockResolvedValueOnce(jsonResponse({ request_id: "r-mp" }));
const boundary = "----handlerBoundary";
const raw = `--${boundary}\r\nContent-Disposition: form-data; name="prompt"\r\n\r\nedit\r\n--${boundary}--\r\n`;
const req = new Request("http://localhost/v1/videos/edits", {
method: "POST",
headers: { "Content-Type": `multipart/form-data; boundary=${boundary}` },
body: raw,
});
const res = await handleVideoCreate(req, "edits");
expect(res.status).toBe(200);
const [url, init] = global.fetch.mock.calls[0];
expect(url).toBe("https://api.x.ai/v1/videos/edits");
expect(Buffer.from(init.body).toString()).toBe(raw);
expect(init.headers["Content-Type"]).toContain(boundary);
});
it("returns 400 when no credentials are connected", async () => {
authMocks.getProviderCredentials.mockResolvedValueOnce(null);
const res = await handleVideoCreate(makeRequest({ prompt: "x" }), "generations");
expect(res.status).toBe(400);
expect(await res.text()).toContain("No credentials for provider: xai");
});
it("returns 400 on invalid JSON", async () => {
const res = await handleVideoCreate(makeRequest("{not json"), "generations");
expect(res.status).toBe(400);
});
});
describe("handleVideoGet", () => {
it("polls upstream pinned to the x-connection-id account and passes status through", async () => {
authMocks.getProviderCredentials.mockResolvedValueOnce(account({ connectionId: "conn-5" }));
global.fetch.mockResolvedValueOnce(jsonResponse({ status: "pending", progress: 42 }));
const req = new Request("http://localhost/v1/videos/req-1", {
headers: { "x-connection-id": "conn-5" },
});
const res = await handleVideoGet(req, "req-1");
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ status: "pending", progress: 42 });
expect(authMocks.getProviderCredentials).toHaveBeenCalledWith(
"xai", null, null, expect.objectContaining({ preferredConnectionId: "conn-5" })
);
expect(global.fetch.mock.calls[0][0]).toBe("https://api.x.ai/v1/videos/req-1");
});
it("records the failure when polling hits a terminal auth error", async () => {
authMocks.getProviderCredentials.mockResolvedValueOnce(account({ refreshToken: null }));
global.fetch.mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401));
const res = await handleVideoGet(new Request("http://localhost/v1/videos/req-1"), "req-1");
expect(res.status).toBe(401);
expect(authMocks.markAccountUnavailable).toHaveBeenCalled();
});
});