diff --git a/.gitignore b/.gitignore
index fa236aa4..8a3f639a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/*
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bd94bd0a..7b0a12b8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index b6cd76b8..a07dd18c 100644
--- a/README.md
+++ b/README.md
@@ -17,7 +17,7 @@
[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com)
-[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md)
+[🇻🇳 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)
@@ -207,6 +207,13 @@ Default URLs:
🇮🇩 Indonesia
Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB
by Krisswuh
+
+
+
+
+ 🇮🇷 Persian-فارسی
+ این شکلی از هر API ای استفاده کن برای هوش مصنوعی by Matin SenPai
+ |
@@ -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.
+
📖 Feature Details
diff --git a/cli/cli.js b/cli/cli.js
index 088d7e87..b9e2f49d 100755
--- a/cli/cli.js
+++ b/cli/cli.js
@@ -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") {
diff --git a/cli/package.json b/cli/package.json
index e72fd187..d7fd2054 100644
--- a/cli/package.json
+++ b/cli/package.json
@@ -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"
diff --git a/cli/src/cli/commands/xaiVideo.js b/cli/src/cli/commands/xaiVideo.js
new file mode 100644
index 00000000..27d829f7
--- /dev/null
+++ b/cli/src/cli/commands/xaiVideo.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 Video description (required)
+ --output Output MP4 path (default: video.mp4)
+ --model Model (default: ${DEFAULT_MODEL})
+ --duration Video duration
+ --aspect-ratio e.g. 16:9, 9:16, 1:1
+ --resolution 480p | 720p | 1080p
+ --image Image input for image-to-video
+ --timeout Max wait for the job (default: ${DEFAULT_TIMEOUT_SEC})
+ --port Gateway port (default: ${DEFAULT_PORT})
+ --host Gateway host (default: ${DEFAULT_HOST})
+ --api-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