mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
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:
@@ -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/*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](https://www.npmjs.com/package/9router)
|
||||
[](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 ดียิ่งขึ้น!
|
||||
|
||||
[](https://github.com/decolua/9router/graphs/contributors)
|
||||
|
||||
---
|
||||
|
||||
## 📄 ลิขสิทธิ์
|
||||
|
||||
MIT License - ดู [LICENSE](../LICENSE) สำหรับรายละเอียด
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<sub>สร้างด้วย ❤️ สำหรับนักพัฒนาที่เขียนโค้ด 24/7</sub>
|
||||
</div>
|
||||
@@ -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 || ""));
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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": "*",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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] };
|
||||
|
||||
|
||||
@@ -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,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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}` };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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();
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "9router-app",
|
||||
"version": "0.5.30",
|
||||
"version": "0.5.35",
|
||||
"description": "9Router web dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
+1376
-180
File diff suppressed because it is too large
Load Diff
+1384
-188
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -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`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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": "*" },
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const DEFAULT_SETTINGS = {
|
||||
tailscaleUrl: "",
|
||||
stickyRoundRobinLimit: 3,
|
||||
providerStrategies: {},
|
||||
quotaVisibility: {},
|
||||
comboStrategy: "fallback",
|
||||
comboStickyRoundRobinLimit: 1,
|
||||
comboStrategies: {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" };
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" } },
|
||||
];
|
||||
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
startWatchdog();
|
||||
startNetworkMonitor();
|
||||
autoStartMitm();
|
||||
startQuotaAutoPing();
|
||||
autoStartMitm(settings);
|
||||
}
|
||||
|
||||
async function autoStartMitm() {
|
||||
configureTunnelMonitoring(settings);
|
||||
|
||||
if (hasQuotaAutoPingEnabled(settings)) {
|
||||
import("@/shared/services/quotaAutoPing")
|
||||
.then(({ startQuotaAutoPing }) => startQuotaAutoPing())
|
||||
.catch((e) => console.log("[AutoPing] scheduler start failed:", e.message));
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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." }],
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user