From 4dadab9d5fdde8e670ab8571d5e8239ce7b3697b Mon Sep 17 00:00:00 2001 From: nguyenha935 <208228297+nguyenha935@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:26:49 +0700 Subject: [PATCH 01/25] feat: add provider quota visibility settings --- .../components/ProviderLimits/QuotaTable.js | 20 +++- .../usage/components/ProviderLimits/index.js | 91 ++++++++++++++++++- .../usage/components/ProviderLimits/utils.js | 24 +++++ src/lib/db/repos/settingsRepo.js | 1 + tests/unit/provider-quota-visibility.test.js | 55 +++++++++++ 5 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 tests/unit/provider-quota-visibility.test.js diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js index 8f2a1bc3..299445ef 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js @@ -89,6 +89,7 @@ export default function QuotaTable({ compact = false, sortMode = "default", showSortLabel = false, + onHideQuota = null, }) { const [page, setPage] = useState(1); @@ -132,6 +133,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 (
@@ -195,7 +197,7 @@ export default function QuotaTable({
- + {countdown !== "-" || resetDisplay ? ( compact ? (
N/A
)} + + {hasHideAction && ( + + + + )} ); })} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index 95300c92..babfa6e2 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -8,6 +8,9 @@ import Tooltip from "@/shared/components/Tooltip"; import { parseQuotaData, calculatePercentage, + filterQuotasByVisibility, + getHiddenQuotaRows, + getQuotaVisibilityKey, getConnectionLabel, getConnectionQuotaRemaining, sortVisibleConnections, @@ -146,6 +149,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); @@ -536,10 +540,13 @@ export default function ProviderLimits() { useEffect(() => { fetch("/api/settings", { cache: "no-store" }) .then((r) => (r.ok ? r.json() : {})) - .then((s) => setAutoPingMaps({ - claude: s?.claudeAutoPing?.connections || {}, - codex: s?.codexAutoPing?.connections || {}, - })) + .then((s) => { + setAutoPingMaps({ + claude: s?.claudeAutoPing?.connections || {}, + codex: s?.codexAutoPing?.connections || {}, + }); + setQuotaVisibility(s?.quotaVisibility || {}); + }) .catch(() => {}); }, []); @@ -565,6 +572,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) { @@ -973,6 +1031,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 ( ) : ( handleHideQuota(conn.provider, quotaRow)} /> )} + {hiddenQuotaRows.length > 0 && ( +
+ + visibility_off + + Hidden: + {hiddenQuotaRows.map((quotaRow) => ( + + ))} +
+ )}
); diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index 688f0ab7..19fdaa8e 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -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) diff --git a/src/lib/db/repos/settingsRepo.js b/src/lib/db/repos/settingsRepo.js index 0057cc1c..60a5054b 100644 --- a/src/lib/db/repos/settingsRepo.js +++ b/src/lib/db/repos/settingsRepo.js @@ -13,6 +13,7 @@ const DEFAULT_SETTINGS = { tailscaleUrl: "", stickyRoundRobinLimit: 3, providerStrategies: {}, + quotaVisibility: {}, comboStrategy: "fallback", comboStickyRoundRobinLimit: 1, comboStrategies: {}, diff --git a/tests/unit/provider-quota-visibility.test.js b/tests/unit/provider-quota-visibility.test.js new file mode 100644 index 00000000..d6616b81 --- /dev/null +++ b/tests/unit/provider-quota-visibility.test.js @@ -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); + }); +}); From f89ba32d79a796fe1c51882b13524cc9a6179b42 Mon Sep 17 00:00:00 2001 From: minnyww Date: Mon, 13 Jul 2026 16:50:09 +0700 Subject: [PATCH 02/25] feat(i18n): add Thai language translation --- README.md | 2 +- i18n/README.th.md | 723 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 724 insertions(+), 1 deletion(-) create mode 100644 i18n/README.th.md diff --git a/README.md b/README.md index b6cd76b8..84a01e07 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) diff --git a/i18n/README.th.md b/i18n/README.th.md new file mode 100644 index 00000000..cfd8dc4b --- /dev/null +++ b/i18n/README.th.md @@ -0,0 +1,723 @@ +นี่คือเอกสารแปลภาษาไทยของไฟล์ Markdown ต้นฉบับ โดยรักษาโครงสร้างและซินแท็กซ์ทางเทคนิคทั้งหมดไว้เหมือนเดิม + +
+ แดชบอร์ด 9Router + + # 9Router - Free AI Router + + **ไม่ต้องหยุดเขียนโค้ด ประหยัดโทเค็น 20-40% ด้วย RTK + สลับอัตโนมัติไปยังโมเดล AI ฟรีและราคาถูก** + + **ผู้ให้บริการ AI ฟรีสำหรับ OpenClaw** + +

+ OpenClaw +

+ + [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) + [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) + [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + + [🚀 เริ่มต้นใช้งาน](#-quick-start) • [💡 ฟีเจอร์](#-key-features) • [📖 การตั้งค่า](#-setup-guide) • [🌐 เว็บไซต์](https://9router.com) +
+ +--- + +## 🤔 ทำไมต้อง 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 ทุกประเภท: + +
+ + + + + + + + + + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ OpenClaw
+ OpenClaw +
+ Codex
+ Codex +
+ OpenCode
+ OpenCode +
+ Cursor
+ Cursor +
+ Antigravity
+ Antigravity +
+ Cline
+ Cline +
+ Continue
+ Continue +
+ Droid
+ Droid +
+ Roo
+ Roo +
+ Copilot
+ Copilot +
+ Kilo Code
+ Kilo Code +
+
+ +--- + +## ผู้ให้บริการที่รองรับ + +### 🔐 ผู้ให้บริการ OAuth + +
+ + + + + + + + +
+ Claude Code
+ Claude-Code +
+ Antigravity
+ Antigravity +
+ Codex
+ Codex +
+ GitHub
+ GitHub +
+ Cursor
+ Cursor +
+
+ +### 🆓 ผู้ให้บริการฟรี + +
+ + + + + + +
+ Kiro
+ Kiro AI
+ Claude 4.5 + GLM-5 + MiniMax • ไม่จำกัด ฟรี +
+ OpenCode
+ OpenCode Free
+ ไม่ต้องยืนยันตัวตน • ดึงโมเดลอัตโนมัติ • ไม่จำกัด ฟรี +
+ Vertex AI
+ Vertex AI
+ Gemini 3 Pro + GLM-5 + DeepSeek • เครดิตฟรี $300 +
+
+ +> **หมายเหตุ:** iFlow, Qwen และ Gemini CLI หยุดให้บริการในปี 2026 แล้ว ใช้ Kiro / OpenCode Free / Vertex แทน + +### 🔑 ผู้ให้บริการ API Key (40+) + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ OpenRouter
+ OpenRouter +
+ GLM
+ GLM +
+ Kimi
+ Kimi +
+ MiniMax
+ MiniMax +
+ OpenAI
+ OpenAI +
+ Anthropic
+ Anthropic +
+ Gemini
+ Gemini +
+ DeepSeek
+ DeepSeek +
+ Groq
+ Groq +
+ xAI
+ xAI +
+ Mistral
+ Mistral +
+ Perplexity
+ Perplexity +
+ Together
+ Together AI +
+ Fireworks
+ Fireworks +
+ Cerebras
+ Cerebras +
+ Cohere
+ Cohere +
+ NVIDIA
+ NVIDIA +
+ SiliconFlow
+ SiliconFlow +
+

...และผู้ให้บริการอีกกว่า 20 ราย รวมถึง Nebius, Chutes, Hyperbolic และ OpenAI/Anthropic compatible endpoints แบบกำหนดเอง

+
+ +--- + +## 💡 ฟีเจอร์หลัก + +| ฟีเจอร์ | ทำอะไร | ทำไมถึงสำคัญ | +|---------|--------------|----------------| +| 🚀 **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 ที่ยืดหยุ่น | + +
+📖 รายละเอียดฟีเจอร์ + +### 🚀 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 ทั่วโลก + +
+ +--- + +## 💰 สรุปราคา + +| ประเภท | ผู้ให้บริการ | ค่าใช้จ่าย | รีเซ็ตโควตา | ดีที่สุดสำหรับ | +|------|----------|------|-------------|----------| +| **💳 สมาชิก** | 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... +``` + +--- + +## ❓ คำถามที่พบบ่อย + +
+💳 9Router เก็บเงินฉันหรือไม่? + +**ไม่.** 9Router เป็นซอฟต์แวร์ฟรีแบบ open source ที่ทำงานบนเครื่องของคุณเอง มันไม่มีวันเรียกเก็บเงินจากคุณ + +**คุณจ่ายเงินเฉพาะ:** +- ✅ **ผู้ให้บริการสมาชิก** (Claude Code $20/เดือน, Codex $20-200/เดือน) → จ่ายตรงให้พวกเขาบนเว็บไซต์ของพวกเขา +- ✅ **ผู้ให้บริการราคาถูก** (GLM, MiniMax) → จ่ายตรงให้พวกเขา, 9Router แค่เลือกเส้นทางคำขอของคุณ +- ❌ **ตัว 9Router เอง** → **ไม่มีวันเรียกเก็บเงินใดๆ ทั้งสิ้น** + +9Router เป็น proxy/router ท้องถิ่น มันไม่มีบัตรเครดิตของคุณ, ไม่สามารถส่งใบแจ้งหนี้ได้ และไม่มีระบบชำระเงิน เป็นซอฟต์แวร์ฟรีทั้งหมด + +
+ +
+🆓 ผู้ให้บริการฟรีไม่จำกัดจริงหรือ? + +**จริง!** ผู้ให้บริการที่ระบุว่าฟรี (Kiro, OpenCode Free, Vertex) ไม่จำกัดจริงๆ **ไม่มีค่าใช้จ่ายแอบแฝง** + +นี่คือบริการฟรีที่บริษัทต่างๆ ให้บริการ: +- **Kiro**: Claude ฟรีไม่จำกัดผ่าน AWS Builder ID +- **OpenCode Free**: ไม่ต้องยืนยันตัวตน, ดึงโมเดลอัตโนมัติ +- **Vertex AI**: $300 เครดิตฟรีสำหรับ Gemini 3 Pro + +9Router แค่เลือกเส้นทางคำขอของคุณไปหาพวกเขา — ไม่มี "กับดัก" หรือการเรียกเก็บเงินในอนาคต เป็นบริการที่ฟรีจริงๆ และ 9Router ทำให้ใช้งานง่ายด้วยการรองรับ fallback + +
+ +
+💰 ทำอย่างไรเพื่อลดค่าใช้จ่าย AI จริงของฉัน? + +**กลยุทธ์ 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/เดือน โดยใช้เฉพาะชั้นฟรี! + +
+ +--- + +## 🐛 การแก้ไขปัญหา + +**"Language model did not provide messages"** +- โควตาผู้ให้บริการหมด → ตรวจสอบตัวติดตามโควตาในแดชบอร์ด +- วิธีแก้: ใช้ combo fallback หรือสลับไปชั้นที่ถูกกว่า + +**Rate Limiting** +- สมาชิกหมดโควตา → Fallback ไป GLM/MiniMax +- เพิ่ม combo: `cc/claude-opus-4-6 → glm/glm-5.1 → kr/claude-sonnet-4.5` + +**OAuth Token หมดอายุ** +- รีเฟรชอัตโนมัติโดย 9Router +- ถ้าปัญหายังคงอยู่: แดชบอร์ด → ผู้ให้บริการ → เชื่อมต่อใหม่ + +**ค่าใช้จ่ายสูง** +- เปิดใช้ RTK ใน แดชบอร์ด → ตั้งค่า Endpoint (เปิดเป็นค่าเริ่มต้น, ประหยัด 20-40% โทเค็น) +- ตรวจสอบสถิติการใช้งานในแดชบอร์ด +- สลับโมเดลหลักไป GLM/MiniMax +- ใช้ชั้นฟรี (Kiro, OpenCode Free, Vertex) สำหรับงานที่ไม่สำคัญ + +**แดชบอร์ดเปิดผิดพอร์ต** +- ตั้ง `PORT=20128` และ `NEXT_PUBLIC_BASE_URL=http://localhost:20128` + +**ล็อกอินครั้งแรกไม่ทำงาน** +- ตรวจสอบ `INITIAL_PASSWORD` ใน `.env` +- ถ้ายังไม่ตั้งค่า รหัสผ่านสำรองคือ `123456` + +**ไม่มี request log ใต้ `logs/`** +- ตั้ง `ENABLE_REQUEST_LOGS=true` + +--- + +## 🛠️ Tech Stack + +- **Runtime**: Node.js 20+ +- **Framework**: Next.js 16 +- **UI**: React 19 + Tailwind CSS 4 +- **Database**: SQLite (better-sqlite3 / node:sqlite / sql.js fallback) +- **Streaming**: Server-Sent Events (SSE) +- **Auth**: OAuth 2.0 (PKCE) + JWT + API Keys + +--- + +## 📝 API Reference + +### Chat Completions + +```bash +POST http://localhost:20128/v1/chat/completions +Authorization: Bearer your-api-key +Content-Type: application/json + +{ + "model": "cc/claude-opus-4-6", + "messages": [ + {"role": "user", "content": "เขียนฟังก์ชันเพื่อ..."} + ], + "stream": true +} +``` + +### List Models + +```bash +GET http://localhost:20128/v1/models +Authorization: Bearer your-api-key + +→ คืนค่าโมเดลทั้งหมด + combo ในรูปแบบ OpenAI +``` + +--- + +## 📧 สนับสนุน + +- **เว็บไซต์**: [9router.com](https://9router.com) +- **GitHub**: [github.com/decolua/9router](https://github.com/decolua/9router) +- **Issues**: [github.com/decolua/9router/issues](https://github.com/decolua/9router/issues) + +--- + +## 👥 ผู้มีส่วนร่วม + +ขอขอบคุณผู้มีส่วนร่วมทุกคนที่ช่วยทำให้ 9Router ดียิ่งขึ้น! + +[![Contributors](https://contrib.rocks/image?repo=decolua/9router&max=150&columns=15&anon=1)](https://github.com/decolua/9router/graphs/contributors) + +--- + +## 📄 ลิขสิทธิ์ + +MIT License - ดู [LICENSE](../LICENSE) สำหรับรายละเอียด + +--- + +
+ สร้างด้วย ❤️ สำหรับนักพัฒนาที่เขียนโค้ด 24/7 +
From 837cfec5a9e6291b31496e0a179d7e62d367dd3d Mon Sep 17 00:00:00 2001 From: minnyww Date: Mon, 13 Jul 2026 17:08:49 +0700 Subject: [PATCH 03/25] feat(i18n): complete Thai translation (1389 keys) + README.th.md --- public/i18n/literals/th.json | 1574 ++++++++++++++++++++++++++++++---- 1 file changed, 1385 insertions(+), 189 deletions(-) diff --git a/public/i18n/literals/th.json b/public/i18n/literals/th.json index 6169829f..7d528201 100644 --- a/public/i18n/literals/th.json +++ b/public/i18n/literals/th.json @@ -1,195 +1,1391 @@ { - "Cancel": "ยกเลิก", - "Delete": "ลบ", - "Edit": "แก้ไข", - "Save": "บันทึก", - "Close": "ปิด", + "($/1M tokens). Example: An input rate of 2.50 means $2.50 per 1,000,000 input tokens.": "(฿/1M tokens) ตัวอย่าง: อัตราขาเข้า 2.50 หมายถึง $2.50 ต่อ 1,000,000 input tokens", + "($/1M tokens). Example: Input rate of 2.50 means $2.50 per 1,000,000 input tokens.": "(฿/1M tokens) ตัวอย่าง: อัตราขาเข้า 2.50 หมายถึง $2.50 ต่อ 1,000,000 input tokens", + "(Caveman)": "(Caveman)", + "(Headroom)": "(Headroom)", + "(Ponytail)": "(Ponytail)", + "(RTK)": "(RTK)", + "(via inference test)": "(ผ่านการทดสอบ推理)", + "+ Browse": "+ เรียกดู", + "+ Combo": "+ Combo", + "+ Custom": "+ กำหนดเอง", + "+ Save current as...": "+ บันทึกปัจจุบันเป็น...", + "-compatible models manually or import them from the /models endpoint.": "- เพิ่มโมเดลแบบ compatible ด้วยตนเอง หรือนำเข้าจาก /models endpoint", + ". Click \"Apply\" to auto-configure.": ". คลิก \"Apply\" เพื่อกำหนดค่าอัตโนมัติ", + "1. CLI & SDKs": "1. CLI & SDKs", + "1. Client Request (Input)": "1. คำขอจากลูกค้า (ขาเข้า)", + "1. Generates SSL cert & adds to system keychain": "1. สร้าง SSL cert แล้วเพิ่มเข้า system keychain", + "2. 9Router Hub": "2. 9Router Hub", + "2. Provider Request (Translated)": "2. คำขอจากผู้ให้บริการ (แปลแล้ว)", + "2. Redirects": "2. Redirects", + "24h": "24 ชม.", + "3. AI Providers": "3. ผู้ให้บริการ AI", + "3. Maps Antigravity models to any provider via 9Router": "3. Map โมเดล Antigravity ไปยังผู้ให้บริการใดก็ได้ผ่าน 9Router", + "3. Provider Response (Raw)": "3. การตอบกลับจากผู้ให้บริการ (ดิบ)", + "30D": "30 วัน", + "4. Client Response (Final)": "4. การตอบกลับลูกค้า (สุดท้าย)", + "60D": "60 วัน", + "7D": "7 วัน", + "9Router (Entry)": "9Router (ทางเข้า)", + "9Router Base URL": "9Router Base URL", + ": Account | Workers Scripts | Edit": ": บัญชี | Workers Scripts | Edit", + ": Include | Account |": ": Include | บัญชี |", + "AI endpoint proxy with web dashboard - A JavaScript port of CLIProxyAPI. Works seamlessly with Claude Code, OpenAI Codex, Cline, RooCode, and other CLI tools.": "AI endpoint proxy พร้อม web dashboard — JavaScript port ของ CLIProxyAPI ใช้งานร่วมกับ Claude Code, OpenAI Codex, Cline, RooCode และเครื่องมือ CLI อื่นๆ ได้อย่างราบรื่น", + "API Endpoint": "API Endpoint", + "API Key": "API Key", + "API Key (for Check)": "API Key (สำหรับตรวจสอบ)", + "API Key Compatible Providers": "ผู้ให้บริการที่ compatible กับ API Key", + "API Key Created": "สร้าง API Key แล้ว", + "API Key Name": "ชื่อ API Key", + "API Key Providers": "ผู้ให้บริการ API Key", + "API Keys": "API Keys", + "API Reference": "เอกสาร API", + "API Token": "API Token", + "API Tokens": "API Tokens", + "API Type": "ประเภท API", + "API Version": "เวอร์ชัน API", + "API endpoint configuration": "การกำหนดค่า API endpoint", + "AWS Builder ID": "AWS Builder ID", + "AWS IAM Identity Center": "AWS IAM Identity Center", + "AWS Region": "AWS Region", + "AWS region for the key (default: us-east-1)": "AWS region สำหรับ key (ค่าเริ่มต้น: us-east-1)", + "AWS region for your Identity Center (default: us-east-1)": "AWS region สำหรับ Identity Center ของคุณ (ค่าเริ่มต้น: us-east-1)", + "About": "เกี่ยวกับ", + "Access Anywhere": "เข้าถึงได้ทุกที่", + "Access Token": "Access Token", + "Access token will be auto-filled...": "Access token จะถูกเติมอัตโนมัติ...", + "Access your terminal, desktop & files from anywhere": "เข้าถึง terminal, desktop และไฟล์ของคุณจากทุกที่", + "Account": "บัญชี", + "Account ID": "Account ID", + "Account Resources": "ทรัพยากรบัญชี", + "Accounts per page": "จำนวนบัญชีต่อหน้า", + "Action": "การดำเนินการ", + "Activate": "เปิดใช้งาน", + "Active": "ใช้งานอยู่", + "Active All": "เปิดใช้งานทั้งหมด", + "Active:": "ใช้งานอยู่:", "Add": "เพิ่ม", - "Remove": "นำออก", - "Settings": "การตั้งค่า", - "Profile": "โปรไฟล์", - "Dashboard": "แดชบอร์ด", - "Logout": "ออกจากระบบ", - "Login": "เข้าสู่ระบบ", - "Providers": "ผู้ให้บริการ", - "Usage": "สถิติการใช้งาน", - "API Key": "คีย์ API", - "Connected": "เชื่อมต่อแล้ว", - "Disconnected": "ตัดการเชื่อมต่อ", - "Active": "ใช้งาน", - "Inactive": "ไม่ใช้งาน", - "Success": "สำเร็จ", - "Failed": "ล้มเหลว", - "Error": "ข้อผิดพลาด", - "Warning": "คำเตือน", - "Info": "ข้อมูล", - "Loading": "กำลังโหลด", - "Search": "ค้นหา", - "Filter": "ตัวกรอง", - "Sort": "เรียงลำดับ", - "Export": "ส่งออก", - "Import": "นำเข้า", - "Refresh": "รีเฟรช", - "Back": "ย้อนกลับ", - "Next": "ถัดไป", - "Previous": "ก่อนหน้า", - "Submit": "ส่ง", - "Confirm": "ยืนยัน", - "Yes": "ใช่", - "No": "ไม่", - "OK": "ตกลง", + "Add API Key": "เพิ่ม API Key", + "Add Anthropic Compatible": "เพิ่ม Anthropic Compatible", + "Add Connection": "เพิ่มการเชื่อมต่อ", + "Add Custom Embedding": "เพิ่ม Custom Embedding", + "Add Custom MCP": "เพิ่ม Custom MCP", + "Add Custom Model": "เพิ่ม Custom Model", + "Add Model": "เพิ่มโมเดล", + "Add Model Config": "เพิ่มการกำหนดค่าโมเดล", + "Add Model for GitHub Copilot": "เพิ่มโมเดลสำหรับ GitHub Copilot", + "Add Model for OpenCode": "เพิ่มโมเดลสำหรับ OpenCode", + "Add Model to Combo": "เพิ่มโมเดลเข้า Combo", + "Add New Provider": "เพิ่มผู้ให้บริการใหม่", + "Add OpenAI Compatible": "เพิ่ม OpenAI Compatible", + "Add Provider": "เพิ่มผู้ให้บริการ", + "Add Proxy Pool": "เพิ่ม Proxy Pool", + "Add Shorthands": "เพิ่ม Shorthands", + "Add a connection to enable importing models.": "เพิ่มการเชื่อมต่อเพื่อเปิดใช้งานการนำเข้าโมเดล", + "Add connection using browser cookie": "เพิ่มการเชื่อมต่อโดยใช้ browser cookie", + "Add model": "เพิ่มโมเดล", + "Add server": "เพิ่มเซิร์ฟเวอร์", + "Add the following configuration to your models array:": "เพิ่มการกำหนดค่าต่อไปนี้ใน models array ของคุณ:", + "Add your first connection to get started": "เพิ่มการเชื่อมต่อแรกของคุณเพื่อเริ่มต้น", + "Administrator required": "ต้องใช้สิทธิ์ผู้ดูแลระบบ", + "Administrator required — restart 9Router as Administrator to use MITM": "ต้องใช้สิทธิ์ผู้ดูแลระบบ — เริ่มต้น 9Router ใหม่ในฐานะผู้ดูแลระบบเพื่อใช้ MITM", + "After authorization, copy the full URL from your browser address bar.": "หลังการอนุมัติ คัดลอก URL เต็มจาก address bar ของเบราว์เซอร์", + "After authorization, copy the full URL from your browser.": "หลังการอนุมัติ คัดลอก URL เต็มจากเบราว์เซอร์ของคุณ", + "After installation, run": "หลังการติดตั้ง รัน", + "After login, you'll need to copy the callback URL from your browser and paste it back here.": "หลังเข้าสู่ระบบ คุณจะต้องคัดลอก callback URL จากเบราว์เซอร์แล้ววางกลับมาที่นี่", + "Alibaba Qwen Code CLI — supports OpenAI, Anthropic & Gemini providers via 9Router": "Alibaba Qwen Code CLI — รองรับผู้ให้บริการ OpenAI, Anthropic & Gemini ผ่าน 9Router", + "All": "ทั้งหมด", + "All AI Providers": "ผู้ให้บริการ AI ทั้งหมด", + "All Providers": "ผู้ให้บริการทั้งหมด", + "All models are responding normally.": "โมเดลทั้งหมดตอบสนองปกติ", + "All providers": "ผู้ให้บริการทั้งหมด", + "All rates are in": "อัตราทั้งหมดเป็น", + "All selected currently unbound": "ที่เลือกทั้งหมดยังไม่ได้เชื่อมต่อ", + "Allow dashboard access via tunnel": "อนุญาตให้เข้าถึง dashboard ผ่าน tunnel", + "Allow either password or OIDC.": "อนุญาตทั้งรหัสผ่านหรือ OIDC", + "An error occurred": "เกิดข้อผิดพลาด", + "An error occurred. Please try again.": "เกิดข้อผิดพลาด กรุณาลองใหม่", + "Anthropic Claude Code CLI": "Anthropic Claude Code CLI", + "Anthropic Compatible (Prod)": "Anthropic Compatible (Production)", + "Anthropic Compatible Details": "รายละเอียด Anthropic Compatible", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "คำขอ Antigravity/Copilot IDE → DNS redirect ไปยัง localhost:443 → MITM proxy ดักจับ → 9Router → ส่งกลับไปยัง Antigravity/Copilot", + "Any model available in 9Router can be used — not just Qwen models. Select from Qwen, Claude, Gemini, GPT, and more.": "โมเดลใดก็ได้ที่มีใน 9Router สามารถใช้ได้ — ไม่ใช่แค่ Qwen เลือกจาก Qwen, Claude, Gemini, GPT และอื่นๆ", + "App Name": "ชื่อแอป", "Apply": "ใช้", - "Reset": "รีเซ็ต", - "Clear": "ล้าง", - "Select": "เลือก", - "Upload": "อัพโหลด", - "Download": "ดาวน์โหลด", - "Copy": "คัดลอก", - "Paste": "วาง", - "Cut": "ตัด", - "Undo": "ยกเลิก", - "Redo": "ทำซ้ำ", - "Name": "ชื่อ", - "Description": "คำอธิบาย", - "Status": "สถานะ", - "Type": "ประเภท", - "Date": "วันที่", - "Time": "เวลา", - "Created": "สร้างแล้ว", - "Updated": "อัพเดตแล้ว", - "Actions": "การกระทำ", - "Details": "รายละเอียด", - "View": "ดู", - "New": "ใหม่", - "Total": "ทั้งหมด", - "Count": "จำนวน", - "Price": "ราคา", - "Cost": "ต้นทุน", - "Free": "ฟรี", - "Paid": "จ่ายเงิน", - "Enable": "เปิดใช้งาน", - "Disable": "ปิดใช้งาน", - "Enabled": "เปิดใช้งานแล้ว", - "Disabled": "ปิดใช้งานแล้ว", - "Online": "ออนไลน์", - "Offline": "ออฟไลน์", + "Apply Proxy": "ใช้ Proxy", + "Applying...": "กำลังใช้...", + "Are you sure you want to close the proxy server?": "คุณแน่ใจหรือว่าต้องการปิด proxy server?", + "Are you sure you want to disable the tunnel?": "คุณแน่ใจหรือว่าต้องการปิด tunnel?", + "Attempting to reconnect...": "กำลังพยายามเชื่อมต่อใหม่...", + "Audio File": "ไฟล์เสียง", + "Auth Mode": "โหมดยืนยันตัวตน", + "Authenticate": "ยืนยันตัวตน", + "Authentication Method": "วิธียืนยันตัวตน", + "Authentication Successful": "ยืนยันตัวตนสำเร็จ", + "Authentication Successful!": "ยืนยันตัวตนสำเร็จ!", + "Authless": "ไม่ต้องยืนยันตัวตน", + "Authorization Successful!": "อนุมัติสำเร็จ!", + "Authorize": "อนุมัติ", + "Auto (by priority)": "อัตโนมัติ (ตามลำดับความสำคัญ)", + "Auto Refresh (3s)": "รีเฟรชอัตโนมัติ (3 วินาที)", + "Auto-detect": "ตรวจจับอัตโนมัติ", + "Auto-detecting token...": "กำลังตรวจจับ token...", + "Auto-detecting tokens...": "กำลังตรวจจับ tokens...", + "Auto-ping": "Auto-ping", + "Auto-refresh": "รีเฟรชอัตโนมัติ", + "Auto:": "อัตโนมัติ:", + "Automatically switch between providers when limits are hit.": "สลับระหว่างผู้ให้บริการโดยอัตโนมัติเมื่อถึงขีดจำกัด", "Available": "พร้อมใช้งาน", - "Unavailable": "ไม่พร้อมใช้งาน", - "Required": "จำเป็น", - "Optional": "ไม่บังคับ", - "Default": "ค่าเริ่มต้น", - "Custom": "กำหนดเอง", - "Advanced": "ขั้นสูง", - "Basic": "พื้นฐาน", - "Help": "ช่วยเหลือ", - "Support": "สนับสนุน", - "Documentation": "เอกสาร", - "Version": "เวอร์ชัน", - "Language": "ภาษา", - "Theme": "ธีม", - "Light": "สว่าง", - "Dark": "มืด", - "Auto": "อัตโนมัติ", - "Endpoint": "จุดสิ้นสุด", - "Combos": "ชุดรวม", - "Quota Tracker": "ตัวติดตามโควต้า", - "MITM": "MITM", - "CLI Tools": "เครื่องมือ", - "Console Log": "บันทึกคอนโซล", - "System": "ระบบ", - "Debug": "ดีบัก", - "Shutdown": "ปิดระบบ", - "Close Proxy": "ปิด Proxy", - "Are you sure you want to close the proxy server?": "คุณแน่ใจหรือว่าต้องการปิดเซิร์ฟเวอร์ proxy?", - "Server Disconnected": "เซิร์ฟเวอร์ตัดการเชื่อมต่อ", - "The proxy server has been stopped.": "เซิร์ฟเวอร์ proxy ถูกหยุดแล้ว", - "Reload Page": "โหลดหน้าใหม่", - "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "บริการกำลังทำงานในเทอร์มินัล คุณสามารถปิดหน้าเว็บนี้ได้ การปิดระบบจะหยุดบริการ", - "Manage your AI provider connections": "จัดการการเชื่อมต่อผู้ให้บริการ AI ของคุณ", - "Model combos with fallback": "ชุดรวมโมเดลที่มี fallback", - "Monitor your API usage, token consumption, and request logs": "ติดตามการใช้งาน API การใช้งาน token และบันทึกคำขอของคุณ", - "Intercept CLI tool traffic and route through 9Router": "สกัดปะท่อ CLI และเส้นทางผ่าน 9Router", - "Configure CLI tools": "กำหนดค่าเครื่องมือ CLI", - "API endpoint configuration": "การตั้งค่าจุดสิ้นสุด API", - "Manage your preferences": "จัดการการตั้งค่าของคุณ", - "Debug translation flow between formats": "ดีบักการไหลของการแปลระหว่างรูปแบบ", - "Live server console output": "ผลลัพธ์คอนโซลเซิร์ฟเวอร์สด", - "Create model combos with fallback support": "สร้างชุดรวมโมเดลที่มีการสนับสนุน fallback", - "Local Mode": "โหมดท้องถิ่น", - "Running on your machine": "ทำงานบนเครื่องของคุณ", - "Database Location": "ตำแหน่งของฐานข้อมูล", - "Download Backup": "ดาวน์โหลดการสำรองข้อมูล", - "Import Backup": "นำเข้าการสำรองข้อมูล", - "Database backup downloaded": "ดาวน์โหลดการสำรองข้อมูลฐานข้อมูลแล้ว", - "Database imported successfully": "นำเข้าฐานข้อมูลเสร็จสิ้น", - "Security": "ความปลอดภัย", - "Require login": "ต้องการการเข้าสู่ระบบ", - "When ON, dashboard requires password. When OFF, access without login.": "เมื่อเปิด แดชบอร์ดต้องการรหัสผ่าน เมื่อปิด เข้าถึงโดยไม่ต้องเข้าสู่ระบบ", - "Current Password": "รหัสผ่านปัจจุบัน", - "Enter current password": "ป้อนรหัสผ่านปัจจุบัน", - "New Password": "รหัสผ่านใหม่", - "Enter new password": "ป้อนรหัสผ่านใหม่", - "Confirm New Password": "ยืนยันรหัสผ่านใหม่", - "Confirm new password": "ยืนยันรหัสผ่านใหม่", - "Update Password": "อัพเดตรหัสผ่าน", - "Set Password": "ตั้งรหัสผ่าน", - "Password updated successfully": "อัพเดตรหัสผ่านเสร็จสิ้น", - "Passwords do not match": "รหัสผ่านไม่ตรงกัน", - "Routing Strategy": "กลยุทธ์การเส้นทาง", - "Round Robin": "โรบินรอบ", - "Cycle through accounts to distribute load": "วนรอบบัญชีเพื่อกระจายการโหลด", - "Sticky Limit": "ขีดจำกัดที่เหนียว", - "Calls per account before switching": "การโทรต่อบัญชีก่อนการสลับ", - "Network": "เครือข่าย", - "Outbound Proxy": "Proxy ขาออก", - "Enable proxy for OAuth + provider outbound requests.": "เปิดใช้งาน proxy สำหรับคำขอขาออก OAuth + ผู้ให้บริการ", - "Proxy URL": "URL Proxy", - "Leave empty to inherit existing env proxy (if any).": "ปล่อยว่างไว้เพื่อสืบทอด proxy env ที่มีอยู่ (หากมี)", - "No Proxy": "ไม่มี Proxy", - "Comma-separated hostnames/domains to bypass the proxy.": "ชื่อโฮสต์/โดเมนคั่นด้วยเครื่องหมายจุลภาค เพื่อข้าม proxy", - "Test proxy URL": "ทดสอบ URL Proxy", - "Proxy settings applied": "ใช้การตั้งค่า proxy แล้ว", - "Proxy enabled": "เปิดใช้งาน proxy", - "Proxy disabled": "ปิดใช้งาน proxy", - "Proxy test OK": "ทดสอบ proxy ตกลง", - "Proxy test failed": "ทดสอบ proxy ล้มเหลว", - "Please enter a Proxy URL to test": "กรุณาป้อน URL Proxy เพื่อทดสอบ", - "Observability": "ความสามารถในการสังเกต", - "Enable Observability": "เปิดใช้งานความสามารถในการสังเกต", - "Turn request detail recording on/off globally": "เปิด/ปิดการบันทึกรายละเอียดคำขอทั่วโลก", - "Max Records": "บันทึกสูงสุด", - "Maximum request detail records to keep (older records are auto-deleted)": "บันทึกรายละเอียดคำขอสูงสุดที่จะเก็บ (บันทึกเก่าจะลบโดยอัตโนมัติ)", - "Batch Size": "ขนาดแบตช์", - "Number of items to accumulate before writing to database (higher = better performance)": "จำนวนรายการที่จะรวบรวมก่อนเขียนลงฐานข้อมูล (สูงกว่า = ประสิทธิภาพดีกว่า)", - "Flush Interval (ms)": "ช่วงเวลาล้าง (ms)", - "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "เวลารอสูงสุดก่อนล้างบัฟเฟอร์ (ป้องกันการสูญหายข้อมูลในช่วงจราจรต่ำ)", - "Max JSON Size (KB)": "ขนาด JSON สูงสุด (KB)", - "Maximum size for each JSON field (request/response) before truncation": "ขนาดสูงสุดสำหรับแต่ละช่อง JSON (คำขอ/การตอบสนอง) ก่อนการตัดทอน", - "All data stored on your machine": "ข้อมูลทั้งหมดจัดเก็บไว้บนเครื่องของคุณ", - "MITM Server": "เซิร์ฟเวอร์ MITM", - "Running": "กำลังทำงาน", - "Stopped": "หยุดแล้ว", + "Available Models": "โมเดลที่พร้อมใช้งาน", + "Azure Endpoint": "Azure Endpoint", + "Azure OpenAI Configuration": "การกำหนดค่า Azure OpenAI", + "BXAuth=xxx; ...": "BXAuth=xxx; ...", + "Back": "ย้อนกลับ", + "Back to CLI Tools": "กลับไป CLI Tools", + "Back to Providers": "กลับไปยังผู้ให้บริการ", + "Base URL": "Base URL", + "Batch Import": "นำเข้าแบบ Batch", + "Batch Import Proxies": "นำเข้า Proxies แบบ Batch", + "Batch Size": "ขนาด Batch", + "Beautiful web dashboard for managing providers and monitoring usage.": "web dashboard สวยงามสำหรับจัดการผู้ให้บริการและตรวจสอบการใช้งาน", + "Best quality, but costs the most": "คุณภาพดีที่สุด แต่มีค่าใช้จ่ายมากที่สุด", + "Bias the model toward minimal code: YAGNI, reuse stdlib, deletion over addition": "บังคับโมเดลให้เขียน code น้อยที่สุด: YAGNI, ใช้ stdlib ซ้ำ, ลบมากกว่าเพิ่ม", + "Binary File": "ไฟล์ไบนารี", + "Blog": "บล็อก", + "Both": "ทั้งคู่", + "Browse & edit files": "เรียกดูและแก้ไขไฟล์", + "Browse MCP Marketplace": "เรียกดู MCP Marketplace", + "Browse source, README, and examples.": "เรียกดู source, README และตัวอย่าง", + "Browser Control (Browser MCP)": "การควบคุมเบราว์เซอร์ (Browser MCP)", + "Bulk Add": "เพิ่มจำนวนมาก", + "CLI Support": "รองรับ CLI", + "CLI Tools": "เครื่องมือ CLI", + "CLI on the host →": "CLI บนโฮสต์ →", + "CLIProxyAPI Auth JSON": "CLIProxyAPI Auth JSON", + "Cache Creation": "สร้าง Cache", + "Cache Creation:": "สร้าง Cache:", + "Cached": "แคช", + "Cached Tokens": "Cached Tokens", + "Cached Tokens:": "Cached Tokens:", + "Cached input tokens (typically 50% of input rate)": "Input tokens ที่แคช (ปกติคิดอัตรา 50% ของ input rate)", + "Cached:": "แคช:", + "Calls per account before switching": "จำนวนเรียกก่อนสลับบัญชี", + "Calls per combo model before switching": "จำนวนเรียกก่อนสลับโมเดล combo", + "Cancel": "ยกเลิก", + "Capacity auto-switch": "สลับอัตโนมัติเมื่อเต็ม", "Cert": "ใบรับรอง", - "Server": "เซิร์ฟเวอร์", - "Purpose:": "วัตถุประสงค์:", - "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "ใช้ Antigravity IDE & GitHub Copilot → ที่มีผู้ให้บริการ/โมเดลใด ๆ จาก 9Router", - "How it works:": "วิธีการทำงาน:", - "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "คำขอ Antigravity/Copilot IDE → เปลี่ยนเส้นทาง DNS เป็น localhost:443 → MITM proxy สกัดปะท่อ → 9Router → ตอบสนอง Antigravity/Copilot", - "No API keys — create one in Keys page": "ไม่มีคีย์ API — สร้างคีย์ในหน้า Keys", - "sk_9router (default)": "sk_9router (ค่าเริ่มต้น)", - "Server started": "เซิร์ฟเวอร์เริ่มต้นแล้ว", - "Failed to start server": "ไม่สามารถเริ่มเซิร์ฟเวอร์", - "Server stopped — all DNS cleared": "หยุดเซิร์ฟเวอร์ — ล้าง DNS ทั้งหมด", - "Failed to stop server": "ไม่สามารถหยุดเซิร์ฟเวอร์", - "Sudo password is required": "ต้องการรหัสผ่าน sudo", - "Stop Server": "หยุดเซิร์ฟเวอร์", - "Start Server": "เริ่มเซิร์ฟเวอร์", - "Enable DNS per tool below to activate interception": "เปิดใช้งาน DNS สำหรับแต่ละเครื่องมือด้านล่างเพื่อเปิดใช้งานการสกัดปะท่อ", - "Sudo Password Required": "ต้องการรหัสผ่าน Sudo", - "Enter your sudo password to start/stop MITM server": "ป้อนรหัสผ่าน sudo ของคุณเพื่อเริ่ม/หยุดเซิร์ฟเวอร์ MITM", - "Sudo Password": "รหัสผ่าน Sudo", + "Change Log": "บันทึกการเปลี่ยนแปลง", + "Changelog": "Changelog", + "Chat": "แชท", + "Chat / code-gen via OpenAI or Anthropic format with streaming.": "แชท / สร้างโค้ดผ่าน OpenAI หรือ Anthropic format พร้อม streaming", + "Chat Completions": "Chat Completions", + "Check": "ตรวจสอบ", + "Checking Claude CLI...": "กำลังตรวจสอบ Claude CLI...", + "Checking Claude Cowork...": "กำลังตรวจสอบ Claude Cowork...", + "Checking Cline...": "กำลังตรวจสอบ Cline...", + "Checking Codex CLI...": "กำลังตรวจสอบ Codex CLI...", + "Checking Copilot config...": "กำลังตรวจสอบ Copilot config...", + "Checking DeepSeek TUI...": "กำลังตรวจสอบ DeepSeek TUI...", + "Checking Factory Droid CLI...": "กำลังตรวจสอบ Factory Droid CLI...", + "Checking Hermes Agent...": "กำลังตรวจสอบ Hermes Agent...", + "Checking Kilo Code...": "กำลังตรวจสอบ Kilo Code...", + "Checking Open Claw CLI...": "กำลังตรวจสอบ Open Claw CLI...", + "Checking OpenCode CLI...": "กำลังตรวจสอบ OpenCode CLI...", + "Checking jcode CLI...": "กำลังตรวจสอบ jcode CLI...", + "Checking...": "กำลังตรวจสอบ...", + "Choose API Provider → Ollama": "เลือก API Provider → Ollama", + "Choose how to authenticate with GitLab Duo:": "เลือกวิธียืนยันตัวตนกับ GitLab Duo:", + "Choose your authentication method:": "เลือกวิธียืนยันตัวตน:", + "Claude": "Claude", + "Claude CLI - Manual Configuration": "Claude CLI - กำหนดค่าด้วยตนเอง", + "Claude CLI not detected locally": "ไม่พบ Claude CLI บนเครื่อง", + "Claude CLI not installed": "ไม่ได้ติดตั้ง Claude CLI", + "Claude Cowork - Manual Configuration": "Claude Cowork - กำหนดค่าด้วยตนเอง", + "Claude Desktop (Cowork mode) not detected": "ไม่พบ Claude Desktop (Cowork mode)", + "Claude Desktop Cowork (third-party inference)": "Claude Desktop Cowork (inference จากบุคคลที่สาม)", + "Clear": "ล้าง", + "Clear (will use main model)": "ล้าง (จะใช้โมเดลหลัก)", + "Clear Filters": "ล้างตัวกรอง", + "Clear search": "ล้างการค้นหา", + "Click": "คลิก", + "Click \"View All Model\" → \"Add Custom Model\"": "คลิก \"View All Model\" → \"Add Custom Model\"", + "Click a model to set/clear active": "คลิกโมเดลเพื่อตั้งค่า/ยกเลิกสถานะใช้งาน", "Click to add, click again to remove. Changes are saved automatically.": "คลิกเพื่อเพิ่ม คลิกอีกครั้งเพื่อลบ การเปลี่ยนแปลงจะถูกบันทึกโดยอัตโนมัติ", - "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ ประกาศความเสี่ยง: ผู้ให้บริการนี้ใช้เซสชันสมัครสมาชิก/OAuth ที่ไม่ได้รับอนุญาตอย่างเป็นทางการสำหรับการใช้งานพร็อกซี/เราเตอร์ บัญชีอาจถูกจำกัดหรือถูกแบน ใช้งานด้วยความเสี่ยงของคุณเอง", - "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM ดักจับการรับส่งข้อมูล HTTPS ของเครื่องมือ IDE (Antigravity, GitHub Copilot, Kiro) ผ่าน CA ท้องถิ่นเพื่อเปลี่ยนเส้นทางคำขอไปยังผู้ให้บริการของคุณ อาจละเมิด ToS → เสี่ยงถูกแบนบัญชี ใช้งานด้วยความเสี่ยงของคุณเอง", - "Endpoint is exposed without an API key.": "เอนด์พอยต์เปิดให้เข้าถึงโดยไม่มีคีย์ API" -} + "Click to edit": "คลิกเพื่อแก้ไข", + "Click to retry": "คลิกเพื่อลองใหม่", + "Client ID": "Client ID", + "Client Request": "คำขอจากลูกค้า", + "Client Response": "การตอบกลับลูกค้า", + "Client Secret": "Client Secret", + "Cline - Manual Configuration": "Cline - กำหนดค่าด้วยตนเอง", + "Cline AI Coding Assistant": "Cline AI Coding Assistant", + "Cline not detected locally": "ไม่พบ Cline บนเครื่อง", + "Close": "ปิด", + "Close Proxy": "ปิด Proxy", + "Close provider filter": "ปิดตัวกรองผู้ให้บริการ", + "Close reset credit expiry modal": "ปิดหน้าต่าง reset credit expiry", + "Close test results": "ปิดผลการทดสอบ", + "Closing in": "ปิดใน", + "Cloud Sync": "Cloud Sync", + "Cloudflare Relay": "Cloudflare Relay", + "Cloudflare Tunnel": "Cloudflare Tunnel", + "Cloudflare Workers AI": "Cloudflare Workers AI", + "Codex CLI - Manual Configuration": "Codex CLI - กำหนดค่าด้วยตนเอง", + "Codex CLI not detected locally": "ไม่พบ Codex CLI บนเครื่อง", + "Codex CLI not installed": "ไม่ได้ติดตั้ง Codex CLI", + "Codex Reset Credit Expiry": "Reset Credit Expiry ของ Codex", + "Codex uses": "Codex ใช้", + "Combo Name": "ชื่อ Combo", + "Combo Round Robin": "Combo Round Robin", + "Combo Sticky Limit": "Combo Sticky Limit", + "Combos": "Combos", + "Coming soon...": "เร็วๆ นี้...", + "Comma-separated hostnames/domains to bypass the proxy.": "โฮสต์/โดเมน คั่นด้วยเครื่องหมายจุลภาค เพื่อข้าม proxy", + "Comma-separated hosts/domains to bypass proxy": "โฮสต์/โดเมน คั่นด้วยเครื่องหมายจุลภาค เพื่อข้าม proxy", + "Company": "บริษัท", + "Complete the authorization in the popup window.": "ดำเนินการอนุมัติให้เสร็จสิ้นในหน้าต่างป๊อปอัป", + "Completion/response tokens": "Completion/Response tokens", + "Compress LLM output": "บีบอัด output ของ LLM", + "Compress context": "บีบอัดบริบท", + "Compress prompts via /v1/compress before routing to the model": "บีบอัด prompt ผ่าน /v1/compress ก่อนส่งไปยังโมเดล", + "Compress tool output": "บีบอัด tool output", + "Compress tool output to reduce token usage.": "บีบอัด tool output เพื่อลดการใช้งาน token", + "Config path: Linux/macOS ~/.deepseek/config.toml • Windows %USERPROFILE%\\.deepseek\\config.toml": "เส้นทาง Config: Linux/macOS ~/.deepseek/config.toml • Windows %USERPROFILE%\\.deepseek\\config.toml", + "Config path: Linux/macOS ~/.qwen/settings.json • Windows %USERPROFILE%\\.qwen\\settings.json": "เส้นทาง Config: Linux/macOS ~/.qwen/settings.json • Windows %USERPROFILE%\\.qwen\\settings.json", + "Configuration": "การกำหนดค่า", + "Configure 9router as an OpenAI-compatible provider to route all jcode requests through 9router's optimization layer.": "กำหนดค่า 9router เป็น OpenAI-compatible provider เพื่อส่งต่อคำขอ jcode ทั้งหมดผ่าน optimization layer ของ 9router", + "Configure CLI tools": "กำหนดค่าเครื่องมือ CLI", + "Configure a new AI provider to use with your applications.": "กำหนดค่า AI provider ใหม่เพื่อใช้กับแอปพลิเคชันของคุณ", + "Configure pricing rates for cost tracking and calculations": "กำหนดค่าอัตราการคิดราคาสำหรับการติดตามและคำนวณค่าใช้จ่าย", + "Configure providers and API keys via web interface": "กำหนดค่า providers และ API keys ผ่าน web interface", + "Configured": "กำหนดค่าแล้ว", + "Confirm": "ยืนยัน", + "Confirm New Password": "ยืนยันรหัสผ่านใหม่", + "Confirm Password": "ยืนยันรหัสผ่าน", + "Confirm new password": "ยืนยันรหัสผ่านใหม่", + "Connect": "เชื่อมต่อ", + "Connect AI tools remotely": "เชื่อมต่อเครื่องมือ AI จากที่ไกล", + "Connect Cursor IDE": "เชื่อมต่อ Cursor IDE", + "Connect GitLab Duo": "เชื่อมต่อ GitLab Duo", + "Connect Kiro": "เชื่อมต่อ Kiro", + "Connect to providers with OAuth to track your API quota limits and usage.": "เชื่อมต่อกับ providers ด้วย OAuth เพื่อติดตาม API quota limits และการใช้งานของคุณ", + "Connect via OAuth or API keys. Securely manage credentials.": "เชื่อมต่อผ่าน OAuth หรือ API keys จัดการข้อมูลรับรองอย่างปลอดภัย", + "Connect with OAuth2": "เชื่อมต่อด้วย OAuth2", + "Connect your account using OAuth2 authentication.": "เชื่อมต่อบัญชีของคุณโดยใช้ OAuth2 authentication", + "Connected": "เชื่อมต่อแล้ว", + "Connected Successfully!": "เชื่อมต่อสำเร็จ!", + "Connected providers only": "เฉพาะ providers ที่เชื่อมต่อแล้ว", + "Connecting...": "กำลังเชื่อมต่อ...", + "Connection": "การเชื่อมต่อ", + "Connection Details": "รายละเอียดการเชื่อมต่อ", + "Connection Failed": "เชื่อมต่อล้มเหลว", + "Connections": "การเชื่อมต่อ", + "Console Log": "Console Log", + "Contact": "ติดต่อ", + "Content": "เนื้อหา", + "Continue": "ดำเนินการต่อ", + "Continue AI Assistant": "Continue AI Assistant", + "Continue to summary": "ดำเนินการต่อไปยังสรุป", + "Continue with GitHub": "ดำเนินการต่อด้วย GitHub", + "Continue with Google": "ดำเนินการต่อด้วย Google", + "Cookie": "Cookie", + "Cookie Auth": "Cookie Auth", + "Cookie String": "Cookie String", + "Cooldown": "พักเครื่อง", + "Copied!": "คัดลอกแล้ว!", + "Copy": "คัดลอก", + "Copy & Shutdown": "คัดลอกและปิดระบบ", + "Copy This URL": "คัดลอก URL นี้", + "Copy a link and paste to your AI to use 9Router — no install needed": "คัดลอกลิงก์แล้ววางให้ AI ของคุณเพื่อใช้ 9Router — ไม่ต้องติดตั้ง", + "Copy combo name": "คัดลอกชื่อ combo", + "Copy install command": "คัดลอกคำสั่งติดตั้ง", + "Copy model": "คัดลอกโมเดล", + "Copy the JSON below to your ~/.qwen/settings.json file.": "คัดลอก JSON ด้านล่างไปยังไฟล์ ~/.qwen/settings.json ของคุณ", + "Copy the entire cookie string (must include BXAuth)": "คัดลอก cookie string ทั้งหมด (ต้องมี BXAuth)", + "Cost": "ค่าใช้จ่าย", + "Cost Calculation:": "การคำนวณค่าใช้จ่าย:", + "Costs": "ค่าใช้จ่าย", + "Costs are calculated based on token usage and pricing rates. Each request's cost is determined by: (input_tokens × input_rate) + (output_tokens × output_rate) + (cached_tokens × cached_rate)": "ค่าใช้จ่ายคำนวณจาก token usage และ pricing rates ค่าใช้จ่ายของแต่ละคำขอคำนวณจาก: (input_tokens × input_rate) + (output_tokens × output_rate) + (cached_tokens × cached_rate)", + "Could not read Cursor database automatically.": "ไม่สามารถอ่าน Cursor database โดยอัตโนมัติได้", + "Create": "สร้าง", + "Create API Key": "สร้าง API Key", + "Create Combo": "สร้าง Combo", + "Create Cowork Combo": "สร้าง Cowork Combo", + "Create Key": "สร้าง Key", + "Create Provider": "สร้าง Provider", + "Create Token": "สร้าง Token", + "Create a": "สร้าง", + "Create a proxy pool entry, then assign it to connections.": "สร้าง proxy pool entry แล้วมอบหมายให้กับการเชื่อมต่อ", + "Create model combos with fallback support": "สร้าง model combos ที่รองรับ fallback", + "Create your first API key to get started": "สร้าง API key แรกของคุณเพื่อเริ่มต้น", + "Created": "สร้างแล้ว", + "Creating...": "กำลังสร้าง...", + "Current": "ปัจจุบัน", + "Current Password": "รหัสผ่านปัจจุบัน", + "Current Pricing Overview": "ภาพรวม Pricing ปัจจุบัน", + "Current password": "รหัสผ่านปัจจุบัน", + "Current: Keeps": "ปัจจุบัน: คงไว้", + "Currently using accounts in priority order (Fill First).": "ใช้บัญชีตามลำดับความสำคัญ (เติมก่อน)", + "Cursor AI Code Editor": "Cursor AI Code Editor", + "Cursor IDE not detected. Please paste your tokens manually.": "ไม่พบ Cursor IDE กรุณาวาง tokens ด้วยตนเอง", + "Cursor routes requests through its own server, so local endpoint is not supported. Please enable Tunnel or Cloud Endpoint in Settings.": "Cursor ส่งต่อคำขอผ่านเซิร์ฟเวอร์ของตัวเอง จึงไม่รองรับ local endpoint กรุณาเปิดใช้งาน Tunnel หรือ Cloud Endpoint ในการตั้งค่า", + "Custom": "กำหนดเอง", + "Custom Pricing:": "กำหนดราคาเอง:", + "Custom Providers (OpenAI/Anthropic Compatible)": "Custom Providers (OpenAI/Anthropic Compatible)", + "Custom Token": "Custom Token", + "Custom accounts per page": "กำหนดจำนวนบัญชีต่อหน้าเอง", + "Custom providers": "Custom providers", + "Custom...": "กำหนดเอง...", + "Cycle through accounts to distribute load": "วนลูปบัญชีเพื่อกระจายโหลด", + "Cycle through providers in combos instead of always starting with first": "วนลูป providers ใน combos แทนที่จะเริ่มจากตัวแรกเสมอ", + "DNS off": "DNS ปิด", + "Dashboard": "แดชบอร์ด", + "Dashboard Password": "รหัสผ่านแดชบอร์ด", + "Dashboard:": "แดชบอร์ด:", + "Data Location:": "ตำแหน่งข้อมูล:", + "Data flows seamlessly from your application through our intelligent routing layer to the best provider for the job.": "ข้อมูลไหลอย่างราบรื่นจากแอปพลิเคชันของคุณผ่าน intelligent routing layer ไปยังผู้ให้บริการที่เหมาะสมที่สุด", + "Data flows seamlessly through our intelligent routing system": "ข้อมูลไหลอย่างราบรื่นผ่านระบบ intelligent routing ของเรา", + "Database Location": "ตำแหน่งฐานข้อมูล", + "Database backup downloaded": "ดาวน์โหลดฐานข้อมูลสำรองแล้ว", + "Database imported successfully": "นำเข้าฐานข้อมูลสำเร็จ", + "DateTime": "วันที่และเวลา", + "Deactivate": "ปิดใช้งาน", + "Debug": "ดีบัก", + "Debug translation flow between formats": "ดีบักการไหลของการแปลระหว่าง formats", + "DeepSeek TUI - Manual Configuration": "DeepSeek TUI - กำหนดค่าด้วยตนเอง", + "DeepSeek TUI not detected locally": "ไม่พบ DeepSeek TUI บนเครื่อง", + "DeepSeek TUI uses ~/.deepseek/config.toml for configuration. 9Router will update the provider to 'openai' mode with your base_url, api_key, and model.": "DeepSeek TUI ใช้ ~/.deepseek/config.toml สำหรับการกำหนดค่า 9Router จะอัปเดต provider เป็น 'openai' mode พร้อม base_url, api_key และ model ของคุณ", + "DeepSeek Terminal Coding Agent (Rust TUI)": "DeepSeek Terminal Coding Agent (Rust TUI)", + "Default Model": "โมเดลเริ่มต้น", + "Default password is": "รหัสผ่านเริ่มต้นคือ", + "Default password is 123456": "รหัสผ่านเริ่มต้นคือ 123456", + "Delete": "ลบ", + "Delete API Key": "ลบ API Key", + "Delete connection": "ลบการเชื่อมต่อ", + "Delete saved endpoint": "ลบ endpoint ที่บันทึกไว้", + "Delete selected preset": "ลบ preset ที่เลือก", + "Delete this combo?": "ลบ combo นี้?", + "Delete this connection?": "ลบการเชื่อมต่อนี้?", + "Deno Deploy API Token": "Deno Deploy API Token", + "Deno Deploy v2 runs on a high-performance global edge network": "Deno Deploy v2 ทำงานบน高性能 global edge network", + "Deno Relay": "Deno Relay", + "Deploy": "deploy", + "Deploy Cloudflare Relay": "Deploy Cloudflare Relay", + "Deploy Deno Relay": "Deploy Deno Relay", + "Deploy Relay": "Deploy Relay", + "Deploy Vercel Relay": "Deploy Vercel Relay", + "Deploy multiple relays for maximum IP diversity": "deploy relays หลายตัวเพื่อความหลากหลายของ IP มากที่สุด", + "Deploy multiple relays on different accounts for more IP diversity": "deploy relays หลายตัวบนบัญชีต่างกันเพื่อความหลากหลายของ IP มากขึ้น", + "Deploying... (may take ~1 min)": "กำลัง deploy... (อาจใช้เวลาประมาณ 1 นาที)", + "Deployment Name": "ชื่อ deployment", + "Deploys a Cloudflare Worker as a proxy relay. All AI provider requests will be forwarded through Cloudflare's global edge network.": "deploy Cloudflare Worker เป็น proxy relay คำขอจาก AI providers ทั้งหมดจะถูกส่งต่อผ่าน global edge network ของ Cloudflare", + "Deploys a relay worker to Deno Deploy's global edge network. All AI provider requests are forwarded through Deno's edge, masking your real IP.": "deploy relay worker ไปยัง global edge network ของ Deno Deploy คำขอจาก AI providers ทั้งหมดจะถูกส่งต่อผ่าน Deno edge ปิดบัง IP จริงของคุณ", + "Deploys an edge relay function to Vercel that proxies requests through Vercel's network.": "deploy edge relay function ไปยัง Vercel ที่ส่งต่อคำขอผ่านเครือข่าย Vercel", + "Deploys an edge relay function to Vercel. All AI provider requests will be forwarded through Vercel's edge network, masking your real IP from providers.": "deploy edge relay function ไปยัง Vercel คำขอจาก AI providers ทั้งหมดจะถูกส่งต่อผ่าน edge network ของ Vercel ปิดบัง IP จริงของคุณจาก providers", + "Desktop": "เดสก์ท็อป", + "Detail": "รายละเอียด", + "Details": "รายละเอียด", + "Dimensions": "มิติ", + "Disable": "ปิดใช้งาน", + "Disable All": "ปิดทั้งหมด", + "Disable Tailscale": "ปิด Tailscale", + "Disable Tunnel": "ปิด Tunnel", + "Disable connections with depleted quota on the current page": "ปิดการเชื่อมต่อที่ quota หมดบนหน้าปัจจุบัน", + "Disable provider": "ปิด provider", + "Disable this model": "ปิดโมเดลนี้", + "Disabled": "ปิดใช้งานแล้ว", + "Disabling...": "กำลังปิด...", + "Disconnected from server": "ตัดการเชื่อมต่อจากเซิร์ฟเวอร์", + "Dismiss notification": "ปิดการแจ้งเตือน", + "Display Name": "ชื่อที่แสดง", + "Display language": "ภาษาที่แสดง", + "Docs": "เอกสาร", + "Documentation": "เอกสาร", + "Domain:": "โดเมน:", + "Donate": "บริจาค", + "Done": "เสร็จสิ้น", + "Download": "ดาวน์โหลด", + "Download Backup": "ดาวน์โหลดข้อมูลสำรอง", + "Drag to reorder": "ลากเพื่อจัดเรียงใหม่", + "Easy Setup": "ตั้งค่าง่าย", + "Edit": "แก้ไข", + "Edit Combo": "แก้ไข Combo", + "Edit Connection": "แก้ไขการเชื่อมต่อ", + "Edit Pricing": "แก้ไข Pricing", + "Edit Proxy Pool": "แก้ไข Proxy Pool", + "Edit connection": "แก้ไขการเชื่อมต่อ", + "Edit hosts file manually to add the following entries:": "แก้ไขไฟล์ hosts ด้วยตนเองเพื่อเพิ่มรายการต่อไปนี้:", + "Email": "อีเมล", + "Embedding": "Embedding", + "Embeddings": "Embeddings", + "Enable": "เปิดใช้งาน", + "Enable DNS per tool below to activate interception": "เปิดใช้งาน DNS สำหรับแต่ละเครื่องมือด้านล่างเพื่อเปิดใช้งานการดักจับ", + "Enable DNS to edit model mappings": "เปิดใช้งาน DNS เพื่อแก้ไข model mappings", + "Enable Observability": "เปิดใช้งาน Observability", + "Enable OpenAI API": "เปิดใช้งาน OpenAI API", + "Enable Tunnel": "เปิดใช้งาน Tunnel", + "Enable connections that still have quota on the current page": "เปิดการเชื่อมต่อที่ยังมี quota บนหน้าปัจจุบัน", + "Enable provider": "เปิด provider", + "Enable proxy for OAuth + provider outbound requests.": "เปิด proxy สำหรับ OAuth + provider outbound requests", + "Encrypted": "เข้ารหัสแล้ว", + "End Date": "วันที่สิ้นสุด", + "End-to-end TLS via Cloudflare": "TLS แบบ End-to-end ผ่าน Cloudflare", + "Endpoint": "Endpoint", + "Endpoint & Key": "Endpoint & Key", + "Endpoint is exposed without an API key.": "Endpoint เปิดให้เข้าถึงโดยไม่มี API key", + "Enter current password": "กรุณาป้อนรหัสผ่านปัจจุบัน", + "Enter model id": "กรุณาป้อน model id", + "Enter model id (provider-specific)": "กรุณาป้อน model id (เจาะจงผู้ให้บริการ)", + "Enter new API key": "ป้อน API key ใหม่", + "Enter new password": "ป้อนรหัสผ่านใหม่", + "Enter or pick API key": "ป้อนหรือเลือก API key", + "Enter password": "ป้อนรหัสผ่าน", + "Enter sudo password": "ป้อนรหัสผ่าน sudo", + "Enter the model ID exactly as your compatible endpoint expects it. This model will be saved as the connection default.": "ป้อน model ID ตรงตามที่ compatible endpoint ของคุณต้องการ โมเดลนี้จะถูกบันทึกเป็นค่าเริ่มต้นของการเชื่อมต่อ", + "Enter your API key": "ป้อน API key ของคุณ", + "Enter your current password to": "ป้อนรหัสผ่านปัจจุบันเพื่อ", + "Enter your password to access the dashboard": "ป้อนรหัสผ่านเพื่อเข้าถึงแดชบอร์ด", + "Error": "ข้อผิดพลาด", + "Est. Cost": "ค่าใช้จ่ายโดยประมาณ", + "Estimated, not actual billing": "ค่าใช้จ่ายโดยประมาณ ไม่ใช่บิลจริง", + "Everything you need to manage your AI infrastructure efficiently.": "ทุกสิ่งที่คุณต้องการในการจัดการ AI infrastructure ของคุณอย่างมีประสิทธิภาพ", + "Everything you need to manage your AI infrastructure in one place, built for scale.": "ทุกสิ่งที่คุณต้องการในการจัดการ AI infrastructure ในที่เดียว สร้างมาเพื่อรองรับขนาดใหญ่", + "Example": "ตัวอย่าง", + "Experimental": "ทดลองใช้", + "Expires At": "หมดอายุ", + "Expiring first": "ใกล้หมดอายุก่อน", + "Expiring-first currently reorders accounts inside the current page. Cross-page ordering still follows backend pagination.": "การจัดเรียงแบบใกล้หมดอายุก่อนจะจัดเรียงบัญชีภายในหน้าปัจจุบัน การจัดเรียงข้ามหน้ายังคงใช้ pagination จาก backend", + "Expose your local 9Router to the internet. No port forwarding, no static IP needed. Share endpoint URL with your team or use it in Cursor, Cline, and other AI tools from anywhere.": "เปิด local 9Router ของคุณสู่อินเทอร์เน็ต ไม่ต้อง port forwarding ไม่ต้อง static IP แชร์ endpoint URL กับทีมหรือใช้ใน Cursor, Cline และเครื่องมือ AI อื่นๆ จากทุกที่", + "Factory Droid - Manual Configuration": "Factory Droid - กำหนดค่าด้วยตนเอง", + "Factory Droid AI Assistant": "Factory Droid AI Assistant", + "Factory Droid CLI not detected locally": "ไม่พบ Factory Droid CLI บนเครื่อง", + "Factory Droid CLI not installed": "ไม่ได้ติดตั้ง Factory Droid CLI", + "Fail request if proxy is unreachable instead of falling back to direct.": "ล้มเหลวคำขอเมื่อ proxy ไม่สามารถเข้าถึงได้แทนที่จะ fallback ไปยัง direct", + "Failed to apply settings": "ไม่สามารถใช้การตั้งค่าได้", + "Failed to create combo": "ไม่สามารถสร้าง combo ได้", + "Failed to load changelog:": "ไม่สามารถโหลด changelog ได้:", + "Failed to load usage statistics.": "ไม่สามารถโหลด usage statistics ได้", + "Failed to reset settings": "ไม่สามารถรีเซ็ตการตั้งค่าได้", + "Failed to set alias": "ไม่สามารถตั้ง alias ได้", + "Failed to update combo": "ไม่สามารถอัปเดต combo ได้", + "Failed to update password": "ไม่สามารถอัปเดตรหัสผ่านได้", + "Failed to update proxy settings": "ไม่สามารถอัปเดต proxy settings ได้", + "Fallback": "Fallback", + "Fallback — tries models in order (next on failure)": "Fallback — ลองโมเดลตามลำดับ (ถัดไปเมื่อล้มเหลว)", + "Fallback — try in order": "Fallback — ลองตามลำดับ", + "Features": "คุณสมบัติ", + "Fetch Qoder Models": "ดึง Qoder Models", + "Fetching...": "กำลังดึงข้อมูล...", + "Files": "ไฟล์", + "Filter accounts by status": "กรองบัญชีตามสถานะ", + "Filter naming": "กรอง naming", + "Filter naming requests": "กรอง naming requests", + "Filter quota providers": "กรอง quota providers", + "Find MCPs →": "ค้นหา MCPs →", + "Find your Account ID in the right sidebar of": "ค้นหา Account ID ของคุณในแถบด้านขวาของ", + "Find your Account ID in the right sidebar of dash.cloudflare.com": "ค้นหา Account ID ของคุณในแถบด้านขวาของ dash.cloudflare.com", + "First Page": "หน้าแรก", + "Flush Interval (ms)": "Flush Interval (ms)", + "For enterprise users with custom AWS IAM Identity Center.": "สำหรับผู้ใช้ enterprise ที่มี AWS IAM Identity Center กำหนดเอง", + "Forgot password? Open": "ลืมรหัสผ่าน? เปิด", + "Format": "รูปแบบ", + "Found on the right side of the Cloudflare dashboard overview page.": "พบที่ด้านขวาของหน้าภาพรวม Cloudflare dashboard", + "Free": "ฟรี", + "Free & Free Tier Providers": "Free & Free Tier Providers", + "Free Providers": "Free Providers", + "Free Tier": "Free Tier", + "Free Tier Providers": "Free Tier Providers", + "Free tier: 100,000 requests per day": "Free tier: 100,000 requests ต่อวัน", + "Free tier: 100GB bandwidth/month, 500K edge invocations": "Free tier: 100GB bandwidth/เดือน, 500K edge invocations", + "Free tier: 1M requests & 100GiB outbound traffic per month": "Free tier: 1M requests & 100GiB outbound traffic ต่อเดือน", + "Fresh API key obtained": "ได้รับ API key ใหม่แล้ว", + "Full shell access": "Full shell access", + "Fusion": "Fusion", + "Fusion — panel + judge": "Fusion — panel + judge", + "Fusion — queries all models in parallel, then a judge synthesizes one answer": "Fusion — query โมเดลทั้งหมดแบบ parallel แล้ว judge สร้างคำตอบเดียว", + "Get 9Remote": "รับ 9Remote", + "Get API Key": "รับ API Key", + "Get API Key →": "รับ API Key →", + "Get Started": "เริ่มต้นใช้งาน", + "Get Started in 30 Seconds": "เริ่มต้นใน 30 วินาที", + "Get started": "เริ่มต้น", + "Get started in seconds. Just install, open, and route.": "เริ่มต้นในไม่กี่วินาที ติดตั้ง เปิด และ route", + "Get token →": "รับ token →", + "GitHub": "GitHub", + "GitHub Account": "GitHub Account", + "GitHub Copilot - Manual Configuration": "GitHub Copilot - กำหนดค่าด้วยตนเอง", + "GitHub Copilot IDE with MITM": "GitHub Copilot IDE พร้อม MITM", + "GitLab Access Tokens": "GitLab Access Tokens", + "GitLab Applications": "GitLab Applications", + "GitLab Base URL": "GitLab Base URL", + "Go to": "ไปที่", + "Go to Roo Settings panel": "ไปที่ Roo Settings panel", + "Google Account": "Google Account", + "Google Antigravity IDE with MITM": "Google Antigravity IDE พร้อม MITM", + "Granted At": "ให้สิทธิ์เมื่อ", + "Group models under one name, then pick a strategy per combo:": "รวมโมเดลภายใต้ชื่อเดียว แล้วเลือกกลยุทธ์สำหรับแต่ละ combo:", + "Headroom proxy is reachable. You can enable the token saver.": "Headroom proxy สามารถเข้าถึงได้ คุณสามารถเปิดใช้งาน token saver", + "Help Center": "ศูนย์ช่วยเหลือ", + "Hermes Agent - Manual Configuration": "Hermes Agent - กำหนดค่าด้วยตนเอง", + "Hermes Agent not detected locally": "ไม่พบ Hermes Agent บนเครื่อง", + "Hide": "ซ่อน", + "Hide key": "ซ่อน key", + "High performance global routing and IP masking via Cloudflare Workers": "High performance global routing และ IP masking ผ่าน Cloudflare Workers", + "High-performance Rust-based coding agent harness": "High-performance coding agent harness ที่สร้างด้วย Rust", + "History": "ประวัติ", + "How 9Router Works": "วิธีการทำงานของ 9Router", + "How Pricing Works": "วิธีการทำงานของ Pricing", + "How it Works": "วิธีการทำงาน", + "How it works:": "วิธีการทำงาน:", + "How to Install": "วิธีการติดตั้ง", + "How to generate API token:": "วิธีสร้าง API token:", + "How to generate your API Token:": "วิธีสร้าง API Token ของคุณ:", + "How to get cookie:": "วิธีรับ cookie:", + "ID:": "ID:", + "IDC Start URL": "IDC Start URL", + "If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead.": "หาก provider ไม่มี /models endpoint ให้ป้อน model ID เพื่อตรวจสอบผ่าน chat/completions แทน", + "Image Generation": "สร้างรูปภาพ", + "Image to Text": "รูปภาพเป็นข้อความ", + "Import": "นำเข้า", + "Import Backup": "นำเข้าข้อมูลสำรอง", + "Import CLIProxyAPI JSON": "นำเข้า CLIProxyAPI JSON", + "Import Token": "นำเข้า Token", + "Importing...": "กำลังนำเข้า...", + "In": "ขาเข้า", + "In / Out": "ขาเข้า / ขาออก", + "Inactive": "ไม่ใช้งาน", + "Inactive pools are ignored by runtime resolution.": "Inactive pools จะถูกเมินโดย runtime resolution", + "Inc. All rights reserved.": "Inc. สงวนลิขสิทธิ์", + "Initializing...": "กำลังเริ่มต้น...", + "Input": "ขาเข้า", + "Input Cost": "ค่าใช้จ่ายขาเข้า", + "Input Tokens": "Input Tokens", + "Input Tokens:": "Input Tokens:", + "Input:": "ขาเข้า:", + "Install 9Router": "ติดตั้ง 9Router", + "Install 9Router, configure your providers via web dashboard, and start routing AI requests.": "ติดตั้ง 9Router กำหนดค่า providers ผ่าน web dashboard แล้วเริ่ม routing AI requests", + "Install Chrome extension": "ติดตั้ง Chrome extension", + "Install Cline VS Code extension or CLI from": "ติดตั้ง Cline VS Code extension หรือ CLI จาก", + "Install Kilo Code from": "ติดตั้ง Kilo Code จาก", + "Install Qwen Code": "ติดตั้ง Qwen Code", + "Install Tailscale": "ติดตั้ง Tailscale", + "Install command:": "คำสั่งติดตั้ง:", + "Install jcode to enable automatic configuration:": "ติดตั้ง jcode เพื่อเปิดใช้งานการกำหนดค่าอัตโนมัติ:", + "Install the Amp CLI using the package manager supported by your environment.": "ติดตั้ง Amp CLI โดยใช้ package manager ที่รองรับในสภาพแวดล้อมของคุณ", + "Install then click Start:": "ติดตั้งแล้วคลิกเริ่ม:", + "Install via npm:": "ติดตั้งผ่าน npm:", + "Installation Guide": "คู่มือการติดตั้ง", + "Installing Tailscale...": "กำลังติดตั้ง Tailscale...", + "Interactive diagram visible on desktop": "แผนภาพแบบ interactive ที่มองเห็นบนเดสก์ท็อป", + "Intercept CLI tool traffic and route through 9Router": "ดักจับ CLI tool traffic แล้วส่งต่อผ่าน 9Router", + "Intercepts Antigravity traffic via DNS redirect, letting you reroute models through 9Router.": "ดักจับ Antigravity traffic ผ่าน DNS redirect ช่วยให้คุณ reroute โมเดลผ่าน 9Router", + "Intercepts Claude Code's topic-naming requests and returns a fake response locally, saving API tokens.": "ดักจับ topic-naming requests ของ Claude Code แล้วส่ง fake response กลับภายในเครื่อง ประหยัด API tokens", + "Invalid": "ไม่ถูกต้อง", + "Invalid password": "รหัสผ่านไม่ถูกต้อง", + "Issuer URL": "Issuer URL", + "JSON Response": "JSON Response", + "Join developers who are streamlining their AI integrations with 9Router. Open source and free to start.": "เข้าร่วมกับนักพัฒนาที่กำลังปรับปรุง AI integrations ของพวกเขาด้วย 9Router Open source และเริ่มต้นใช้งานฟรี", + "Judge": "Judge", + "Just now": "เมื่อสักครู่", + "KB per field": "KB ต่อฟิลด์", + "Keep the legacy password login.": "คงการเข้าสู่ระบบด้วยรหัสผ่านแบบเดิมไว้", + "Key Name": "ชื่อ Key", + "KiRo dashboard": "KiRo dashboard", + "Kill & Start": "หยุดและเริ่มใหม่", + "Kill this process to start MITM Server?": "หยุด process นี้เพื่อเริ่ม MITM Server?", + "Kilo Code - Manual Configuration": "Kilo Code - กำหนดค่าด้วยตนเอง", + "Kilo Code AI Assistant": "Kilo Code AI Assistant", + "Kilo Code not detected locally": "ไม่พบ Kilo Code บนเครื่อง", + "Kimi": "Kimi", + "Kiro AI": "Kiro AI", + "Kiro IDE not detected. Please paste your refresh token manually.": "ไม่พบ Kiro IDE กรุณาวาง refresh token ด้วยตนเอง", + "Kiro IDE with MITM": "Kiro IDE พร้อม MITM", + "Language": "ภาษา", + "Languages": "ภาษา", + "Last Page": "หน้าสุดท้าย", + "Last Used": "ใช้ล่าสุด", + "Last tested:": "ทดสอบล่าสุด:", + "Last updated:": "อัปเดตล่าสุด:", + "Latency": "ความเร็ว", + "Latency:": "ความเร็ว:", + "Lazy senior dev": "senior dev ขี้เกียจ", + "Lean": "Lean", + "Leave blank to keep existing secret": "เว้นว่างไว้เพื่อคง secret ที่มีอยู่", + "Leave blank to use": "เว้นว่างไว้เพื่อใช้", + "Leave empty for public PKCE app": "เว้นว่างสำหรับ PKCE app สาธารณะ", + "Leave empty to inherit existing env proxy (if any).": "เว้นว่างไว้เพื่อสืบทอด env proxy ที่มีอยู่ (ถ้ามี)", + "Legacy manual proxy fields are still accepted by API for backward compatibility.": "Legacy manual proxy fields ยังคงได้รับการยอมรับจาก API เพื่อ backward compatibility", + "Legacy:": "เดิม:", + "Legal": "กฎหมาย", + "Live server console output": "Live server console output", + "Load": "โหลด", + "Loading logs...": "กำลังโหลด logs...", + "Loading models from provider...": "กำลังโหลดโมเดลจาก provider...", + "Loading pricing data...": "กำลังโหลด pricing data...", + "Loading registry...": "กำลังโหลด registry...", + "Loading reset credits...": "กำลังโหลด reset credits...", + "Loading...": "กำลังโหลด...", + "Local": "ท้องถิ่น", + "Local Mode": "Local Mode", + "Local Mode - All data stored on your machine": "Local Mode - ข้อมูลทั้งหมดจัดเก็บบนเครื่องของคุณ", + "Local Plugins": "Local Plugins", + "Locked. Retry in": "ล็อก. ลองใหม่ใน", + "Login": "เข้าสู่ระบบ", + "Login Button Label": "ป้ายปุ่มเข้าสู่ระบบ", + "Login URL": "Login URL", + "Login to your account": "เข้าสู่ระบบบัญชีของคุณ", + "Login with your GitHub account (manual callback).": "เข้าสู่ระบบด้วย GitHub account (manual callback)", + "Login with your Google account (manual callback).": "เข้าสู่ระบบด้วย Google account (manual callback)", + "Logout": "ออกจากระบบ", + "Logs": "Logs", + "Logs are loaded from the request history database.": "Logs จะถูกโหลดจาก request history database", + "Logs are saved to log.txt in the application data directory.": "Logs จะถูกบันทึกลง log.txt ใน application data directory", + "MIT License": "MIT License", + "MITM": "MITM", + "MITM Proxy": "MITM Proxy", + "MITM Server": "MITM Server", + "MITM Tools": "MITM Tools", + "Machine ID": "Machine ID", + "Machine ID will be auto-filled...": "Machine ID จะถูกเติมอัตโนมัติ...", + "Make sure Cursor IDE has been opened at least once, then click": "ตรวจสอบว่า Cursor IDE เปิดอย่างน้อยหนึ่งครั้งแล้ว แล้วคลิก", + "Manage": "จัดการ", + "Manage reusable per-connection proxies and bind them to provider connections.": "จัดการ reusable per-connection proxies แล้ว bind เข้ากับ provider connections", + "Manage your AI provider connections": "จัดการการเชื่อมต่อ AI providers ของคุณ", + "Manage your Embedding providers": "จัดการ Embedding providers ของคุณ", + "Manage your Image to Text providers": "จัดการ Image to Text providers ของคุณ", + "Manage your Music providers": "จัดการ Music providers ของคุณ", + "Manage your Speech To Text providers": "จัดการ Speech To Text providers ของคุณ", + "Manage your Text To Speech providers": "จัดการ Text To Speech providers ของคุณ", + "Manage your Text to Image providers": "จัดการ Text to Image providers ของคุณ", + "Manage your Video providers": "จัดการ Video providers ของคุณ", + "Manage your Web Fetch providers": "จัดการ Web Fetch providers ของคุณ", + "Manage your Web Search providers": "จัดการ Web Search providers ของคุณ", + "Manage your preferences": "จัดการการตั้งค่าของคุณ", + "Manage your proxy pool configurations": "จัดการการกำหนดค่า proxy pool ของคุณ", + "Manual / current endpoint": "Manual / current endpoint", + "Manual Callback Required": "ต้องใช้ Manual Callback", + "Manual Config": "กำหนดค่าด้วยตนเอง", + "Manual configuration is still available if 9router is deployed on a remote server.": "การกำหนดค่าด้วยตนเองยังใช้ได้หาก 9router ถูก deploy บน remote server", + "Map Amp shorthand names such as g25p or cs45 to 9Router aliases in your local config.": "Map Amp shorthand names เช่น g25p หรือ cs45 ไปยัง 9Router aliases ในการกำหนดค่าท้องถิ่นของคุณ", + "Mask (URL)": "Mask (URL)", + "Max JSON Size (KB)": "Max JSON Size (KB)", + "Max Records": "Max Records", + "Maximum request detail records to keep (older records are auto-deleted)": "จำนวน request detail records สูงสุดที่จะเก็บ (records เก่าจะถูกลบอัตโนมัติ)", + "Maximum size for each JSON field (request/response) before truncation": "ขนาดสูงสุดสำหรับ JSON field แต่ละตัว (request/response) ก่อนถูกตัด", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "เวลาสูงสุดที่จะรอก่อน flush buffer (ป้องกันข้อมูลสูญหายในช่วง traffic ต่ำ)", + "Media Providers": "Media Providers", + "Menu": "เมนู", + "Message AI": "ส่งข้อความหา AI", + "Messages": "ข้อความ", + "Messages API": "Messages API", + "MiniMax": "MiniMax", + "Model": "โมเดล", + "Model Fallback": "Model Fallback", + "Model ID": "Model ID", + "Model ID (from OpenRouter)": "Model ID (จาก OpenRouter)", + "Model ID (optional)": "Model ID (ไม่บังคับ)", + "Model Status": "สถานะโมเดล", + "Model combos": "Model combos", + "Model combos with fallback": "Model combos พร้อม fallback", + "Model is reachable": "โมเดลเข้าถึงได้", + "Model list is filtered from connected providers.": "รายชื่อโมเดลถูกกรองจาก connected providers", + "Model mappings will be available soon.": "Model mappings จะพร้อมใช้งานเร็วๆ นี้", + "Model not reachable": "โมเดลเข้าถึงไม่ได้", + "Model:": "โมเดล:", + "Models": "โมเดล", + "Monitor your API usage, token consumption, and request logs": "ตรวจสอบ API usage, token consumption และ request logs ของคุณ", + "More on GitHub": "ดูเพิ่มเติมบน GitHub", + "Move down": "เลื่อนลง", + "Move up": "เลื่อนขึ้น", + "Music": "เพลง", + "My Profile": "โปรไฟล์ของฉัน", + "N/A": "ไม่มี", + "NPM": "NPM", + "Name": "ชื่อ", + "Name is required": "ต้องระบุชื่อ", + "Native CLI tool support for Cursor, Claude, Copilot, and more.": "รองรับ CLI tools อย่างเป็นทางการสำหรับ Cursor, Claude, Copilot และอื่นๆ", + "Navigate to home": "ไปที่หน้าแรก", + "Network": "เครือข่าย", + "Network Error": "เครือข่ายขัดข้อง", + "Network error": "เครือข่ายขัดข้อง", + "Never": "ไม่เคย", + "New Password": "รหัสผ่านใหม่", + "New password": "รหัสผ่านใหม่", + "Next": "ถัดไป", + "Next accounts page": "หน้าบัญชีถัดไป", + "No API keys - Create one in Keys page": "ยังไม่มี API keys - สร้างในหน้า Keys", + "No API keys yet": "ยังไม่มี API keys", + "No MCPs added": "ยังไม่ได้เพิ่ม MCPs", + "No Providers Connected": "ยังไม่มี Providers เชื่อมต่อ", + "No Proxy": "ไม่มี Proxy", + "No active connections found for this group.": "ไม่พบการเชื่อมต่อที่ใช้งานอยู่สำหรับกลุ่มนี้", + "No active providers": "ไม่มี providers ที่ใช้งานอยู่", + "No active proxy pools available. Create one in Proxy Pools page first.": "ไม่มี proxy pools ที่ใช้งานอยู่ สร้างในหน้า Proxy Pools ก่อน", + "No authentication required": "ไม่ต้องยืนยันตัวตน", + "No combos yet": "ยังไม่มี combos", + "No combos yet.": "ยังไม่มี combos", + "No compatible providers added yet": "ยังไม่ได้เพิ่ม compatible providers", + "No connections": "ไม่มีการเชื่อมต่อ", + "No connections yet": "ยังไม่มีการเชื่อมต่อ", + "No console logs yet.": "ยังไม่มี console logs", + "No conversations yet.": "ยังไม่มีการสนทนา", + "No custom providers": "ไม่มี custom providers", + "No custom providers — use buttons above to add OpenAI/Anthropic compatible endpoints": "ไม่มี custom providers — ใช้ปุ่มด้านบนเพื่อเพิ่ม OpenAI/Anthropic compatible endpoints", + "No data for this period": "ไม่มีข้อมูลสำหรับช่วงเวลานี้", + "No key configured": "ยังไม่ได้กำหนดค่า key", + "No language selected": "ยังไม่ได้เลือกภาษา", + "No languages found.": "ไม่พบภาษา", + "No logs recorded yet.": "ยังไม่มี logs บันทึกไว้", + "No model selected.": "ยังไม่ได้เลือกโมเดล", + "No models": "ยังไม่มีโมเดล", + "No models added yet": "ยังไม่ได้เพิ่มโมเดล", + "No models configured": "ยังไม่ได้กำหนดค่าโมเดล", + "No models found": "ไม่พบโมเดล", + "No models match your filter.": "ไม่มีโมเดลที่ตรงกับตัวกรองของคุณ", + "No models selected": "ยังไม่ได้เลือกโมเดล", + "No port forwarding needed": "ไม่ต้อง port forwarding", + "No pricing data available": "ไม่มี pricing data ที่พร้อมใช้งาน", + "No providers connected": "ยังไม่มี providers เชื่อมต่อ", + "No providers match your search": "ไม่มี providers ที่ตรงกับการค้นหาของคุณ", + "No providers support": "ไม่มี providers รองรับ", + "No providers yet.": "ยังไม่มี providers", + "No providers.": "ไม่มี providers", + "No proxy pool entries yet": "ยังไม่มี proxy pool entries", + "No proxy:": "ไม่มี proxy:", + "No quota data available": "ไม่มี quota data ที่พร้อมใช้งาน", + "No request details found": "ไม่พบ request details", + "No requests yet.": "ยังไม่มี requests", + "No reset credit details returned for this account.": "ไม่มี reset credit details สำหรับบัญชีนี้", + "No results": "ไม่มีผลลัพธ์", + "No servers match filter": "ไม่มีเซิร์ฟเวอร์ที่ตรงกับตัวกรอง", + "No tools advertised by server.": "เซิร์ฟเวอร์ไม่ได้โฆษณาเครื่องมือใดๆ", + "No usage yet.": "ยังไม่มีการใช้งาน", + "None": "ไม่มี", + "None (unbind all)": "ไม่มี (unbind ทั้งหมด)", + "Not configured": "ยังไม่ได้กำหนดค่า", + "Not installed": "ไม่ได้ติดตั้ง", + "Notice": "ประกาศ", + "Nous Research self-improving AI agent": "Nous Research self-improving AI agent", + "Number of items to accumulate before writing to database (higher = better performance)": "จำนวนรายการที่สะสมก่อนเขียนลง database (ยิ่งมาก = ประสิทธิภาพยิ่งดี)", + "OAuth": "OAuth", + "OAuth & API Keys": "OAuth & API Keys", + "OAuth Account": "OAuth Account", + "OAuth App": "OAuth App", + "OAuth Providers": "OAuth Providers", + "OAuth required": "ต้องใช้ OAuth", + "OIDC Dashboard Login": "OIDC Dashboard Login", + "OIDC active": "OIDC ใช้งานอยู่", + "OIDC login is currently active. Password login is disabled until you switch back.": "OIDC login ใช้งานอยู่ในขณะนี้ การเข้าสู่ระบบด้วยรหัสผ่านจะปิดอยู่จนกว่าจะสลับกลับ", + "OIDC login is enabled, but the issuer/client fields are not configured yet. Password login is still available for recovery.": "OIDC login เปิดใช้งานแล้ว แต่ issuer/client fields ยังไม่ได้กำหนดค่า การเข้าสู่ระบบด้วยรหัสผ่านยังใช้ได้สำหรับการกู้คืน", + "OIDC only": "OIDC เท่านั้น", + "Observability": "Observability", + "Office Proxy": "Office Proxy", + "Ollama Host URL": "Ollama Host URL", + "One Endpoint for": "One Endpoint สำหรับ", + "One key per line. Format:": "หนึ่ง key ต่อบรรทัด รูปแบบ:", + "One-to-one (rotate)": "One-to-one (rotate)", + "Only from connected providers": "เฉพาะจาก connected providers", + "Only letters, numbers, - and _ allowed": "อนุญาตเฉพาะตัวอักษร, ตัวเลข, - และ _", + "Only letters, numbers, -, _ and .": "อนุญาตเฉพาะตัวอักษร, ตัวเลข, -, _ และ .", + "Only letters, numbers, -, _ and . allowed": "อนุญาตเฉพาะตัวอักษร, ตัวเลข, -, _ และ .", + "Only one connection is allowed per compatible node. Add another node if you need more connections.": "อนุญาตหนึ่งการเชื่อมต่อต่อ compatible node หากต้องการการเชื่อมต่อเพิ่มเติม ให้เพิ่ม node อีกตัว", + "Open": "เปิด", + "Open Claude Desktop → Help → Troubleshooting → Enable Developer mode → Configure third-party inference, then return here.": "เปิด Claude Desktop → Help → Troubleshooting → Enable Developer mode → Configure third-party inference แล้วกลับมาที่นี่", + "Open Claw - Manual Configuration": "Open Claw - กำหนดค่าด้วยตนเอง", + "Open Claw AI Assistant": "Open Claw AI Assistant", + "Open Claw CLI not detected locally": "ไม่พบ Open Claw CLI บนเครื่อง", + "Open Claw CLI not installed": "ไม่ได้ติดตั้ง Open Claw CLI", + "Open Continue configuration file": "เปิด Continue configuration file", + "Open Dashboard": "เปิด Dashboard", + "Open DevTools (F12) → Application/Storage → Cookies": "เปิด DevTools (F12) → Application/Storage → Cookies", + "Open Settings": "เปิดการตั้งค่า", + "Open platform.iflow.cn in your browser": "เปิด platform.iflow.cn ในเบราว์เซอร์ของคุณ", + "OpenAI / ElevenLabs / Edge / Google / Deepgram voices.": "OpenAI / ElevenLabs / Edge / Google / Deepgram voices", + "OpenAI Codex CLI": "OpenAI Codex CLI", + "OpenAI Compatible (Prod)": "OpenAI Compatible (Production)", + "OpenAI Compatible Details": "รายละเอียด OpenAI Compatible", + "OpenAI Intermediate": "OpenAI Intermediate", + "OpenAI Response": "OpenAI Response", + "OpenCode - Manual Configuration": "OpenCode - กำหนดค่าด้วยตนเอง", + "OpenCode AI Terminal Assistant": "OpenCode AI Terminal Assistant", + "OpenCode CLI not detected locally": "ไม่พบ OpenCode CLI บนเครื่อง", + "OpenCode CLI not installed": "ไม่ได้ติดตั้ง OpenCode CLI", + "OpenRouter": "OpenRouter", + "OpenRouter supports any model. Add models and create aliases for quick access.": "OpenRouter รองรับโมเดลใดก็ได้ เพิ่มโมเดลและสร้าง aliases เพื่อเข้าถึงอย่างรวดเร็ว", + "Optional SSO via Authentik/Keycloak/Google": "SSO ทางเลือกผ่าน Authentik/Keycloak/Google", + "Or paste callback URL manually": "หรือวาง callback URL ด้วยตนเอง", + "Organization": "องค์กร", + "Organization Domain": "Organization Domain", + "Organization ID": "Organization ID", + "Organization Token": "Organization Token", + "Organization Tokens": "Organization Tokens", + "Other": "อื่นๆ", + "Our engine analyzes the prompt and routes through your subscription, cheap, and free provider tiers with automatic fallback.": "engine ของเราจะวิเคราะห์ prompt แล้วส่งต่อผ่าน subscription, cheap และ free provider tiers ของคุณ พร้อม automatic fallback", + "Our engine analyzes the prompt, checks provider health, and routes for lowest latency or cost.": "engine ของเราจะวิเคราะห์ prompt ตรวจสอบ provider health แล้ว route ไปยัง latency ต่ำสุดหรือค่าใช้จ่ายน้อยที่สุด", + "Out": "ขาออก", + "Outbound Proxy": "Outbound Proxy", + "Output": "ขาออก", + "Output Cost": "ค่าใช้จ่ายขาออก", + "Output Format": "Output Format", + "Output Tokens": "Output Tokens", + "Output Tokens:": "Output Tokens:", + "Output:": "ขาออก:", + "Overview": "ภาพรวม", + "Paid": "เสียเงิน", + "Partial preview": "ตัวอย่างบางส่วน", + "Password": "รหัสผ่าน", + "Password + OIDC active": "รหัสผ่าน + OIDC ใช้งานอยู่", + "Password and OIDC login are both active.": "รหัสผ่านและ OIDC login ใช้งานอยู่ทั้งคู่", + "Password and OIDC login are both enabled.": "รหัสผ่านและ OIDC login เปิดใช้งานทั้งคู่", + "Password only": "เฉพาะรหัสผ่าน", + "Password updated successfully": "อัปเดตรหัสผ่านสำเร็จ", + "Passwords do not match": "รหัสผ่านไม่ตรงกัน", + "Paste Proxy List (One per line)": "วาง Proxy List (หนึ่งต่อบรรทัด)", + "Paste a long-lived Kiro/CodeWhisperer API key. It is validated against AWS and stored directly as a bearer credential (no refresh).": "วาง Kiro/CodeWhisperer API key ที่มีอายุการใช้งานยาวนาน จะถูกตรวจสอบกับ AWS แล้วจัดเก็บโดยตรงเป็น bearer credential (ไม่ต้องรีเฟรช)", + "Paste external_idp auth JSON from CLIProxyAPI/Kiro Microsoft login.": "วาง external_idp auth JSON จาก CLIProxyAPI/Kiro Microsoft login", + "Paste it below": "วางด้านล่าง", + "Paste refresh token from Kiro IDE.": "วาง refresh token จาก Kiro IDE", + "Paste the Kiro CLIProxyAPI auth JSON containing auth_method=external_idp. Only Microsoft login token endpoints are accepted.": "วาง Kiro CLIProxyAPI auth JSON ที่มี auth_method=external_idp จะยอมรับเฉพาะ Microsoft login token endpoints", + "Paste the URL from your browser address bar": "วาง URL จาก address bar ของเบราว์เซอร์", + "Paste the command into your terminal and press Enter.": "วางคำสั่งลงใน terminal แล้วกด Enter", + "Paste this to your AI:": "วางสิ่งนี้ให้ AI ของคุณ:", + "Paste your Kiro API key...": "วาง Kiro API key ของคุณ...", + "Pause API Key": "พัก API Key", + "Pause key": "พัก key", + "Paused": "หยุดชั่วคราว", + "Permissions": "สิทธิ์", + "Personal Access Token": "Personal Access Token", + "Pick the model that fuses panel answers": "เลือกโมเดลที่ fuse panel answers", + "Please add an active Qoder connection first": "กรุณาเพิ่ม Qoder connection ที่ใช้งานอยู่ก่อน", + "Please add and connect providers first to configure CLI tools.": "กรุณาเพิ่มและเชื่อมต่อ providers ก่อนเพื่อกำหนดค่า CLI tools", + "Please copy the URL from the address bar and paste it in the application.": "กรุณาคัดลอก URL จาก address bar แล้ววางในแอปพลิเคชัน", + "Please enter a Proxy URL to test": "กรุณาป้อน Proxy URL เพื่อทดสอบ", + "Please install Claude CLI to use this feature.": "กรุณาติดตั้ง Claude CLI เพื่อใช้คุณสมบัตินี้", + "Please install Codex CLI to use auto-apply feature.": "กรุณาติดตั้ง Codex CLI เพื่อใช้คุณสมบัติ auto-apply", + "Please install Factory Droid CLI to use this feature.": "กรุณาติดตั้ง Factory Droid CLI เพื่อใช้คุณสมบัตินี้", + "Please install Open Claw CLI to use this feature.": "กรุณาติดตั้ง Open Claw CLI เพื่อใช้คุณสมบัตินี้", + "Please install OpenCode CLI to use auto-apply feature.": "กรุณาติดตั้ง OpenCode CLI เพื่อใช้คุณสมบัติ auto-apply", + "Please wait while we complete the authorization.": "กรุณารอในขณะที่เราดำเนินการอนุมัติให้เสร็จสิ้น", + "Point your CLI tools to http://localhost:20128": "ชี้ CLI tools ของคุณไปที่ http://localhost:20128", + "Pool:": "Pool:", + "Popup blocked? Enter URL manually": "ป๊อปอัปถูกบล็อก? ป้อน URL ด้วยตนเอง", + "Port 443 Already In Use": "Port 443 ถูกใช้งานอยู่แล้ว", + "Port 443 is currently used by another process:": "Port 443 ถูกใช้งานโดย process อื่นอยู่ในขณะนี้:", + "Powerful Features": "คุณสมบัติที่ทรงพลัง", + "Prefix": "Prefix", + "Preset": "Preset", + "Prev": "ก่อนหน้า", + "Preview": "ตัวอย่าง", + "Previous accounts page": "หน้าบัญชีก่อนหน้า", + "Pricing": "Pricing", + "Pricing Configuration": "การกำหนดค่า Pricing", + "Pricing Format:": "Pricing Format:", + "Pricing Rates Format": "Pricing Rates Format", + "Pricing Settings": "Pricing Settings", + "Priority": "ลำดับความสำคัญ", + "Privacy Policy": "นโยบายความเป็นส่วนตัว", + "Probing server for tools...": "กำลังตรวจสอบเซิร์ฟเวอร์สำหรับเครื่องมือ...", + "Processing...": "กำลังประมวลผล...", + "Product": "ผลิตภัณฑ์", + "Production Key": "Production Key", + "Project Name": "ชื่อโครงการ", + "Prompt": "Prompt", + "Provider": "Provider", + "Provider Details": "รายละเอียด Provider", + "Provider Limits": "Provider Limits", + "Provider Response": "Provider Response", + "Provider not found": "ไม่พบ Provider", + "Provider test failed": "ทดสอบ Provider ล้มเหลว", + "Provider:": "Provider:", + "Providers": "Providers", + "Proxy": "Proxy", + "Proxy Action": "Proxy Action", + "Proxy Pool": "Proxy Pool", + "Proxy Pools": "Proxy Pools", + "Proxy URL": "Proxy URL", + "Proxy disabled": "ปิด Proxy แล้ว", + "Proxy enabled": "เปิด Proxy แล้ว", + "Proxy pool created": "สร้าง Proxy Pool แล้ว", + "Proxy pool deleted": "ลบ Proxy Pool แล้ว", + "Proxy pool updated": "อัปเดต Proxy Pool แล้ว", + "Proxy settings applied": "ใช้ Proxy settings แล้ว", + "Proxy test OK": "ทดสอบ Proxy สำเร็จ", + "Proxy test failed": "ทดสอบ Proxy ล้มเหลว", + "Proxy test passed": "ทดสอบ Proxy ผ่าน", + "Purpose:": "วัตถุประสงค์:", + "Python >= 3.10 required for local managed mode. Install Python first, or use an external proxy URL.": "ต้องใช้ Python >= 3.10 สำหรับ local managed mode กรุณาติดตั้ง Python ก่อน หรือใช้ external proxy URL", + "Python ≥ 3.10 required for local managed mode. Install Python first, or use an external proxy URL.": "ต้องใช้ Python ≥ 3.10 สำหรับ local managed mode กรุณาติดตั้ง Python ก่อน หรือใช้ external proxy URL", + "Quota Tracker": "Quota Tracker", + "Qwen": "Qwen", + "Qwen Code supports multiple provider types (openai, anthropic, gemini) via modelProviders in settings.json. 9Router works as an OpenAI-compatible endpoint.": "Qwen Code รองรับ provider types หลายประเภท (openai, anthropic, gemini) ผ่าน modelProviders ใน settings.json 9Router ทำงานเป็น OpenAI-compatible endpoint", + "Qwen OAuth free tier was discontinued on 2026-04-15. Use 9Router with alicode/openrouter/anthropic/gemini providers instead.": "Qwen OAuth free tier ถูกยกเลิกเมื่อ 15 เมษายน 2569 ให้ใช้ 9Router กับ alicode/openrouter/anthropic/gemini providers แทน", + "Rate Limited": "ถูกจำกัดอัตรา", + "Read Documentation": "อ่านเอกสาร", + "Reading from AWS SSO cache": "อ่านจาก AWS SSO cache", + "Reading from Cursor IDE database": "อ่านจาก Cursor IDE database", + "Ready": "พร้อม", + "Ready to Simplify Your AI Infrastructure?": "พร้อมที่จะทำให้ AI infrastructure ของคุณง่ายขึ้น?", + "Ready to route! ✓": "พร้อม route แล้ว! ✓", + "Ready! Requests route automatically through your configured providers.": "พร้อมแล้ว! Requests จะ route อัตโนมัติผ่าน providers ที่คุณกำหนดค่าไว้", + "Reasoning": "Reasoning", + "Reasoning:": "Reasoning:", + "Recent Requests": "Requests ล่าสุด", + "Recent chats": "แชทล่าสุด", + "Recheck": "ตรวจสอบอีกครั้ง", + "Recommended for most users. Free AWS account required.": "แนะนำสำหรับผู้ใช้ส่วนใหญ่ ต้องใช้ AWS account ฟรี", + "Record request details for inspection in the logs view": "บันทึก request details สำหรับตรวจสอบใน logs view", + "Redirect URI": "Redirect URI", + "Ref Image (URL)": "Ref Image (URL)", + "Refresh": "รีเฟรช", + "Refresh All": "รีเฟรชทั้งหมด", + "Refresh Token": "Refresh Token", + "Refresh all": "รีเฟรชทั้งหมด", + "Refresh quota": "รีเฟรช quota", + "Region": "Region", + "Reload Page": "โหลดหน้าใหม่", + "Reload VS Code after applying for changes to take effect.": "โหลด VS Code ใหม่หลังจากใช้เพื่อให้การเปลี่ยนแปลงมีผล", + "Remaining": "เหลือ", + "Remote": "ระยะไกล", + "Remove": "นำออก", + "Remove attachment": "ลบไฟล์แนบ", + "Remove custom model": "ลบ custom model", + "Remove model": "ลบโมเดล", + "Replaces built-in WebSearch/WebFetch. Auto-strips duplicates from tool list.": "แทนที่ built-in WebSearch/WebFetch จะลบรายการซ้ำจาก tool list โดยอัตโนมัติ", + "Replay request flow — matches log files": "เล่นซ้ำ request flow — ตรงกับ log files", + "Request": "Request", + "Request Details": "Request Details", + "Request Logs": "Request Logs", + "Requests": "Requests", + "Requests without a valid key will be rejected": "Requests ที่ไม่มี key ที่ถูกต้องจะถูกปฏิเสธ", + "Require API key": "ต้องใช้ API key", + "Require OIDC for dashboard access.": "ต้องใช้ OIDC เพื่อเข้าถึง dashboard", + "Require login": "ต้องเข้าสู่ระบบ", + "Required for SSL certificate and DNS configuration": "ต้องใช้สำหรับ SSL certificate และ DNS configuration", + "Required for SSL certificate and server startup": "ต้องใช้สำหรับ SSL certificate และ server startup", + "Required to modify /etc/hosts and flush DNS cache": "ต้องใช้สำหรับแก้ไข /etc/hosts และ flush DNS cache", + "Required. A friendly label for this node.": "จำเป็น ป้ายที่อ่านง่ายสำหรับ node นี้", + "Required. Used as the provider prefix for model IDs.": "จำเป็น ใช้เป็น provider prefix สำหรับ model IDs", + "Requires \"Workers Scripts: Edit\" permission.": "ต้องใช้สิทธิ์ \"Workers Scripts: Edit\"", + "Requires Cloudflare Account ID and a Workers API Token (Edit Workers permission)": "ต้องใช้ Cloudflare Account ID และ Workers API Token (สิทธิ์ Edit Workers)", + "Requires Cursor Pro account to use this feature.": "ต้องใช้ Cursor Pro account เพื่อใช้คุณสมบัตินี้", + "Requires jcode installed. Install via: curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash": "ต้องติดตั้ง jcode ติดตั้งผ่าน: curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash", + "Requires outbound port 7844 (TCP/UDP). Connection may take 10-30s.": "ต้องใช้ outbound port 7844 (TCP/UDP) การเชื่อมต่ออาจใช้เวลา 10-30 วินาที", + "Reset": "รีเซ็ต", + "Reset Codex limit?": "รีเซ็ต Codex limit?", + "Reset Password to Default": "รีเซ็ตรหัสผ่านเป็นค่าเริ่มต้น", + "Reset judge to Auto": "รีเซ็ต judge เป็น Auto", + "Reset time": "เวลาที่รีเซ็ต", + "Reset to Defaults": "รีเซ็ตเป็นค่าเริ่มต้น", + "Reset to default": "รีเซ็ตเป็นค่าเริ่มต้น", + "Resources": "ทรัพยากร", + "Response": "Response", + "Response Format": "Response Format", + "Responses": "Responses", + "Responses API": "Responses API", + "Restart": "เริ่มต้นใหม่", + "Restore model": "กู้คืนโมเดล", + "Resume key": "Resume key", + "Retry": "ลองใหม่", + "Risk Notice": "ประกาศความเสี่ยง", + "Roo AI Assistant": "Roo AI Assistant", + "Rotate providers across requests instead of strict fallback order.": "หมุนเวียน providers ผ่าน requests แทนที่จะใช้ fallback order อย่างเคร่งครัด", + "Round Robin": "Round Robin", + "Round Robin — rotate": "Round Robin — หมุนเวียน", + "Round Robin — rotates models across requests to spread load": "Round Robin — หมุนเวียนโมเดลผ่าน requests เพื่อกระจายโหลด", + "Route AI requests through subscription, cheap, and free tiers with auto-fallback. One endpoint for Claude, GPT, Gemini, and more.": "Route AI requests ผ่าน subscription, cheap และ free tiers พร้อม auto-fallback One endpoint สำหรับ Claude, GPT, Gemini และอื่นๆ", + "Route Requests": "Route Requests", + "Routing Strategy": "Routing Strategy", + "Rows:": "Rows:", + "Run": "รัน", + "Run npx command to start the server instantly": "รันคำสั่ง npx เพื่อเริ่มต้นเซิร์ฟเวอร์ทันที", + "Run this command in your terminal, then click": "รันคำสั่งนี้ใน terminal ของคุณ แล้วคลิก", + "Running": "กำลังทำงาน", + "Running on your machine": "ทำงานบนเครื่องของคุณ", + "Runtime": "Runtime", + "SSE URL": "SSE URL", + "START HERE": "เริ่มที่นี่", + "Save": "บันทึก", + "Save Changes": "บันทึกการเปลี่ยนแปลง", + "Save Config": "บันทึก Config", + "Save Mappings": "บันทึก Mappings", + "Save auth mode": "บันทึกโหมดยืนยันตัวตน", + "Save current Base URL and API key as a browser-local preset": "บันทึก Base URL และ API key ปัจจุบันเป็น preset ที่เก็บในเบราว์เซอร์", + "Save this key now!": "บันทึก key นี้ตอนนี้!", + "Saved": "บันทึกแล้ว", + "Saving": "กำลังบันทึก", + "Saving...": "กำลังบันทึก...", + "Scan QR to connect instantly": "สแกน QR เพื่อเชื่อมต่อทันที", + "Scopes": "Scopes", + "Screen sharing": "แชร์หน้าจอ", + "Scroll down to": "เลื่อนลงมาที่", + "Search by name or description...": "ค้นหาตามชื่อหรือคำอธิบาย...", + "Search language...": "ค้นหาภาษา...", + "Search model id": "ค้นหา model id", + "Search providers...": "ค้นหา providers...", + "Search...": "ค้นหา...", + "Security": "ความปลอดภัย", + "Security required: ": "ต้องใช้ความปลอดภัย:", + "Security risk: no password set. You will be asked to set one when logging in remotely.": "ความเสี่ยงด้านความปลอดภัย: ไม่ได้ตั้งรหัสผ่าน คุณจะถูกถามให้ตั้งรหัสผ่านเมื่อเข้าสู่ระบบจากที่ไกล", + "Select": "เลือก", + "Select All": "เลือกทั้งหมด", + "Select Cowork Model": "เลือก Cowork Model", + "Select Endpoint": "เลือก Endpoint", + "Select Judge Model": "เลือก Judge Model", + "Select Language": "เลือกภาษา", + "Select Model": "เลือกโมเดล", + "Select Model for Cline": "เลือกโมเดลสำหรับ Cline", + "Select Model for Codex": "เลือกโมเดลสำหรับ Codex", + "Select Model for DeepSeek TUI": "เลือกโมเดลสำหรับ DeepSeek TUI", + "Select Model for Factory Droid": "เลือกโมเดลสำหรับ Factory Droid", + "Select Model for GitHub Copilot": "เลือกโมเดลสำหรับ GitHub Copilot", + "Select Model for Hermes Agent": "เลือกโมเดลสำหรับ Hermes Agent", + "Select Model for Kilo Code": "เลือกโมเดลสำหรับ Kilo Code", + "Select Model for Open Claw": "เลือกโมเดลสำหรับ Open Claw", + "Select Model for OpenCode": "เลือกโมเดลสำหรับ OpenCode", + "Select Model for jcode": "เลือกโมเดลสำหรับ jcode", + "Select Provider": "เลือก Provider", + "Select Subagent Model for Codex": "เลือก Subagent Model สำหรับ Codex", + "Select Subagent Model for OpenCode": "เลือก Subagent Model สำหรับ OpenCode", + "Select a provider": "เลือก provider", + "Select all": "เลือกทั้งหมด", + "Select language": "เลือกภาษา", + "Select models to add": "เลือกโมเดลที่ต้องการเพิ่ม", + "Select one or more connections, then click Proxy Action.": "เลือกการเชื่อมต่อหนึ่งรายการขึ้นไป แล้วคลิก Proxy Action", + "Select to pre-fill, then edit model ID in the input": "เลือกเพื่อเติมล่วงหน้า แล้วแก้ไข model ID ในช่องป้อน", + "Select your": "เลือก", + "Selected connections have mixed proxy bindings": "การเชื่อมต่อที่เลือกมี proxy bindings ที่แตกต่างกัน", + "Selected only": "เฉพาะที่เลือก", + "Selected provider": "Provider ที่เลือก", + "Selecting None will unbind selected connections from proxy pool.": "เลือก None จะ unbind selected connections จาก proxy pool", + "Send": "ส่ง", + "Send to Provider": "ส่งไปยัง Provider", + "Sent to provider as:": "ส่งไปยัง Provider ในรูปแบบ:", + "Server": "เซิร์ฟเวอร์", + "Server Disconnected": "เซิร์ฟเวอร์ตัดการเชื่อมต่อ", + "Server off": "ปิดเซิร์ฟเวอร์", + "Server running on": "เซิร์ฟเวอร์ทำงานบน", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "บริการกำลังทำงานใน terminal คุณสามารถปิดหน้าเว็บนี้ได้ การปิดระบบจะหยุดบริการ", + "Set Password": "ตั้งรหัสผ่าน", + "Set a new password before accessing the dashboard remotely.": "ตั้งรหัสผ่านใหม่ก่อนเข้าถึง dashboard จากที่ไกล", + "Set password": "ตั้งรหัสผ่าน", + "Setting password for the first time. Leave current password empty or use default:": "ตั้งรหัสผ่านครั้งแรก ปล่อยรหัสผ่านปัจจุบันว่างหรือใช้ค่าเริ่มต้น:", + "Setting up": "กำลังตั้งค่า", + "Settings": "การตั้งค่า", + "Settings applied successfully!": "ใช้การตั้งค่าสำเร็จ!", + "Settings reset successfully!": "รีเซ็ตการตั้งค่าสำเร็จ!", + "Setup": "ตั้งค่า", + "Setup + index of all capabilities. Start here — covers base URL, auth, model discovery, and links to every capability skill.": "การตั้งค่า + index ของทุกคุณสมบัติ เริ่มที่นี่ — ครอบคลุม base URL, auth, model discovery และลิงก์ไปยังทุกคุณสมบัติ", + "Share Endpoint": "แชร์ Endpoint", + "Share URL with team members": "แชร์ URL กับสมาชิกทีม", + "Show": "แสดง", + "Show all": "แสดงทั้งหมด", + "Show key": "แสดง key", + "Show only selected models": "แสดงเฉพาะโมเดลที่เลือก", + "Showing": "กำลังแสดง", + "Shutdown": "ปิดระบบ", + "Sign in with OIDC": "เข้าสู่ระบบด้วย OIDC", + "Simple chat interface to interact with any AI model from connected providers. Select a model and start chatting!": "แชท interface ง่ายๆ สำหรับโต้ตอบกับ AI models จาก connected providers เลือกโมเดลแล้วเริ่มแชท!", + "Single": "เดี่ยว", + "Single API endpoint for all major AI providers. Simplify your integration.": "API endpoint เดียวสำหรับ AI providers ทั้งหมด ทำให้ integration ของคุณง่ายขึ้น", + "Some models are not responding": "โมเดลบางตัวไม่ตอบสนอง", + "Sort Codex quotas by remaining": "เรียง Codex quotas ตามจำนวนที่เหลือ", + "Sort accounts by earliest quota reset time": "เรียงบัญชีตามเวลา quota reset เร็วที่สุด", + "Source Body": "Source Body", + "Sourcegraph Amp coding assistant CLI": "Sourcegraph Amp coding assistant CLI", + "Special reasoning/thinking tokens (fallback to output rate)": "Special reasoning/thinking tokens (fallback ไปยัง output rate)", + "Speech To Text": "Speech To Text", + "Speech-to-Text": "Speech-to-Text", + "Standard prompt tokens": "Standard prompt tokens", + "Start DNS": "เริ่ม DNS", + "Start Date": "วันที่เริ่มต้น", + "Start Free": "เริ่มฟรี", + "Start Headroom": "เริ่ม Headroom", + "Start Headroom separately at the configured URL, then recheck.": "เริ่ม Headroom แยกต่างหากที่ URL ที่กำหนด แล้วตรวจสอบอีกครั้ง", + "Start MITM": "เริ่ม MITM", + "Start Server": "เริ่มเซิร์ฟเวอร์", + "Start Tunnel": "เริ่ม Tunnel", + "Start a conversation": "เริ่มการสนทนา", + "Starting 9Router...": "กำลังเริ่ม 9Router...", + "Status": "สถานะ", + "Status:": "สถานะ:", + "Step 1: Open this URL in your browser": "ขั้นตอนที่ 1: เปิด URL นี้ในเบราว์เซอร์ของคุณ", + "Step 2: Paste the callback URL here": "ขั้นตอนที่ 2: วาง callback URL ที่นี่", + "Sticky Limit": "Sticky Limit", + "Sticky:": "Sticky:", + "Stop": "หยุด", + "Stop DNS": "หยุด DNS", + "Stop Headroom": "หยุด Headroom", + "Stop MITM": "หยุด MITM", + "Stop Server": "หยุดเซิร์ฟเวอร์", + "Stopped": "หยุดแล้ว", + "Strict Proxy": "Strict Proxy", + "Subagent Model": "Subagent Model", + "Sudo Password Required": "ต้องใช้ Sudo Password", + "Sudo password is required": "ต้องใช้ Sudo password", + "Suggested free models (≥200k context):": "โมเดลฟรีที่แนะนำ (≥200k context):", + "Suggested shorthand examples: g25p → gemini/gemini-2.5-pro, g25f → gemini/gemini-2.5-flash, cs45 → cc/claude-sonnet-4-5-20250929.": "ตัวอย่าง shorthand ที่แนะนำ: g25p → gemini/gemini-2.5-pro, g25f → gemini/gemini-2.5-flash, cs45 → cc/claude-sonnet-4-5-20250929", + "Support up to 20 active apps & 50 custom domains": "รองรับสูงสุด 20 active apps & 50 custom domains", + "Supported formats: protocol://user:pass@host:port, host:port:user:pass": "Supported formats: protocol://user:pass@host:port, host:port:user:pass", + "Sync settings across devices with optional cloud storage.": "ซิงค์การตั้งค่าผ่านอุปกรณ์ต่างๆ ด้วย cloud storage ทางเลือก", + "System": "ระบบ", + "TTFT:": "TTFT:", + "Tailscale": "Tailscale", + "Tailscale Funnel": "Tailscale Funnel", + "Tailscale Funnel will be stopped. Remote access via Tailscale URL will stop working.": "Tailscale Funnel จะหยุด การเข้าถึงจากที่ไกลผ่าน Tailscale URL จะหยุดทำงาน", + "Tailscale installed": "ติดตั้ง Tailscale แล้ว", + "Tailscale is not installed. Install it to enable Funnel.": "ไม่ได้ติดตั้ง Tailscale ติดตั้งเพื่อเปิดใช้งาน Funnel", + "Target Request": "Target Request", + "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.": "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com", + "Temperature": "Temperature", + "Terminal": "Terminal", + "Terms of Service": "ข้อกำหนดการให้บริการ", + "Terse-style system prompt → ~65% fewer output tokens (up to 87%)": "Terse-style system prompt → ลด output tokens ประมาณ 65% (สูงสุด 87%)", + "Test": "ทดสอบ", + "Test Again": "ทดสอบอีกครั้ง", + "Test All": "ทดสอบทั้งหมด", + "Test Example": "ตัวอย่างทดสอบ", + "Test Results": "ผลการทดสอบ", + "Test all API Key connections": "ทดสอบ API Key connections ทั้งหมด", + "Test all Compatible connections": "ทดสอบ Compatible connections ทั้งหมด", + "Test all Free connections": "ทดสอบ Free connections ทั้งหมด", + "Test all Free provider connections": "ทดสอบ Free provider connections ทั้งหมด", + "Test all OAuth connections": "ทดสอบ OAuth connections ทั้งหมด", + "Test connection": "ทดสอบการเชื่อมต่อ", + "Test model": "ทดสอบโมเดล", + "Test proxy": "ทดสอบ proxy", + "Test proxy URL": "ทดสอบ proxy URL", + "Testing...": "กำลังทดสอบ...", + "Text To Speech": "Text To Speech", + "Text To Speech combo": "Text To Speech combo", + "Text to Image": "Text to Image", + "Text to Image combo": "Text to Image combo", + "Text-to-Speech": "Text-to-Speech", + "Text-to-image via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…": "Text-to-image ผ่าน DALL-E, Imagen, FLUX, MiniMax, SDWebUI...", + "The Cloudflare tunnel will be disconnected. Remote access via tunnel URL will stop working.": "Cloudflare tunnel จะถูกตัดการเชื่อมต่อ การเข้าถึงจากที่ไกลผ่าน tunnel URL จะหยุดทำงาน", + "The proxy server has been stopped.": "Proxy server ถูกหยุดแล้ว", + "The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.": "Request ได้รับการตอบสนองจาก OpenAI, Anthropic, Gemini หรือผู้ให้บริการอื่นทันที", + "The tunnel will be disconnected. Remote access will stop working.": "Tunnel จะถูกตัดการเชื่อมต่อ การเข้าถึงจากที่ไกลจะหยุดทำงาน", + "The unified endpoint for AI generation. Connect, route, and manage your AI providers with ease.": "Endpoint เดียวสำหรับ AI generation เชื่อมต่อ route และจัดการ AI providers ของคุณอย่างง่ายดาย", + "The unified interface for modern AI infrastructure": "Interface เดียวสำหรับ AI infrastructure สมัยใหม่", + "The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "Interface เดียวสำหรับ AI infrastructure สมัยใหม่ ปลอดภัย ตรวจสอบได้ และขยายขนาดได้", + "Theme": "ธีม", + "Thinking": "Thinking", + "Thinking Process": "Thinking Process", + "This is the only time you will see this key. Store it securely.": "นี่เป็นครั้งเดียวที่คุณจะเห็น key นี้ กรุณาจัดเก็บอย่างปลอดภัย", + "This provider is ready to use.": "Provider นี้พร้อมใช้งาน", + "This provider is ready to use. Optionally route requests through a proxy pool to bypass IP-based limits.": "Provider นี้พร้อมใช้งาน ทางเลือกสามารถ route requests ผ่าน proxy pool เพื่อหลีกเลี่ยง IP-based limits", + "This value is write-only after saving.": "ค่านี้จะเขียนได้เฉพาะหลังจากบันทึกแล้ว", + "Timestamp": "Timestamp", + "Timestamp:": "Timestamp:", + "To get a fresh API key, paste your browser cookie from": "เพื่อรับ API key ใหม่ วาง browser cookie ของคุณจาก", + "Today": "วันนี้", + "Toggle DNS to redirect": "เปิด/ปิด DNS เพื่อ redirect", + "Toggle auto-ping": "เปิด/ปิด auto-ping", + "Token Saver": "Token Saver", + "Token Types:": "Token Types:", + "Token auto-detected from Kiro IDE successfully!": "ตรวจจับ Token จาก Kiro IDE สำเร็จ!", + "Token is used once for deployment and not stored.": "Token ใช้ครั้งเดียวสำหรับ deployment ไม่ได้จัดเก็บ", + "Token is used once for deployment, not stored. Found in Organization Settings.": "Token ใช้ครั้งเดียวสำหรับ deployment ไม่ได้จัดเก็บ พบใน Organization Settings", + "Token will be auto-filled...": "Token จะถูกเติมอัตโนมัติ...", + "Tokens": "Tokens", + "Tokens auto-detected from Cursor IDE successfully!": "ตรวจจับ Tokens จาก Cursor IDE สำเร็จ!", + "Tokens used to create cache entries (fallback to input rate)": "Tokens ที่ใช้สร้าง cache entries (fallback ไปยัง input rate)", + "Tomorrow": "พรุ่งนี้", + "Tool not found or disabled.": "ไม่พบเครื่องมือหรือถูกปิดใช้งาน", + "Tools": "เครื่องมือ", + "Tools:": "เครื่องมือ:", + "Total Cost": "Total Cost", + "Total Input Tokens": "Input Tokens ทั้งหมด", + "Total Models": "โมเดลทั้งหมด", + "Total Requests": "Requests ทั้งหมด", + "Total Tokens": "Tokens ทั้งหมด", + "Total:": "ทั้งหมด:", + "Track and manage your API quota limits": "ติดตามและจัดการ API quota limits ของคุณ", + "Track token usage, costs, and performance across all providers.": "ติดตาม token usage, costs และ performance ของ providers ทั้งหมด", + "Transcribe audio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…": "Transcribe audio ผ่าน OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI...", + "Transferring data...": "กำลังถ่ายโอนข้อมูล...", + "Translator": "Translator", + "Translator Debug": "Translator Debug", + "Tried in order (top-down) or rotated when round-robin is on.": "ลองตามลำดับ (บนลงล่าง) หรือหมุนเวียนเมื่อ round-robin เปิดอยู่", + "Trust Cert": "Trust Cert", + "Trusted": "เชื่อถือแล้ว", + "Try Again": "ลองอีกครั้ง", + "Tunnel": "Tunnel", + "Tunnel connected!": "Tunnel เชื่อมต่อแล้ว!", + "Tunnel disabled": "ปิด Tunnel แล้ว", + "Turn off Empty": "ปิด Empty", + "Turn on Available": "เปิด Available", + "Turn request detail recording on/off globally": "เปิด/ปิดการบันทึก request details ทั่วโลก", + "Twitter": "Twitter", + "URL → markdown / text / HTML via Firecrawl, Jina, Tavily, Exa.": "URL → markdown / text / HTML ผ่าน Firecrawl, Jina, Tavily, Exa", + "Unavailable": "ไม่พร้อมใช้งาน", + "Under": "ภายใต้", + "Unified Endpoint": "Unified Endpoint", + "Unknown": "ไม่ทราบ", + "Unselect all": "ยกเลิกเลือกทั้งหมด", + "Update": "อัปเดต", + "Update 9Router": "อัปเดต 9Router", + "Update Password": "อัปเดตรหัสผ่าน", + "Update now": "อัปเดตตอนนี้", + "Upstream Auth Error": "Upstream Auth Error", + "Upstream Unavailable": "Upstream ไม่พร้อมใช้งาน", + "Usage": "การใช้งาน", + "Usage & Analytics": "การใช้งานและ Analytics", + "Usage / Limit": "การใช้งาน / ขีดจำกัด", + "Usage Logs": "Usage Logs", + "Usage Tracking": "Usage Tracking", + "Usage by API Key": "การใช้งานตาม API Key", + "Usage by Account": "การใช้งานตามบัญชี", + "Usage by Endpoint": "การใช้งานตาม Endpoint", + "Usage by Model": "การใช้งานตามโมเดล", + "Usage:": "การใช้งาน:", + "Use 9Router model aliases to keep Amp shorthand mappings stable across provider updates.": "ใช้ 9Router model aliases เพื่อรักษา Amp shorthand mappings ให้คงที่ตลอดการอัปเดต providers", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "ใช้ Antigravity IDE & GitHub Copilot → กับ providers/models ใดก็ได้จาก 9Router", + "Use Authentik or any OIDC provider to sign in to the dashboard.": "ใช้ Authentik หรือ OIDC providers ใดก็ได้เพื่อเข้าสู่ระบบ dashboard", + "Use Authentik or any OIDC provider to sign in to the dashboard. You can enable password-only, OIDC-only, or both for the dashboard; model API access still uses API keys.": "ใช้ Authentik หรือ OIDC providers ใดก็ได้เพื่อเข้าสู่ระบบ dashboard คุณสามารถเปิดใช้งานเฉพาะรหัสผ่าน เฉพาะ OIDC หรือทั้งคู่สำหรับ dashboard; model API access ยังคงใช้ API keys", + "Use a GitLab OAuth application": "ใช้ GitLab OAuth application", + "Use a GitLab PAT with api scope": "ใช้ GitLab PAT ที่มี api scope", + "Use a direct xAI API key from console.x.ai. This is separate from Grok Build OAuth.": "ใช้ xAI API key โดยตรงจาก console.x.ai นี้แยกจาก Grok Build OAuth", + "Use a local proxy for Start/Stop, or an external Docker sidecar like http://headroom:8787.": "ใช้ local proxy สำหรับ Start/Stop หรือ external Docker sidecar เช่น http://headroom:8787", + "Use a long-lived Kiro/CodeWhisperer API key (headless auth).": "ใช้ Kiro/CodeWhisperer API key ที่มีอายุการใช้งานยาวนาน (headless auth)", + "Use in Cursor/Cline": "ใช้ใน Cursor/Cline", + "Use the buttons above to add OpenAI or Anthropic compatible endpoints": "ใช้ปุ่มด้านบนเพื่อเพิ่ม OpenAI หรือ Anthropic compatible endpoints", + "Use your API from any network": "ใช้ API ของคุณจากเครือข่ายใดก็ได้", + "Valid": "ถูกต้อง", + "Vectors for RAG / semantic search via OpenAI, Gemini, Mistral…": "Vectors สำหรับ RAG / semantic search ผ่าน OpenAI, Gemini, Mistral...", + "Vercel API Token": "Vercel API Token", + "Vercel Relay": "Vercel Relay", + "Vercel serves millions of apps — providers can't block Vercel IPs without affecting legitimate traffic": "Vercel ให้บริการ millions of apps — providers ไม่สามารถบล็อก Vercel IPs โดยไม่กระทบ traffic ที่ถูกกฎหมาย", + "Verification URL": "Verification URL", + "Video": "วิดีโอ", + "View Codex reset credit expiry": "ดู Codex reset credit expiry", + "View Full Details": "ดูรายละเอียดทั้งหมด", + "View on GitHub": "ดูบน GitHub", + "Visit the URL below and enter the code:": "เยี่ยมชม URL ด้านล่างแล้วป้อนรหัส:", + "Visit the login URL below and authorize:": "เยี่ยมชม login URL ด้านล่างแล้วอนุมัติ:", + "Voice": "เสียง", + "Voice ID": "Voice ID", + "Voyage AI": "Voyage AI", + "Waiting for Authorization": "รอการอนุมัติ", + "Waiting for authorization...": "รอการอนุมัติ...", + "Warning": "คำเตือน", + "Web Fetch": "Web Fetch", + "Web Fetch & Search": "Web Fetch & Search", + "Web Search": "Web Search", + "Web Search & Fetch (Exa)": "Web Search & Fetch (Exa)", + "Welcome": "ยินดีต้อนรับ", + "What is Cloudflare Relay?": "Cloudflare Relay คืออะไร?", + "What is Deno Relay?": "Deno Relay คืออะไร?", + "What is Vercel Relay?": "Vercel Relay คืออะไร?", + "When": "เมื่อ", + "When ON, dashboard requires password. When OFF, access without login.": "เมื่อเปิด dashboard ต้องใช้รหัสผ่าน เมื่อปิด เข้าถึงได้โดยไม่ต้องเข้าสู่ระบบ", + "Windows:": "Windows:", + "Windows: Run 9Router terminal as Administrator": "Windows: รัน 9Router terminal ในฐานะผู้ดูแลระบบ", + "Windows: Run terminal (9Router) as Administrator to enable MITM": "Windows: รัน terminal (9Router) ในฐานะผู้ดูแลระบบเพื่อเปิดใช้งาน MITM", + "Worker Name": "Worker Name", + "Works on any device": "ใช้งานได้บนทุกอุปกรณ์", + "Writes to": "เขียนไปที่", + "You can override default pricing for specific models. Reset to defaults anytime to restore standard rates.": "คุณสามารถแทนที่ default pricing สำหรับโมเดลเฉพาะได้ รีเซ็ตเป็นค่าเริ่มต้นเมื่อใดก็ได้เพื่อกลับไปใช้อัตราปกติ", + "Your": "ของคุณ", + "Your Account Name": "ชื่อบัญชีของคุณ", + "Your Code": "โค้ดของคุณ", + "Your Kiro account via": "Kiro account ของคุณผ่าน", + "Your OAuth application client ID": "OAuth application client ID ของคุณ", + "Your organization's AWS IAM Identity Center URL": "AWS IAM Identity Center URL ขององค์กรคุณ", + "Your requests start from your favorite tools or our unified SDK. Just change the base URL.": "Request ของคุณเริ่มจากเครื่องมือที่คุณชื่นชอบหรือ unified SDK ของเรา เพียงเปลี่ยน base URL", + "Your requests start from your favorite tools — Cursor, Claude, Copilot, or any OpenAI-compatible SDK.": "Request ของคุณเริ่มจากเครื่องมือที่คุณชื่นชอบ — Cursor, Claude, Copilot หรือ OpenAI-compatible SDK ใดก็ได้", + "account has been connected.": "บัญชีเชื่อมต่อแล้ว", + "active": "ใช้งานอยู่", + "add OpenAI/Anthropic compatible endpoints": "เพิ่ม OpenAI/Anthropic compatible endpoints", + "added)": "เพิ่มแล้ว)", + "again after install.": "อีกครั้งหลังการติดตั้ง", + "and click": "แล้วคลิก", + "apiKey": "apiKey", + "below.": "ด้านล่าง", + "bound": "เชื่อมต่อแล้ว", + "chars)": "ตัวอักษร)", + "cloudflare relay": "cloudflare relay", + "connection": "การเชื่อมต่อ", + "connections": "การเชื่อมต่อ", + "daily-cloudcode-pa.googleapis.com": "daily-cloudcode-pa.googleapis.com", + "dark": "มืด", + "disabled": "ปิดใช้งานแล้ว", + "dollars per million tokens": "ดอลลาร์ต่อล้าน tokens", + "e.g. CwhRBWXzGAHq8TQ4Fs17": "เช่น CwhRBWXzGAHq8TQ4Fs17", + "e.g. claude-opus-4-5": "เช่น claude-opus-4-5", + "e.g. my-model-id": "เช่น my-model-id", + "e.g. tts-1-hd": "เช่น tts-1-hd", + "e.g. voyage-3, embed-english-v3.0, text-embedding-3-small": "เช่น voyage-3, embed-english-v3.0, text-embedding-3-small", + "e.g., Production API, Dev Environment": "เช่น Production API, Dev Environment", + "every request bills all panel models + the judge": "ทุก request จะคิดค่าใช้จ่าย panel models ทั้งหมด + judge", + "export": "ส่งออก", + "failed": "ล้มเหลว", + "git/grep/ls/tree/logs → 60-90% fewer input tokens": "git/grep/ls/tree/logs → ลด input tokens 60-90%", + "h ago": "ชั่วโมงที่แล้ว", + "has been connected.": "เชื่อมต่อแล้ว", + "iFlow AI": "iFlow AI", + "iFlow Cookie Authentication": "iFlow Cookie Authentication", + "import": "นำเข้า", + "inactive": "ไม่ใช้งาน", + "jcode - Manual Configuration": "jcode - กำหนดค่าด้วยตนเอง", + "jcode CLI not detected locally": "ไม่พบ jcode CLI บนเครื่อง", + "jcode is a Rust-based coding agent with semantic memory, multi-agent swarms, and extreme performance (27.8 MB RAM, 14ms boot).": "jcode เป็น coding agent ที่สร้างด้วย Rust พร้อม semantic memory, multi-agent swarms และ extreme performance (27.8 MB RAM, 14ms boot)", + "kiro://kiro.kiroAgent/authenticate-success?code=...": "kiro://kiro.kiroAgent/authenticate-success?code=...", + "light": "สว่าง", + "m ago": "นาทีที่แล้ว", + "macOS / Linux / Windows:": "macOS / Linux / Windows:", + "macOS / Linux:": "macOS / Linux:", + "macOS/Linux:": "macOS/Linux:", + "more": "เพิ่มเติม", + "more providers": "providers เพิ่มเติม", + "ms / Total": "ms / ทั้งหมด", + "name|apiKey": "name|apiKey", + "no_proxy:": "ไม่มี proxy:", + "not detected locally": "ไม่พบบนเครื่อง", + "npm install -g 9router": "npm install -g 9router", + "npx 9router": "npx 9router", + "open http://localhost:9099": "open http://localhost:9099", + "openid profile email": "openid profile email", + "optional context to improve accuracy": "optional context เพื่อเพิ่มความแม่นยำ", + "or VS Code extension marketplace.": "หรือ VS Code extension marketplace", + "or just": "หรือเพียง", + "passed": "ผ่าน", + "platform.iflow.cn": "platform.iflow.cn", + "queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)": "query โมเดลทั้งหมดแบบ parallel แล้ว judge สร้างคำตอบเดียว คุณภาพดีที่สุด แต่มีค่าใช้จ่ายมากที่สุด: ทุก request จะคิดค่าใช้จ่าย panel models ทั้งหมด + judge (N+1 calls)", + "records, batches every": "records, ทุก batch", + "requests, max": "requests, สูงสุด", + "rotates models across requests to spread load": "หมุนเวียนโมเดลผ่าน requests เพื่อกระจายโหลด", + "s)": "วินาที)", + "s...": "วินาที...", + "seconds...": "วินาที...", + "sends image/PDF/audio requests to a model that supports them first": "ส่ง image/PDF/audio requests ไปยังโมเดลที่รองรับก่อน", + "sk-...": "sk-...", + "sk_9router (default)": "sk_9router (ค่าเริ่มต้น)", + "system": "ตามระบบ", + "tested": "ทดสอบแล้ว", + "the database.": "ฐานข้อมูล", + "to apply changes": "เพื่อให้การเปลี่ยนแปลงมีผล", + "to verify.": "เพื่อตรวจสอบ", + "traffic through 9Router via MITM.": "traffic ผ่าน 9Router ผ่าน MITM", + "tries models in order (next on failure)": "ลองโมเดลตามลำดับ (ถัดไปเมื่อล้มเหลว)", + "unknown": "ไม่ทราบ", + "v1.0 is now live": "v1.0 พร้อมใช้งานแล้ว", + "vercel relay": "vercel relay", + "yet.": "ในตอนนี้", + "your-org.deno.net": "your-org.deno.net", + "© 2025 9Router. All rights reserved.": "© 2025 9Router สงวนลิขสิทธิ์", + "— queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)": "— query โมเดลทั้งหมดแบบ parallel แล้ว judge สร้างคำตอบเดียว คุณภาพดีที่สุด แต่มีค่าใช้จ่ายมากที่สุด: ทุก request จะคิดค่าใช้จ่าย panel models ทั้งหมด + judge (N+1 calls)", + "— rotates models across requests to spread load": "— หมุนเวียนโมเดลผ่าน requests เพื่อกระจายโหลด", + "— sends image/PDF/audio requests to a model that supports them first": "— ส่ง image/PDF/audio requests ไปยังโมเดลที่รองรับก่อน", + "— tries models in order (next on failure)": "— ลองโมเดลตามลำดับ (ถัดไปเมื่อล้มเหลว)", + "→ OpenAI": "→ OpenAI", + "→ Target": "→ Target", + "→ localhost": "→ localhost", + "⚠️ Enable DNS to edit model mappings": "⚠️ เปิดใช้งาน DNS เพื่อแก้ไข model mappings", + "⚠️ Local plugins run as subprocess via": "⚠️ Local plugins ทำงานในฐานะ subprocess ผ่าน", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM ดักจับ HTTPS traffic ของ IDE tools (Antigravity, GitHub Copilot, Kiro) ผ่าน local CA เพื่อ redirect requests ไปยัง providers ของคุณ อาจละเมิด ToS → บัญชีถูกแบน ใช้ด้วยความเสี่ยงของคุณเอง", + "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ ประกาศความเสี่ยง: Provider นี้ใช้ subscription/OAuth session ที่ไม่ได้รับอนุญาตอย่างเป็นทางการสำหรับการใช้งาน proxy/router บัญชีอาจถูกจำกัดหรือแบน ใช้ด้วยความเสี่ยงของคุณเอง", + "✓ Confirm Add": "✓ ยืนยันการเพิ่ม", + "📝 Configure providers in dashboard or use environment variables": "📝 กำหนดค่า providers ใน dashboard หรือใช้ environment variables", + "🔐 OAuth required. Add now and authenticate after Apply; tool list will be discovered after first connect.": "🔐 ต้องใช้ OAuth เพิ่มตอนนี้แล้ว authenticate หลัง Apply; รายการเครื่องมือจะถูกค้นพบหลังการเชื่อมต่อครั้งแรก" +} \ No newline at end of file From eceac9d7aea58a9d4ffaa8d8bd7790252f1f9e74 Mon Sep 17 00:00:00 2001 From: decolua Date: Wed, 15 Jul 2026 16:40:41 +0700 Subject: [PATCH 04/25] gitignore --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index edd8c086..9f6e0d46 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,9 @@ gitbook/README.md open-sse.old/ .graphifyignore graphify-out/* + +# Local-only working dirs (notes, vendored repos, scripts, skills) +.claude/ +.docs/ +.repo/ +.script/ From 9173c29b66885b44a55d12643218740433e647aa Mon Sep 17 00:00:00 2001 From: "Moradii.Mohammadreza" Date: Wed, 15 Jul 2026 17:08:06 +0700 Subject: [PATCH 05/25] feat(translator): drop temperature for all Claude models Broaden strip rule from /claude-opus-4/i to /claude/i so temperature is removed for every Claude model, not just opus-4. Fixes Anthropic 400 on OpenAI-compatible routes. #1748 --- open-sse/translator/concerns/paramSupport.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/open-sse/translator/concerns/paramSupport.js b/open-sse/translator/concerns/paramSupport.js index dc030194..4780b3c1 100644 --- a/open-sse/translator/concerns/paramSupport.js +++ b/open-sse/translator/concerns/paramSupport.js @@ -4,8 +4,8 @@ // 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 From 542a088c045f2dbfe44ebedc5a3c7f26a8fc2447 Mon Sep 17 00:00:00 2001 From: luoyide Date: Wed, 15 Jul 2026 17:29:57 +0700 Subject: [PATCH 06/25] feat(github): route Claude models through Copilot's native /v1/messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub Copilot's /chat/completions and /responses endpoints never surface prompt-cache token counts for Claude models. Route Claude models (detected by name pattern) to Copilot's Anthropic-native /v1/messages shim via a new executeWithMessagesEndpoint(), translating OpenAI-shape requests to Claude natively so cache_control gets injected and cached_tokens surface. Also fixes translateRequest()'s internal _toolNameMap being sent upstream, which made Anthropic's strict schema reject tool-call requests with a 400 — now stripped and threaded through response state. Removes the now-dead response_format Claude JSON-mode workaround. --- open-sse/executors/github.js | 157 ++++++++++++++++++++------ open-sse/providers/registry/github.js | 9 ++ 2 files changed, 132 insertions(+), 34 deletions(-) diff --git a/open-sse/executors/github.js b/open-sse/executors/github.js index 2f4d68ba..208ff8f2 100644 --- a/open-sse/executors/github.js +++ b/open-sse/executors/github.js @@ -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", { diff --git a/open-sse/providers/registry/github.js b/open-sse/providers/registry/github.js index 95169eb3..1104eeb3 100644 --- a/open-sse/providers/registry/github.js +++ b/open-sse/providers/registry/github.js @@ -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" }, From e567ba800f39b2ccb1680a0ea5947279c544f207 Mon Sep 17 00:00:00 2001 From: qianze <2469710983@qq.com> Date: Wed, 15 Jul 2026 17:38:22 +0700 Subject: [PATCH 07/25] fix(translator): strip client_metadata when converting openai-responses to openai client_metadata is an OpenAI Responses API-specific field. When translating openai-responses requests to openai (Chat Completions), it leaked through to providers like NVIDIA, which rejected it with a 400 "Unsupported parameter". Strip it in the Responses-specific cleanup block alongside input, instructions, store, and reasoning. --- open-sse/translator/request/openai-responses.js | 1 + 1 file changed, 1 insertion(+) diff --git a/open-sse/translator/request/openai-responses.js b/open-sse/translator/request/openai-responses.js index 98c516cd..ca06731d 100644 --- a/open-sse/translator/request/openai-responses.js +++ b/open-sse/translator/request/openai-responses.js @@ -189,6 +189,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; } From a077ee85bd6df1ae31ab0ab0094ea0ebffcafbfa Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 16 Jul 2026 11:16:57 +0700 Subject: [PATCH 08/25] gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 9f6e0d46..f12a2cb9 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,5 @@ graphify-out/* .docs/ .repo/ .script/ +.codegraph/ +.PR/ From ba508f250629776022f608eaade4a8cd1ada53f5 Mon Sep 17 00:00:00 2001 From: luoyide Date: Thu, 16 Jul 2026 11:08:46 +0700 Subject: [PATCH 09/25] fix(thinking): send explicit thinking:{type:adaptive} alongside output_config.effort --- open-sse/translator/concerns/thinkingUnified.js | 6 ++++++ .../translator/__snapshots__/golden-request.test.js.snap | 3 +++ tests/translator/thinking-unified.test.js | 8 ++++++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/open-sse/translator/concerns/thinkingUnified.js b/open-sse/translator/concerns/thinkingUnified.js index 1cf44384..d6082724 100644 --- a/open-sse/translator/concerns/thinkingUnified.js +++ b/open-sse/translator/concerns/thinkingUnified.js @@ -173,6 +173,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; diff --git a/tests/translator/__snapshots__/golden-request.test.js.snap b/tests/translator/__snapshots__/golden-request.test.js.snap index 17fa66dd..a7a0c219 100644 --- a/tests/translator/__snapshots__/golden-request.test.js.snap +++ b/tests/translator/__snapshots__/golden-request.test.js.snap @@ -118,6 +118,9 @@ exports[`GOLDEN request: OpenAI → Claude > reasoning_effort → adaptive outpu "type": "text", }, ], + "thinking": { + "type": "adaptive", + }, } `; diff --git a/tests/translator/thinking-unified.test.js b/tests/translator/thinking-unified.test.js index bae22c33..dad57622 100644 --- a/tests/translator/thinking-unified.test.js +++ b/tests/translator/thinking-unified.test.js @@ -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"); From 88a8c72d2da686a0b9fd3d05e29e7bbacb5fda12 Mon Sep 17 00:00:00 2001 From: liamgnc Date: Thu, 16 Jul 2026 11:15:58 +0700 Subject: [PATCH 10/25] fix(models): list compatible provider models in /v1/models Replace the overly-broad UPSTREAM_CONNECTION_RE regex (which matched all provider IDs with UUID suffixes) with an x-9r-internal-models-fetch header to detect cross-instance recursive /models fetches. fetchCompatibleModelIds now sends the header when fetching upstream /models; the GET handler detects it and skips dynamic fetching, breaking the recursion loop while letting compatible providers (MLX, Ollama, vLLM) list their models. Fixes #2626. --- src/app/api/v1/models/route.js | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index 32647304..f2451faf 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -79,8 +79,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"; @@ -145,7 +146,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, }); @@ -189,7 +190,11 @@ 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"]). */ -export async function buildModelsList(kindFilter) { +export async function buildModelsList(kindFilter, options = {}) { + // 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 +324,7 @@ export async function buildModelsList(kindFilter) { ) : 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); } @@ -475,9 +480,11 @@ export async function OPTIONS() { * GET /v1/models - OpenAI compatible models list (LLM/chat models only by default). * For other capabilities use /v1/models/{kind} (image, tts, stt, embedding, image-to-text, web). */ -export async function GET() { +export async function GET(request) { try { - const data = await buildModelsList([LLM_KIND]); + // Detect cross-instance recursive /models fetch (another 9router fetching our /models) + const skipDynamicFetch = request?.headers?.get(INTERNAL_MODELS_FETCH_HEADER) === "1"; + const data = await buildModelsList([LLM_KIND], { skipDynamicFetch }); return Response.json({ object: "list", data }, { headers: { "Access-Control-Allow-Origin": "*" }, }); From c9926897ba0f816ae0829bcb66a4458a9a53def8 Mon Sep 17 00:00:00 2001 From: joachimBrindeau Date: Thu, 16 Jul 2026 11:18:27 +0700 Subject: [PATCH 11/25] feat(rtk): add X-9Router-Token-Saver header to bypass token savers per request --- README.md | 2 ++ open-sse/config/runtimeConfig.js | 2 ++ open-sse/handlers/chatCore.js | 15 +++++---- tests/unit/headroom-chat-core.test.js | 44 +++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a9e169a9..a70294e3 100644 --- a/README.md +++ b/README.md @@ -425,6 +425,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/open-sse/config/runtimeConfig.js b/open-sse/config/runtimeConfig.js index de199233..648281cc 100644 --- a/open-sse/config/runtimeConfig.js +++ b/open-sse/config/runtimeConfig.js @@ -56,6 +56,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, diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index b5cf8a84..be58866e 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -9,7 +9,7 @@ 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"; @@ -156,14 +156,17 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred delete translatedBody.tools; } + // 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); + 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 }); + 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) { @@ -171,16 +174,16 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred if (isHeadroomPhantomSavings(headroomStats, headroomDiagnostics)) { log?.warn?.("HEADROOM", `reported token delta, but outbound JSON shrank <5%; provider may bill near-original payload | ${headroomSizeLine}`); } - } 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})` : ""}`); // Caveman: inject terse-style system prompt - if (cavemanEnabled && cavemanLevel) { + if (tokenSaverEnabled && cavemanEnabled && cavemanLevel) { injectCaveman(translatedBody, finalFormat, cavemanLevel); log?.debug?.("CAVEMAN", `${cavemanLevel} | ${finalFormat}`); } // Ponytail: inject lazy-senior-dev system prompt - if (ponytailEnabled && ponytailLevel) { + if (tokenSaverEnabled && ponytailEnabled && ponytailLevel) { injectPonytail(translatedBody, finalFormat, ponytailLevel); log?.debug?.("PONYTAIL", `${ponytailLevel} | ${finalFormat}`); } diff --git a/tests/unit/headroom-chat-core.test.js b/tests/unit/headroom-chat-core.test.js index 5c552ab9..e61ab898 100644 --- a/tests/unit/headroom-chat-core.test.js +++ b/tests/unit/headroom-chat-core.test.js @@ -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." }], + }), + })); + }); }); From 2629218b0413e0429e0a111180ab33e52e4aa3d3 Mon Sep 17 00:00:00 2001 From: luoyide Date: Thu, 16 Jul 2026 11:32:14 +0700 Subject: [PATCH 12/25] fix(models): populate capabilities for live-catalog LLM models --- src/app/api/v1/models/route.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index f2451faf..6260ffa4 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -13,7 +13,7 @@ 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 { updateProviderCredentials } from "@/sse/services/tokenRefresh"; -import { capabilitiesFromServiceKind } from "open-sse/providers/capabilities.js"; +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. @@ -426,7 +426,13 @@ export async function buildModelsList(kindFilter, options = {}) { 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); } From 7dfb3466676af3a581906a79f2a3b2cd89fefc92 Mon Sep 17 00:00:00 2001 From: Ella CEO Date: Thu, 16 Jul 2026 11:32:51 +0700 Subject: [PATCH 13/25] fix(grok-cli): surface expiresAt so proactive token refresh fires (#2546) --- src/lib/oauth/providers.js | 10 ++++ tests/unit/grok-cli-expiresat-2546.test.js | 57 ++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 tests/unit/grok-cli-expiresat-2546.test.js diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index 484ecf99..d2d14daf 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -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, diff --git a/tests/unit/grok-cli-expiresat-2546.test.js b/tests/unit/grok-cli-expiresat-2546.test.js new file mode 100644 index 00000000..f3f8cc8c --- /dev/null +++ b/tests/unit/grok-cli-expiresat-2546.test.js @@ -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); + }); +}); From 27b37705b32c7bd68069b023b57e8dba50713921 Mon Sep 17 00:00:00 2001 From: hungtrinh Date: Thu, 16 Jul 2026 11:17:46 +0700 Subject: [PATCH 14/25] perf(startup): skip inactive background services --- src/app/api/settings/route.js | 11 ++-- src/app/api/tunnel/disable/route.js | 5 ++ src/app/api/tunnel/enable/route.js | 5 ++ src/app/api/tunnel/tailscale-disable/route.js | 5 ++ src/app/api/tunnel/tailscale-enable/route.js | 5 ++ src/shared/services/initializeApp.js | 54 +++++++++++++++---- src/shared/services/quotaAutoPing.js | 15 ++++++ tests/unit/quota-auto-ping.test.js | 23 +++++++- 8 files changed, 107 insertions(+), 16 deletions(-) diff --git a/src/app/api/settings/route.js b/src/app/api/settings/route.js index ccbaee3a..6a713433 100644 --- a/src/app/api/settings/route.js +++ b/src/app/api/settings/route.js @@ -2,7 +2,6 @@ import { NextResponse } from "next/server"; import { getSettings, 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 bcrypt from "bcryptjs"; export const dynamic = "force-dynamic"; @@ -101,10 +100,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; diff --git a/src/app/api/tunnel/disable/route.js b/src/app/api/tunnel/disable/route.js index 2d49e031..4c15245a 100644 --- a/src/app/api/tunnel/disable/route.js +++ b/src/app/api/tunnel/disable/route.js @@ -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); diff --git a/src/app/api/tunnel/enable/route.js b/src/app/api/tunnel/enable/route.js index 910ad617..05d55fc0 100644 --- a/src/app/api/tunnel/enable/route.js +++ b/src/app/api/tunnel/enable/route.js @@ -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); diff --git a/src/app/api/tunnel/tailscale-disable/route.js b/src/app/api/tunnel/tailscale-disable/route.js index 1a0dca08..5258e3dd 100644 --- a/src/app/api/tunnel/tailscale-disable/route.js +++ b/src/app/api/tunnel/tailscale-disable/route.js @@ -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); diff --git a/src/app/api/tunnel/tailscale-enable/route.js b/src/app/api/tunnel/tailscale-enable/route.js index 61b7bda1..11e2d5d6 100644 --- a/src/app/api/tunnel/tailscale-enable/route.js +++ b/src/app/api/tunnel/tailscale-enable/route.js @@ -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); diff --git a/src/shared/services/initializeApp.js b/src/shared/services/initializeApp.js index e8f5acfc..536b6676 100644 --- a/src/shared/services/initializeApp.js +++ b/src/shared/services/initializeApp.js @@ -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 - syncMitmAliasCache().catch(() => {}); + if (settings.mitmEnabled) { + // Sync mitmAlias DB → JSON cache so standalone MITM server can read it. + syncMitmAliasCache().catch(() => {}); + autoStartMitm(settings); + } - startWatchdog(); - startNetworkMonitor(); - autoStartMitm(); - startQuotaAutoPing(); + configureTunnelMonitoring(settings); + + if (hasQuotaAutoPingEnabled(settings)) { + import("@/shared/services/quotaAutoPing") + .then(({ startQuotaAutoPing }) => startQuotaAutoPing()) + .catch((e) => console.log("[AutoPing] scheduler start failed:", e.message)); + } } -async function autoStartMitm() { +function hasQuotaAutoPingEnabled(settings) { + return [settings?.claudeAutoPing, settings?.codexAutoPing] + .some((config) => Object.values(config?.connections || {}).some(Boolean)); +} + +async function autoStartMitm(settings) { if (g.mitmStartInProgress) return; g.mitmStartInProgress = true; try { - const settings = await getSettings(); if (!settings.mitmEnabled) return; const mitmStatus = await getMitmStatus(); if (mitmStatus.running) return; @@ -232,6 +241,12 @@ function startWatchdog() { if (g.watchdogInterval.unref) g.watchdogInterval.unref(); } +function stopWatchdog() { + if (!g.watchdogInterval) return; + clearInterval(g.watchdogInterval); + g.watchdogInterval = null; +} + // ─── Network monitor: detect IPv4 fingerprint change + sleep/wake ──────────── function getNetworkFingerprint() { @@ -293,4 +308,23 @@ function startNetworkMonitor() { if (g.networkMonitorInterval.unref) g.networkMonitorInterval.unref(); } + +function stopNetworkMonitor() { + if (!g.networkMonitorInterval) return; + clearInterval(g.networkMonitorInterval); + g.networkMonitorInterval = null; + g.lastNetworkFingerprint = null; + g.lastOnline = null; +} + +export function configureTunnelMonitoring(settings) { + if (settings?.tunnelEnabled || settings?.tailscaleEnabled) { + startWatchdog(); + startNetworkMonitor(); + return; + } + stopWatchdog(); + stopNetworkMonitor(); +} + export default initializeApp; diff --git a/src/shared/services/quotaAutoPing.js b/src/shared/services/quotaAutoPing.js index 694a5e2b..1fbf39f7 100644 --- a/src/shared/services/quotaAutoPing.js +++ b/src/shared/services/quotaAutoPing.js @@ -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(); +} diff --git a/tests/unit/quota-auto-ping.test.js b/tests/unit/quota-auto-ping.test.js index de601df5..f7edb771 100644 --- a/tests/unit/quota-auto-ping.test.js +++ b/tests/unit/quota-auto-ping.test.js @@ -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 }) => ( From b94685b80dbdd911c16d45690cc06900fb72defd Mon Sep 17 00:00:00 2001 From: Edison42 Date: Thu, 16 Jul 2026 14:37:08 +0700 Subject: [PATCH 15/25] feat(kiro): add GPT-5.6 model family (#2596) Add GPT-5.6 Sol/Terra/Luna and their synthetic thinking/agentic/ thinking-agentic variants to the Kiro static catalog with the observed 272k context window and credit multipliers (2.4/1.2/0.6), register MITM mapping slots for the new base ids, and override runtime capabilities so the GPT-5.6 family reports the 272k window instead of the generic GPT-5 profile. --- open-sse/providers/capabilities.js | 28 +++++++++++++--- open-sse/providers/registry/kiro.js | 12 +++++++ src/shared/constants/cliTools.js | 3 ++ tests/unit/capabilities.test.js | 17 ++++++++++ tests/unit/kiro-model-slots.test.js | 50 +++++++++++++++++++++++++++++ 5 files changed, 106 insertions(+), 4 deletions(-) diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 3d302628..6e54a683 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -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] }; diff --git a/open-sse/providers/registry/kiro.js b/open-sse/providers/registry/kiro.js index dab23a3f..1a47adaa 100644 --- a/open-sse/providers/registry/kiro.js +++ b/open-sse/providers/registry/kiro.js @@ -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", diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index b9d000ca..685d2bc6 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -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" }, ], }, diff --git a/tests/unit/capabilities.test.js b/tests/unit/capabilities.test.js index 31512e9a..ccfddfa5 100644 --- a/tests/unit/capabilities.test.js +++ b/tests/unit/capabilities.test.js @@ -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); + }); }); diff --git a/tests/unit/kiro-model-slots.test.js b/tests/unit/kiro-model-slots.test.js index ffc3e382..8bb1afab 100644 --- a/tests/unit/kiro-model-slots.test.js +++ b/tests/unit/kiro-model-slots.test.js @@ -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"); + } + }); }); From 70e8dc49748e96a2461c2240a55a9684d7f8436b Mon Sep 17 00:00:00 2001 From: rixzkiye Date: Thu, 16 Jul 2026 14:38:13 +0700 Subject: [PATCH 16/25] feat(cli-tools): add Grok Build setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Grok Build to Dashboard → CLI Tools. Apply writes a [model.9router] custom model to ~/.grok/config.toml and sets [models].default, routing the xAI Grok TUI through 9Router. Reset removes the slot and restores the previous default. --- CHANGELOG.md | 1 + .../cli-tools/[toolId]/ToolDetailClient.js | 4 +- .../cli-tools/components/GrokBuildToolCard.js | 387 ++++++++++++++++++ .../dashboard/cli-tools/components/index.js | 1 + src/app/api/cli-tools/all-statuses/route.js | 2 + .../cli-tools/grok-build-settings/route.js | 242 +++++++++++ src/shared/constants/cliTools.js | 24 ++ 7 files changed, 660 insertions(+), 1 deletion(-) create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js create mode 100644 src/app/api/cli-tools/grok-build-settings/route.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c6ba2133..5cdd2373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Features - **Perplexity**: add Agent API provider (#2492) - **Grok CLI**: add Grok CLI / Grok Build provider with OAuth device-code flow (#2502) +- **CLI tools**: add Grok Build setup — writes `[model.9router]` custom model to `~/.grok/config.toml` - **Featherless**: add OpenAI-compatible provider presets - **SearXNG**: configure endpoint via SEARXNG_URL env (#2499) - **Providers**: add max thinking level for gpt-5.6-sol (#2500) diff --git a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js index fa8d7111..2e735647 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js @@ -9,7 +9,7 @@ import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard, CopilotToolCard, ClineToolCard, KiloToolCard, DeepSeekTuiToolCard, - JcodeToolCard, + JcodeToolCard, GrokBuildToolCard, } from "../components"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -139,6 +139,8 @@ export default function ToolDetailClient({ toolId, machineId }) { return ; case "jcode": return ; + case "grok-build": + return ; default: return ; } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js new file mode 100644 index 00000000..cc72ea4a --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js @@ -0,0 +1,387 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; +import Image from "next/image"; +import BaseUrlSelect from "./BaseUrlSelect"; +import ApiKeySelect from "./ApiKeySelect"; +import { matchKnownEndpoint } from "./cliEndpointMatch"; + +const ENDPOINT = "/api/cli-tools/grok-build-settings"; +const MODEL_SLOT = "9router"; + +export default function GrokBuildToolCard({ + tool, + isExpanded, + onToggle, + baseUrl, + hasActiveProviders, + apiKeys, + activeProviders, + cloudEnabled, + initialStatus, + tunnelEnabled, + tunnelPublicUrl, + tailscaleEnabled, + tailscaleUrl, +}) { + const [grokStatus, setGrokStatus] = useState(initialStatus || null); + const [checking, setChecking] = useState(false); + const [applying, setApplying] = useState(false); + const [restoring, setRestoring] = useState(false); + const [message, setMessage] = useState(null); + const [selectedApiKey, setSelectedApiKey] = useState(""); + const [selectedModel, setSelectedModel] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [modelAliases, setModelAliases] = useState({}); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); + const [customBaseUrl, setCustomBaseUrl] = useState(""); + const hasInitializedModel = useRef(false); + + const getConfigStatus = () => { + if (!grokStatus?.installed) return null; + const cfg = grokStatus.settings?.model; + if (!cfg?.base_url) return "not_configured"; + if (matchKnownEndpoint(cfg.base_url, { tunnelPublicUrl, tailscaleUrl })) return "configured"; + return "other"; + }; + + const configStatus = getConfigStatus(); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) { + setSelectedApiKey(apiKeys[0].key); + } + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (initialStatus) setGrokStatus(initialStatus); + }, [initialStatus]); + + useEffect(() => { + if (isExpanded && !grokStatus) { + checkStatus(); + fetchModelAliases(); + } + if (isExpanded) fetchModelAliases(); + }, [isExpanded]); + + const fetchModelAliases = async () => { + try { + const res = await fetch("/api/models/alias"); + const data = await res.json(); + if (res.ok) setModelAliases(data.aliases || {}); + } catch (error) { + console.log("Error fetching model aliases:", error); + } + }; + + useEffect(() => { + if (grokStatus?.installed && !hasInitializedModel.current) { + hasInitializedModel.current = true; + const cfg = grokStatus.settings?.model; + if (cfg?.model) setSelectedModel(cfg.model); + } + }, [grokStatus]); + + const checkStatus = async () => { + setChecking(true); + try { + const res = await fetch(ENDPOINT); + const data = await res.json(); + setGrokStatus(data); + } catch (error) { + setGrokStatus({ installed: false, error: error.message }); + } finally { + setChecking(false); + } + }; + + const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1"); + + const getLocalBaseUrl = () => { + if (typeof window !== "undefined") { + return normalizeLocalhost(window.location.origin); + } + return "http://127.0.0.1:20128"; + }; + + const getEffectiveBaseUrl = () => { + const url = customBaseUrl || getLocalBaseUrl(); + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const handleApply = async () => { + setApplying(true); + setMessage(null); + try { + const keyToUse = selectedApiKey?.trim() + || (apiKeys?.length > 0 ? apiKeys[0].key : null) + || (!cloudEnabled ? "sk_9router" : null); + + const res = await fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: getEffectiveBaseUrl(), + apiKey: keyToUse, + model: selectedModel, + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings applied successfully!" }); + checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to apply settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setApplying(false); + } + }; + + const handleReset = async () => { + setRestoring(true); + setMessage(null); + try { + const res = await fetch(ENDPOINT, { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings reset successfully!" }); + setSelectedModel(""); + checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to reset settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoring(false); + } + }; + + const handleModelSelect = (model) => { + setSelectedModel(model.value); + setModalOpen(false); + }; + + const getManualConfigs = () => { + const keyToUse = (selectedApiKey && selectedApiKey.trim()) + ? selectedApiKey + : (!cloudEnabled ? "sk_9router" : ""); + + const modelId = selectedModel || "provider/model-id"; + const tomlContent = `[models] +default = "${MODEL_SLOT}" + +[model.${MODEL_SLOT}] +model = "${modelId}" +base_url = "${getEffectiveBaseUrl()}" +name = "9Router" +description = "Routed via 9Router gateway" +api_backend = "chat_completions" +api_key = "${keyToUse}" +`; + + return [ + { filename: "~/.grok/config.toml", content: tomlContent }, + ]; + }; + + return ( + +
+
+
+ {tool.name} { e.target.style.display = "none"; }} + /> +
+
+
+

{tool.name}

+ {configStatus === "configured" && Connected} + {configStatus === "not_configured" && Not configured} + {configStatus === "other" && Other} +
+

{tool.description}

+
+
+ expand_more +
+ + {isExpanded && ( +
+ {checking && ( +
+ progress_activity + Checking Grok Build... +
+ )} + + {!checking && grokStatus && !grokStatus.installed && ( +
+
+
+ warning +
+

Grok Build not detected locally

+

Install:

+ curl -fsSL https://x.ai/cli/install.sh | bash +

Manual configuration is still available if 9router is deployed on a remote server.

+
+
+
+ +
+
+
+ )} + + {!checking && grokStatus?.installed && ( + <> +
+ {tool.notes && tool.notes.length > 0 && ( +
+ {tool.notes.map((note, idx) => ( +
+ + {note.type === "warning" ? "warning" : note.type === "error" ? "error" : "info"} + + {note.text} +
+ ))} +
+ )} + +
+ Select Endpoint + arrow_forward + +
+ + {grokStatus?.settings?.model?.base_url && ( +
+ Current + arrow_forward + + {grokStatus.settings.model.base_url} + {grokStatus.settings.model.model ? ` · ${grokStatus.settings.model.model}` : ""} + +
+ )} + +
+ API Key + arrow_forward + +
+ +
+ Default Model + arrow_forward +
+ setSelectedModel(e.target.value)} + placeholder="provider/model-id" + className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" + /> + {selectedModel && ( + + )} +
+ +
+
+ + {message && ( +
+ {message.type === "success" ? "check_circle" : "error"} + {message.text} +
+ )} + +
+ + + +
+ + )} +
+ )} + + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Grok Build" + /> + + setShowManualConfigModal(false)} + title="Grok Build - Manual Configuration" + configs={getManualConfigs()} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js index aeca8700..e1399677 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -12,6 +12,7 @@ export { default as ClineToolCard } from "./ClineToolCard"; export { default as KiloToolCard } from "./KiloToolCard"; export { default as DeepSeekTuiToolCard } from "./DeepSeekTuiToolCard"; export { default as JcodeToolCard } from "./JcodeToolCard"; +export { default as GrokBuildToolCard } from "./GrokBuildToolCard"; export { default as MitmServerCard } from "./MitmServerCard"; export { default as MitmToolCard } from "./MitmToolCard"; export { default as MitmLinkCard } from "./MitmLinkCard"; diff --git a/src/app/api/cli-tools/all-statuses/route.js b/src/app/api/cli-tools/all-statuses/route.js index 4d5174f5..c3ac832b 100644 --- a/src/app/api/cli-tools/all-statuses/route.js +++ b/src/app/api/cli-tools/all-statuses/route.js @@ -13,6 +13,7 @@ import { GET as clineGet } from "../cline-settings/route"; import { GET as kiloGet } from "../kilo-settings/route"; import { GET as deepseekTuiGet } from "../deepseek-tui-settings/route"; import { GET as jcodeGet } from "../jcode-settings/route"; +import { GET as grokBuildGet } from "../grok-build-settings/route"; const STATUS_GETTERS = { claude: claudeGet, @@ -27,6 +28,7 @@ const STATUS_GETTERS = { kilo: kiloGet, "deepseek-tui": deepseekTuiGet, jcode: jcodeGet, + "grok-build": grokBuildGet, }; // Batch endpoint: gather all CLI tool statuses in one round-trip diff --git a/src/app/api/cli-tools/grok-build-settings/route.js b/src/app/api/cli-tools/grok-build-settings/route.js new file mode 100644 index 00000000..afc747ef --- /dev/null +++ b/src/app/api/cli-tools/grok-build-settings/route.js @@ -0,0 +1,242 @@ +"use server"; + +import { NextResponse } from "next/server"; +import { exec } from "child_process"; +import { promisify } from "util"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +const execAsync = promisify(exec); + +const PROVIDER_NAME = "9router"; +const MODEL_SLOT = "9router"; +const BUILTIN_DEFAULT = "grok-build"; + +// [model.9router] ... until next [section] header or EOF +const MODEL_SECTION_RE = new RegExp( + `^\\[model\\.${MODEL_SLOT}\\][ \\t]*\\r?\\n(?:(?!\\[)[^\\r\\n]*\\r?\\n?)*`, + "m" +); + +const MODELS_SECTION_RE = /^\[models\][ \t]*\r?\n((?:(?!\[)[^\r\n]*\r?\n?)*)/m; + +// Marker written on Apply so Reset can restore the previous [models].default +const PREV_DEFAULT_RE = /^# 9router-prev-default = "([^"]*)"[ \t]*\r?\n?/m; + +const getGrokDir = () => path.join(os.homedir(), ".grok"); +const getGrokConfigPath = () => path.join(getGrokDir(), "config.toml"); +const getGrokBinPath = () => path.join(getGrokDir(), "bin", "grok"); + +const checkGrokInstalled = async () => { + try { + const isWindows = os.platform() === "win32"; + const command = isWindows ? "where grok" : "which grok"; + await execAsync(command, { windowsHide: true }); + return true; + } catch { + try { + await fs.access(getGrokBinPath()); + return true; + } catch { + try { + await fs.access(getGrokConfigPath()); + return true; + } catch { + return false; + } + } + } +}; + +const readConfigToml = async () => { + try { + return await fs.readFile(getGrokConfigPath(), "utf-8"); + } catch (error) { + if (error.code === "ENOENT") return ""; + throw error; + } +}; + +const getTomlField = (body, key) => { + const m = body.match(new RegExp(`^[ \\t]*${key}[ \\t]*=[ \\t]*"([^"]*)"`, "m")); + return m ? m[1] : null; +}; + +const parseModelSection = (toml) => { + const match = toml.match(MODEL_SECTION_RE); + if (!match) return null; + const body = match[0].replace(/^\[model\.[^\]]+\][ \t]*\r?\n/, ""); + return { + model: getTomlField(body, "model"), + base_url: getTomlField(body, "base_url"), + name: getTomlField(body, "name"), + api_key: getTomlField(body, "api_key"), + api_backend: getTomlField(body, "api_backend"), + }; +}; + +const parseModelsDefault = (toml) => { + const match = toml.match(MODELS_SECTION_RE); + if (!match) return null; + return getTomlField(match[1] || "", "default"); +}; + +const buildModelSection = (model, baseUrl, apiKey) => { + const lines = [ + `[model.${MODEL_SLOT}]`, + `model = "${model}"`, + `base_url = "${baseUrl}"`, + `name = "9Router"`, + `description = "Routed via 9Router gateway"`, + `api_backend = "chat_completions"`, + ]; + if (apiKey) lines.push(`api_key = "${apiKey}"`); + return `${lines.join("\n")}\n`; +}; + +const upsertModelSection = (toml, section) => { + if (MODEL_SECTION_RE.test(toml)) return toml.replace(MODEL_SECTION_RE, section); + const needsNl = toml.length > 0 && !toml.endsWith("\n"); + return `${toml}${needsNl ? "\n" : ""}\n${section}`; +}; + +const removeModelSection = (toml) => + toml.replace(MODEL_SECTION_RE, "").replace(/\n{3,}/g, "\n\n"); + +// Set or insert default = "..." inside existing [models], or create the section +const setModelsDefault = (toml, value) => { + const match = toml.match(MODELS_SECTION_RE); + if (match) { + const body = match[1] || ""; + let newBody; + if (/^[ \t]*default[ \t]*=/m.test(body)) { + newBody = body.replace(/^[ \t]*default[ \t]*=[ \t]*"[^"]*"/m, `default = "${value}"`); + } else { + newBody = `default = "${value}"\n${body}`; + } + return toml.replace(match[0], `[models]\n${newBody}`); + } + const block = `[models]\ndefault = "${value}"\n\n`; + return toml.length > 0 ? block + toml : block; +}; + +// Remember the previous default once (so re-Apply does not overwrite it with "9router") +const rememberPrevDefault = (toml) => { + if (PREV_DEFAULT_RE.test(toml)) return toml; + const current = parseModelsDefault(toml); + if (!current || current === MODEL_SLOT) return toml; + const marker = `# 9router-prev-default = "${current}"\n`; + // Prefer placing the marker just above [model.9router] if present, else at EOF + if (MODEL_SECTION_RE.test(toml)) { + return toml.replace(MODEL_SECTION_RE, (section) => marker + section); + } + const needsNl = toml.length > 0 && !toml.endsWith("\n"); + return `${toml}${needsNl ? "\n" : ""}${marker}`; +}; + +// If default points at our slot, restore previous (or built-in) default and drop marker +const clearModelsDefaultIfOurs = (toml) => { + const prevMatch = toml.match(PREV_DEFAULT_RE); + const restoreTo = prevMatch?.[1] || BUILTIN_DEFAULT; + let next = toml.replace(PREV_DEFAULT_RE, ""); + const current = parseModelsDefault(next); + if (current === MODEL_SLOT) { + next = setModelsDefault(next, restoreTo); + } + return next; +}; + +const has9RouterConfig = (modelCfg) => { + if (!modelCfg?.base_url) return false; + return true; +}; + +export async function GET() { + try { + const installed = await checkGrokInstalled(); + if (!installed) { + return NextResponse.json({ + installed: false, + settings: null, + message: "Grok Build is not installed", + }); + } + + const toml = await readConfigToml(); + const model = parseModelSection(toml); + const defaultModel = parseModelsDefault(toml); + + return NextResponse.json({ + installed: true, + settings: { + model, + default: defaultModel, + }, + has9Router: has9RouterConfig(model), + configPath: getGrokConfigPath(), + }); + } catch (error) { + console.log("Error checking grok-build settings:", error); + return NextResponse.json({ error: "Failed to check grok-build settings" }, { status: 500 }); + } +} + +export async function POST(request) { + try { + const { baseUrl, apiKey, model } = await request.json(); + if (!baseUrl || !model) { + return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 }); + } + + const dir = getGrokDir(); + await fs.mkdir(dir, { recursive: true }); + + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + const keyToWrite = apiKey || "sk_9router"; + + let toml = await readConfigToml(); + toml = rememberPrevDefault(toml); + toml = upsertModelSection(toml, buildModelSection(model, normalizedBaseUrl, keyToWrite)); + toml = setModelsDefault(toml, MODEL_SLOT); + + await fs.writeFile(getGrokConfigPath(), toml); + + return NextResponse.json({ + success: true, + message: "Grok Build settings applied successfully!", + configPath: getGrokConfigPath(), + modelSlot: MODEL_SLOT, + }); + } catch (error) { + console.log("Error updating grok-build settings:", error); + return NextResponse.json({ error: "Failed to update grok-build settings" }, { status: 500 }); + } +} + +export async function DELETE() { + try { + const configPath = getGrokConfigPath(); + let toml = ""; + try { + toml = await fs.readFile(configPath, "utf-8"); + } catch (error) { + if (error.code === "ENOENT") { + return NextResponse.json({ success: true, message: "No config file to reset" }); + } + throw error; + } + + toml = removeModelSection(toml); + toml = clearModelsDefaultIfOurs(toml); + await fs.writeFile(configPath, toml); + + return NextResponse.json({ + success: true, + message: `${PROVIDER_NAME} model slot removed from Grok Build`, + }); + } catch (error) { + console.log("Error resetting grok-build settings:", error); + return NextResponse.json({ error: "Failed to reset grok-build settings" }, { status: 500 }); + } +} diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 685d2bc6..b501e884 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -363,6 +363,30 @@ amp --model "{{model}}" { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", alias: "gemini", defaultValue: "gemini/gemini-3.1-pro" }, ], }, + "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. 9Router writes a [model.9router] custom model and sets it as the default.", + }, + { + type: "info", + text: "After Apply, run grok (or /model 9router) to use the routed model. Switch back anytime with /model grok-build.", + }, + { + type: "warning", + text: "Config path: Linux/macOS ~/.grok/config.toml • Windows %USERPROFILE%\\.grok\\config.toml", + }, + ], + }, // HIDDEN: gemini-cli // "gemini-cli": { // id: "gemini-cli", From 9c58ba645efeae1f24ebff70271beb1220135a96 Mon Sep 17 00:00:00 2001 From: Edison42 Date: Thu, 16 Jul 2026 15:13:28 +0700 Subject: [PATCH 17/25] fix(kiro): improve direct session cache reuse Reshape Kiro direct requests so resumed client sessions reuse Kiro's cache-affinity fields instead of starting unrelated CodeWhisperer conversations. - keep conversationState.conversationId stable when the client sends an explicit session id (x-session-id, session_id, conversation_id, Claude Code session metadata) - add a stable conversationState.agentContinuationId per Kiro session - send conversationState.agentTaskType: "vibe" and agentMode: "vibe", matching the normal Kiro CLI/KAS chat path - move Kiro thinking instructions into Kiro-compatible systemPrompt / additionalModelRequestFields instead of generic top-level thinking - keep volatile timestamp context out of the top-level systemPrompt; it remains only in user content fallback - suppress additionalModelRequestFields for legacy 4.5-era Claude/Kiro models that reject it, while defaulting future Claude/Kiro model ids to supported - preserve Kiro meteringEvent credit usage internally for accounting without leaking provider-specific fields into OpenAI-compatible usage - prevent unrelated headerless Kiro requests from sharing one connection-wide continuation - cap/evict continuation sessions so long-running processes do not grow the continuation map unbounded - treat generated headerless Kiro sessions as one-shot so they do not evict real explicit-session continuations - keep credit-only Kiro metering valid for internal persistence when token metrics are unavailable --- open-sse/config/kiroConstants.js | 44 +++++ open-sse/translator/index.js | 12 +- open-sse/translator/request/claude-to-kiro.js | 103 +++++++---- open-sse/translator/request/openai-to-kiro.js | 69 ++++--- open-sse/utils/kiroSessionReplay.js | 125 +++++++++++++ open-sse/utils/sessionManager.js | 62 +++++-- tests/translator/claude-kiro-direct.test.js | 75 +++++++- tests/unit/kiro-thinking-strip.test.js | 57 ++++++ tests/unit/openai-to-kiro.test.js | 161 ++++++++++++++++- tests/unit/session-manager.test.js | 168 +++++++++++++++++- 10 files changed, 786 insertions(+), 90 deletions(-) create mode 100644 open-sse/utils/kiroSessionReplay.js diff --git a/open-sse/config/kiroConstants.js b/open-sse/config/kiroConstants.js index 3ff6acbb..93b8f456 100644 --- a/open-sse/config/kiroConstants.js +++ b/open-sse/config/kiroConstants.js @@ -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). diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index e9c84971..37e5bde6 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -103,8 +103,16 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream } } - // Normalize thinking to the target provider-native format (config-driven, capability-aware) - applyThinking(targetFormat, model, result, provider, thinkingIntent); + // 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) diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index c42823b6..bcb8b97f 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -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 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 = `\n${systemText}\n\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 = {}; diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index 5c8cc00f..a2681b5a 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -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 = {}; diff --git a/open-sse/utils/kiroSessionReplay.js b/open-sse/utils/kiroSessionReplay.js new file mode 100644 index 00000000..11cae9cd --- /dev/null +++ b/open-sse/utils/kiroSessionReplay.js @@ -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(); diff --git a/open-sse/utils/sessionManager.js b/open-sse/utils/sessionManager.js index 05f90896..b6f16f1a 100644 --- a/open-sse/utils/sessionManager.js +++ b/open-sse/utils/sessionManager.js @@ -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(); diff --git a/tests/translator/claude-kiro-direct.test.js b/tests/translator/claude-kiro-direct.test.js index 1b189ec6..01e21709 100644 --- a/tests/translator/claude-kiro-direct.test.js +++ b/tests/translator/claude-kiro-direct.test.js @@ -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( "enabled" ); + 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( - "24576" - ); + expect(out.additionalModelRequestFields).toBeUndefined(); + expect(out.thinking).toBeUndefined(); + expect(out.systemPrompt).toContain("24576"); + }); + + 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("24576"); + }); + + 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"); }); }); diff --git a/tests/unit/kiro-thinking-strip.test.js b/tests/unit/kiro-thinking-strip.test.js index 91b5009c..1fff6e9e 100644 --- a/tests/unit/kiro-thinking-strip.test.js +++ b/tests/unit/kiro-thinking-strip.test.js @@ -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 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"); + }); }); diff --git a/tests/unit/openai-to-kiro.test.js b/tests/unit/openai-to-kiro.test.js index ddda87ab..8b1c97e8 100644 --- a/tests/unit/openai-to-kiro.test.js +++ b/tests/unit/openai-to-kiro.test.js @@ -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("1024"); + expect(systemPromptOf(result)).toContain("1024"); + 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("24576"); + expect(systemPromptOf(result)).toContain("24576"); + 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("24576"); + 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("24576"); + 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("24576"); + 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("24576"); + 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("24576"); + 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("24576"); + 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("32000"); + expect(systemPromptOf(result)).toContain("32000"); + 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("32000"); + expect(systemPromptOf(result)).toContain("32000"); + 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("4096"); + expect(systemPromptOf(result)).toContain("4096"); }); 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("16000"); + expect(systemPromptOf(result)).toContain("16000"); + }); + + 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("enabled"); - expect(contentOf(result)).not.toContain(""); + expect(systemPromptOf(result)).not.toContain("enabled"); + expect(systemPromptOf(result)).not.toContain(""); + expect(result.additionalModelRequestFields).toBeUndefined(); }); }); }); diff --git a/tests/unit/session-manager.test.js b/tests/unit/session-manager.test.js index 37474813..a7cecb59 100644 --- a/tests/unit/session-manager.test.js +++ b/tests/unit/session-manager.test.js @@ -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); + }); }); From 02ccdc2d221acd44648468fa110e2be21c6f664b Mon Sep 17 00:00:00 2001 From: M0nt <67793307+montajebii@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:15:26 +0700 Subject: [PATCH 18/25] i18n: add Persian (fa) translations for README and UI Add full Persian (Farsi) translation of README and sync UI literals to match zh-CN key set (1389 keys). No runtime code changes. --- README.md | 2 +- i18n/README.fa_IR.md | 1442 +++++++++++++++++++++++++++++++ public/i18n/literals/fa.json | 1556 ++++++++++++++++++++++++++++++---- 3 files changed, 2819 insertions(+), 181 deletions(-) create mode 100644 i18n/README.fa_IR.md diff --git a/README.md b/README.md index c2b5ff69..0dee052a 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) • [🇹🇭 ไทย](./i18n/README.th.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) diff --git a/i18n/README.fa_IR.md b/i18n/README.fa_IR.md new file mode 100644 index 00000000..e486fa61 --- /dev/null +++ b/i18n/README.fa_IR.md @@ -0,0 +1,1442 @@ +
+ داشبورد 9Router + + # 9Router - مسیریاب رایگان هوش مصنوعی و ذخیره‌ساز توکن + + **هرگز کدنویسی را متوقف نکنید. با RTK بین ۲۰ تا ۴۰٪ در توکن‌ها صرفه‌جویی کنید + بازگشت خودکار به مدل‌های رایگان و ارزان هوش مصنوعی.** + + **همه ابزارهای کدنویسی مبتنی بر هوش مصنوعی (Claude Code، Cursor، Antigravity، Copilot، Codex، Gemini، OpenCode، Cline، OpenClaw...) را به بیش از ۴۰ ارائه‌دهنده و ۱۰۰+ مدل متصل کنید.** + + [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) + [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) + [![Docker Pulls](https://img.shields.io/docker/pulls/decolua/9router.svg?logo=docker&label=Docker%20pulls)](https://hub.docker.com/r/decolua/9router) + [![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router) + [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + +decolua%2F9router | Trendshift + +[🚀 شروع سریع](#-شروع-سریع) • [💡 ویژگی‌ها](#-ویژگی‌های-کلیدی) • [📖 راه‌اندازی](#-راهنمای-راه‌اندازی) • [🌐 وب‌سایت](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) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) + +
+ +--- + +## 🤔 چرا 9Router؟ + +**هدررفت پول، توکن و برخورد با محدودیت‌ها را متوقف کنید:** + +- ❌ سهمیه اشتراک هر ماه بدون استفاده منقضی می‌شود +- ❌ محدودیت نرخ درخواست، شما را در میانه کدنویسی متوقف می‌کند +- ❌ خروجی ابزارها (git diff، grep، ls...) به سرعت توکن می‌سوزانند +- ❌ APIهای گران قیمت (۲۰ تا ۵۰ دلار در ماه برای هر ارائه‌دهنده) +- ❌ جابجایی دستی بین ارائه‌دهندگان + +**9Router این مشکلات را حل می‌کند:** + +- ✅ **ذخیره‌ساز توکن RTK** - فشرده‌سازی خودکار محتوای tool_result، صرفه‌جویی ۲۰ تا ۴۰٪ توکن در هر درخواست +- ✅ **حداکثر استفاده از اشتراک‌ها** - پیگیری سهمیه، استفاده از هر ذره قبل از بازنشانی +- ✅ **بازگشت خودکار** - اشتراک → ارزان → رایگان، بدون توقف +- ✅ **چند حساب کاربری** - چرخش گردشی بین حساب‌ها برای هر ارائه‌دهنده +- ✅ **جهانی** - با Claude Code، Codex، Cursor، Cline و هر ابزار خط فرمان کار می‌کند + +--- + +## 🔄 نحوه عملکرد + +``` +┌─────────────┐ +│ ابزار خط │ (Claude Code, Codex, OpenClaw, Cursor, Cline...) +│ فرمان شما │ +└──────┬──────┘ + │ http://localhost:20128/v1 + ↓ +┌─────────────────────────────────────────────┐ +│ 9Router (مسیریاب هوشمند) │ +│ • ذخیره‌ساز توکن RTK (کاهش توکن‌های tool_result) │ +│ • ترجمه قالب (OpenAI ↔ Claude) │ +│ • پیگیری سهمیه │ +│ • بازسازی خودکار توکن │ +└──────┬──────────────────────────────────────┘ + │ + ├─→ [لایه ۱: اشتراک] Claude Code, Codex, GitHub Copilot + │ ↓ اتمام سهمیه + ├─→ [لایه ۲: ارزان] GLM (۰.۶ دلار/میلیون), MiniMax (۰.۲ دلار/میلیون) + │ ↓ محدودیت بودجه + └─→ [لایه ۳: رایگان] Kiro, OpenCode Free, Vertex (۳۰۰ دلار اعتبار) + +نتیجه: هرگز کدنویسی را متوقف نکنید، حداقل هزینه + صرفه‌جویی ۲۰-۴۰٪ توکن با RTK +``` + +--- + +## ⚡ شروع سریع + +**۱. نصب سراسری:** + +```bash +npm install -g 9router +9router +``` + +🎉 داشبورد در آدرس `http://localhost:20128` باز می‌شود + +**۲. اتصال یک ارائه‌دهنده رایگان (بدون نیاز به ثبت‌نام):** + +داشبورد → ارائه‌دهندگان → اتصال **Kiro AI** (کلود رایگان نامحدود) یا **OpenCode Free** (بدون احراز هویت) → انجام شد! + +**۳. استفاده در ابزار خط فرمان خود:** + +``` +تنظیمات Claude Code/Codex/OpenClaw/Cursor/Cline: + آدرس端点: http://localhost:20128/v1 + کلید API: [کپی از داشبورد] + مدل: kr/claude-sonnet-4.5 +``` + +**کار تمام!** با مدل‌های رایگان هوش مصنوعی کدنویسی را شروع کنید. + +**روش جایگزین: اجرا از سورس (این مخزن):** + +بسته این مخزن خصوصی است (`9router-app`)، بنابراین اجرا از سورس/داکر مسیر معمول توسعه محلی است. + +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev +``` + +حالت تولید: + +```bash +npm run build +PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start +``` + +آدرس‌های پیش‌فرض: + +- داشبورد: `http://localhost:20128/dashboard` +- API سازگار با OpenAI: `http://localhost:20128/v1` + +--- + +## راهنماهای تصویری + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + صرفه‌جویی در هزینه LLM با 9Router +
+ 🇻🇳 ویتنامی
+ صرفه‌جویی در هزینه LLM برای OpenClaw با 9Router
توسط Mì AI
+
+ + راه‌اندازی نامحدود رایگان 9Router + Claude Code +
+ 🇵🇰 اردو / हिन्दी
+ راه‌اندازی نامحدود رایگان 9Router + Claude Code
توسط Build AI With Hamid
+
+ + آموزش راه‌اندازی 9Router +
+ 🇺🇸 انگلیسی
+ راه‌اندازی رایگان 9Router + Claude Code
توسط Build AI With Hamid
+
+ + آموزش راه‌اندازی 9Router +
+ 🇺🇸 انگلیسی
+ راه‌اندازی رایگان 9Router + Claude Code
توسط Build AI With Hamid
+
+ + Claude Code رایگان برای همیشه +
+ 🇺🇸 انگلیسی
+ Claude Code رایگان برای همیشه — مدل‌های نامحدود
توسط Build AI With Hamid
+
+ + راه‌اندازی رایگان Claude CLI +
+ 🇺🇸 انگلیسی
+ راه‌اندازی رایگان Claude CLI با 9Router 🚀
توسط CodeVerse Soban
+
+ + نصب کامل OpenClaw رایگان +
+ 🇻🇳 ویتنامی
+ نصب کامل OpenClaw رایگان از صفر تا صد + 9Router
توسط Mai Gia
+
+ + OpenClaw رایگان با Claude Opus +
+ 🇺🇸 انگلیسی
+ OpenClaw رایگان + Claude Opus 4.6
توسط Build AI With Hamid
+
+ + راه‌اندازی رایگان Claude CLI +
+ 🇮🇩 اندونزیایی
+ کدنویسی ۲۴ ساعته بدون محدودیت نرخ! صرفه‌جویی ۶۵٪ توکن هوش مصنوعی | آموزش راه‌اندازی سریع 9Router 🚀
توسط Krisswuh
+
+ + روش استقرار 9Router در Hugging Face رایگان و همیشه روشن! | جایگزین VPS با ۱۶ گیگابایت رم +
+ 🇮🇩 اندونزیایی
+ روش استقرار 9Router در Hugging Face رایگان و همیشه روشن! | جایگزین VPS با ۱۶ گیگابایت رم
توسط Krisswuh
+
+ +
+ +> 🎬 **درباره 9Router ویدیو ساخته‌اید؟** یک [درخواست Pull](https://github.com/decolua/9router/pulls) برای افزودن ویدیوی خود به این بخش ارسال کنید — ما آن را ادغام خواهیم کرد! + +--- + +## 🛠️ ابزارهای خط فرمان پشتیبانی شده + +9Router به‌طور یکپارچه با تمام ابزارهای اصلی کدنویسی هوش مصنوعی کار می‌کند: + +
+ + + + + + + + + + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ OpenClaw
+ OpenClaw +
+ Codex
+ Codex +
+ OpenCode
+ OpenCode +
+ Cursor
+ Cursor +
+ Antigravity
+ Antigravity +
+ Cline
+ Cline +
+ Continue
+ Continue +
+ Droid
+ Droid +
+ Roo
+ Roo +
+ Copilot
+ Copilot +
+ Kilo Code
+ Kilo Code +
+
+ +--- + +## 🌐 ارائه‌دهندگان پشتیبانی شده + +### 🔐 ارائه‌دهندگان OAuth + +
+ + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ Antigravity
+ Antigravity +
+ Codex
+ Codex +
+ GitHub
+ GitHub +
+ Cursor
+ Cursor +
+ Kimchi
+ Kimchi +
+
+ +### 🆓 ارائه‌دهندگان رایگان + +
+ + + + + + +
+ Kiro
+ Kiro AI
+ Claude 4.5 + GLM-5 + MiniMax
نامحدود رایگان
+
+ OpenCode Free
+ OpenCode Free
+ بدون احراز هویت • دریافت خودکار مدل‌ها
نامحدود رایگان
+
+ Vertex AI
+ Vertex AI
+ Gemini 3 Pro + GLM-5 + DeepSeek
۳۰۰ دلار اعتبار رایگان
+
+
+ +> **توجه:** لایه‌های رایگان iFlow، Qwen و Gemini CLI در سال ۲۰۲۶ متوقف شدند. به جای آنها از Kiro / OpenCode Free / Vertex استفاده کنید. + +### 🔑 ارائه‌دهندگان کلید API (۴۰+) + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ OpenRouter
+ OpenRouter +
+ GLM
+ GLM +
+ Kimi
+ Kimi +
+ MiniMax
+ MiniMax +
+ OpenAI
+ OpenAI +
+ Anthropic
+ Anthropic +
+ Gemini
+ Gemini +
+ DeepSeek
+ DeepSeek +
+ Groq
+ Groq +
+ xAI
+ xAI +
+ Mistral
+ Mistral +
+ Perplexity
+ Perplexity +
+ Together
+ Together AI +
+ Fireworks
+ Fireworks +
+ Cerebras
+ Cerebras +
+ Cohere
+ Cohere +
+ NVIDIA
+ NVIDIA +
+ SiliconFlow
+ SiliconFlow +
+

...و بیش از ۲۰ ارائه‌دهنده دیگر از جمله Nebius، Chutes، Hyperbolic و نقاط پایانی سفارشی سازگار با OpenAI/Anthropic

+
+ +--- + +## 💡 ویژگی‌های کلیدی + +| ویژگی | عملکرد | اهمیت آن | +| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------- | +| 🚀 **ذخیره‌ساز توکن RTK** ([RTK](https://github.com/rtk-ai/rtk) ⭐۴۰هزار) | فشرده‌سازی خروجی ابزارها (`git diff`، `grep`، `ls`، `tree`...) قبل از ارسال به LLM | صرفه‌جویی **۲۰ تا ۴۰٪ توکن ورودی** در هر درخواست | +| 🧠 **ذخیره‌ساز توکن Headroom** ([Headroom](https://github.com/chopratejas/headroom)) | پروکسی خارجی اختیاری `/v1/compress` قبل از مسیریابی به ارائه‌دهنده | صرفه‌جویی توکن‌های زمینه بیشتر بدون تغییر کلاینت | +| 🪨 **حالت غارنشین** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐۵۲هزار) | تزریق پرامپت حالت غارنشین → پاسخ‌های مختصر LLM با حفظ محتوای فنی | صرفه‌جویی **تا ۶۵٪ توکن خروجی** | +| 🐴 **دم‌اسب** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | تزریق پرامپت "توسعه‌دهنده ارشد تنبل" → کدنویسی حداقلی و YAGNI-first (سبک/کامل/فوق‌سبک) | **توکن خروجی کمتر، بازنویسی کمتر** | +| 🎯 **بازگشت هوشمند ۳ لایه** | مسیریابی خودکار: اشتراک → ارزان → رایگان | هرگز کدنویسی متوقف نمی‌شود، بدون توقف | +| 📊 **پیگیری سهمیه به‌روز** | تعداد توکن زنده + شمارش معکوس بازنشانی | حداکثر استفاده از اشتراک | +| 🔄 **ترجمه قالب** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | کار با هر ابزار خط فرمان | +| 👥 **پشتیبانی از چند حساب** | چند حساب برای هر ارائه‌دهنده | توزیع بار + افزونگی | +| 🔄 **بازسازی خودکار توکن** | توکن‌های OAuth به‌طور خودکار بازسازی می‌شوند | بدون نیاز به ورود مجدد دستی | +| 🎨 **ترکیب‌های سفارشی** | ایجاد ترکیب‌های نامحدود مدل | تنظیم بازگشت بر اساس نیاز شما | +| 📝 **ثبت درخواست** | حالت اشکال‌زدایی با لاگ‌های کامل درخواست/پاسخ | عیب‌یابی آسان مسائل | +| 💾 **همگام‌سازی ابری** | همگام‌سازی تنظیمات بین دستگاه‌ها | همان تنظیمات در همه جا | +| 📊 **تحلیل استفاده** | پیگیری توکن‌ها، هزینه، روندها در طول زمان | بهینه‌سازی هزینه‌ها | +| 🌐 **استقرار در هر جا** | لوکال‌هست، VPS، داکر، Cloudflare Workers | گزینه‌های استقرار انعطاف‌پذیر | + +
+📖 جزئیات ویژگی‌ها + +### 🚀 ذخیره‌ساز توکن RTK + +خروجی ابزارها (`git diff`، `grep`، `find`، `ls`، `tree`، دامپ لاگ‌ها...) اغلب ۳۰ تا ۵۰٪ از بودجه پرامپت شما را مصرف می‌کنند. RTK آنها را شناسایی کرده و فشرده‌سازی هوشمند و بدون افت کیفیت **قبل از رسیدن درخواست به LLM** اعمال می‌کند: + +- **فیلترها:** `git-diff`، `git-status`، `grep`، `find`، `ls`، `tree`، `dedup-log`، `smart-truncate`، `read-numbered`، `search-list` +- **تشخیص خودکار:** نیازی به تنظیمات نیست — RTK یک کیلوبایت اول هر `tool_result` را بررسی کرده و فیلتر مناسب را انتخاب می‌کند. +- **ایمن در طراحی:** اگر فیلتری با شکست مواجه شود، خطا دهد یا خروجی را بزرگ‌تر کند، RTK بی‌صدا متن اصلی را نگه می‌دارد. خطاها هرگز درخواست شما را خراب نمی‌کنند. +- **جهانی:** در همه فرمت‌ها (OpenAI، Claude، Gemini، Cursor، Kiro، OpenAI Responses) کار می‌کند زیرا **قبل از** هرگونه ترجمه قالب اجرا می‌شود. +- **روشن پیش‌فرض:** در هر زمان در داشبورد → تنظیمات نقطه پایانی قابل تغییر است. + +``` +بدون RTK: ۴۷ هزار توکن ارسال شده به LLM +با RTK: ۲۸ هزار توکن ارسال شده به LLM (۴۰٪ صرفه‌جویی · همان زمینه · همان پاسخ) +``` + +### 🧠 ذخیره‌ساز توکن Headroom + +Headroom اختیاری است و به‌طور جداگانه اجرا می‌شود. 9Router نقطه پایانی محلی `/v1/compress` Headroom را فراخوانی کرده، سپس مسیریابی معمولی، بازگشت، احراز هویت و پیگیری مصرف را ادامه می‌دهد: + +``` +کلاینت → 9Router → Headroom /v1/compress → 9Router → ارائه‌دهنده +``` + +راه‌اندازی محلی: + +```bash +pip install "headroom-ai[proxy]" +headroom proxy --port 8787 +``` + +در داشبورد → نقطه پایانی → ذخیره‌ساز توکن → Headroom فعال کنید. آدرس پیش‌فرض: `http://localhost:8787`. + +مثال‌های داکر: + +```bash +# سرویس Headroom در همان شبکه داکر +http://headroom:8787 + +# Headroom در حال اجرا روی ماشین میزبان +http://host.docker.internal:8787 +``` + +اگر Headroom از کار بیفتد یا خطا برگرداند، 9Router به‌حالت بازگشت باز می‌شود و درخواست اصلی را ارسال می‌کند. + +### 🐴 دم‌اسب (توسعه‌دهنده ارشد تنبل) + +دم‌اسب یک پرامپت سیستمی _"توسعه‌دهنده ارشد تنبل"_ را به هر درخواست تزریق می‌کند و LLM را به سمت کدنویسی حداقلی و YAGNI-first سوق می‌دهد — حذف به جای افزودن، کتابخانه استاندارد به جای وابستگی‌های جدید، یک خطی به جای انتزاعات. اقتباس شده از [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail). + +- **سبک** — آنچه خواسته شده را بساز، جایگزین تنبل‌تر را نام ببر. +- **کامل** — نردبان YAGNI اعمال می‌شود: کتابخانه استاندارد → بومی → وابستگی‌های موجود → یک خطی → حداقل کد. +- **فوق‌سبک** — افراط‌گرای YAGNI: اول حذف، یک خطی را ارسال کن، بقیه نیازمندی را در همان پاسخ به چالش بکش. + +``` +بدون دم‌اسب: کد پرحجم، انتزاعات اضافی، داربست‌های "فقط در صورت نیاز" +با دم‌اسب: کوتاه‌ترین دیف کاری، بدون انتزاعات درخواست نشده، توکن کمتر +``` + +هرگز موارد زیر را قربانی نمی‌کند: اعتبارسنجی ورودی، مدیریت خطا که از از دست رفتن داده جلوگیری می‌کند، امنیت، دسترس‌پذیری، یا هر چیزی که به‌صراحت درخواست شده باشد. در داشبورد → نقطه پایانی → دم‌اسب فعال کنید. با حالت غارنشین (مختصر بودن خروجی) و RTK (فشرده‌سازی ورودی) ترکیب می‌شود. + +### 🎯 بازگشت هوشمند ۳ لایه + +ترکیب‌هایی با بازگشت خودکار ایجاد کنید: + +``` +ترکیب: "my-coding-stack" + 1. cc/claude-opus-4-6 (اشتراک شما) + 2. glm/glm-4.7 (پشتیبان ارزان، ۰.۶ دلار/میلیون) + 3. if/kimi-k2-thinking (بازگشت رایگان) + +→ وقتی سهمیه تمام شود یا خطا رخ دهد، به‌طور خودکار تغییر می‌کند +``` + +### 📊 پیگیری سهمیه به‌روز + +- مصرف توکن به ازای هر ارائه‌دهنده +- شمارش معکوس بازنشانی (۵ ساعته، روزانه، هفتگی) +- تخمین هزینه برای لایه‌های پولی +- گزارش‌های هزینه ماهانه + +### 🔄 ترجمه قالب + +ترجمه یکپارچه بین قالب‌ها: + +- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **Cursor** ↔ **Kiro** ↔ **Vertex** ↔ **Antigravity** ↔ **Ollama** ↔ **OpenAI Responses** +- ابزار خط فرمان شما قالب OpenAI ارسال می‌کند → 9Router ترجمه می‌کند → ارائه‌دهنده قالب بومی دریافت می‌کند +- با هر ابزاری که از نقاط پایانی سفارشی OpenAI پشتیبانی می‌کند کار می‌کند + +### 👥 پشتیبانی از چند حساب + +- افزودن چند حساب برای هر ارائه‌دهنده +- مسیریابی خودکار گردشی یا اولویت‌محور +- بازگشت به حساب بعدی وقتی یکی به سهمیه رسید + +### 🔄 بازسازی خودکار توکن + +- توکن‌های OAuth به‌طور خودکار قبل از انقضا بازسازی می‌شوند +- بدون نیاز به احراز هویت مجدد دستی +- تجربه یکپارچه در همه ارائه‌دهندگان + +### 🎨 ترکیب‌های سفارشی + +- ایجاد ترکیب‌های نامحدود مدل +- ترکیب لایه‌های اشتراک، ارزان و رایگان +- نام‌گذاری ترکیب‌ها برای دسترسی آسان +- اشتراک‌گذاری ترکیب‌ها بین دستگاه‌ها با همگام‌سازی ابری + +### 📝 ثبت درخواست + +- فعال‌سازی حالت اشکال‌زدایی برای لاگ‌های کامل درخواست/پاسخ +- پیگیری فراخوانی‌های API، هدرها و محموله‌ها +- عیب‌یابی مسائل یکپارچه‌سازی +- خروجی لاگ‌ها برای تحلیل + +### 💾 همگام‌سازی ابری + +- همگام‌سازی ارائه‌دهندگان، ترکیب‌ها و تنظیمات بین دستگاه‌ها +- همگام‌سازی خودکار در پس‌زمینه +- ذخیره‌سازی رمزگذاری شده امن +- دسترسی به تنظیمات خود از هر جا + +#### نکات اجرای ابری + +- در تولید از متغیرهای سمت سرور ابری استفاده کنید: + - `BASE_URL` (آدرس داخلی بازگشت برای برنامه‌ریز همگام‌سازی) + - `CLOUD_URL` (آدرس پایه نقطه پایانی همگام‌سازی ابری) +- `NEXT_PUBLIC_BASE_URL` و `NEXT_PUBLIC_CLOUD_URL` همچنان برای سازگاری/رابط کاربری پشتیبانی می‌شوند، اما زمان اجرای سرور اکنون `BASE_URL`/`CLOUD_URL` را اولویت می‌دهد. +- درخواست‌های همگام‌سازی ابری اکنون از زمان‌بندی + رفتار شکست سریع برای جلوگیری از هنگ کردن رابط کاربری در صورت عدم دسترسی شبکه ابری/DNS استفاده می‌کنند. + +### 📊 تحلیل استفاده + +- پیگیری مصرف توکن به ازای هر ارائه‌دهنده و مدل +- تخمین هزینه و روندهای هزینه +- گزارش‌های ماهانه و بینش‌ها +- بهینه‌سازی هزینه هوش مصنوعی + +> **💡 مهم - درک هزینه‌های داشبورد:** +> +> "هزینه" نمایش داده شده در تحلیل استفاده **فقط برای پیگیری و مقایسه** است. +> خود 9Router **هرگز از شما هزینه‌ای دریافت نمی‌کند**. شما فقط مستقیماً به ارائه‌دهندگان هزینه می‌پردازید (در صورت استفاده از خدمات پولی). +> +> **مثال:** اگر داشبورد شما "۲۹۰ دلار هزینه کل" را هنگام استفاده از مدل‌های iFlow نشان می‌دهد، این مبلغ چیزی است که در صورت استفاده مستقیم از APIهای پولی پرداخت می‌کردید. هزینه واقعی شما = **۰ دلار** (iFlow رایگان نامحدود است). +> +> به آن به عنوان "ردیاب پس‌انداز" فکر کنید که نشان می‌دهد با استفاده از مدل‌های رایگان یا مسیریابی از طریق 9Router چقدر صرفه‌جویی می‌کنید! + +### 🌐 استقرار در هر جا + +- 💻 **لوکال‌هست** - پیش‌فرض، آفلاین کار می‌کند +- ☁️ **VPS/ابر** - اشتراک‌گذاری بین دستگاه‌ها +- 🐳 **داکر** - استقرار با یک دستور +- 🚀 **Cloudflare Workers** - شبکه لبه جهانی + +
+ +--- + +## 💰 قیمت‌گذاری در یک نگاه + +| لایه | ارائه‌دهنده | هزینه | بازنشانی سهمیه | بهترین استفاده | +| ------------------- | --------------------- | ------------ | ---------------- | --------------------------------------- | +| **🚀 ذخیره‌ساز توکن** | **RTK (ساخته شده)** | **رایگان** | همیشه روشن | **صرفه‌جویی ۲۰-۴۰٪ توکن در هر درخواست** | +| **💳 اشتراک** | Claude Code (Pro/Max) | ۲۰-۲۰۰ دلار/ماه | ۵ ساعته + هفتگی | قبلاً اشتراک دارید | +| | Codex (Plus/Pro) | ۲۰-۲۰۰ دلار/ماه | ۵ ساعته + هفتگی | کاربران OpenAI | +| | GitHub Copilot | ۱۰-۱۹ دلار/ماه | ماهانه | کاربران GitHub | +| | Cursor IDE | ۲۰ دلار/ماه | ماهانه | کاربران Cursor | +| **💰 ارزان** | GLM-5.1 / GLM-4.7 | ۰.۶ دلار/میلیون | روزانه ساعت ۱۰ صبح | پشتیبان بودجه | +| | MiniMax M2.7 | ۰.۲ دلار/میلیون | ۵ ساعته گردشی | ارزان‌ترین گزینه | +| | Kimi K2.5 | ۹ دلار/ماه مسطح | ۱۰ میلیون توکن/ماه | هزینه قابل پیش‌بینی | +| **🆓 رایگان** | Kiro AI | ۰ دلار | نامحدود | Claude 4.5 + GLM-5 + MiniMax رایگان | +| | OpenCode Free | ۰ دلار | نامحدود | بدون احراز هویت، دریافت خودکار مدل‌ها | +| | Vertex AI | ۳۰۰ دلار اعتبار | حساب‌های جدید GCP | Gemini 3 Pro + DeepSeek + GLM-5 | + +**💡 نکته حرفه‌ای:** ترکیب RTK + Kiro AI + OpenCode Free = **۰ دلار هزینه + ۲۰-۴۰٪ صرفه‌جویی توکن**! + +--- + +### 📊 درک هزینه‌ها و صورتحساب 9Router + +**واقعیت صورتحساب 9Router:** + +✅ **نرم‌افزار 9Router = رایگان برای همیشه** (منبع باز، هرگز هزینه‌ای دریافت نمی‌کند) +✅ **"هزینه‌های" داشبورد = فقط نمایش/پیگیری** (صورتحساب واقعی نیستند) +✅ **شما مستقیماً به ارائه‌دهندگان هزینه می‌پردازید** (اشتراک‌ها یا هزینه‌های API) +✅ **ارائه‌دهندگان رایگان واقعاً رایگان هستند** (iFlow، Kiro، Qwen = ۰ دلار نامحدود) +❌ **9Router هرگز صورتحساب ارسال نمی‌کند** یا کارت شما را شارژ نمی‌کند + +**نحوه عملکرد نمایش هزینه:** + +داشبورد **هزینه‌های تخمینی** را نشان می‌دهد گویی مستقیماً از APIهای پولی استفاده می‌کنید. این **صورتحساب نیست** - این یک ابزار مقایسه برای نشان دادن پس‌انداز شماست. + +**سناریوی مثال:** + +``` +نمایش داشبورد: +• تعداد درخواست‌ها: ۱,۶۶۲ +• کل توکن‌ها: ۴۷ میلیون +• هزینه نمایشی: ۲۹۰ دلار + +بررسی واقعیت: +• ارائه‌دهنده: iFlow (رایگان نامحدود) +• پرداخت واقعی: ۰.۰۰ دلار +• منظور از ۲۹۰ دلار: مبلغی که با استفاده از مدل‌های رایگان پس‌انداز کرده‌اید! +``` + +**قوانین پرداخت:** + +- **ارائه‌دهندگان اشتراک** (Claude Code، Codex): مستقیماً از طریق وب‌سایت‌هایشان به آنها پرداخت کنید +- **ارائه‌دهندگان ارزان** (GLM، MiniMax): مستقیماً به آنها پرداخت کنید، 9Router فقط مسیریابی می‌کند +- **ارائه‌دهندگان رایگان** (iFlow، Kiro، Qwen): واقعاً برای همیشه رایگان، بدون هزینه پنهان +- **9Router**: هرگز هیچ هزینه‌ای دریافت نمی‌کند، همیشه + +--- + +## 🎯 موارد استفاده + +### مورد ۱: "من اشتراک Claude Pro دارم" + +**مشکل:** سهمیه بدون استفاده منقضی می‌شود، محدودیت نرخ در حین کدنویسی سنگین + +**راه‌حل:** + +``` +ترکیب: "maximize-claude" + 1. cc/claude-opus-4-7 (استفاده کامل از اشتراک) + 2. glm/glm-5.1 (پشتیبان ارزان وقتی سهمیه تمام شد) + 3. kr/claude-sonnet-4.5 (بازگشت اضطراری رایگان) + +هزینه ماهانه: ۲۰ دلار (اشتراک) + حدود ۵ دلار (پشتیبان) = ۲۵ دلار کل +در مقابل ۲۰ دلار + برخورد با محدودیت = ناامیدی +``` + +### مورد ۲: "من هزینه صفر می‌خواهم" + +**مشکل:** توانایی پرداخت اشتراک را ندارم، به هوش مصنوعی کدنویسی قابل اعتماد نیاز دارم + +**راه‌حل:** + +``` +ترکیب: "free-forever" + 1. kr/claude-sonnet-4.5 (Claude 4.5 رایگان نامحدود) + 2. kr/glm-5 (GLM-5 رایگان از طریق Kiro) + 3. oc/ (OpenCode Free، بدون احراز هویت) + +هزینه ماهانه: ۰ دلار +کیفیت: مدل‌های آماده تولید + RTK صرفه‌جویی ۲۰-۴۰٪ توکن +``` + +### مورد ۳: "به کدنویسی ۲۴/۷ بدون وقفه نیاز دارم" + +**مشکل:** ضرب‌الاجل‌ها، توانایی پرداخت هزینه توقف را ندارم + +**راه‌حل:** + +``` +ترکیب: "always-on" + 1. cc/claude-opus-4-7 (بهترین کیفیت) + 2. cx/gpt-5.5 (اشتراک دوم) + 3. glm/glm-5.1 (ارزان، بازنشانی روزانه) + 4. minimax/MiniMax-M2.7 (ارزان‌ترین، بازنشانی ۵ ساعته) + 5. kr/claude-sonnet-4.5 (رایگان نامحدود) + +نتیجه: ۵ لایه بازگشت = بدون توقف +هزینه ماهانه: ۲۰-۲۰۰ دلار (اشتراک‌ها) + ۱۰-۲۰ دلار (پشتیبان) +``` + +### مورد ۴: "من هوش مصنوعی رایگان در OpenClaw می‌خواهم" + +**مشکل:** به دستیار هوش مصنوعی در برنامه‌های پیام‌رسان (واتساپ، تلگرام، اسلک...) نیاز دارم، کاملاً رایگان + +**راه‌حل:** + +``` +ترکیب: "openclaw-free" + 1. kr/claude-sonnet-4.5 (Claude 4.5 رایگان) + 2. kr/glm-5 (GLM-5 رایگان) + 3. kr/MiniMax-M2.5 (MiniMax رایگان) + +هزینه ماهانه: ۰ دلار +دسترسی از طریق: واتساپ، تلگرام، اسلک، دیسکورد، iMessage، سیگنال... +``` + +--- + +## ❓ سوالات متداول + +
+📊 چرا داشبورد من هزینه‌های بالا نشان می‌دهد؟ + +داشبورد مصرف توکن شما را پیگیری کرده و **هزینه‌های تخمینی** را نشان می‌دهد گویی مستقیماً از APIهای پولی استفاده می‌کنید. این **صورتحساب واقعی نیست** - این یک مرجع برای نشان دادن میزان پس‌انداز شما با استفاده از مدل‌های رایگان یا اشتراک‌های موجود از طریق 9Router است. + +**مثال:** + +- **داشبورد نشان می‌دهد:** "۲۹۰ دلار هزینه کل" +- **واقعیت:** شما از iFlow (رایگان نامحدود) استفاده می‌کنید +- **هزینه واقعی شما:** **۰.۰۰ دلار** +- **منظور از ۲۹۰ دلار:** مبلغی که با استفاده از مدل‌های رایگان به جای APIهای پولی **پس‌انداز** کرده‌اید! + +نمایش هزینه یک "ردیاب پس‌انداز" است تا به شما در درک الگوهای مصرف و فرصت‌های بهینه‌سازی کمک کند. + +
+ +
+💳 آیا توسط 9Router شارژ می‌شوم؟ + +**خیر.** 9Router نرم‌افزاری رایگان و منبع باز است که روی رایانه خودتان اجرا می‌شود. هرگز از شما هزینه‌ای دریافت نمی‌کند. + +**شما فقط پرداخت می‌کنید:** + +- ✅ **ارائه‌دهندگان اشتراک** (Claude Code ۲۰ دلار/ماه، Codex ۲۰-۲۰۰ دلار/ماه) → مستقیماً در وب‌سایت‌هایشان به آنها پرداخت کنید +- ✅ **ارائه‌دهندگان ارزان** (GLM، MiniMax) → مستقیماً به آنها پرداخت کنید، 9Router فقط درخواست‌های شما را مسیریابی می‌کند +- ❌ **خود 9Router** → **هرگز هیچ هزینه‌ای دریافت نمی‌کند، همیشه** + +9Router یک پروکسی/مسیریاب محلی است. کارت اعتباری شما را ندارد، نمی‌تواند صورتحساب ارسال کند و سیستم صورتحساب ندارد. این نرم‌افزار کاملاً رایگان است. + +
+ +
+🆓 آیا ارائه‌دهندگان رایگان واقعاً نامحدود هستند؟ + +**بله!** ارائه‌دهندگان رایگان فعلی (Kiro، OpenCode Free، Vertex) واقعاً رایگان هستند و **هزینه پنهانی ندارند**. + +اینها خدمات رایگانی هستند که توسط آن شرکت‌ها ارائه می‌شوند: + +- **Kiro AI**: Claude 4.5 + GLM-5 + MiniMax نامحدود رایگان از طریق AWS Builder ID / Google / GitHub OAuth +- **OpenCode Free**: پروکسی عبوری بدون احراز هویت، مدل‌ها به‌طور خودکار از `opencode.ai/zen/v1/models` دریافت می‌شوند +- **Vertex AI**: ۳۰۰ دلار اعتبار رایگان برای حساب‌های جدید Google Cloud (۹۰ روز) + +9Router فقط درخواست‌های شما را به آنها مسیریابی می‌کند - هیچ "دام" یا صورتحساب آینده‌ای وجود ندارد. آنها واقعاً خدمات رایگان هستند و 9Router استفاده از آنها را با پشتیبانی از بازگشت آسان می‌کند. + +**لایه‌های رایگان متوقف شده (دیگر توصیه نمی‌شوند):** + +- ❌ **iFlow**: قبلاً رایگان نامحدود بود، اکنون به پولی تغییر کرده است (۲۰۲۶) +- ❌ **Qwen Code**: لایه رایگان OAuth توسط علی‌بابا در ۲۰۲۶-۰۴-۱۵ متوقف شد +- ❌ **Gemini CLI**: همچنان کار می‌کند، اما استفاده از آن با ابزارهای غیر CLI (Claude، Codex، Cursor...) ممکن است منجر به مسدود شدن حساب شود — فقط در صورت استفاده از خود Gemini CLI از آن استفاده کنید + +
+ +
+💰 چگونه هزینه‌های واقعی هوش مصنوعی خود را به حداقل برسانم؟ + +**استراتژی اولویت با رایگان:** + +۱. **با ترکیب ۱۰۰٪ رایگان شروع کنید:** + + ``` + 1. gc/gemini-3-flash (۱۸۰ هزار توکن/ماه رایگان از گوگل) + 2. if/kimi-k2-thinking (نامحدود رایگان از iFlow) + 3. qw/qwen3-coder-plus (نامحدود رایگان از Qwen) + ``` + + **هزینه: ۰ دلار/ماه** + +۲. **در صورت نیاز، پشتیبان ارزان اضافه کنید:** + + ``` + 4. glm/glm-4.7 (۰.۶ دلار/میلیون توکن) + ``` + + **هزینه اضافی: فقط برای چیزی که واقعاً استفاده می‌کنید پرداخت کنید** + +۳. **از ارائه‌دهندگان اشتراک در آخر استفاده کنید:** + - فقط در صورتی که از قبل آنها را دارید + - 9Router با پیگیری سهمیه به حداکثر رساندن ارزش آنها کمک می‌کند + +**نتیجه:** اکثر کاربران می‌توانند با استفاده فقط از لایه‌های رایگان با ۰ دلار/ماه کار کنند! + +
+ +
+📈 اگر مصرف من ناگهان افزایش یابد چه؟ + +بازگشت هوشمند 9Router از هزینه‌های غافلگیرکننده جلوگیری می‌کند: + +**سناریو:** شما در یک ماراتن کدنویسی هستید و سهمیه‌های خود را تمام می‌کنید + +**بدون 9Router:** + +- ❌ برخورد با محدودیت نرخ → کار متوقف می‌شود → ناامیدی +- ❌ یا: به‌طور تصادفی صورت‌حساب‌های عظیم API جمع می‌کنید + +**با 9Router:** + +- ✅ اشتراک به حد مجاز می‌رسد → بازگشت خودکار به لایه ارزان +- ✅ لایه ارزان گران می‌شود → بازگشت خودکار به لایه رایگان +- ✅ هرگز کدنویسی را متوقف نکنید → هزینه‌های قابل پیش‌بینی + +**شما کنترل دارید:** محدودیت‌های هزینه را برای هر ارائه‌دهنده در داشبورد تنظیم کنید و 9Router به آنها احترام می‌گذارد. + +
+ +--- + +## 📖 راهنمای راه‌اندازی + +
+🔐 ارائه‌دهندگان اشتراک (حداکثر کردن ارزش) + +### Claude Code (Pro/Max) + +```bash +داشبورد → ارائه‌دهندگان → اتصال Claude Code +→ ورود OAuth → بازسازی خودکار توکن +→ پیگیری سهمیه ۵ ساعته + هفتگی + +مدل‌ها: + cc/claude-opus-4-7 + cc/claude-opus-4-6 + cc/claude-sonnet-4-6 + cc/claude-haiku-4-5-20251001 +``` + +**نکته حرفه‌ای:** از Opus برای کارهای پیچیده و Sonnet برای سرعت استفاده کنید. 9Router سهمیه را به ازای هر مدل پیگیری می‌کند! + +### OpenAI Codex (Plus/Pro) + +```bash +داشبورد → ارائه‌دهندگان → اتصال Codex +→ ورود OAuth (پورت ۱۴۵۵) +→ بازنشانی ۵ ساعته + هفتگی + +مدل‌ها: + cx/gpt-5.5 + cx/gpt-5.4 + cx/gpt-5.3-codex + cx/gpt-5.2-codex +``` + +### GitHub Copilot + +```bash +داشبورد → ارائه‌دهندگان → اتصال GitHub +→ OAuth از طریق GitHub +→ بازنشانی ماهانه (اول ماه) + +مدل‌ها: + gh/gpt-5.4 + gh/claude-opus-4.7 + gh/claude-sonnet-4.6 + gh/gemini-3.1-pro-preview + gh/grok-code-fast-1 +``` + +### Cursor IDE + +```bash +داشبورد → ارائه‌دهندگان → اتصال Cursor +→ ورود OAuth +→ اشتراک ماهانه + +مدل‌ها: + cu/claude-4.6-opus-max + cu/claude-4.5-sonnet-thinking + cu/gpt-5.3-codex +``` + +
+ +
+💰 ارائه‌دهندگان ارزان (پشتیبان) + +### GLM-5.1 / GLM-4.7 (بازنشانی روزانه، ۰.۶ دلار/میلیون) + +۱. ثبت‌نام: [Zhipu AI](https://open.bigmodel.cn/) +۲. دریافت کلید API از Coding Plan +۳. داشبورد → افزودن کلید API: + - ارائه‌دهنده: `glm` + - کلید API: `your-key` + +**استفاده:** `glm/glm-5.1`، `glm/glm-5`، `glm/glm-4.7` + +**نکته حرفه‌ای:** Coding Plan ۳ برابر سهمیه با ۱/۷ هزینه ارائه می‌دهد! بازنشانی روزانه ساعت ۱۰:۰۰ صبح. + +### MiniMax M2.7 (بازنشانی ۵ ساعته، ۰.۲۰ دلار/میلیون) + +۱. ثبت‌نام: [MiniMax](https://www.minimax.io/) +۲. دریافت کلید API +۳. داشبورد → افزودن کلید API + +**استفاده:** `minimax/MiniMax-M2.7`، `minimax/MiniMax-M2.5` + +**نکته حرفه‌ای:** ارزان‌ترین گزینه برای زمینه طولانی (۱ میلیون توکن)! + +### Kimi K2.5 (۹ دلار/ماه مسطح) + +۱. اشتراک: [Moonshot AI](https://platform.moonshot.ai/) +۲. دریافت کلید API +۳. داشبورد → افزودن کلید API + +**استفاده:** `kimi/kimi-k2.5`، `kimi/kimi-k2.5-thinking` + +**نکته حرفه‌ای:** ۹ دلار/ماه ثابت برای ۱۰ میلیون توکن = هزینه مؤثر ۰.۹۰ دلار/میلیون! + +
+ +
+🆓 ارائه‌دهندگان رایگان (توصیه شده) + +### Kiro AI (Claude 4.5 + GLM-5 + MiniMax رایگان) + +```bash +داشبورد → اتصال Kiro +→ AWS Builder ID، AWS IAM Identity Center، Google، یا GitHub +→ استفاده نامحدود + +مدل‌ها: + kr/claude-sonnet-4.5 + kr/claude-haiku-4.5 + kr/glm-5 + kr/MiniMax-M2.5 + kr/qwen3-coder-next + kr/deepseek-3.2 +``` + +**نکته حرفه‌ای:** بهترین گزینه رایگان برای Claude. بدون کلید API، بدون پرداخت، کاملاً نامحدود. + +### OpenCode Free (بدون احراز هویت، دریافت خودکار مدل‌ها) + +```bash +داشبورد → اتصال OpenCode Free +→ بدون نیاز به ورود (پروکسی عبوری) +→ مدل‌ها به‌طور خودکار از opencode.ai/zen/v1/models دریافت می‌شوند +``` + +**نکته حرفه‌ای:** سریع‌ترین راه‌اندازی. فقط متصل شوید و کدنویسی را شروع کنید. + +### Vertex AI (۳۰۰ دلار اعتبار رایگان برای حساب‌های جدید GCP) + +```bash +داشبورد → اتصال Vertex AI +→ آپلود JSON حساب سرویس Google Cloud +→ فعال‌سازی API Vertex AI در پروژه GCP خود + +مدل‌ها: + vertex/gemini-3.1-pro-preview + vertex/gemini-3-flash-preview + vertex/gemini-2.5-flash + +Vertex Partner (Anthropic / DeepSeek / GLM / Qwen از طریق Vertex): + vertex-partner/glm-5-maas + vertex-partner/deepseek-v3.2-maas + vertex-partner/qwen3-next-80b-a3b-thinking-maas +``` + +**نکته حرفه‌ای:** حساب‌های جدید Google Cloud ۳۰۰ دلار اعتبار رایگان به مدت ۹۰ روز دریافت می‌کنند. برای کدنویسی روزانه کافی است. + +
+ +
+🎨 ایجاد ترکیب‌ها + +### مثال ۱: حداکثر اشتراک → پشتیبان ارزان + +``` +داشبورد → ترکیب‌ها → ایجاد جدید + +نام: premium-coding +مدل‌ها: + 1. cc/claude-opus-4-7 (اشتراک اصلی) + 2. glm/glm-5.1 (پشتیبان ارزان، ۰.۶ دلار/میلیون) + 3. minimax/MiniMax-M2.7 (ارزان‌ترین بازگشت، ۰.۲۰ دلار/میلیون) + +استفاده در CLI: premium-coding + +مثال هزینه ماهانه (۱۰۰ میلیون توکن): + ۸۰ میلیون از طریق Claude (اشتراک): ۰ دلار اضافی + ۱۵ میلیون از طریق GLM: ۹ دلار + ۵ میلیون از طریق MiniMax: ۱ دلار + کل: ۱۰ دلار + اشتراک شما +``` + +### مثال ۲: فقط رایگان (هزینه صفر) + +``` +نام: free-combo +مدل‌ها: + 1. kr/claude-sonnet-4.5 (Claude 4.5 رایگان نامحدود) + 2. kr/glm-5 (GLM-5 رایگان از طریق Kiro) + 3. vertex/gemini-3.1-pro-preview (۳۰۰ دلار اعتبار رایگان) + +هزینه: ۰ دلار برای همیشه (+ صرفه‌جویی ۲۰-۴۰٪ توکن با RTK)! +``` + +
+ +
+🔧 یکپارچه‌سازی با CLI + +### Cursor IDE + +``` +تنظیمات → مدل‌ها → پیشرفته: + آدرس پایه API OpenAI: http://localhost:20128/v1 + کلید API OpenAI: [از داشبورد 9router] + مدل: cc/claude-opus-4-7 +``` + +یا از ترکیب استفاده کنید: `premium-coding` + +### Claude Code + +ویرایش `~/.claude/config.json`: + +```json +{ + "anthropic_api_base": "http://localhost:20128/v1", + "anthropic_api_key": "your-9router-api-key" +} +``` + +### Codex CLI + +```bash +export OPENAI_BASE_URL="http://localhost:20128" +export OPENAI_API_KEY="your-9router-api-key" + +codex "your prompt" +``` + +### OpenClaw + +**گزینه ۱ — داشبورد (توصیه می‌شود):** + +``` +داشبورد → ابزارهای CLI → OpenClaw → انتخاب مدل → اعمال +``` + +**گزینه ۲ — دستی:** ویرایش `~/.openclaw/openclaw.json`: + +```json +{ + "agents": { + "defaults": { + "model": { + "primary": "9router/kr/claude-sonnet-4.5" + } + } + }, + "models": { + "providers": { + "9router": { + "baseUrl": "http://127.0.0.1:20128/v1", + "apiKey": "sk_9router", + "api": "openai-completions", + "models": [ + { + "id": "kr/claude-sonnet-4.5", + "name": "Claude Sonnet 4.5 (Kiro Free)" + } + ] + } + } + } +} +``` + +> **توجه:** OpenClaw فقط با 9Router محلی کار می‌کند. برای جلوگیری از مشکلات وضوح IPv6 از `127.0.0.1` به جای `localhost` استفاده کنید. + +### Cline / Continue / RooCode + +``` +ارائه‌دهنده: سازگار با OpenAI +آدرس پایه: http://localhost:20128/v1 +کلید API: [از داشبورد] +مدل: cc/claude-opus-4-7 +``` + +
+ +
+🚀 استقرار + +### استقرار در VPS + +```bash +# کلون و نصب +git clone https://github.com/decolua/9router.git +cd 9router +npm install +npm run build + +# پیکربندی +export JWT_SECRET="your-secure-secret-change-this" +export INITIAL_PASSWORD="your-password" +export DATA_DIR="/var/lib/9router" +export PORT="20128" +export HOSTNAME="0.0.0.0" +export NODE_ENV="production" +export NEXT_PUBLIC_BASE_URL="http://localhost:20128" +export NEXT_PUBLIC_CLOUD_URL="https://9router.com" +export API_KEY_SECRET="endpoint-proxy-api-key-secret" +export MACHINE_ID_SALT="endpoint-proxy-salt" + +# شروع +npm run start + +# یا استفاده از PM2 +npm install -g pm2 +pm2 start npm --name 9router -- start +pm2 save +pm2 startup +``` + +### داکر + +تصاویر منتشر شده (چند پلتفرم `linux/amd64` + `linux/arm64`): + +- Docker Hub: [`decolua/9router`](https://hub.docker.com/r/decolua/9router) +- GHCR: [`ghcr.io/decolua/9router`](https://github.com/decolua/9router/pkgs/container/9router) + +**شروع سریع (استفاده از تصویر منتشر شده):** + +```bash +docker run -d \ + --name 9router \ + -p 20128:20128 \ + -v "$HOME/.9router:/app/data" \ + -e DATA_DIR=/app/data \ + decolua/9router:latest +``` + +→ باز کردن http://localhost:20128 + +**ساخت از سورس (توسعه):** + +```bash +git clone https://github.com/decolua/9router.git +cd 9router/app +docker build -t 9router . +docker run -d --name 9router -p 20128:20128 \ + -v "$HOME/.9router:/app/data" -e DATA_DIR=/app/data 9router +``` + +**پیش‌فرض‌های کانتینر:** + +- `PORT=20128` +- `HOSTNAME=0.0.0.0` + +**دستورات مفید:** + +```bash +docker logs -f 9router +docker restart 9router +docker stop 9router && docker rm 9router +docker pull decolua/9router:latest # به‌روزرسانی به آخرین نسخه +``` + +**ماندگاری داده:** `$HOME/.9router/db/data.sqlite` در میزبان ↔ `/app/data/db/data.sqlite` در کانتینر. + +### متغیرهای محیطی + +| متغیر | پیش‌فرض | توضیحات | +| ---------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | +| `JWT_SECRET` | تولید خودکار (`~/.9router/jwt-secret`) | راز امضای JWT برای کوکی احراز هویت داشبورد (برای اشتراک بین نمونه‌ها بازنویسی کنید) | +| `INITIAL_PASSWORD` | `123456` | رمز عبور اولین ورود در صورت عدم وجود هش ذخیره شده | +| `DATA_DIR` | `~/.9router` | مکان اصلی داده‌های برنامه (SQLite در `$DATA_DIR/db/data.sqlite`) | +| `PORT` | پیش‌فرض فریم‌ورک | پورت سرویس (`۲۰۱۲۸` در مثال‌ها) | +| `HOSTNAME` | پیش‌فرض فریم‌ورک | هاست بایند (داکر پیش‌فرض `۰.۰.۰.۰` است) | +| `NODE_ENV` | پیش‌فرض زمان اجرا | برای استقرار `production` را تنظیم کنید | +| `BASE_URL` | `http://localhost:20128` | آدرس پایه داخلی سمت سرور که توسط کارهای همگام‌سازی ابری استفاده می‌شود | +| `CLOUD_URL` | `https://9router.com` | آدرس پایه نقطه پایانی همگام‌سازی ابری سمت سرور | +| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | آدرس پایه عمومی/سازگار با گذشته (برای زمان اجرای سرور `BASE_URL` را ترجیح دهید) | +| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | آدرس ابری عمومی/سازگار با گذشته (برای زمان اجرای سرور `CLOUD_URL` را ترجیح دهید) | +| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | راز HMAC برای کلیدهای API تولید شده | +| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | نمک برای هش کردن شناسه ماشین پایدار | +| `ENABLE_REQUEST_LOGS` | `false` | لاگ‌های درخواست/پاسخ را در `logs/` فعال می‌کند | +| `AUTH_COOKIE_SECURE` | `false` | کوکی احراز هویت `Secure` را اعمال می‌کند (در پشت پروکسی معکوس HTTPS `true` تنظیم کنید) | +| `REQUIRE_API_KEY` | `false` | اعمال کلید API Bearer در مسیرهای `/v1/*` (برای استقرارهای در معرض اینترنت توصیه می‌شود) | +| `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY` | خالی | پروکسی خروجی اختیاری برای فراخوانی‌های ارائه‌دهنده بالا دست | +| `SEARXNG_URL` | `http://localhost:8888/search` | نقطه پایانی برای ارائه‌دهنده جستجوی وب SearXNG ساخته شده بدون احراز هویت | + +نکات: + +- متغیرهای پروکسی با حروف کوچک نیز پشتیبانی می‌شوند: `http_proxy`، `https_proxy`، `all_proxy`، `no_proxy`. +- `.env` در تصویر داکر تعبیه نشده است (`.dockerignore`)؛ پیکربندی زمان اجرا را با `--env-file` یا `-e` تزریق کنید. +- در ویندوز، می‌توان از `APPDATA` برای وضوح مسیر ذخیره‌سازی محلی استفاده کرد. +- `INSTANCE_NAME` در مستندات قدیمی/الگوهای env ظاهر می‌شود، اما در حال حاضر در زمان اجرا استفاده نمی‌شود. + +### فایل‌های زمان اجرا و ذخیره‌سازی + +- وضعیت اصلی برنامه: `${DATA_DIR}/db/data.sqlite` (SQLite — ارائه‌دهندگان، ترکیب‌ها، نام‌های مستعار، کلیدها، تنظیمات، تاریخچه استفاده) +- پشتیبان‌گیری خودکار: `${DATA_DIR}/db/backups/` +- لاگ‌های اختیاری درخواست/مترجم: `/logs/...` وقتی `ENABLE_REQUEST_LOGS=true` +- هر دو `${DATA_DIR}` و `~/.9router` در یک کانتینر داکر به یک مکان اشاره می‌کنند — symlink `/root/.9router -> /app/data` در زمان ساخت ایجاد می‌شود. + +
+ +--- + +## 📊 مدل‌های موجود + +
+مشاهده همه مدل‌های موجود + +**Claude Code (`cc/`)** - Pro/Max: + +- `cc/claude-opus-4-7` +- `cc/claude-opus-4-6` +- `cc/claude-sonnet-4-6` +- `cc/claude-sonnet-4-5-20250929` +- `cc/claude-haiku-4-5-20251001` + +**Codex (`cx/`)** - Plus/Pro: + +- `cx/gpt-5.5` +- `cx/gpt-5.4` +- `cx/gpt-5.3-codex` +- `cx/gpt-5.2-codex` +- `cx/gpt-5.1-codex-max` + +**GitHub Copilot (`gh/`)**: + +- `gh/gpt-5.4` +- `gh/claude-opus-4.7` +- `gh/claude-sonnet-4.6` +- `gh/gemini-3.1-pro-preview` +- `gh/grok-code-fast-1` + +**Cursor (`cu/`)** - اشتراک: + +- `cu/claude-4.6-opus-max` +- `cu/claude-4.5-sonnet-thinking` +- `cu/gpt-5.3-codex` +- `cu/kimi-k2.5` + +**GLM (`glm/`)** - ۰.۶ دلار/میلیون: + +- `glm/glm-5.1` +- `glm/glm-5` +- `glm/glm-4.7` + +**MiniMax (`minimax/`)** - ۰.۲ دلار/میلیون: + +- `minimax/MiniMax-M2.7` +- `minimax/MiniMax-M2.5` + +**Kimi (`kimi/`)** - ۹ دلار/ماه مسطح: + +- `kimi/kimi-k2.5` +- `kimi/kimi-k2.5-thinking` + +**Kiro (`kr/`)** - رایگان نامحدود: + +- `kr/claude-sonnet-4.5` +- `kr/claude-haiku-4.5` +- `kr/glm-5` +- `kr/MiniMax-M2.5` +- `kr/qwen3-coder-next` +- `kr/deepseek-3.2` + +**OpenCode Free (`oc/`)** - رایگان بدون احراز هویت: + +- دریافت خودکار از `opencode.ai/zen/v1/models` + +**Vertex AI (`vertex/`)** - ۳۰۰ دلار اعتبار رایگان: + +- `vertex/gemini-3.1-pro-preview` +- `vertex/gemini-3-flash-preview` +- `vertex/gemini-2.5-flash` +- `vertex-partner/glm-5-maas` +- `vertex-partner/deepseek-v3.2-maas` + +
+ +--- + +## 🐛 عیب‌یابی + +**"مدل زبان پیامی ارائه نکرد"** + +- سهمیه ارائه‌دهنده تمام شده → پیگیری سهمیه در داشبورد را بررسی کنید +- راه‌حل: از بازگشت ترکیبی استفاده کنید یا به لایه ارزان‌تر تغییر دهید + +**محدودیت نرخ درخواست** + +- سهمیه اشتراک تمام شده → بازگشت به GLM/MiniMax +- ترکیب اضافه کنید: `cc/claude-opus-4-7 → glm/glm-5.1 → kr/claude-sonnet-4.5` + +**توکن OAuth منقضی شده است** + +- توسط 9Router به‌طور خودکار بازسازی می‌شود +- اگر مشکل ادامه داشت: داشبورد → ارائه‌دهنده → اتصال مجدد + +**هزینه‌های بالا** + +- RTK را در داشبورد → تنظیمات نقطه پایانی فعال کنید (پیش‌فرض روشن است، ۲۰-۴۰٪ توکن صرفه‌جویی می‌کند) +- آمار مصرف را در داشبورد بررسی کنید +- مدل اصلی را به GLM/MiniMax تغییر دهید +- برای کارهای غیر حیاتی از لایه رایگان (Kiro، OpenCode Free، Vertex) استفاده کنید + +**داشبورد در پورت اشتباه باز می‌شود** + +- `PORT=20128` و `NEXT_PUBLIC_BASE_URL=http://localhost:20128` را تنظیم کنید + +**اولین ورود کار نمی‌کند** + +- `INITIAL_PASSWORD` را در `.env` بررسی کنید +- در صورت تنظیم نشدن، رمز عبور پیش‌فرض `123456` است + +**لاگ‌های درخواست در `logs/` وجود ندارد** + +- `ENABLE_REQUEST_LOGS=true` را تنظیم کنید + +--- + +## 🛠️ پشته فنی + +- **زمان اجرا**: Node.js 20+ +- **فریم‌ورک**: Next.js 16 +- **UI**: React 19 + Tailwind CSS 4 +- **پایگاه داده**: SQLite (better-sqlite3 / node:sqlite / بازگشت sql.js) +- **پخش جریانی**: رویدادهای ارسال شده از سرور (SSE) +- **احراز هویت**: OAuth 2.0 (PKCE) + JWT + کلیدهای API + +--- + +## 📝 مرجع API + +### تکمیل‌های چت + +```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": "Write a function to..."} + ], + "stream": true +} +``` + +### لیست مدل‌ها + +```bash +GET http://localhost:20128/v1/models +Authorization: Bearer your-api-key + +→ همه مدل‌ها + ترکیب‌ها را در قالب OpenAI برمی‌گرداند +``` + +## 📧 پشتیبانی + +- **وب‌سایت**: [9router.com](https://9router.com) +- **GitHub**: [github.com/decolua/9router](https://github.com/decolua/9router) +- **مسائل**: [github.com/decolua/9router/issues](https://github.com/decolua/9router/issues) + +--- + +## 👥 مشارکت‌کنندگان + +با تشکر از همه مشارکت‌کنندگانی که به بهتر شدن 9Router کمک کردند! + +[![Contributors](https://contrib.rocks/image?repo=decolua/9router&max=150&columns=15&anon=1&v=20260309)](https://github.com/decolua/9router/graphs/contributors) + +--- + +## 📊 نمودار ستاره + +[![Star Chart](https://starchart.cc/decolua/9router.svg?variant=adaptive)](https://starchart.cc/decolua/9router) + +## 🔀 فورک‌ها + +**[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** — یک فورک کامل TypeScript از 9Router. بیش از ۳۶ ارائه‌دهنده، بازگشت خودکار ۴ لایه، APIهای چندوجهی (تصاویر، جاسازی‌ها، صدا، TTS)، قطع‌کننده مدار، حافظه پنهان معنایی، ارزیابی‌های LLM و داشبوردی زیبا اضافه می‌کند. بیش از ۳۶۸ تست واحد. از طریق npm و داکر در دسترس است. + +--- + +## 🙏 قدردانی + +ساخته شده بر روی شانه‌های غول‌ها: + +- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — پیاده‌سازی اصلی Go که الهام‌بخش این پورت جاوااسکریپت بود. +- **[RTK](https://github.com/rtk-ai/rtk)** ![Stars](https://img.shields.io/github/stars/rtk-ai/rtk?style=flat&color=yellow) — ذخیره‌ساز توکن Rust. 9Router خط لوله فشرده‌سازی آن را به JS منتقل می‌کند → **۲۰-۴۰٪- توکن ورودی** در هر درخواست. +- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) توسط **[@JuliusBrussee](https://github.com/JuliusBrussee)** — پرامپت ویروسی _"چرا از توکن زیاد استفاده کنی وقتی توکن کم کار را انجام می‌دهد"_. 9Router پرامپت آن را تطبیق می‌دهد → **۶۵٪- توکن خروجی**. +- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Stars](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) توسط **[@DietrichGebert](https://github.com/DietrichGebert)** — مهارت _"توسعه‌دهنده ارشد تنبل"_. 9Router نردبان YAGNI-first آن را تزریق می‌کند → **توکن کمتر، کد کمتر، دیف‌های کوتاه‌تر**. + +تشکر فراوان از این نویسندگان — بدون کار آنها، ویژگی‌های ذخیره‌سازی توکن 9Router وجود نداشت. ⭐ آنها را در GitHub بدهید! + +--- + +## 📄 مجوز + +مجوز MIT - برای جزئیات به [LICENSE](LICENSE) مراجعه کنید. + +--- + +
+ ساخته شده با ❤️ برای توسعه‌دهندگانی که ۲۴/۷ کدنویسی می‌کنند +
diff --git a/public/i18n/literals/fa.json b/public/i18n/literals/fa.json index 770518a3..b298fc57 100644 --- a/public/i18n/literals/fa.json +++ b/public/i18n/literals/fa.json @@ -1,195 +1,1391 @@ { - "Cancel": "لغو", - "Delete": "حذف", - "Edit": "ویرایش", - "Save": "ذخیره", - "Close": "بستن", - "Add": "افزودن", - "Remove": "حذف", - "Settings": "تنظیمات", - "Profile": "پروفایل", - "Dashboard": "پیش‌خوان", - "Logout": "خروج", - "Login": "ورود", - "Providers": "ارائه‌دهندگان", - "Usage": "آمار مصرف", + "($/1M tokens). Example: An input rate of 2.50 means $2.50 per 1,000,000 input tokens.": "($/۱ میلیون توکن). مثال: نرخ ورودی ۲.۵۰ به معنای ۲.۵۰ دلار به ازای هر ۱٬۰۰۰٬۰۰۰ توکن ورودی است.", + "($/1M tokens). Example: Input rate of 2.50 means $2.50 per 1,000,000 input tokens.": "($/۱ میلیون توکن). مثال: نرخ ورودی ۲.۵۰ به معنای ۲.۵۰ دلار به ازای هر ۱٬۰۰۰٬۰۰۰ توکن ورودی است.", + "(Caveman)": "(Caveman)", + "(Headroom)": "(Headroom)", + "(Ponytail)": "(Ponytail)", + "(RTK)": "(RTK)", + "(via inference test)": "(از طریق آزمون استنتاج)", + "+ Browse": "+ مرور", + "+ Combo": "+ ترکیب", + "+ Custom": "+ سفارشی", + "+ Save current as...": "+ ذخیره فعلی به عنوان...", + "-compatible models manually or import them from the /models endpoint.": "مدل‌های سازگار را به صورت دستی وارد کنید یا از نقطه پایانی /models وارد کنید.", + ". Click \"Apply\" to auto-configure.": ". برای پیکربندی خودکار روی «اعمال» کلیک کنید.", + "1. CLI & SDKs": "۱. CLI و SDK", + "1. Client Request (Input)": "۱. درخواست مشتری (ورودی)", + "1. Generates SSL cert & adds to system keychain": "۱. گواهی SSL تولید می‌کند و به زنجیره کلید سیستم اضافه می‌کند", + "2. 9Router Hub": "۲. مرکز 9Router", + "2. Provider Request (Translated)": "۲. درخواست ارائه‌دهنده (ترجمه شده)", + "2. Redirects": "۲. تغییر مسیرها", + "24h": "۲۴ ساعت", + "3. AI Providers": "۳. ارائه‌دهندگان هوش مصنوعی", + "3. Maps Antigravity models to any provider via 9Router": "۳. مدل‌های Antigravity را از طریق 9Router به هر ارائه‌دهنده‌ای نگاشت می‌کند", + "3. Provider Response (Raw)": "۳. پاسخ ارائه‌دهنده (خام)", + "30D": "۳۰ روز", + "4. Client Response (Final)": "۴. پاسخ مشتری (نهایی)", + "60D": "۶۰ روز", + "7D": "۷ روز", + "9Router (Entry)": "9Router (ورودی)", + "9Router Base URL": "آدرس پایه 9Router", + ": Account | Workers Scripts | Edit": ": حساب | اسکریپت‌های Workers | ویرایش", + ": Include | Account |": ": شامل | حساب |", + "AI endpoint proxy with web dashboard - A JavaScript port of CLIProxyAPI. Works seamlessly with Claude Code, OpenAI Codex, Cline, RooCode, and other CLI tools.": "پروکسی نقطه پایانی هوش مصنوعی با داشبورد وب - یک پورت جاوااسکریپت از CLIProxyAPI. به‌طور یکپارچه با Claude Code، OpenAI Codex، Cline، RooCode و سایر ابزارهای CLI کار می‌کند.", + "API Endpoint": "نقطه پایانی API", "API Key": "کلید API", - "Connected": "متصل", - "Disconnected": "قطع شده", - "Active": "فعال", - "Inactive": "غیرفعال", - "Success": "موفق", - "Failed": "ناموفق", - "Error": "خطا", - "Warning": "هشدار", - "Info": "اطلاعات", - "Loading": "در حال بارگذاری", - "Search": "جستجو", - "Filter": "فیلتر", - "Sort": "مرتب‌سازی", - "Export": "خروجی", - "Import": "ورودی", - "Refresh": "تازه‌سازی", - "Back": "بازگشت", - "Next": "بعدی", - "Previous": "قبلی", - "Submit": "ارسال", - "Confirm": "تأیید", - "Yes": "بله", - "No": "خیر", - "OK": "تأیید", - "Apply": "اعمال", - "Reset": "بازنشانی", - "Clear": "پاک کردن", - "Select": "انتخاب", - "Upload": "آپلود", - "Download": "دانلود", - "Copy": "کپی", - "Paste": "چسباندن", - "Cut": "برش", - "Undo": "بازگشت", - "Redo": "انجام مجدد", - "Name": "نام", - "Description": "توضیحات", - "Status": "وضعیت", - "Type": "نوع", - "Date": "تاریخ", - "Time": "زمان", - "Created": "ایجاد شده", - "Updated": "بروزرسانی شده", - "Actions": "عملیات", - "Details": "جزئیات", - "View": "مشاهده", - "New": "جدید", - "Total": "مجموع", - "Count": "تعداد", - "Price": "قیمت", - "Cost": "هزینه", - "Free": "رایگان", - "Paid": "پولی", - "Enable": "فعال‌سازی", - "Disable": "غیرفعال‌سازی", - "Enabled": "فعال شده", - "Disabled": "غیرفعال شده", - "Online": "آنلاین", - "Offline": "آفلاین", - "Available": "موجود", - "Unavailable": "ناموجود", - "Required": "الزامی", - "Optional": "اختیاری", - "Default": "پیش‌فرض", - "Custom": "سفارشی", - "Advanced": "پیشرفته", - "Basic": "ساده", - "Help": "راهنما", - "Support": "پشتیبانی", - "Documentation": "مستندات", - "Version": "نسخه", - "Language": "زبان", - "Theme": "پوسته", - "Light": "روشن", - "Dark": "تاریک", - "Auto": "خودکار", - "Endpoint": "اندپوینت", - "Combos": "ترکیبات", - "Quota Tracker": "پیگیر سهمیه", - "MITM": "MITM", - "CLI Tools": "ابزارهای CLI", - "Console Log": "لاگ کنسول", - "System": "سیستم", - "Debug": "اشکال‌زدایی", - "Shutdown": "خاموش کردن", - "Close Proxy": "بستن پروکسی", - "Are you sure you want to close the proxy server?": "آیا مطمئن هستید که می‌خواهید سرور پروکسی را ببندید؟", - "Server Disconnected": "سرور قطع شد", - "The proxy server has been stopped.": "سرور پروکسی متوقف شده است.", - "Reload Page": "بارگذاری مجدد صفحه", - "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "سرویس در ترمینال در حال اجراست. می‌توانید این صفحه وب را ببندید. خاموش کردن، سرویس را متوقف می‌کند.", - "Manage your AI provider connections": "مدیریت اتصالات ارائه‌دهندگان هوش مصنوعی خود", - "Model combos with fallback": "ترکیبات مدل با پشتیبان جایگزین", - "Monitor your API usage, token consumption, and request logs": "نظارت بر مصرف API، مصرف توکن و لاگ درخواست‌ها", - "Intercept CLI tool traffic and route through 9Router": "拦截 ترافیک ابزار CLI و مسیردهی از طریق 9Router", - "Configure CLI tools": "پیکربندی ابزارهای CLI", + "API Key (for Check)": "کلید API (برای بررسی)", + "API Key Compatible Providers": "ارائه‌دهندگان سازگار با کلید API", + "API Key Created": "کلید API ایجاد شد", + "API Key Name": "نام کلید API", + "API Key Providers": "ارائه‌دهندگان کلید API", + "API Keys": "کلیدهای API", + "API Reference": "مرجع API", + "API Token": "توکن API", + "API Tokens": "توکن‌های API", + "API Type": "نوع API", + "API Version": "نسخه API", "API endpoint configuration": "پیکربندی نقطه پایانی API", - "Manage your preferences": "مدیریت تنظیمات شخصی", - "Debug translation flow between formats": "اشکال‌زدایی جریان ترجمه بین فرمت‌ها", - "Live server console output": "خروجی کنسول سرور زنده", + "AWS Builder ID": "AWS Builder ID", + "AWS IAM Identity Center": "مرکز هویت AWS IAM", + "AWS Region": "منطقه AWS", + "AWS region for the key (default: us-east-1)": "منطقه AWS برای کلید (پیش‌فرض: us-east-1)", + "AWS region for your Identity Center (default: us-east-1)": "منطقه AWS برای مرکز هویت شما (پیش‌فرض: us-east-1)", + "About": "درباره", + "Access Anywhere": "دسترسی از هر جا", + "Access Token": "توکن دسترسی", + "Access token will be auto-filled...": "توکن دسترسی به‌طور خودکار پر می‌شود...", + "Access your terminal, desktop & files from anywhere": "از هر جایی به ترمینال، دسکتاپ و فایل‌های خود دسترسی داشته باشید", + "Account": "حساب", + "Account ID": "شناسه حساب", + "Account Resources": "منابع حساب", + "Accounts per page": "تعداد حساب در هر صفحه", + "Action": "عملیات", + "Activate": "فعال‌سازی", + "Active": "فعال", + "Active All": "فعال‌سازی همه", + "Active:": "فعال:", + "Add": "افزودن", + "Add API Key": "افزودن کلید API", + "Add Anthropic Compatible": "افزودن سازگار با Anthropic", + "Add Connection": "افزودن اتصال", + "Add Custom Embedding": "افزودن تعبیه سفارشی", + "Add Custom MCP": "افزودن MCP سفارشی", + "Add Custom Model": "افزودن مدل سفارشی", + "Add Model": "افزودن مدل", + "Add Model Config": "افزودن پیکربندی مدل", + "Add Model for GitHub Copilot": "افزودن مدل برای GitHub Copilot", + "Add Model for OpenCode": "افزودن مدل برای OpenCode", + "Add Model to Combo": "افزودن مدل به ترکیب", + "Add New Provider": "افزودن ارائه‌دهنده جدید", + "Add OpenAI Compatible": "افزودن سازگار با OpenAI", + "Add Provider": "افزودن ارائه‌دهنده", + "Add Proxy Pool": "افزودن استخر پروکسی", + "Add Shorthands": "افزودن میان‌نویس‌ها", + "Add a connection to enable importing models.": "برای فعال‌سازی وارد کردن مدل‌ها، یک اتصال اضافه کنید.", + "Add connection using browser cookie": "افزودن اتصال با استفاده از کوکی مرورگر", + "Add model": "افزودن مدل", + "Add server": "افزودن سرور", + "Add the following configuration to your models array:": "پیکربندی زیر را به آرایه مدل‌های خود اضافه کنید:", + "Add your first connection to get started": "اولین اتصال خود را برای شروع اضافه کنید", + "Administrator required": "نیاز به مدیر سیستم", + "Administrator required — restart 9Router as Administrator to use MITM": "نیاز به مدیر سیستم — برای استفاده از MITM، 9Router را به عنوان مدیر راه‌اندازی مجدد کنید", + "After authorization, copy the full URL from your browser address bar.": "پس از مجوز، URL کامل را از نوار آدرس مرورگر خود کپی کنید.", + "After authorization, copy the full URL from your browser.": "پس از مجوز، URL کامل را از مرورگر خود کپی کنید.", + "After installation, run": "پس از نصب، اجرا کنید", + "After login, you'll need to copy the callback URL from your browser and paste it back here.": "پس از ورود، باید URL پاسخ بازگشت را از مرورگر خود کپی کرده و در اینجا بچسبانید.", + "Alibaba Qwen Code CLI — supports OpenAI, Anthropic & Gemini providers via 9Router": "علی‌بابا Qwen Code CLI — از ارائه‌دهندگان OpenAI، Anthropic و Gemini از طریق 9Router پشتیبانی می‌کند", + "All": "همه", + "All AI Providers": "همه ارائه‌دهندگان هوش مصنوعی", + "All Providers": "همه ارائه‌دهندگان", + "All models are responding normally.": "همه مدل‌ها به‌طور عادی پاسخ می‌دهند.", + "All providers": "همه ارائه‌دهندگان", + "All rates are in": "همه نرخ‌ها بر حسب", + "All selected currently unbound": "همه موارد انتخاب شده در حال حاضر بدون اتصال هستند", + "Allow dashboard access via tunnel": "اجازه دسترسی به داشبورد از طریق تونل", + "Allow either password or OIDC.": "اجازه ورود با رمز عبور یا OIDC را بدهید.", + "An error occurred": "خطایی رخ داد", + "An error occurred. Please try again.": "خطایی رخ داد. لطفاً دوباره تلاش کنید.", + "Anthropic Claude Code CLI": "Anthropic Claude Code CLI", + "Anthropic Compatible (Prod)": "سازگار با Anthropic (تولید)", + "Anthropic Compatible Details": "جزئیات سازگاری با Anthropic", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "درخواست Antigravity/Copilot IDE → تغییر مسیر DNS به localhost:443 → رهگیری پروکسی MITM → 9Router → پاسخ به Antigravity/Copilot", + "Any model available in 9Router can be used — not just Qwen models. Select from Qwen, Claude, Gemini, GPT, and more.": "هر مدلی که در 9Router موجود است قابل استفاده است — نه فقط مدل‌های Qwen. از بین Qwen، Claude، Gemini، GPT و بیشتر انتخاب کنید.", + "App Name": "نام برنامه", + "Apply": "اعمال", + "Apply Proxy": "اعمال پروکسی", + "Applying...": "در حال اعمال...", + "Are you sure you want to close the proxy server?": "آیا مطمئن هستید که می‌خواهید سرور پروکسی را ببندید؟", + "Are you sure you want to disable the tunnel?": "آیا مطمئن هستید که می‌خواهید تونل را غیرفعال کنید؟", + "Attempting to reconnect...": "در حال تلاش برای اتصال مجدد...", + "Audio File": "فایل صوتی", + "Auth Mode": "حالت احراز هویت", + "Authenticate": "احراز هویت", + "Authentication Method": "روش احراز هویت", + "Authentication Successful": "احراز هویت موفق", + "Authentication Successful!": "احراز هویت موفق!", + "Authless": "بدون احراز هویت", + "Authorization Successful!": "مجوز با موفقیت انجام شد!", + "Authorize": "مجوز", + "Auto (by priority)": "خودکار (بر اساس اولویت)", + "Auto Refresh (3s)": "تازه‌سازی خودکار (۳ ثانیه)", + "Auto-detect": "تشخیص خودکار", + "Auto-detecting token...": "در حال تشخیص خودکار توکن...", + "Auto-detecting tokens...": "در حال تشخیص خودکار توکن‌ها...", + "Auto-ping": "پینگ خودکار", + "Auto-refresh": "تازه‌سازی خودکار", + "Auto:": "خودکار:", + "Automatically switch between providers when limits are hit.": "هنگام رسیدن به محدودیت‌ها به‌طور خودکار بین ارائه‌دهندگان جابجا شوید.", + "Available": "موجود", + "Available Models": "مدل‌های موجود", + "Azure Endpoint": "نقطه پایانی Azure", + "Azure OpenAI Configuration": "پیکربندی Azure OpenAI", + "BXAuth=xxx; ...": "BXAuth=xxx; ...", + "Back": "بازگشت", + "Back to CLI Tools": "بازگشت به ابزارهای CLI", + "Back to Providers": "بازگشت به ارائه‌دهندگان", + "Base URL": "آدرس پایه", + "Batch Import": "وارد کردن دسته‌ای", + "Batch Import Proxies": "وارد کردن دسته‌ای پروکسی‌ها", + "Batch Size": "اندازه دسته", + "Beautiful web dashboard for managing providers and monitoring usage.": "داشبورد وب زیبا برای مدیریت ارائه‌دهندگان و نظارت بر مصرف.", + "Best quality, but costs the most": "بهترین کیفیت، اما هزینه‌برترین", + "Bias the model toward minimal code: YAGNI, reuse stdlib, deletion over addition": "مدل را به سمت کد حداقلی سوق دهید: YAGNI، استفاده مجدد از کتابخانه استاندارد، حذف به جای افزودن", + "Binary File": "فایل باینری", + "Blog": "وبلاگ", + "Both": "هر دو", + "Browse & edit files": "مرور و ویرایش فایل‌ها", + "Browse MCP Marketplace": "مرور بازار MCP", + "Browse source, README, and examples.": "مرور کد منبع، README و مثال‌ها.", + "Browser Control (Browser MCP)": "کنترل مرورگر (Browser MCP)", + "Bulk Add": "افزودن عمده", + "CLI Support": "پشتیبانی CLI", + "CLI Tools": "ابزارهای CLI", + "CLI on the host →": "CLI روی میزبان →", + "CLIProxyAPI Auth JSON": "احراز هویت CLIProxyAPI JSON", + "Cache Creation": "ایجاد حافظه پنهان", + "Cache Creation:": "ایجاد حافظه پنهان:", + "Cached": "ذخیره شده در حافظه پنهان", + "Cached Tokens": "توکن‌های ذخیره شده در حافظه پنهان", + "Cached Tokens:": "توکن‌های ذخیره شده در حافظه پنهان:", + "Cached input tokens (typically 50% of input rate)": "توکن‌های ورودی ذخیره شده در حافظه پنهان (معمولاً ۵۰٪ نرخ ورودی)", + "Cached:": "ذخیره شده در حافظه پنهان:", + "Calls per account before switching": "تعداد تماس به ازای هر حساب قبل از تغییر", + "Calls per combo model before switching": "تعداد تماس به ازای هر مدل ترکیبی قبل از تغییر", + "Cancel": "لغو", + "Capacity auto-switch": "تغییر خودکار ظرفیت", + "Cert": "گواهی", + "Change Log": "تاریخچه تغییرات", + "Changelog": "تاریخچه تغییرات", + "Chat": "گفتگو", + "Chat / code-gen via OpenAI or Anthropic format with streaming.": "گفتگو / تولید کد از طریق فرمت OpenAI یا Anthropic با پخش جریانی.", + "Chat Completions": "تکمیل گفتگو", + "Check": "بررسی", + "Checking Claude CLI...": "در حال بررسی Claude CLI...", + "Checking Claude Cowork...": "در حال بررسی Claude Cowork...", + "Checking Cline...": "در حال بررسی Cline...", + "Checking Codex CLI...": "در حال بررسی Codex CLI...", + "Checking Copilot config...": "در حال بررسی پیکربندی Copilot...", + "Checking DeepSeek TUI...": "در حال بررسی DeepSeek TUI...", + "Checking Factory Droid CLI...": "در حال بررسی Factory Droid CLI...", + "Checking Hermes Agent...": "در حال بررسی Hermes Agent...", + "Checking Kilo Code...": "در حال بررسی Kilo Code...", + "Checking Open Claw CLI...": "در حال بررسی Open Claw CLI...", + "Checking OpenCode CLI...": "در حال بررسی OpenCode CLI...", + "Checking jcode CLI...": "در حال بررسی jcode CLI...", + "Checking...": "در حال بررسی...", + "Choose API Provider → Ollama": "ارائه‌دهنده API را انتخاب کنید → Ollama", + "Choose how to authenticate with GitLab Duo:": "نحوه احراز هویت با GitLab Duo را انتخاب کنید:", + "Choose your authentication method:": "روش احراز هویت خود را انتخاب کنید:", + "Claude": "Claude", + "Claude CLI - Manual Configuration": "Claude CLI - پیکربندی دستی", + "Claude CLI not detected locally": "Claude CLI در سیستم محلی شناسایی نشد", + "Claude CLI not installed": "Claude CLI نصب نشده است", + "Claude Cowork - Manual Configuration": "Claude Cowork - پیکربندی دستی", + "Claude Desktop (Cowork mode) not detected": "Claude Desktop (حالت Cowork) شناسایی نشد", + "Claude Desktop Cowork (third-party inference)": "Claude Desktop Cowork (استنتاج شخص ثالث)", + "Clear": "پاک کردن", + "Clear (will use main model)": "پاک کردن (از مدل اصلی استفاده خواهد شد)", + "Clear Filters": "پاک کردن فیلترها", + "Clear search": "پاک کردن جستجو", + "Click": "کلیک", + "Click \"View All Model\" → \"Add Custom Model\"": "روی «مشاهده همه مدل‌ها» → «افزودن مدل سفارشی» کلیک کنید", + "Click a model to set/clear active": "برای تنظیم/لغو فعال بودن، روی یک مدل کلیک کنید", + "Click to add, click again to remove. Changes are saved automatically.": "برای افزودن کلیک کنید، برای حذف دوباره کلیک کنید. تغییرات به‌طور خودکار ذخیره می‌شوند.", + "Click to edit": "برای ویرایش کلیک کنید", + "Click to retry": "برای تلاش مجدد کلیک کنید", + "Client ID": "شناسه مشتری", + "Client Request": "درخواست مشتری", + "Client Response": "پاسخ مشتری", + "Client Secret": "راز مشتری", + "Cline - Manual Configuration": "Cline - پیکربندی دستی", + "Cline AI Coding Assistant": "دستیار کدنویسی هوش مصنوعی Cline", + "Cline not detected locally": "Cline در سیستم محلی شناسایی نشد", + "Close": "بستن", + "Close Proxy": "بستن پروکسی", + "Close provider filter": "بستن فیلتر ارائه‌دهنده", + "Close reset credit expiry modal": "بستن پنجره انقضای اعتبار بازنشانی", + "Close test results": "بستن نتایج آزمایش", + "Closing in": "در حال بسته شدن در", + "Cloud Sync": "همگام‌سازی ابری", + "Cloudflare Relay": "Cloudflare Relay", + "Cloudflare Tunnel": "تونل Cloudflare", + "Cloudflare Workers AI": "Cloudflare Workers AI", + "Codex CLI - Manual Configuration": "Codex CLI - پیکربندی دستی", + "Codex CLI not detected locally": "Codex CLI در سیستم محلی شناسایی نشد", + "Codex CLI not installed": "Codex CLI نصب نشده است", + "Codex Reset Credit Expiry": "انقضای اعتبار بازنشانی Codex", + "Codex uses": "Codex استفاده می‌کند", + "Combo Name": "نام ترکیب", + "Combo Round Robin": "چرخشی ترکیب", + "Combo Sticky Limit": "محدودیت چسبندگی ترکیب", + "Combos": "ترکیبات", + "Coming soon...": "به زودی...", + "Comma-separated hostnames/domains to bypass the proxy.": "نام میزبان/دامنه‌ها با جداکننده ویرگول برای دور زدن پروکسی.", + "Comma-separated hosts/domains to bypass proxy": "نام میزبان/دامنه‌ها با جداکننده ویرگول برای دور زدن پروکسی", + "Company": "شرکت", + "Complete the authorization in the popup window.": "مجوز را در پنجره بازشو تکمیل کنید.", + "Completion/response tokens": "توکن‌های تکمیل/پاسخ", + "Compress LLM output": "فشرده‌سازی خروجی LLM", + "Compress context": "فشرده‌سازی زمینه", + "Compress prompts via /v1/compress before routing to the model": "فشرده‌سازی پرامپت‌ها از طریق /v1/compress قبل از مسیردهی به مدل", + "Compress tool output": "فشرده‌سازی خروجی ابزار", + "Compress tool output to reduce token usage.": "خروجی ابزار را برای کاهش مصرف توکن فشرده کنید.", + "Config path: Linux/macOS ~/.deepseek/config.toml • Windows %USERPROFILE%\\.deepseek\\config.toml": "مسیر پیکربندی: Linux/macOS ~/.deepseek/config.toml • Windows %USERPROFILE%\\.deepseek\\config.toml", + "Config path: Linux/macOS ~/.qwen/settings.json • Windows %USERPROFILE%\\.qwen\\settings.json": "مسیر پیکربندی: Linux/macOS ~/.qwen/settings.json • Windows %USERPROFILE%\\.qwen\\settings.json", + "Configuration": "پیکربندی", + "Configure 9router as an OpenAI-compatible provider to route all jcode requests through 9router's optimization layer.": "9router را به عنوان یک ارائه‌دهنده سازگار با OpenAI پیکربندی کنید تا تمام درخواست‌های jcode را از طریق لایه بهینه‌سازی 9router مسیردهی کند.", + "Configure CLI tools": "پیکربندی ابزارهای CLI", + "Configure a new AI provider to use with your applications.": "یک ارائه‌دهنده هوش مصنوعی جدید برای استفاده با برنامه‌های خود پیکربندی کنید.", + "Configure pricing rates for cost tracking and calculations": "نرخ‌های قیمت‌گذاری را برای پیگیری و محاسبه هزینه پیکربندی کنید", + "Configure providers and API keys via web interface": "پیکربندی ارائه‌دهندگان و کلیدهای API از طریق رابط وب", + "Configured": "پیکربندی شده", + "Confirm": "تأیید", + "Confirm New Password": "تأیید رمز عبور جدید", + "Confirm Password": "تأیید رمز عبور", + "Confirm new password": "تأیید رمز عبور جدید", + "Connect": "اتصال", + "Connect AI tools remotely": "اتصال ابزارهای هوش مصنوعی از راه دور", + "Connect Cursor IDE": "اتصال Cursor IDE", + "Connect GitLab Duo": "اتصال GitLab Duo", + "Connect Kiro": "اتصال Kiro", + "Connect to providers with OAuth to track your API quota limits and usage.": "با استفاده از OAuth به ارائه‌دهندگان متصل شوید تا محدودیت‌ها و مصرف سهمیه API خود را پیگیری کنید.", + "Connect via OAuth or API keys. Securely manage credentials.": "از طریق OAuth یا کلیدهای API متصل شوید. اعتبارنامه‌ها را به‌طور امن مدیریت کنید.", + "Connect with OAuth2": "اتصال با OAuth2", + "Connect your account using OAuth2 authentication.": "حساب خود را با استفاده از احراز هویت OAuth2 متصل کنید.", + "Connected": "متصل", + "Connected Successfully!": "اتصال با موفقیت انجام شد!", + "Connected providers only": "فقط ارائه‌دهندگان متصل", + "Connecting...": "در حال اتصال...", + "Connection": "اتصال", + "Connection Details": "جزئیات اتصال", + "Connection Failed": "اتصال ناموفق", + "Connections": "اتصالات", + "Console Log": "لاگ کنسول", + "Contact": "تماس", + "Content": "محتوای", + "Continue": "ادامه", + "Continue AI Assistant": "دستیار هوش مصنوعی Continue", + "Continue to summary": "ادامه به خلاصه", + "Continue with GitHub": "ادامه با GitHub", + "Continue with Google": "ادامه با Google", + "Cookie": "کوکی", + "Cookie Auth": "احراز هویت کوکی", + "Cookie String": "رشته کوکی", + "Cooldown": "آرامش", + "Copied!": "کپی شد!", + "Copy": "کپی", + "Copy & Shutdown": "کپی و خاموش کردن", + "Copy This URL": "کپی این URL", + "Copy a link and paste to your AI to use 9Router — no install needed": "یک لینک کپی کرده و به هوش مصنوعی خود بچسبانید تا از 9Router استفاده کنید — نیازی به نصب نیست", + "Copy combo name": "کپی نام ترکیب", + "Copy install command": "کپی دستور نصب", + "Copy model": "کپی مدل", + "Copy the JSON below to your ~/.qwen/settings.json file.": "JSON زیر را در فایل ~/.qwen/settings.json خود کپی کنید.", + "Copy the entire cookie string (must include BXAuth)": "کل رشته کوکی را کپی کنید (باید شامل BXAuth باشد)", + "Cost": "هزینه", + "Cost Calculation:": "محاسبه هزینه:", + "Costs": "هزینه‌ها", + "Costs are calculated based on token usage and pricing rates. Each request's cost is determined by: (input_tokens × input_rate) + (output_tokens × output_rate) + (cached_tokens × cached_rate)": "هزینه‌ها بر اساس مصرف توکن و نرخ‌های قیمت‌گذاری محاسبه می‌شوند. هزینه هر درخواست با فرمول زیر تعیین می‌شود: (توکن‌های ورودی × نرخ ورودی) + (توکن‌های خروجی × نرخ خروجی) + (توکن‌های ذخیره شده × نرخ ذخیره شده)", + "Could not read Cursor database automatically.": "امکان خواندن خودکار پایگاه داده Cursor وجود ندارد.", + "Create": "ایجاد", + "Create API Key": "ایجاد کلید API", + "Create Combo": "ایجاد ترکیب", + "Create Cowork Combo": "ایجاد ترکیب Cowork", + "Create Key": "ایجاد کلید", + "Create Provider": "ایجاد ارائه‌دهنده", + "Create Token": "ایجاد توکن", + "Create a": "ایجاد یک", + "Create a proxy pool entry, then assign it to connections.": "یک ورودی استخر پروکسی ایجاد کنید، سپس آن را به اتصالات اختصاص دهید.", "Create model combos with fallback support": "ایجاد ترکیبات مدل با پشتیبانی از پشتیبان جایگزین", - "Local Mode": "حالت محلی", - "Running on your machine": "در حال اجرا روی دستگاه شما", + "Create your first API key to get started": "اولین کلید API خود را برای شروع ایجاد کنید", + "Created": "ایجاد شد", + "Creating...": "در حال ایجاد...", + "Current": "فعلی", + "Current Password": "رمز عبور فعلی", + "Current Pricing Overview": "بررسی قیمت‌گذاری فعلی", + "Current password": "رمز عبور فعلی", + "Current: Keeps": "فعلی: نگهداری می‌کند", + "Currently using accounts in priority order (Fill First).": "در حال حاضر از حساب‌ها به ترتیب اولویت استفاده می‌کند (ابتدا پر کردن).", + "Cursor AI Code Editor": "ویرایشگر کد هوش مصنوعی Cursor", + "Cursor IDE not detected. Please paste your tokens manually.": "Cursor IDE شناسایی نشد. لطفاً توکن‌های خود را به صورت دستی بچسبانید.", + "Cursor routes requests through its own server, so local endpoint is not supported. Please enable Tunnel or Cloud Endpoint in Settings.": "Cursor درخواست‌ها را از طریق سرور خود مسیردهی می‌کند، بنابراین نقطه پایانی محلی پشتیبانی نمی‌شود. لطفاً تونل یا نقطه پایانی ابری را در تنظیمات فعال کنید.", + "Custom": "سفارشی", + "Custom Pricing:": "قیمت‌گذاری سفارشی:", + "Custom Providers (OpenAI/Anthropic Compatible)": "ارائه‌دهندگان سفارشی (سازگار با OpenAI/Anthropic)", + "Custom Token": "توکن سفارشی", + "Custom accounts per page": "تعداد حساب سفارشی در هر صفحه", + "Custom providers": "ارائه‌دهندگان سفارشی", + "Custom...": "سفارشی...", + "Cycle through accounts to distribute load": "چرخش بین حساب‌ها برای توزیع بار", + "Cycle through providers in combos instead of always starting with first": "چرخش بین ارائه‌دهندگان در ترکیبات به جای همیشه شروع با اولین", + "DNS off": "DNS خاموش", + "Dashboard": "داشبورد", + "Dashboard Password": "رمز عبور داشبورد", + "Dashboard:": "داشبورد:", + "Data Location:": "مکان داده:", + "Data flows seamlessly from your application through our intelligent routing layer to the best provider for the job.": "داده‌ها به‌طور یکپارچه از برنامه شما از طریق لایه مسیریابی هوشمند ما به بهترین ارائه‌دهنده برای کار جریان می‌یابد.", + "Data flows seamlessly through our intelligent routing system": "داده‌ها به‌طور یکپارچه از طریق سیستم مسیریابی هوشمند ما جریان می‌یابند", "Database Location": "مکان پایگاه داده", - "Download Backup": "دانلود پشتیبان", - "Import Backup": "وارد کردن پشتیبان", "Database backup downloaded": "پشتیبان پایگاه داده دانلود شد", "Database imported successfully": "پایگاه داده با موفقیت وارد شد", - "Security": "امنیت", - "Require login": "نیاز به ورود", - "When ON, dashboard requires password. When OFF, access without login.": "در حالت روشن، داشبورد به رمز عبور نیاز دارد. در حالت خاموش، دسترسی بدون نیاز به ورود.", - "Current Password": "رمز عبور فعلی", - "Enter current password": "رمز عبور فعلی را وارد کنید", - "New Password": "رمز عبور جدید", - "Enter new password": "رمز عبور جدید را وارد کنید", - "Confirm New Password": "تأیید رمز عبور جدید", - "Confirm new password": "تأیید رمز عبور جدید", - "Update Password": "بروزرسانی رمز عبور", - "Set Password": "تنظیم رمز عبور", - "Password updated successfully": "رمز عبور با موفقیت بروزرسانی شد", - "Passwords do not match": "رمزهای عبور مطابقت ندارند", - "Routing Strategy": "استراتژی مسیردهی", - "Round Robin": "چرخشی", - "Cycle through accounts to distribute load": "چرخش بین حساب‌ها برای توزیع بار", - "Sticky Limit": "محدودیت چسبندگی", - "Calls per account before switching": "تعداد تماس به ازای هر حساب قبل از تغییر", - "Network": "شبکه", - "Outbound Proxy": "پروکسی خروجی", - "Enable proxy for OAuth + provider outbound requests.": "فعال‌سازی پروکسی برای درخواست‌های خروجی OAuth + ارائه‌دهنده.", - "Proxy URL": "آدرس پروکسی", - "Leave empty to inherit existing env proxy (if any).": "برای ارث‌بری از پروکسی موجود محیط، خالی بگذارید (در صورت وجود).", - "No Proxy": "بدون پروکسی", - "Comma-separated hostnames/domains to bypass the proxy.": "نام میزبان/دامنه‌ها با جداکننده ویرگول برای دور زدن پروکسی.", - "Test proxy URL": "آزمایش آدرس پروکسی", - "Proxy settings applied": "تنظیمات پروکسی اعمال شد", - "Proxy enabled": "پروکسی فعال شد", - "Proxy disabled": "پروکسی غیرفعال شد", - "Proxy test OK": "آزمایش پروکسی موفق", - "Proxy test failed": "آزمایش پروکسی ناموفق", - "Please enter a Proxy URL to test": "لطفاً یک آدرس پروکسی برای آزمایش وارد کنید", - "Observability": "مشاهده‌پذیری", + "DateTime": "تاریخ و زمان", + "Deactivate": "غیرفعال‌سازی", + "Debug": "اشکال‌زدایی", + "Debug translation flow between formats": "اشکال‌زدایی جریان ترجمه بین فرمت‌ها", + "DeepSeek TUI - Manual Configuration": "DeepSeek TUI - پیکربندی دستی", + "DeepSeek TUI not detected locally": "DeepSeek TUI در سیستم محلی شناسایی نشد", + "DeepSeek TUI uses ~/.deepseek/config.toml for configuration. 9Router will update the provider to 'openai' mode with your base_url, api_key, and model.": "DeepSeek TUI از ~/.deepseek/config.toml برای پیکربندی استفاده می‌کند. 9Router ارائه‌دهنده را به حالت 'openai' با base_url، api_key و model شما به‌روز می‌کند.", + "DeepSeek Terminal Coding Agent (Rust TUI)": "عامل کدنویسی ترمینال DeepSeek (Rust TUI)", + "Default Model": "مدل پیش‌فرض", + "Default password is": "رمز عبور پیش‌فرض است", + "Default password is 123456": "رمز عبور پیش‌فرض ۱۲۳۴۵۶ است", + "Delete": "حذف", + "Delete API Key": "حذف کلید API", + "Delete connection": "حذف اتصال", + "Delete saved endpoint": "حذف نقطه پایانی ذخیره شده", + "Delete selected preset": "حذف تنظیم از پیش انتخاب شده", + "Delete this combo?": "این ترکیب حذف شود؟", + "Delete this connection?": "این اتصال حذف شود؟", + "Deno Deploy API Token": "توکن Deno Deploy API", + "Deno Deploy v2 runs on a high-performance global edge network": "Deno Deploy v2 بر روی شبکه لبه جهانی با کارایی بالا اجرا می‌شود", + "Deno Relay": "Deno Relay", + "Deploy": "استقرار", + "Deploy Cloudflare Relay": "استقرار Cloudflare Relay", + "Deploy Deno Relay": "استقرار Deno Relay", + "Deploy Relay": "استقرار Relay", + "Deploy Vercel Relay": "استقرار Vercel Relay", + "Deploy multiple relays for maximum IP diversity": "استقرار چندین Relay برای حداکثر تنوع IP", + "Deploy multiple relays on different accounts for more IP diversity": "استقرار چندین Relay در حساب‌های مختلف برای تنوع بیشتر IP", + "Deploying... (may take ~1 min)": "در حال استقرار... (ممکن است حدود ۱ دقیقه طول بکشد)", + "Deployment Name": "نام استقرار", + "Deploys a Cloudflare Worker as a proxy relay. All AI provider requests will be forwarded through Cloudflare's global edge network.": "یک Cloudflare Worker را به عنوان Relay پروکسی استقرار می‌دهد. تمام درخواست‌های ارائه‌دهنده هوش مصنوعی از طریق شبکه لبه جهانی Cloudflare ارسال می‌شوند.", + "Deploys a relay worker to Deno Deploy's global edge network. All AI provider requests are forwarded through Deno's edge, masking your real IP.": "یک Relay Worker را به شبکه لبه جهانی Deno Deploy استقرار می‌دهد. تمام درخواست‌های ارائه‌دهنده هوش مصنوعی از طریق لبه Deno ارسال می‌شوند و IP واقعی شما را پنهان می‌کنند.", + "Deploys an edge relay function to Vercel that proxies requests through Vercel's network.": "یک تابع Relay لبه را در Vercel استقرار می‌دهد که درخواست‌ها را از طریق شبکه Vercel پروکسی می‌کند.", + "Deploys an edge relay function to Vercel. All AI provider requests will be forwarded through Vercel's edge network, masking your real IP from providers.": "یک تابع Relay لبه را در Vercel استقرار می‌دهد. تمام درخواست‌های ارائه‌دهنده هوش مصنوعی از طریق شبکه لبه Vercel ارسال می‌شوند و IP واقعی شما را از ارائه‌دهندگان پنهان می‌کنند.", + "Desktop": "دسکتاپ", + "Detail": "جزئیات", + "Details": "جزئیات", + "Dimensions": "ابعاد", + "Disable": "غیرفعال‌سازی", + "Disable All": "غیرفعال‌سازی همه", + "Disable Tailscale": "غیرفعال‌سازی Tailscale", + "Disable Tunnel": "غیرفعال‌سازی تونل", + "Disable connections with depleted quota on the current page": "غیرفعال‌سازی اتصالات با سهمیه تمام شده در صفحه فعلی", + "Disable provider": "غیرفعال‌سازی ارائه‌دهنده", + "Disable this model": "غیرفعال‌سازی این مدل", + "Disabled": "غیرفعال", + "Disabling...": "در حال غیرفعال‌سازی...", + "Disconnected from server": "قطع شده از سرور", + "Dismiss notification": "رد اعلان", + "Display Name": "نام نمایشی", + "Display language": "زبان نمایش", + "Docs": "مستندات", + "Documentation": "مستندات", + "Domain:": "دامنه:", + "Donate": "کمک مالی", + "Done": "انجام شد", + "Download": "دانلود", + "Download Backup": "دانلود پشتیبان", + "Drag to reorder": "برای مرتب‌سازی دوباره بکشید", + "Easy Setup": "راه‌اندازی آسان", + "Edit": "ویرایش", + "Edit Combo": "ویرایش ترکیب", + "Edit Connection": "ویرایش اتصال", + "Edit Pricing": "ویرایش قیمت‌گذاری", + "Edit Proxy Pool": "ویرایش استخر پروکسی", + "Edit connection": "ویرایش اتصال", + "Edit hosts file manually to add the following entries:": "فایل hosts را به صورت دستی ویرایش کنید تا ورودی‌های زیر را اضافه کنید:", + "Email": "ایمیل", + "Embedding": "تعبیه", + "Embeddings": "تعبیه‌ها", + "Enable": "فعال‌سازی", + "Enable DNS per tool below to activate interception": "DNS را برای هر ابزار در زیر فعال کنید تا رهگیری فعال شود", + "Enable DNS to edit model mappings": "برای ویرایش نگاشت‌های مدل، DNS را فعال کنید", "Enable Observability": "فعال‌سازی مشاهده‌پذیری", - "Turn request detail recording on/off globally": "روشن/خاموش کردن ضبط جزئیات درخواست به صورت سراسری", + "Enable OpenAI API": "فعال‌سازی OpenAI API", + "Enable Tunnel": "فعال‌سازی تونل", + "Enable connections that still have quota on the current page": "فعال‌سازی اتصالاتی که هنوز در صفحه فعلی سهمیه دارند", + "Enable provider": "فعال‌سازی ارائه‌دهنده", + "Enable proxy for OAuth + provider outbound requests.": "فعال‌سازی پروکسی برای درخواست‌های خروجی OAuth + ارائه‌دهنده.", + "Encrypted": "رمزگذاری شده", + "End Date": "تاریخ پایان", + "End-to-end TLS via Cloudflare": "TLS انتها به انتها از طریق Cloudflare", + "Endpoint": "نقطه پایانی", + "Endpoint & Key": "نقطه پایانی و کلید", + "Endpoint is exposed without an API key.": "نقطه پایانی بدون کلید API در معرض دسترسی است.", + "Enter current password": "رمز عبور فعلی را وارد کنید", + "Enter model id": "شناسه مدل را وارد کنید", + "Enter model id (provider-specific)": "شناسه مدل را وارد کنید (مخصوص ارائه‌دهنده)", + "Enter new API key": "کلید API جدید را وارد کنید", + "Enter new password": "رمز عبور جدید را وارد کنید", + "Enter or pick API key": "کلید API را وارد یا انتخاب کنید", + "Enter password": "رمز عبور را وارد کنید", + "Enter sudo password": "رمز عبور sudo را وارد کنید", + "Enter the model ID exactly as your compatible endpoint expects it. This model will be saved as the connection default.": "شناسه مدل را دقیقاً همانطور که نقطه پایانی سازگار شما انتظار دارد وارد کنید. این مدل به عنوان پیش‌فرض اتصال ذخیره می‌شود.", + "Enter your API key": "کلید API خود را وارد کنید", + "Enter your current password to": "رمز عبور فعلی خود را وارد کنید تا", + "Enter your password to access the dashboard": "برای دسترسی به داشبورد رمز عبور خود را وارد کنید", + "Error": "خطا", + "Est. Cost": "هزینه تقریبی", + "Estimated, not actual billing": "تخمینی، نه صورتحساب واقعی", + "Everything you need to manage your AI infrastructure efficiently.": "هر آنچه برای مدیریت کارآمد زیرساخت هوش مصنوعی خود نیاز دارید.", + "Everything you need to manage your AI infrastructure in one place, built for scale.": "هر آنچه برای مدیریت زیرساخت هوش مصنوعی خود در یک مکان نیاز دارید، ساخته شده برای مقیاس.", + "Example": "مثال", + "Experimental": "آزمایشی", + "Expires At": "منقضی می‌شود در", + "Expiring first": "ابتدا در حال انقضا", + "Expiring-first currently reorders accounts inside the current page. Cross-page ordering still follows backend pagination.": "«ابتدا در حال انقضا» در حال حاضر حساب‌ها را در صفحه فعلی دوباره مرتب می‌کند. ترتیب بین صفحه‌ها همچنان از صفحه‌بندی backend پیروی می‌کند.", + "Expose your local 9Router to the internet. No port forwarding, no static IP needed. Share endpoint URL with your team or use it in Cursor, Cline, and other AI tools from anywhere.": "9Router محلی خود را به اینترنت نمایش دهید. نیازی به انتقال پورت یا IP ثابت نیست. URL نقطه پایانی را با تیم خود به اشتراک بگذارید یا از آن در Cursor، Cline و سایر ابزارهای هوش مصنوعی از هر جایی استفاده کنید.", + "Factory Droid - Manual Configuration": "Factory Droid - پیکربندی دستی", + "Factory Droid AI Assistant": "دستیار هوش مصنوعی Factory Droid", + "Factory Droid CLI not detected locally": "Factory Droid CLI در سیستم محلی شناسایی نشد", + "Factory Droid CLI not installed": "Factory Droid CLI نصب نشده است", + "Fail request if proxy is unreachable instead of falling back to direct.": "در صورت عدم دسترسی به پروکسی، درخواست را با شکست مواجه کنید به جای بازگشت به مستقیم.", + "Failed to apply settings": "اعمال تنظیمات ناموفق بود", + "Failed to create combo": "ایجاد ترکیب ناموفق بود", + "Failed to load changelog:": "بارگذاری تاریخچه تغییرات ناموفق بود:", + "Failed to load usage statistics.": "بارگذاری آمار مصرف ناموفق بود.", + "Failed to reset settings": "بازنشانی تنظیمات ناموفق بود", + "Failed to set alias": "تنظیم نام مستعار ناموفق بود", + "Failed to update combo": "به‌روزرسانی ترکیب ناموفق بود", + "Failed to update password": "به‌روزرسانی رمز عبور ناموفق بود", + "Failed to update proxy settings": "به‌روزرسانی تنظیمات پروکسی ناموفق بود", + "Fallback": "پشتیبان جایگزین", + "Fallback — tries models in order (next on failure)": "پشتیبان جایگزین — مدل‌ها را به ترتیب امتحان می‌کند (در صورت شکست به بعدی می‌رود)", + "Fallback — try in order": "پشتیبان جایگزین — به ترتیب امتحان کنید", + "Features": "ویژگی‌ها", + "Fetch Qoder Models": "دریافت مدل‌های Qoder", + "Fetching...": "در حال دریافت...", + "Files": "فایل‌ها", + "Filter accounts by status": "فیلتر حساب‌ها بر اساس وضعیت", + "Filter naming": "فیلتر نام‌گذاری", + "Filter naming requests": "فیلتر درخواست‌های نام‌گذاری", + "Filter quota providers": "فیلتر ارائه‌دهندگان سهمیه", + "Find MCPs →": "یافتن MCPها →", + "Find your Account ID in the right sidebar of": "شناسه حساب خود را در نوار کناری سمت راست پیدا کنید", + "Find your Account ID in the right sidebar of dash.cloudflare.com": "شناسه حساب خود را در نوار کناری سمت راست dash.cloudflare.com پیدا کنید", + "First Page": "صفحه اول", + "Flush Interval (ms)": "فاصله تخلیه (میلی‌ثانیه)", + "For enterprise users with custom AWS IAM Identity Center.": "برای کاربران سازمانی با مرکز هویت AWS IAM سفارشی.", + "Forgot password? Open": "رمز عبور را فراموش کرده‌اید؟ باز کنید", + "Format": "فرمت", + "Found on the right side of the Cloudflare dashboard overview page.": "در سمت راست صفحه نمای کلی داشبورد Cloudflare یافت می‌شود.", + "Free": "رایگان", + "Free & Free Tier Providers": "ارائه‌دهندگان رایگان و لایه رایگان", + "Free Providers": "ارائه‌دهندگان رایگان", + "Free Tier": "لایه رایگان", + "Free Tier Providers": "ارائه‌دهندگان لایه رایگان", + "Free tier: 100,000 requests per day": "لایه رایگان: ۱۰۰٬۰۰۰ درخواست در روز", + "Free tier: 100GB bandwidth/month, 500K edge invocations": "لایه رایگان: ۱۰۰ گیگابایت پهنای باند در ماه، ۵۰۰٬۰۰۰ فراخوانی لبه", + "Free tier: 1M requests & 100GiB outbound traffic per month": "لایه رایگان: ۱ میلیون درخواست و ۱۰۰ گیگابایت ترافیک خروجی در ماه", + "Fresh API key obtained": "کلید API جدید دریافت شد", + "Full shell access": "دسترسی کامل به شل", + "Fusion": "همجوشی", + "Fusion — panel + judge": "همجوشی — پنل + داور", + "Fusion — queries all models in parallel, then a judge synthesizes one answer": "همجوشی — همه مدل‌ها را به طور موازی پرس و جو می‌کند، سپس یک داور یک پاسخ را ترکیب می‌کند", + "Get 9Remote": "دریافت 9Remote", + "Get API Key": "دریافت کلید API", + "Get API Key →": "دریافت کلید API →", + "Get Started": "شروع کنید", + "Get Started in 30 Seconds": "شروع در ۳۰ ثانیه", + "Get started": "شروع کنید", + "Get started in seconds. Just install, open, and route.": "در چند ثانیه شروع کنید. فقط نصب کنید، باز کنید و مسیردهی کنید.", + "Get token →": "دریافت توکن →", + "GitHub": "GitHub", + "GitHub Account": "حساب GitHub", + "GitHub Copilot - Manual Configuration": "GitHub Copilot - پیکربندی دستی", + "GitHub Copilot IDE with MITM": "GitHub Copilot IDE با MITM", + "GitLab Access Tokens": "توکن‌های دسترسی GitLab", + "GitLab Applications": "برنامه‌های GitLab", + "GitLab Base URL": "آدرس پایه GitLab", + "Go to": "رفتن به", + "Go to Roo Settings panel": "رفتن به پنل تنظیمات Roo", + "Google Account": "حساب Google", + "Google Antigravity IDE with MITM": "Google Antigravity IDE با MITM", + "Granted At": "اعطا شده در", + "Group models under one name, then pick a strategy per combo:": "مدل‌ها را تحت یک نام گروه‌بندی کنید، سپس برای هر ترکیب یک استراتژی انتخاب کنید:", + "Headroom proxy is reachable. You can enable the token saver.": "پروکسی Headroom قابل دسترسی است. می‌توانید ذخیره‌ساز توکن را فعال کنید.", + "Help Center": "مرکز راهنما", + "Hermes Agent - Manual Configuration": "Hermes Agent - پیکربندی دستی", + "Hermes Agent not detected locally": "Hermes Agent در سیستم محلی شناسایی نشد", + "Hide": "پنهان کردن", + "Hide key": "پنهان کردن کلید", + "High performance global routing and IP masking via Cloudflare Workers": "مسیریابی جهانی با کارایی بالا و پنهان‌سازی IP از طریق Cloudflare Workers", + "High-performance Rust-based coding agent harness": "چارچوب عامل کدنویسی مبتنی بر Rust با کارایی بالا", + "History": "تاریخچه", + "How 9Router Works": "نحوه عملکرد 9Router", + "How Pricing Works": "نحوه عملکرد قیمت‌گذاری", + "How it Works": "نحوه عملکرد", + "How it works:": "نحوه عملکرد:", + "How to Install": "نحوه نصب", + "How to generate API token:": "نحوه تولید توکن API:", + "How to generate your API Token:": "نحوه تولید توکن API خود:", + "How to get cookie:": "نحوه دریافت کوکی:", + "ID:": "شناسه:", + "IDC Start URL": "آدرس شروع IDC", + "If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead.": "اگر ارائه‌دهنده نقطه پایانی /models را ندارد، یک شناسه مدل را برای اعتبارسنجی از طریق chat/completions وارد کنید.", + "Image Generation": "تولید تصویر", + "Image to Text": "تصویر به متن", + "Import": "وارد کردن", + "Import Backup": "وارد کردن پشتیبان", + "Import CLIProxyAPI JSON": "وارد کردن CLIProxyAPI JSON", + "Import Token": "وارد کردن توکن", + "Importing...": "در حال وارد کردن...", + "In": "ورودی", + "In / Out": "ورودی/خروجی", + "Inactive": "غیرفعال", + "Inactive pools are ignored by runtime resolution.": "استخرهای غیرفعال توسط وضوح زمان اجرا نادیده گرفته می‌شوند.", + "Inc. All rights reserved.": "شرکت. تمام حقوق محفوظ است.", + "Initializing...": "در حال مقداردهی اولیه...", + "Input": "ورودی", + "Input Cost": "هزینه ورودی", + "Input Tokens": "توکن‌های ورودی", + "Input Tokens:": "توکن‌های ورودی:", + "Input:": "ورودی:", + "Install 9Router": "نصب 9Router", + "Install 9Router, configure your providers via web dashboard, and start routing AI requests.": "9Router را نصب کنید، ارائه‌دهندگان خود را از طریق داشبورد وب پیکربندی کنید و مسیردهی درخواست‌های هوش مصنوعی را شروع کنید.", + "Install Chrome extension": "نصب افزونه Chrome", + "Install Cline VS Code extension or CLI from": "افزونه یا CLI Cline VS Code را از نصب کنید", + "Install Kilo Code from": "Kilo Code را از نصب کنید", + "Install Qwen Code": "نصب Qwen Code", + "Install Tailscale": "نصب Tailscale", + "Install command:": "دستور نصب:", + "Install jcode to enable automatic configuration:": "jcode را نصب کنید تا پیکربندی خودکار فعال شود:", + "Install the Amp CLI using the package manager supported by your environment.": "Amp CLI را با استفاده از مدیر بسته پشتیبانی شده توسط محیط خود نصب کنید.", + "Install then click Start:": "نصب کنید سپس روی شروع کلیک کنید:", + "Install via npm:": "نصب از طریق npm:", + "Installation Guide": "راهنمای نصب", + "Installing Tailscale...": "در حال نصب Tailscale...", + "Interactive diagram visible on desktop": "نمودار تعاملی در دسکتاپ قابل مشاهده است", + "Intercept CLI tool traffic and route through 9Router": "ترافیک ابزار CLI را رهگیری کرده و از طریق 9Router مسیردهی کنید", + "Intercepts Antigravity traffic via DNS redirect, letting you reroute models through 9Router.": "ترافیک Antigravity را از طریق تغییر مسیر DNS رهگیری می‌کند و به شما امکان می‌دهد مدل‌ها را از طریق 9Router مسیردهی مجدد کنید.", + "Intercepts Claude Code's topic-naming requests and returns a fake response locally, saving API tokens.": "درخواست‌های نام‌گذاری موضوع Claude Code را رهگیری کرده و یک پاسخ ساختگی به صورت محلی برمی‌گرداند و توکن‌های API را ذخیره می‌کند.", + "Invalid": "نامعتبر", + "Invalid password": "رمز عبور نامعتبر", + "Issuer URL": "آدرس صادرکننده", + "JSON Response": "پاسخ JSON", + "Join developers who are streamlining their AI integrations with 9Router. Open source and free to start.": "به توسعه‌دهندگانی بپیوندید که با 9Router یکپارچه‌سازی‌های هوش مصنوعی خود را ساده‌سازی می‌کنند. منبع باز و رایگان برای شروع.", + "Judge": "داور", + "Just now": "همین الان", + "KB per field": "کیلوبایت در هر فیلد", + "Keep the legacy password login.": "ورود با رمز عبور قدیمی را حفظ کنید.", + "Key Name": "نام کلید", + "KiRo dashboard": "داشبورد KiRo", + "Kill & Start": "پایان و شروع", + "Kill this process to start MITM Server?": "برای راه‌اندازی سرور MITM این فرآیند را پایان دهید؟", + "Kilo Code - Manual Configuration": "Kilo Code - پیکربندی دستی", + "Kilo Code AI Assistant": "دستیار هوش مصنوعی Kilo Code", + "Kilo Code not detected locally": "Kilo Code در سیستم محلی شناسایی نشد", + "Kimi": "Kimi", + "Kiro AI": "Kiro AI", + "Kiro IDE not detected. Please paste your refresh token manually.": "Kiro IDE شناسایی نشد. لطفاً توکن بازسازی خود را به صورت دستی بچسبانید.", + "Kiro IDE with MITM": "Kiro IDE با MITM", + "Language": "زبان", + "Languages": "زبان‌ها", + "Last Page": "آخرین صفحه", + "Last Used": "آخرین استفاده", + "Last tested:": "آخرین آزمایش:", + "Last updated:": "آخرین به‌روزرسانی:", + "Latency": "تاخیر", + "Latency:": "تاخیر:", + "Lazy senior dev": "توسعه‌دهنده ارشد تنبل", + "Lean": "ساده", + "Leave blank to keep existing secret": "برای حفظ راز موجود خالی بگذارید", + "Leave blank to use": "برای استفاده خالی بگذارید", + "Leave empty for public PKCE app": "برای برنامه PKCE عمومی خالی بگذارید", + "Leave empty to inherit existing env proxy (if any).": "برای ارث‌بری از پروکسی موجود محیط، خالی بگذارید (در صورت وجود).", + "Legacy manual proxy fields are still accepted by API for backward compatibility.": "فیلدهای پروکسی دستی قدیمی هنوز برای سازگاری با گذشته توسط API پذیرفته می‌شوند.", + "Legacy:": "قدیمی:", + "Legal": "قانونی", + "Live server console output": "خروجی کنسول سرور زنده", + "Load": "بارگذاری", + "Loading logs...": "در حال بارگذاری لاگ‌ها...", + "Loading models from provider...": "در حال بارگذاری مدل‌ها از ارائه‌دهنده...", + "Loading pricing data...": "در حال بارگذاری داده‌های قیمت‌گذاری...", + "Loading registry...": "در حال بارگذاری رجیستری...", + "Loading reset credits...": "در حال بارگذاری اعتبارات بازنشانی...", + "Loading...": "در حال بارگذاری...", + "Local": "محلی", + "Local Mode": "حالت محلی", + "Local Mode - All data stored on your machine": "حالت محلی - تمام داده‌ها روی دستگاه شما ذخیره می‌شوند", + "Local Plugins": "افزونه‌های محلی", + "Locked. Retry in": "قفل شد. دوباره تلاش کنید در", + "Login": "ورود", + "Login Button Label": "برچسب دکمه ورود", + "Login URL": "آدرس ورود", + "Login to your account": "وارد حساب خود شوید", + "Login with your GitHub account (manual callback).": "با حساب GitHub خود وارد شوید (بازگشت دستی).", + "Login with your Google account (manual callback).": "با حساب Google خود وارد شوید (بازگشت دستی).", + "Logout": "خروج", + "Logs": "لاگ‌ها", + "Logs are loaded from the request history database.": "لاگ‌ها از پایگاه داده تاریخچه درخواست بارگذاری می‌شوند.", + "Logs are saved to log.txt in the application data directory.": "لاگ‌ها در log.txt در دایرکتوری داده برنامه ذخیره می‌شوند.", + "MIT License": "مجوز MIT", + "MITM": "MITM", + "MITM Proxy": "پروکسی MITM", + "MITM Server": "سرور MITM", + "MITM Tools": "ابزارهای MITM", + "Machine ID": "شناسه ماشین", + "Machine ID will be auto-filled...": "شناسه ماشین به‌طور خودکار پر می‌شود...", + "Make sure Cursor IDE has been opened at least once, then click": "مطمئن شوید Cursor IDE حداقل یک بار باز شده است، سپس کلیک کنید", + "Manage": "مدیریت", + "Manage reusable per-connection proxies and bind them to provider connections.": "پروکسی‌های قابل استفاده مجدد به ازای هر اتصال را مدیریت کرده و آنها را به اتصالات ارائه‌دهنده متصل کنید.", + "Manage your AI provider connections": "مدیریت اتصالات ارائه‌دهندگان هوش مصنوعی خود", + "Manage your Embedding providers": "مدیریت ارائه‌دهندگان تعبیه خود", + "Manage your Image to Text providers": "مدیریت ارائه‌دهندگان تصویر به متن خود", + "Manage your Music providers": "مدیریت ارائه‌دهندگان موسیقی خود", + "Manage your Speech To Text providers": "مدیریت ارائه‌دهندگان گفتار به متن خود", + "Manage your Text To Speech providers": "مدیریت ارائه‌دهندگان متن به گفتار خود", + "Manage your Text to Image providers": "مدیریت ارائه‌دهندگان متن به تصویر خود", + "Manage your Video providers": "مدیریت ارائه‌دهندگان ویدیوی خود", + "Manage your Web Fetch providers": "مدیریت ارائه‌دهندگان دریافت وب خود", + "Manage your Web Search providers": "مدیریت ارائه‌دهندگان جستجوی وب خود", + "Manage your preferences": "مدیریت تنظیمات شخصی", + "Manage your proxy pool configurations": "مدیریت پیکربندی‌های استخر پروکسی خود", + "Manual / current endpoint": "دستی / نقطه پایانی فعلی", + "Manual Callback Required": "بازگشت دستی مورد نیاز است", + "Manual Config": "پیکربندی دستی", + "Manual configuration is still available if 9router is deployed on a remote server.": "اگر 9router روی یک سرور راه دور مستقر شده باشد، پیکربندی دستی همچنان در دسترس است.", + "Map Amp shorthand names such as g25p or cs45 to 9Router aliases in your local config.": "نام‌های میان‌نویس Amp مانند g25p یا cs45 را به نام‌های مستعار 9Router در پیکربندی محلی خود نگاشت کنید.", + "Mask (URL)": "ماسک (URL)", + "Max JSON Size (KB)": "حداکثر اندازه JSON (کیلوبایت)", "Max Records": "حداکثر تعداد رکوردها", "Maximum request detail records to keep (older records are auto-deleted)": "حداکثر تعداد رکوردهای جزئیات درخواست برای نگهداری (رکوردهای قدیمی‌تر به صورت خودکار حذف می‌شوند)", - "Batch Size": "اندازه دسته", - "Number of items to accumulate before writing to database (higher = better performance)": "تعداد موارد قبل از نوشتن در پایگاه داده (بیشتر = عملکرد بهتر)", - "Flush Interval (ms)": "فاصله تخلیه (میلی‌ثانیه)", - "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "حداکثر زمان انتظار قبل از تخلیه بافر (از از دست رفتن داده در ترافیک کم جلوگیری می‌کند)", - "Max JSON Size (KB)": "حداکثر اندازه JSON (کیلوبایت)", "Maximum size for each JSON field (request/response) before truncation": "حداکثر اندازه برای هر فیلد JSON (درخواست/پاسخ) قبل از برش", - "All data stored on your machine": "تمام داده‌ها روی دستگاه شما ذخیره می‌شوند", - "MITM Server": "سرور MITM", - "Running": "در حال اجرا", - "Stopped": "متوقف شده", - "Cert": "گواهی", - "Server": "سرور", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "حداکثر زمان انتظار قبل از تخلیه بافر (از از دست رفتن داده در ترافیک کم جلوگیری می‌کند)", + "Media Providers": "ارائه‌دهندگان رسانه", + "Menu": "منو", + "Message AI": "ارسال پیام به هوش مصنوعی", + "Messages": "پیام‌ها", + "Messages API": "API پیام‌ها", + "MiniMax": "MiniMax", + "Model": "مدل", + "Model Fallback": "پشتیبان مدل", + "Model ID": "شناسه مدل", + "Model ID (from OpenRouter)": "شناسه مدل (از OpenRouter)", + "Model ID (optional)": "شناسه مدل (اختیاری)", + "Model Status": "وضعیت مدل", + "Model combos": "ترکیبات مدل", + "Model combos with fallback": "ترکیبات مدل با پشتیبان جایگزین", + "Model is reachable": "مدل قابل دسترسی است", + "Model list is filtered from connected providers.": "لیست مدل‌ها از ارائه‌دهندگان متصل فیلتر شده است.", + "Model mappings will be available soon.": "نگاشت‌های مدل به زودی در دسترس خواهند بود.", + "Model not reachable": "مدل قابل دسترسی نیست", + "Model:": "مدل:", + "Models": "مدل‌ها", + "Monitor your API usage, token consumption, and request logs": "نظارت بر مصرف API، مصرف توکن و لاگ درخواست‌ها", + "More on GitHub": "بیشتر در GitHub", + "Move down": "پایین آوردن", + "Move up": "بالا بردن", + "Music": "موسیقی", + "My Profile": "پروفایل من", + "N/A": "ناموجود", + "NPM": "NPM", + "Name": "نام", + "Name is required": "نام الزامی است", + "Native CLI tool support for Cursor, Claude, Copilot, and more.": "پشتیبانی بومی از ابزارهای CLI برای Cursor، Claude، Copilot و بیشتر.", + "Navigate to home": "رفتن به صفحه اصلی", + "Network": "شبکه", + "Network Error": "خطای شبکه", + "Network error": "خطای شبکه", + "Never": "هرگز", + "New Password": "رمز عبور جدید", + "New password": "رمز عبور جدید", + "Next": "بعدی", + "Next accounts page": "صفحه بعدی حساب‌ها", + "No API keys - Create one in Keys page": "بدون کلید API - یکی در صفحه کلیدها ایجاد کنید", + "No API keys yet": "هنوز کلید API وجود ندارد", + "No MCPs added": "هیچ MCP اضافه نشده است", + "No Providers Connected": "هیچ ارائه‌دهنده‌ای متصل نیست", + "No Proxy": "بدون پروکسی", + "No active connections found for this group.": "هیچ اتصال فعالی برای این گروه یافت نشد.", + "No active providers": "هیچ ارائه‌دهنده فعالی وجود ندارد", + "No active proxy pools available. Create one in Proxy Pools page first.": "هیچ استخر پروکسی فعالی در دسترس نیست. ابتدا یکی را در صفحه استخرهای پروکسی ایجاد کنید.", + "No authentication required": "نیازی به احراز هویت نیست", + "No combos yet": "هنوز ترکیبی وجود ندارد", + "No combos yet.": "هنوز ترکیبی وجود ندارد.", + "No compatible providers added yet": "هنوز هیچ ارائه‌دهنده سازگاری اضافه نشده است", + "No connections": "بدون اتصال", + "No connections yet": "هنوز اتصالی وجود ندارد", + "No console logs yet.": "هنوز لاگ کنسولی وجود ندارد.", + "No conversations yet.": "هنوز گفتگویی وجود ندارد.", + "No custom providers": "هیچ ارائه‌دهنده سفارشی وجود ندارد", + "No custom providers — use buttons above to add OpenAI/Anthropic compatible endpoints": "هیچ ارائه‌دهنده سفارشی وجود ندارد — از دکمه‌های بالا برای افزودن نقاط پایانی سازگار با OpenAI/Anthropic استفاده کنید", + "No data for this period": "داده‌ای برای این دوره وجود ندارد", + "No key configured": "هیچ کلیدی پیکربندی نشده است", + "No language selected": "هیچ زبانی انتخاب نشده است", + "No languages found.": "هیچ زبانی یافت نشد.", + "No logs recorded yet.": "هنوز هیچ لاگی ثبت نشده است.", + "No model selected.": "هیچ مدلی انتخاب نشده است.", + "No models": "هیچ مدلی", + "No models added yet": "هنوز هیچ مدلی اضافه نشده است", + "No models configured": "هیچ مدلی پیکربندی نشده است", + "No models found": "هیچ مدلی یافت نشد", + "No models match your filter.": "هیچ مدلی با فیلتر شما مطابقت ندارد.", + "No models selected": "هیچ مدلی انتخاب نشده است", + "No port forwarding needed": "نیازی به انتقال پورت نیست", + "No pricing data available": "هیچ داده قیمت‌گذاری در دسترس نیست", + "No providers connected": "هیچ ارائه‌دهنده‌ای متصل نیست", + "No providers match your search": "هیچ ارائه‌دهنده‌ای با جستجوی شما مطابقت ندارد", + "No providers support": "هیچ ارائه‌دهنده‌ای پشتیبانی نمی‌کند", + "No providers yet.": "هنوز هیچ ارائه‌دهنده‌ای وجود ندارد.", + "No providers.": "هیچ ارائه‌دهنده‌ای وجود ندارد.", + "No proxy pool entries yet": "هنوز هیچ ورودی استخر پروکسی وجود ندارد", + "No proxy:": "بدون پروکسی:", + "No quota data available": "هیچ داده سهمیه‌ای در دسترس نیست", + "No request details found": "هیچ جزئیات درخواستی یافت نشد", + "No requests yet.": "هنوز هیچ درخواستی وجود ندارد.", + "No reset credit details returned for this account.": "هیچ جزئیات اعتبار بازنشانی برای این حساب بازگردانده نشد.", + "No results": "نتیجه‌ای یافت نشد", + "No servers match filter": "هیچ سروری با فیلتر مطابقت ندارد", + "No tools advertised by server.": "هیچ ابزاری توسط سرور اعلام نشده است.", + "No usage yet.": "هنوز مصرفی وجود ندارد.", + "None": "هیچکدام", + "None (unbind all)": "هیچکدام (لغو پیوند همه)", + "Not configured": "پیکربندی نشده", + "Not installed": "نصب نشده", + "Notice": "توجه", + "Nous Research self-improving AI agent": "عامل هوش مصنوعی خودبهبود Nous Research", + "Number of items to accumulate before writing to database (higher = better performance)": "تعداد موارد قبل از نوشتن در پایگاه داده (بیشتر = عملکرد بهتر)", + "OAuth": "OAuth", + "OAuth & API Keys": "OAuth و کلیدهای API", + "OAuth Account": "حساب OAuth", + "OAuth App": "برنامه OAuth", + "OAuth Providers": "ارائه‌دهندگان OAuth", + "OAuth required": "نیاز به OAuth", + "OIDC Dashboard Login": "ورود به داشبورد با OIDC", + "OIDC active": "OIDC فعال است", + "OIDC login is currently active. Password login is disabled until you switch back.": "ورود با OIDC در حال حاضر فعال است. ورود با رمز عبور تا زمانی که تغییر دهید غیرفعال است.", + "OIDC login is enabled, but the issuer/client fields are not configured yet. Password login is still available for recovery.": "ورود با OIDC فعال است، اما فیلدهای صادرکننده/مشتری هنوز پیکربندی نشده‌اند. ورود با رمز عبور همچنان برای بازیابی در دسترس است.", + "OIDC only": "فقط OIDC", + "Observability": "مشاهده‌پذیری", + "Office Proxy": "پروکسی اداری", + "Ollama Host URL": "آدرس میزبان Ollama", + "One Endpoint for": "یک نقطه پایانی برای", + "One key per line. Format:": "یک کلید در هر خط. فرمت:", + "One-to-one (rotate)": "یک به یک (چرخش)", + "Only from connected providers": "فقط از ارائه‌دهندگان متصل", + "Only letters, numbers, - and _ allowed": "فقط حروف، اعداد، - و _ مجاز است", + "Only letters, numbers, -, _ and .": "فقط حروف، اعداد، -، _ و .", + "Only letters, numbers, -, _ and . allowed": "فقط حروف، اعداد، -، _ و . مجاز است", + "Only one connection is allowed per compatible node. Add another node if you need more connections.": "به ازای هر گره سازگار فقط یک اتصال مجاز است. در صورت نیاز به اتصالات بیشتر، گره دیگری اضافه کنید.", + "Open": "باز کردن", + "Open Claude Desktop → Help → Troubleshooting → Enable Developer mode → Configure third-party inference, then return here.": "Claude Desktop → Help → Troubleshooting → Enable Developer mode → Configure third-party inference را باز کنید، سپس به اینجا بازگردید.", + "Open Claw - Manual Configuration": "Open Claw - پیکربندی دستی", + "Open Claw AI Assistant": "دستیار هوش مصنوعی Open Claw", + "Open Claw CLI not detected locally": "Open Claw CLI در سیستم محلی شناسایی نشد", + "Open Claw CLI not installed": "Open Claw CLI نصب نشده است", + "Open Continue configuration file": "باز کردن فایل پیکربندی Continue", + "Open Dashboard": "باز کردن داشبورد", + "Open DevTools (F12) → Application/Storage → Cookies": "DevTools (F12) → Application/Storage → Cookies را باز کنید", + "Open Settings": "باز کردن تنظیمات", + "Open platform.iflow.cn in your browser": "platform.iflow.cn را در مرورگر خود باز کنید", + "OpenAI / ElevenLabs / Edge / Google / Deepgram voices.": "صداهای OpenAI / ElevenLabs / Edge / Google / Deepgram.", + "OpenAI Codex CLI": "OpenAI Codex CLI", + "OpenAI Compatible (Prod)": "سازگار با OpenAI (تولید)", + "OpenAI Compatible Details": "جزئیات سازگاری با OpenAI", + "OpenAI Intermediate": "قالب میانی OpenAI", + "OpenAI Response": "پاسخ OpenAI", + "OpenCode - Manual Configuration": "OpenCode - پیکربندی دستی", + "OpenCode AI Terminal Assistant": "دستیار ترمینال هوش مصنوعی OpenCode", + "OpenCode CLI not detected locally": "OpenCode CLI در سیستم محلی شناسایی نشد", + "OpenCode CLI not installed": "OpenCode CLI نصب نشده است", + "OpenRouter": "OpenRouter", + "OpenRouter supports any model. Add models and create aliases for quick access.": "OpenRouter از هر مدلی پشتیبانی می‌کند. مدل‌ها را اضافه کرده و برای دسترسی سریع نام مستعار ایجاد کنید.", + "Optional SSO via Authentik/Keycloak/Google": "SSO اختیاری از طریق Authentik/Keycloak/Google", + "Or paste callback URL manually": "یا آدرس پاسخ بازگشت را به صورت دستی بچسبانید", + "Organization": "سازمان", + "Organization Domain": "دامنه سازمان", + "Organization ID": "شناسه سازمان", + "Organization Token": "توکن سازمان", + "Organization Tokens": "توکن‌های سازمان", + "Other": "سایر", + "Our engine analyzes the prompt and routes through your subscription, cheap, and free provider tiers with automatic fallback.": "موتور ما پرامپت را تحلیل کرده و از طریق لایه‌های اشتراک، ارزان و رایگان ارائه‌دهنده با بازگشت خودکار مسیردهی می‌کند.", + "Our engine analyzes the prompt, checks provider health, and routes for lowest latency or cost.": "موتور ما پرامپت را تحلیل کرده، سلامت ارائه‌دهنده را بررسی کرده و برای کمترین تاخیر یا هزینه مسیردهی می‌کند.", + "Out": "خروجی", + "Outbound Proxy": "پروکسی خروجی", + "Output": "خروجی", + "Output Cost": "هزینه خروجی", + "Output Format": "قالب خروجی", + "Output Tokens": "توکن‌های خروجی", + "Output Tokens:": "توکن‌های خروجی:", + "Output:": "خروجی:", + "Overview": "بررسی کلی", + "Paid": "پولی", + "Partial preview": "پیش‌نمایش جزئی", + "Password": "رمز عبور", + "Password + OIDC active": "رمز عبور + OIDC فعال است", + "Password and OIDC login are both active.": "ورود با رمز عبور و OIDC هر دو فعال هستند.", + "Password and OIDC login are both enabled.": "ورود با رمز عبور و OIDC هر دو فعال شده‌اند.", + "Password only": "فقط رمز عبور", + "Password updated successfully": "رمز عبور با موفقیت به‌روزرسانی شد", + "Passwords do not match": "رمزهای عبور مطابقت ندارند", + "Paste Proxy List (One per line)": "چسباندن لیست پروکسی (یک در هر خط)", + "Paste a long-lived Kiro/CodeWhisperer API key. It is validated against AWS and stored directly as a bearer credential (no refresh).": "یک کلید API طولانی‌مدت Kiro/CodeWhisperer را بچسبانید. در برابر AWS تأیید شده و مستقیماً به عنوان اعتبارنامه Bearer ذخیره می‌شود (بدون بازسازی).", + "Paste external_idp auth JSON from CLIProxyAPI/Kiro Microsoft login.": "JSON احراز هویت external_idp را از ورود Microsoft CLIProxyAPI/Kiro بچسبانید.", + "Paste it below": "آن را در زیر بچسبانید", + "Paste refresh token from Kiro IDE.": "توکن بازسازی را از Kiro IDE بچسبانید.", + "Paste the Kiro CLIProxyAPI auth JSON containing auth_method=external_idp. Only Microsoft login token endpoints are accepted.": "JSON احراز هویت Kiro CLIProxyAPI حاوی auth_method=external_idp را بچسبانید. فقط نقاط پایانی توکن ورود Microsoft پذیرفته می‌شوند.", + "Paste the URL from your browser address bar": "URL را از نوار آدرس مرورگر خود بچسبانید", + "Paste the command into your terminal and press Enter.": "دستور را در ترمینال خود بچسبانید و Enter را فشار دهید.", + "Paste this to your AI:": "این را به هوش مصنوعی خود بچسبانید:", + "Paste your Kiro API key...": "کلید API Kiro خود را بچسبانید...", + "Pause API Key": "مکث کلید API", + "Pause key": "مکث کلید", + "Paused": "مکث شده", + "Permissions": "مجوزها", + "Personal Access Token": "توکن دسترسی شخصی", + "Pick the model that fuses panel answers": "مدلی را انتخاب کنید که پاسخ‌های پنل را ترکیب می‌کند", + "Please add an active Qoder connection first": "لطفاً ابتدا یک اتصال Qoder فعال اضافه کنید", + "Please add and connect providers first to configure CLI tools.": "لطفاً ابتدا ارائه‌دهندگان را اضافه و متصل کنید تا ابزارهای CLI پیکربندی شوند.", + "Please copy the URL from the address bar and paste it in the application.": "لطفاً URL را از نوار آدرس کپی کرده و در برنامه بچسبانید.", + "Please enter a Proxy URL to test": "لطفاً یک آدرس پروکسی برای آزمایش وارد کنید", + "Please install Claude CLI to use this feature.": "لطفاً برای استفاده از این ویژگی، Claude CLI را نصب کنید.", + "Please install Codex CLI to use auto-apply feature.": "لطفاً برای استفاده از ویژگی اعمال خودکار، Codex CLI را نصب کنید.", + "Please install Factory Droid CLI to use this feature.": "لطفاً برای استفاده از این ویژگی، Factory Droid CLI را نصب کنید.", + "Please install Open Claw CLI to use this feature.": "لطفاً برای استفاده از این ویژگی، Open Claw CLI را نصب کنید.", + "Please install OpenCode CLI to use auto-apply feature.": "لطفاً برای استفاده از ویژگی اعمال خودکار، OpenCode CLI را نصب کنید.", + "Please wait while we complete the authorization.": "لطفاً در حالی که مجوز را تکمیل می‌کنیم، منتظر بمانید.", + "Point your CLI tools to http://localhost:20128": "ابزارهای CLI خود را به http://localhost:20128 هدایت کنید", + "Pool:": "استخر:", + "Popup blocked? Enter URL manually": "پنجره بازشو مسدود شد؟ URL را به صورت دستی وارد کنید", + "Port 443 Already In Use": "پورت ۴۴۳ در حال استفاده است", + "Port 443 is currently used by another process:": "پورت ۴۴۳ در حال حاضر توسط فرآیند دیگری استفاده می‌شود:", + "Powerful Features": "ویژگی‌های قدرتمند", + "Prefix": "پیشوند", + "Preset": "تنظیم از پیش", + "Prev": "قبلی", + "Preview": "پیش‌نمایش", + "Previous accounts page": "صفحه قبلی حساب‌ها", + "Pricing": "قیمت‌گذاری", + "Pricing Configuration": "پیکربندی قیمت‌گذاری", + "Pricing Format:": "فرمت قیمت‌گذاری:", + "Pricing Rates Format": "قالب نرخ‌های قیمت‌گذاری", + "Pricing Settings": "تنظیمات قیمت‌گذاری", + "Priority": "اولویت", + "Privacy Policy": "سیاست حفظ حریم خصوصی", + "Probing server for tools...": "در حال بررسی سرور برای ابزارها...", + "Processing...": "در حال پردازش...", + "Product": "محصول", + "Production Key": "کلید تولید", + "Project Name": "نام پروژه", + "Prompt": "پرامپت", + "Provider": "ارائه‌دهنده", + "Provider Details": "جزئیات ارائه‌دهنده", + "Provider Limits": "محدودیت‌های ارائه‌دهنده", + "Provider Response": "پاسخ ارائه‌دهنده", + "Provider not found": "ارائه‌دهنده یافت نشد", + "Provider test failed": "آزمایش ارائه‌دهنده ناموفق بود", + "Provider:": "ارائه‌دهنده:", + "Providers": "ارائه‌دهندگان", + "Proxy": "پروکسی", + "Proxy Action": "عملیات پروکسی", + "Proxy Pool": "استخر پروکسی", + "Proxy Pools": "استخرهای پروکسی", + "Proxy URL": "آدرس پروکسی", + "Proxy disabled": "پروکسی غیرفعال شد", + "Proxy enabled": "پروکسی فعال شد", + "Proxy pool created": "استخر پروکسی ایجاد شد", + "Proxy pool deleted": "استخر پروکسی حذف شد", + "Proxy pool updated": "استخر پروکسی به‌روزرسانی شد", + "Proxy settings applied": "تنظیمات پروکسی اعمال شد", + "Proxy test OK": "آزمایش پروکسی موفق بود", + "Proxy test failed": "آزمایش پروکسی ناموفق بود", + "Proxy test passed": "آزمایش پروکسی گذرانده شد", "Purpose:": "هدف:", - "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "استفاده از Antigravity IDE و GitHub Copilot → با هر ارائه‌دهنده/مدلی از 9Router", - "How it works:": "نحوه عملکرد:", - "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "درخواست Antigravity/Copilot IDE → تغییر مسیر DNS به localhost:443 → رهگیری پروکسی MITM → 9Router → پاسخ به Antigravity/Copilot", - "No API keys — create one in Keys page": "بدون کلید API — یکی در صفحه کلیدها ایجاد کنید", - "sk_9router (default)": "sk_9router (پیش‌فرض)", - "Server started": "سرور راه‌اندازی شد", - "Failed to start server": "خطا در راه‌اندازی سرور", - "Server stopped — all DNS cleared": "سرور متوقف شد — تمام DNS پاک شد", - "Failed to stop server": "خطا در توقف سرور", - "Sudo password is required": "گذرواژه sudo الزامی است", - "Stop Server": "توقف سرور", + "Python >= 3.10 required for local managed mode. Install Python first, or use an external proxy URL.": "برای حالت مدیریت محلی به Python >= 3.10 نیاز است. ابتدا Python را نصب کنید یا از آدرس پروکسی خارجی استفاده کنید.", + "Python ≥ 3.10 required for local managed mode. Install Python first, or use an external proxy URL.": "برای حالت مدیریت محلی به Python ≥ 3.10 نیاز است. ابتدا Python را نصب کنید یا از آدرس پروکسی خارجی استفاده کنید.", + "Quota Tracker": "پیگیری سهمیه", + "Qwen": "Qwen", + "Qwen Code supports multiple provider types (openai, anthropic, gemini) via modelProviders in settings.json. 9Router works as an OpenAI-compatible endpoint.": "Qwen Code از انواع مختلف ارائه‌دهندگان (openai، anthropic، gemini) از طریق modelProviders در settings.json پشتیبانی می‌کند. 9Router به عنوان یک نقطه پایانی سازگار با OpenAI کار می‌کند.", + "Qwen OAuth free tier was discontinued on 2026-04-15. Use 9Router with alicode/openrouter/anthropic/gemini providers instead.": "لایه رایگان OAuth Qwen در ۲۰۲۶-۰۴-۱۵ متوقف شد. به جای آن از 9Router با ارائه‌دهندگان alicode/openrouter/anthropic/gemini استفاده کنید.", + "Rate Limited": "محدودیت نرخ", + "Read Documentation": "مطالعه مستندات", + "Reading from AWS SSO cache": "خواندن از حافظه پنهان AWS SSO", + "Reading from Cursor IDE database": "خواندن از پایگاه داده Cursor IDE", + "Ready": "آماده", + "Ready to Simplify Your AI Infrastructure?": "آماده ساده‌سازی زیرساخت هوش مصنوعی خود هستید؟", + "Ready to route! ✓": "آماده برای مسیردهی! ✓", + "Ready! Requests route automatically through your configured providers.": "آماده! درخواست‌ها به‌طور خودکار از طریق ارائه‌دهندگان پیکربندی شده شما مسیردهی می‌شوند.", + "Reasoning": "استدلال", + "Reasoning:": "استدلال:", + "Recent Requests": "درخواست‌های اخیر", + "Recent chats": "گفتگوهای اخیر", + "Recheck": "بررسی مجدد", + "Recommended for most users. Free AWS account required.": "توصیه شده برای اکثر کاربران. نیاز به حساب رایگان AWS دارد.", + "Record request details for inspection in the logs view": "ثبت جزئیات درخواست برای بازرسی در نمای لاگ‌ها", + "Redirect URI": "URI تغییر مسیر", + "Ref Image (URL)": "تصویر مرجع (URL)", + "Refresh": "تازه‌سازی", + "Refresh All": "تازه‌سازی همه", + "Refresh Token": "توکن بازسازی", + "Refresh all": "تازه‌سازی همه", + "Refresh quota": "تازه‌سازی سهمیه", + "Region": "منطقه", + "Reload Page": "بارگذاری مجدد صفحه", + "Reload VS Code after applying for changes to take effect.": "پس از اعمال، VS Code را دوباره بارگذاری کنید تا تغییرات اعمال شوند.", + "Remaining": "باقیمانده", + "Remote": "دور", + "Remove": "حذف", + "Remove attachment": "حذف پیوست", + "Remove custom model": "حذف مدل سفارشی", + "Remove model": "حذف مدل", + "Replaces built-in WebSearch/WebFetch. Auto-strips duplicates from tool list.": "جایگزین WebSearch/WebFetch داخلی می‌شود. به‌طور خودکار موارد تکراری را از لیست ابزارها حذف می‌کند.", + "Replay request flow — matches log files": "پخش مجدد جریان درخواست — مطابق با فایل‌های لاگ", + "Request": "درخواست", + "Request Details": "جزئیات درخواست", + "Request Logs": "لاگ‌های درخواست", + "Requests": "درخواست‌ها", + "Requests without a valid key will be rejected": "درخواست‌های بدون کلید معتبر رد می‌شوند", + "Require API key": "نیاز به کلید API", + "Require OIDC for dashboard access.": "برای دسترسی به داشبورد به OIDC نیاز است.", + "Require login": "نیاز به ورود", + "Required for SSL certificate and DNS configuration": "برای گواهی SSL و پیکربندی DNS مورد نیاز است", + "Required for SSL certificate and server startup": "برای گواهی SSL و راه‌اندازی سرور مورد نیاز است", + "Required to modify /etc/hosts and flush DNS cache": "برای تغییر /etc/hosts و پاک کردن حافظه پنهان DNS مورد نیاز است", + "Required. A friendly label for this node.": "الزامی. یک برچسب دوستانه برای این گره.", + "Required. Used as the provider prefix for model IDs.": "الزامی. به عنوان پیشوند ارائه‌دهنده برای شناسه‌های مدل استفاده می‌شود.", + "Requires \"Workers Scripts: Edit\" permission.": "نیاز به مجوز \"Workers Scripts: Edit\" دارد.", + "Requires Cloudflare Account ID and a Workers API Token (Edit Workers permission)": "نیاز به شناسه حساب Cloudflare و یک توکن Workers API (مجوز ویرایش Workers) دارد", + "Requires Cursor Pro account to use this feature.": "برای استفاده از این ویژگی به حساب Cursor Pro نیاز است.", + "Requires jcode installed. Install via: curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash": "به نصب jcode نیاز دارد. نصب از طریق: curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash", + "Requires outbound port 7844 (TCP/UDP). Connection may take 10-30s.": "نیاز به پورت خروجی ۷۸۴۴ (TCP/UDP) دارد. اتصال ممکن است ۱۰-۳۰ ثانیه طول بکشد.", + "Reset": "بازنشانی", + "Reset Codex limit?": "بازنشانی محدودیت Codex؟", + "Reset Password to Default": "بازنشانی رمز عبور به پیش‌فرض", + "Reset judge to Auto": "بازنشانی داور به خودکار", + "Reset time": "زمان بازنشانی", + "Reset to Defaults": "بازنشانی به پیش‌فرض", + "Reset to default": "بازنشانی به پیش‌فرض", + "Resources": "منابع", + "Response": "پاسخ", + "Response Format": "قالب پاسخ", + "Responses": "پاسخ‌ها", + "Responses API": "API پاسخ‌ها", + "Restart": "راه‌اندازی مجدد", + "Restore model": "بازیابی مدل", + "Resume key": "ادامه کلید", + "Retry": "تلاش مجدد", + "Risk Notice": "اطلاعیه ریسک", + "Roo AI Assistant": "دستیار هوش مصنوعی Roo", + "Rotate providers across requests instead of strict fallback order.": "ارائه‌دهندگان را در بین درخواست‌ها به جای ترتیب بازگشت دقیق، بچرخانید.", + "Round Robin": "چرخشی", + "Round Robin — rotate": "چرخشی — چرخش", + "Round Robin — rotates models across requests to spread load": "چرخشی — مدل‌ها را در بین درخواست‌ها برای توزیع بار می‌چرخاند", + "Route AI requests through subscription, cheap, and free tiers with auto-fallback. One endpoint for Claude, GPT, Gemini, and more.": "درخواست‌های هوش مصنوعی را از طریق لایه‌های اشتراک، ارزان و رایگان با بازگشت خودکار مسیردهی کنید. یک نقطه پایانی برای Claude، GPT، Gemini و بیشتر.", + "Route Requests": "مسیردهی درخواست‌ها", + "Routing Strategy": "استراتژی مسیردهی", + "Rows:": "ردیف‌ها:", + "Run": "اجرا", + "Run npx command to start the server instantly": "دستور npx را برای راه‌اندازی فوری سرور اجرا کنید", + "Run this command in your terminal, then click": "این دستور را در ترمینال خود اجرا کنید، سپس کلیک کنید", + "Running": "در حال اجرا", + "Running on your machine": "در حال اجرا روی دستگاه شما", + "Runtime": "زمان اجرا", + "SSE URL": "آدرس SSE", + "START HERE": "از اینجا شروع کنید", + "Save": "ذخیره", + "Save Changes": "ذخیره تغییرات", + "Save Config": "ذخیره پیکربندی", + "Save Mappings": "ذخیره نگاشت‌ها", + "Save auth mode": "ذخیره حالت احراز هویت", + "Save current Base URL and API key as a browser-local preset": "ذخیره آدرس پایه و کلید API فعلی به عنوان یک تنظیم از پیش محلی مرورگر", + "Save this key now!": "این کلید را همین حالا ذخیره کنید!", + "Saved": "ذخیره شد", + "Saving": "در حال ذخیره", + "Saving...": "در حال ذخیره...", + "Scan QR to connect instantly": "برای اتصال فوری، QR را اسکن کنید", + "Scopes": "حوزه‌ها", + "Screen sharing": "اشتراک‌گذاری صفحه", + "Scroll down to": "به پایین اسکرول کنید تا", + "Search by name or description...": "جستجو بر اساس نام یا توضیحات...", + "Search language...": "جستجوی زبان...", + "Search model id": "جستجوی شناسه مدل", + "Search providers...": "جستجوی ارائه‌دهندگان...", + "Search...": "جستجو...", + "Security": "امنیت", + "Security required: ": "نیاز به امنیت: ", + "Security risk: no password set. You will be asked to set one when logging in remotely.": "خطر امنیتی: رمز عبور تنظیم نشده است. هنگام ورود از راه دور از شما خواسته می‌شود یک رمز عبور تنظیم کنید.", + "Select": "انتخاب", + "Select All": "انتخاب همه", + "Select Cowork Model": "انتخاب مدل Cowork", + "Select Endpoint": "انتخاب نقطه پایانی", + "Select Judge Model": "انتخاب مدل داور", + "Select Language": "انتخاب زبان", + "Select Model": "انتخاب مدل", + "Select Model for Cline": "انتخاب مدل برای Cline", + "Select Model for Codex": "انتخاب مدل برای Codex", + "Select Model for DeepSeek TUI": "انتخاب مدل برای DeepSeek TUI", + "Select Model for Factory Droid": "انتخاب مدل برای Factory Droid", + "Select Model for GitHub Copilot": "انتخاب مدل برای GitHub Copilot", + "Select Model for Hermes Agent": "انتخاب مدل برای Hermes Agent", + "Select Model for Kilo Code": "انتخاب مدل برای Kilo Code", + "Select Model for Open Claw": "انتخاب مدل برای Open Claw", + "Select Model for OpenCode": "انتخاب مدل برای OpenCode", + "Select Model for jcode": "انتخاب مدل برای jcode", + "Select Provider": "انتخاب ارائه‌دهنده", + "Select Subagent Model for Codex": "انتخاب مدل زیرعامل برای Codex", + "Select Subagent Model for OpenCode": "انتخاب مدل زیرعامل برای OpenCode", + "Select a provider": "یک ارائه‌دهنده انتخاب کنید", + "Select all": "انتخاب همه", + "Select language": "انتخاب زبان", + "Select models to add": "مدل‌ها را برای افزودن انتخاب کنید", + "Select one or more connections, then click Proxy Action.": "یک یا چند اتصال را انتخاب کنید، سپس روی عملیات پروکسی کلیک کنید.", + "Select to pre-fill, then edit model ID in the input": "برای پیش‌پر کردن انتخاب کنید، سپس شناسه مدل را در ورودی ویرایش کنید", + "Select your": "خود را انتخاب کنید", + "Selected connections have mixed proxy bindings": "اتصالات انتخاب شده دارای پیوندهای پروکسی مختلط هستند", + "Selected only": "فقط انتخاب شده", + "Selected provider": "ارائه‌دهنده انتخاب شده", + "Selecting None will unbind selected connections from proxy pool.": "انتخاب «هیچکدام» پیوند اتصالات انتخاب شده را از استخر پروکسی لغو می‌کند.", + "Send": "ارسال", + "Send to Provider": "ارسال به ارائه‌دهنده", + "Sent to provider as:": "ارسال به ارائه‌دهنده به عنوان:", + "Server": "سرور", + "Server Disconnected": "سرور قطع شد", + "Server off": "سرور خاموش", + "Server running on": "سرور در حال اجرا روی", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "سرویس در ترمینال در حال اجراست. می‌توانید این صفحه وب را ببندید. خاموش کردن، سرویس را متوقف می‌کند.", + "Set Password": "تنظیم رمز عبور", + "Set a new password before accessing the dashboard remotely.": "قبل از دسترسی از راه دور به داشبورد، یک رمز عبور جدید تنظیم کنید.", + "Set password": "تنظیم رمز عبور", + "Setting password for the first time. Leave current password empty or use default:": "تنظیم رمز عبور برای اولین بار. رمز عبور فعلی را خالی بگذارید یا از پیش‌فرض استفاده کنید:", + "Setting up": "در حال راه‌اندازی", + "Settings": "تنظیمات", + "Settings applied successfully!": "تنظیمات با موفقیت اعمال شد!", + "Settings reset successfully!": "تنظیمات با موفقیت بازنشانی شد!", + "Setup": "راه‌اندازی", + "Setup + index of all capabilities. Start here — covers base URL, auth, model discovery, and links to every capability skill.": "راه‌اندازی + فهرست همه قابلیت‌ها. از اینجا شروع کنید — شامل آدرس پایه، احراز هویت، کشف مدل و پیوند به هر مهارت قابلیت است.", + "Share Endpoint": "اشتراک‌گذاری نقطه پایانی", + "Share URL with team members": "اشتراک‌گذاری URL با اعضای تیم", + "Show": "نمایش", + "Show all": "نمایش همه", + "Show key": "نمایش کلید", + "Show only selected models": "فقط مدل‌های انتخاب شده را نشان دهید", + "Showing": "در حال نمایش", + "Shutdown": "خاموش کردن", + "Sign in with OIDC": "ورود با OIDC", + "Simple chat interface to interact with any AI model from connected providers. Select a model and start chatting!": "رابط گفتگوی ساده برای تعامل با هر مدل هوش مصنوعی از ارائه‌دهندگان متصل. یک مدل انتخاب کنید و شروع به گفتگو کنید!", + "Single": "تک", + "Single API endpoint for all major AI providers. Simplify your integration.": "یک نقطه پایانی API برای همه ارائه‌دهندگان اصلی هوش مصنوعی. یکپارچه‌سازی خود را ساده کنید.", + "Some models are not responding": "برخی از مدل‌ها پاسخ نمی‌دهند", + "Sort Codex quotas by remaining": "مرتب‌سازی سهمیه‌های Codex بر اساس باقیمانده", + "Sort accounts by earliest quota reset time": "مرتب‌سازی حساب‌ها بر اساس زودترین زمان بازنشانی سهمیه", + "Source Body": "بدنه منبع", + "Sourcegraph Amp coding assistant CLI": "دستیار کدنویسی Sourcegraph Amp CLI", + "Special reasoning/thinking tokens (fallback to output rate)": "توکن‌های استدلال/تفکر ویژه (بازگشت به نرخ خروجی)", + "Speech To Text": "گفتار به متن", + "Speech-to-Text": "گفتار به متن", + "Standard prompt tokens": "توکن‌های پرامپت استاندارد", + "Start DNS": "راه‌اندازی DNS", + "Start Date": "تاریخ شروع", + "Start Free": "شروع رایگان", + "Start Headroom": "راه‌اندازی Headroom", + "Start Headroom separately at the configured URL, then recheck.": "Headroom را به صورت جداگانه در آدرس پیکربندی شده راه‌اندازی کنید، سپس دوباره بررسی کنید.", + "Start MITM": "راه‌اندازی MITM", "Start Server": "راه‌اندازی سرور", - "Enable DNS per tool below to activate interception": "DNS را برای هر ابزار در زیر فعال کنید تا رهگیری فعال شود", - "Sudo Password Required": "گذرواژه sudo الزامی است", - "Enter your sudo password to start/stop MITM server": "رمز عبور sudo خود را برای راه‌اندازی/توقف سرور MITM وارد کنید", - "Sudo Password": "گذرواژه sudo", - "Click to add, click again to remove. Changes are saved automatically.": "کلیک برای افزودن، کلیک مجدد برای حذف. تغییرات به صورت خودکار ذخیره می‌شوند.", + "Start Tunnel": "راه‌اندازی تونل", + "Start a conversation": "شروع یک گفتگو", + "Starting 9Router...": "در حال راه‌اندازی 9Router...", + "Status": "وضعیت", + "Status:": "وضعیت:", + "Step 1: Open this URL in your browser": "مرحله ۱: این URL را در مرورگر خود باز کنید", + "Step 2: Paste the callback URL here": "مرحله ۲: URL پاسخ بازگشت را در اینجا بچسبانید", + "Sticky Limit": "محدودیت چسبندگی", + "Sticky:": "چسبنده:", + "Stop": "توقف", + "Stop DNS": "توقف DNS", + "Stop Headroom": "توقف Headroom", + "Stop MITM": "توقف MITM", + "Stop Server": "توقف سرور", + "Stopped": "متوقف شد", + "Strict Proxy": "پروکسی سختگیرانه", + "Subagent Model": "مدل زیرعامل", + "Sudo Password Required": "رمز عبور sudo الزامی است", + "Sudo password is required": "رمز عبور sudo الزامی است", + "Suggested free models (≥200k context):": "مدل‌های رایگان پیشنهادی (≥۲۰۰k زمینه):", + "Suggested shorthand examples: g25p → gemini/gemini-2.5-pro, g25f → gemini/gemini-2.5-flash, cs45 → cc/claude-sonnet-4-5-20250929.": "مثال‌های میان‌نویس پیشنهادی: g25p → gemini/gemini-2.5-pro, g25f → gemini/gemini-2.5-flash, cs45 → cc/claude-sonnet-4-5-20250929.", + "Support up to 20 active apps & 50 custom domains": "پشتیبانی از حداکثر ۲۰ برنامه فعال و ۵۰ دامنه سفارشی", + "Supported formats: protocol://user:pass@host:port, host:port:user:pass": "فرمت‌های پشتیبانی شده: protocol://user:pass@host:port, host:port:user:pass", + "Sync settings across devices with optional cloud storage.": "همگام‌سازی تنظیمات بین دستگاه‌ها با ذخیره‌سازی اختیاری ابری.", + "System": "سیستم", + "TTFT:": "TTFT:", + "Tailscale": "Tailscale", + "Tailscale Funnel": "قیف Tailscale", + "Tailscale Funnel will be stopped. Remote access via Tailscale URL will stop working.": "قیف Tailscale متوقف خواهد شد. دسترسی از راه دور از طریق URL Tailscale از کار خواهد افتاد.", + "Tailscale installed": "Tailscale نصب شد", + "Tailscale is not installed. Install it to enable Funnel.": "Tailscale نصب نشده است. برای فعال‌سازی Funnel آن را نصب کنید.", + "Target Request": "درخواست هدف", + "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.": "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.", + "Temperature": "دما", + "Terminal": "ترمینال", + "Terms of Service": "شرایط خدمات", + "Terse-style system prompt → ~65% fewer output tokens (up to 87%)": "پرامپت سیستم مختصر → ~۶۵٪ توکن خروجی کمتر (تا ۸۷٪)", + "Test": "آزمایش", + "Test Again": "آزمایش مجدد", + "Test All": "آزمایش همه", + "Test Example": "مثال آزمایش", + "Test Results": "نتایج آزمایش", + "Test all API Key connections": "آزمایش همه اتصالات کلید API", + "Test all Compatible connections": "آزمایش همه اتصالات سازگار", + "Test all Free connections": "آزمایش همه اتصالات رایگان", + "Test all Free provider connections": "آزمایش همه اتصالات ارائه‌دهنده رایگان", + "Test all OAuth connections": "آزمایش همه اتصالات OAuth", + "Test connection": "آزمایش اتصال", + "Test model": "آزمایش مدل", + "Test proxy": "آزمایش پروکسی", + "Test proxy URL": "آزمایش آدرس پروکسی", + "Testing...": "در حال آزمایش...", + "Text To Speech": "متن به گفتار", + "Text To Speech combo": "ترکیب متن به گفتار", + "Text to Image": "متن به تصویر", + "Text to Image combo": "ترکیب متن به تصویر", + "Text-to-Speech": "متن به گفتار", + "Text-to-image via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…": "تولید متن به تصویر از طریق DALL-E، Imagen، FLUX، MiniMax، SDWebUI…", + "The Cloudflare tunnel will be disconnected. Remote access via tunnel URL will stop working.": "تونل Cloudflare قطع خواهد شد. دسترسی از راه دور از طریق URL تونل از کار خواهد افتاد.", + "The proxy server has been stopped.": "سرور پروکسی متوقف شده است.", + "The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.": "درخواست فوراً توسط OpenAI، Anthropic، Gemini یا دیگران برآورده می‌شود.", + "The tunnel will be disconnected. Remote access will stop working.": "تونل قطع خواهد شد. دسترسی از راه دور از کار خواهد افتاد.", + "The unified endpoint for AI generation. Connect, route, and manage your AI providers with ease.": "نقطه پایانی یکپارچه برای تولید هوش مصنوعی. به راحتی ارائه‌دهندگان هوش مصنوعی خود را متصل، مسیردهی و مدیریت کنید.", + "The unified interface for modern AI infrastructure": "رابط یکپارچه برای زیرساخت مدرن هوش مصنوعی", + "The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "رابط یکپارچه برای زیرساخت مدرن هوش مصنوعی. امن، قابل مشاهده و مقیاس‌پذیر.", + "Theme": "پوسته", + "Thinking": "تفکر", + "Thinking Process": "فرآیند تفکر", + "This is the only time you will see this key. Store it securely.": "این تنها باری است که این کلید را می‌بینید. آن را به‌طور امن ذخیره کنید.", + "This provider is ready to use.": "این ارائه‌دهنده آماده استفاده است.", + "This provider is ready to use. Optionally route requests through a proxy pool to bypass IP-based limits.": "این ارائه‌دهنده آماده استفاده است. در صورت تمایل، درخواست‌ها را از طریق یک استخر پروکسی برای دور زدن محدودیت‌های مبتنی بر IP مسیردهی کنید.", + "This value is write-only after saving.": "این مقدار پس از ذخیره فقط نوشتنی است.", + "Timestamp": "زمان‌سنج", + "Timestamp:": "زمان‌سنج:", + "To get a fresh API key, paste your browser cookie from": "برای دریافت یک کلید API جدید، کوکی مرورگر خود را از", + "Today": "امروز", + "Toggle DNS to redirect": "تغییر وضعیت DNS برای تغییر مسیر", + "Toggle auto-ping": "تغییر وضعیت پینگ خودکار", + "Token Saver": "ذخیره‌ساز توکن", + "Token Types:": "انواع توکن:", + "Token auto-detected from Kiro IDE successfully!": "توکن با موفقیت از Kiro IDE به‌طور خودکار تشخیص داده شد!", + "Token is used once for deployment and not stored.": "توکن فقط یک بار برای استقرار استفاده می‌شود و ذخیره نمی‌شود.", + "Token is used once for deployment, not stored. Found in Organization Settings.": "توکن فقط یک بار برای استقرار استفاده می‌شود، ذخیره نمی‌شود. در تنظیمات سازمان یافت می‌شود.", + "Token will be auto-filled...": "توکن به‌طور خودکار پر می‌شود...", + "Tokens": "توکن‌ها", + "Tokens auto-detected from Cursor IDE successfully!": "توکن‌ها با موفقیت از Cursor IDE به‌طور خودکار تشخیص داده شدند!", + "Tokens used to create cache entries (fallback to input rate)": "توکن‌های استفاده شده برای ایجاد ورودی‌های حافظه پنهان (بازگشت به نرخ ورودی)", + "Tomorrow": "فردا", + "Tool not found or disabled.": "ابزار یافت نشد یا غیرفعال است.", + "Tools": "ابزارها", + "Tools:": "ابزارها:", + "Total Cost": "هزینه کل", + "Total Input Tokens": "کل توکن‌های ورودی", + "Total Models": "تعداد کل مدل‌ها", + "Total Requests": "کل درخواست‌ها", + "Total Tokens": "کل توکن‌ها", + "Total:": "مجموع:", + "Track and manage your API quota limits": "پیگیری و مدیریت محدودیت‌های سهمیه API خود", + "Track token usage, costs, and performance across all providers.": "پیگیری مصرف توکن، هزینه‌ها و عملکرد در همه ارائه‌دهندگان.", + "Transcribe audio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…": "رونویسی صدا از طریق OpenAI Whisper، Groq، Gemini، Deepgram، AssemblyAI…", + "Transferring data...": "در حال انتقال داده...", + "Translator": "مترجم", + "Translator Debug": "اشکال‌زدایی مترجم", + "Tried in order (top-down) or rotated when round-robin is on.": "به ترتیب امتحان شده (بالا به پایین) یا در صورت روشن بودن چرخشی، چرخش می‌یابد.", + "Trust Cert": "اعتماد به گواهی", + "Trusted": "معتمد", + "Try Again": "دوباره تلاش کنید", + "Tunnel": "تونل", + "Tunnel connected!": "تونل متصل شد!", + "Tunnel disabled": "تونل غیرفعال شد", + "Turn off Empty": "خاموش کردن حساب‌های خالی", + "Turn on Available": "روشن کردن حساب‌های موجود", + "Turn request detail recording on/off globally": "روشن/خاموش کردن ضبط جزئیات درخواست به صورت سراسری", + "Twitter": "توییتر", + "URL → markdown / text / HTML via Firecrawl, Jina, Tavily, Exa.": "URL → مارک‌داون / متن / HTML از طریق Firecrawl، Jina، Tavily، Exa.", + "Unavailable": "ناموجود", + "Under": "زیر", + "Unified Endpoint": "نقطه پایانی یکپارچه", + "Unknown": "ناشناخته", + "Unselect all": "لغو انتخاب همه", + "Update": "به‌روزرسانی", + "Update 9Router": "به‌روزرسانی 9Router", + "Update Password": "به‌روزرسانی رمز عبور", + "Update now": "همین حالا به‌روزرسانی کنید", + "Upstream Auth Error": "خطای احراز هویت بالادست", + "Upstream Unavailable": "بالادست در دسترس نیست", + "Usage": "مصرف", + "Usage & Analytics": "مصرف و تحلیل", + "Usage / Limit": "مصرف / محدودیت", + "Usage Logs": "لاگ‌های مصرف", + "Usage Tracking": "پیگیری مصرف", + "Usage by API Key": "مصرف بر اساس کلید API", + "Usage by Account": "مصرف بر اساس حساب", + "Usage by Endpoint": "مصرف بر اساس نقطه پایانی", + "Usage by Model": "مصرف بر اساس مدل", + "Usage:": "مصرف:", + "Use 9Router model aliases to keep Amp shorthand mappings stable across provider updates.": "برای حفظ پایداری نگاشت‌های میان‌نویس Amp در به‌روزرسانی‌های ارائه‌دهنده، از نام‌های مستعار مدل 9Router استفاده کنید.", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "استفاده از Antigravity IDE و GitHub Copilot → با هر ارائه‌دهنده/مدلی از 9Router", + "Use Authentik or any OIDC provider to sign in to the dashboard.": "برای ورود به داشبورد از Authentik یا هر ارائه‌دهنده OIDC استفاده کنید.", + "Use Authentik or any OIDC provider to sign in to the dashboard. You can enable password-only, OIDC-only, or both for the dashboard; model API access still uses API keys.": "برای ورود به داشبورد از Authentik یا هر ارائه‌دهنده OIDC استفاده کنید. می‌توانید برای داشبورد فقط رمز عبور، فقط OIDC یا هر دو را فعال کنید؛ دسترسی به API مدل همچنان از کلیدهای API استفاده می‌کند.", + "Use a GitLab OAuth application": "از یک برنامه OAuth GitLab استفاده کنید", + "Use a GitLab PAT with api scope": "از یک GitLab PAT با محدوده api استفاده کنید", + "Use a direct xAI API key from console.x.ai. This is separate from Grok Build OAuth.": "از یک کلید API مستقیم xAI از console.x.ai استفاده کنید. این از Grok Build OAuth جدا است.", + "Use a local proxy for Start/Stop, or an external Docker sidecar like http://headroom:8787.": "برای شروع/توقف از یک پروکسی محلی استفاده کنید، یا از یک sidecar خارجی داکر مانند http://headroom:8787.", + "Use a long-lived Kiro/CodeWhisperer API key (headless auth).": "از یک کلید API طولانی‌مدت Kiro/CodeWhisperer (احراز هویت بدون رابط) استفاده کنید.", + "Use in Cursor/Cline": "استفاده در Cursor/Cline", + "Use the buttons above to add OpenAI or Anthropic compatible endpoints": "از دکمه‌های بالا برای افزودن نقاط پایانی سازگار با OpenAI یا Anthropic استفاده کنید", + "Use your API from any network": "از API خود از هر شبکه‌ای استفاده کنید", + "Valid": "معتبر", + "Vectors for RAG / semantic search via OpenAI, Gemini, Mistral…": "بردارها برای RAG / جستجوی معنایی از طریق OpenAI، Gemini، Mistral…", + "Vercel API Token": "توکن Vercel API", + "Vercel Relay": "Vercel Relay", + "Vercel serves millions of apps — providers can't block Vercel IPs without affecting legitimate traffic": "Vercel به میلیون‌ها برنامه خدمت می‌کند — ارائه‌دهندگان نمی‌توانند IPهای Vercel را بدون تأثیر بر ترافیک قانونی مسدود کنند", + "Verification URL": "آدرس تأیید", + "Video": "ویدیو", + "View Codex reset credit expiry": "مشاهده انقضای اعتبار بازنشانی Codex", + "View Full Details": "مشاهده جزئیات کامل", + "View on GitHub": "مشاهده در GitHub", + "Visit the URL below and enter the code:": "از URL زیر بازدید کرده و کد را وارد کنید:", + "Visit the login URL below and authorize:": "از URL ورود زیر بازدید کرده و مجوز دهید:", + "Voice": "صدا", + "Voice ID": "شناسه صدا", + "Voyage AI": "Voyage AI", + "Waiting for Authorization": "در انتظار مجوز", + "Waiting for authorization...": "در انتظار مجوز...", + "Warning": "هشدار", + "Web Fetch": "دریافت وب", + "Web Fetch & Search": "جستجو و دریافت وب", + "Web Search": "جستجوی وب", + "Web Search & Fetch (Exa)": "جستجو و دریافت وب (Exa)", + "Welcome": "خوش آمدید", + "What is Cloudflare Relay?": "Cloudflare Relay چیست؟", + "What is Deno Relay?": "Deno Relay چیست؟", + "What is Vercel Relay?": "Vercel Relay چیست؟", + "When": "زمان", + "When ON, dashboard requires password. When OFF, access without login.": "در حالت روشن، داشبورد به رمز عبور نیاز دارد. در حالت خاموش، دسترسی بدون نیاز به ورود.", + "Windows:": "ویندوز:", + "Windows: Run 9Router terminal as Administrator": "ویندوز: ترمینال 9Router را به عنوان مدیر اجرا کنید", + "Windows: Run terminal (9Router) as Administrator to enable MITM": "ویندوز: ترمینال (9Router) را به عنوان مدیر اجرا کنید تا MITM فعال شود", + "Worker Name": "نام Worker", + "Works on any device": "روی هر دستگاهی کار می‌کند", + "Writes to": "نوشته می‌شود به", + "You can override default pricing for specific models. Reset to defaults anytime to restore standard rates.": "می‌توانید قیمت‌گذاری پیش‌فرض را برای مدل‌های خاص بازنویسی کنید. هر زمان که بخواهید با بازنشانی به پیش‌فرض، نرخ‌های استاندارد را بازیابی کنید.", + "Your": "شما", + "Your Account Name": "نام حساب شما", + "Your Code": "کد شما", + "Your Kiro account via": "حساب Kiro شما از طریق", + "Your OAuth application client ID": "شناسه مشتری برنامه OAuth شما", + "Your organization's AWS IAM Identity Center URL": "URL مرکز هویت AWS IAM سازمان شما", + "Your requests start from your favorite tools or our unified SDK. Just change the base URL.": "درخواست‌های شما از ابزارهای مورد علاقه شما یا SDK یکپارچه ما شروع می‌شود. فقط آدرس پایه را تغییر دهید.", + "Your requests start from your favorite tools — Cursor, Claude, Copilot, or any OpenAI-compatible SDK.": "درخواست‌های شما از ابزارهای مورد علاقه شما شروع می‌شوند — Cursor، Claude، Copilot یا هر SDK سازگار با OpenAI.", + "account has been connected.": "حساب متصل شده است.", + "active": "فعال", + "add OpenAI/Anthropic compatible endpoints": "افزودن نقاط پایانی سازگار با OpenAI/Anthropic", + "added)": "افزوده شد)", + "again after install.": "دوباره پس از نصب.", + "and click": "و کلیک کنید", + "apiKey": "apiKey", + "below.": "در زیر.", + "bound": "پیوند شده", + "chars)": "کاراکتر)", + "cloudflare relay": "cloudflare relay", + "connection": "اتصال", + "connections": "اتصالات", + "daily-cloudcode-pa.googleapis.com": "daily-cloudcode-pa.googleapis.com", + "dark": "تاریک", + "disabled": "غیرفعال", + "dollars per million tokens": "دلار به ازای هر میلیون توکن", + "e.g. CwhRBWXzGAHq8TQ4Fs17": "مثلاً CwhRBWXzGAHq8TQ4Fs17", + "e.g. claude-opus-4-5": "مثلاً claude-opus-4-5", + "e.g. my-model-id": "مثلاً my-model-id", + "e.g. tts-1-hd": "مثلاً tts-1-hd", + "e.g. voyage-3, embed-english-v3.0, text-embedding-3-small": "مثلاً voyage-3, embed-english-v3.0, text-embedding-3-small", + "e.g., Production API, Dev Environment": "مثلاً، Production API، Dev Environment", + "every request bills all panel models + the judge": "هر درخواست همه مدل‌های پنل + داور را صورتحساب می‌کند", + "export": "خروجی", + "failed": "ناموفق", + "git/grep/ls/tree/logs → 60-90% fewer input tokens": "git/grep/ls/tree/logs → ۶۰-۹۰٪ توکن ورودی کمتر", + "h ago": "ساعت قبل", + "has been connected.": "متصل شده است.", + "iFlow AI": "iFlow AI", + "iFlow Cookie Authentication": "احراز هویت کوکی iFlow", + "import": "وارد کردن", + "inactive": "غیرفعال", + "jcode - Manual Configuration": "jcode - پیکربندی دستی", + "jcode CLI not detected locally": "jcode CLI در سیستم محلی شناسایی نشد", + "jcode is a Rust-based coding agent with semantic memory, multi-agent swarms, and extreme performance (27.8 MB RAM, 14ms boot).": "jcode یک عامل کدنویسی مبتنی بر Rust با حافظه معنایی، خوشه‌های چندعاملی و عملکرد فوق‌العاده (۲۷.۸ مگابایت رم، ۱۴ میلی‌ثانیه بوت) است.", + "kiro://kiro.kiroAgent/authenticate-success?code=...": "kiro://kiro.kiroAgent/authenticate-success?code=...", + "light": "روشن", + "m ago": "دقیقه قبل", + "macOS / Linux / Windows:": "macOS / Linux / Windows:", + "macOS / Linux:": "macOS / Linux:", + "macOS/Linux:": "macOS/Linux:", + "more": "بیشتر", + "more providers": "ارائه‌دهندگان بیشتر", + "ms / Total": "میلی‌ثانیه / کل", + "name|apiKey": "name|apiKey", + "no_proxy:": "بدون پروکسی:", + "not detected locally": "در سیستم محلی شناسایی نشد", + "npm install -g 9router": "npm install -g 9router", + "npx 9router": "npx 9router", + "open http://localhost:9099": "باز کردن http://localhost:9099", + "openid profile email": "openid profile email", + "optional context to improve accuracy": "زمینه اختیاری برای بهبود دقت", + "or VS Code extension marketplace.": "یا بازار افزونه VS Code.", + "or just": "یا فقط", + "passed": "گذرانده شد", + "platform.iflow.cn": "platform.iflow.cn", + "queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)": "همه مدل‌ها را به طور موازی پرس و جو می‌کند، سپس یک داور یک پاسخ را ترکیب می‌کند. بهترین کیفیت، اما هزینه‌برترین: هر درخواست همه مدل‌های پنل + داور را صورتحساب می‌کند (تماس‌های N+1)", + "records, batches every": "رکوردها، هر دسته", + "requests, max": "درخواست‌ها، حداکثر", + "rotates models across requests to spread load": "مدل‌ها را در بین درخواست‌ها برای توزیع بار می‌چرخاند", + "s)": "ثانیه)", + "s...": "ثانیه...", + "seconds...": "ثانیه...", + "sends image/PDF/audio requests to a model that supports them first": "درخواست‌های تصویر/PDF/صدا را ابتدا به مدلی که از آنها پشتیبانی می‌کند ارسال می‌کند", + "sk-...": "sk-...", + "sk_9router (default)": "sk_9router (پیش‌فرض)", + "system": "سیستم", + "tested": "آزمایش شد", + "the database.": "پایگاه داده.", + "to apply changes": "برای اعمال تغییرات", + "to verify.": "برای تأیید.", + "traffic through 9Router via MITM.": "ترافیک از طریق 9Router از طریق MITM.", + "tries models in order (next on failure)": "مدل‌ها را به ترتیب امتحان می‌کند (در صورت شکست به بعدی می‌رود)", + "unknown": "ناشناخته", + "v1.0 is now live": "v1.0 اکنون زنده است", + "vercel relay": "vercel relay", + "yet.": "هنوز.", + "your-org.deno.net": "your-org.deno.net", + "© 2025 9Router. All rights reserved.": "© ۲۰۲۵ 9Router. تمام حقوق محفوظ است.", + "— queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)": "— همه مدل‌ها را به طور موازی پرس و جو می‌کند، سپس یک داور یک پاسخ را ترکیب می‌کند. بهترین کیفیت، اما هزینه‌برترین: هر درخواست همه مدل‌های پنل + داور را صورتحساب می‌کند (تماس‌های N+1)", + "— rotates models across requests to spread load": "— مدل‌ها را در بین درخواست‌ها برای توزیع بار می‌چرخاند", + "— sends image/PDF/audio requests to a model that supports them first": "— درخواست‌های تصویر/PDF/صدا را ابتدا به مدلی که از آنها پشتیبانی می‌کند ارسال می‌کند", + "— tries models in order (next on failure)": "— مدل‌ها را به ترتیب امتحان می‌کند (در صورت شکست به بعدی می‌رود)", + "→ OpenAI": "→ OpenAI", + "→ Target": "→ هدف", + "→ localhost": "→ localhost", + "⚠️ Enable DNS to edit model mappings": "⚠️ برای ویرایش نگاشت‌های مدل، DNS را فعال کنید", + "⚠️ Local plugins run as subprocess via": "⚠️ افزونه‌های محلی به عنوان زیرفرآیند از طریق اجرا می‌شوند", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM ترافیک HTTPS ابزارهای IDE (Antigravity، GitHub Copilot، Kiro) را از طریق CA محلی رهگیری می‌کند تا درخواست‌ها را به ارائه‌دهندگان شما مسیردهی کند. ممکن است شرایط خدمات را نقض کند → مسدود شدن حساب. با مسئولیت خود استفاده کنید.", "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ اطلاعیه ریسک: این ارائه‌دهنده از اشتراک/جلسه OAuth استفاده می‌کند که به طور رسمی برای استفاده پروکسی/روتر مجوز ندارد. حساب ممکن است محدود یا مسدود شود. با مسئولیت خود استفاده کنید.", - "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM ترافیک HTTPS ابزارهای IDE (Antigravity, GitHub Copilot, Kiro) را از طریق CA محلی رهگیری می‌کند تا درخواست‌ها را به ارائه‌دهندگان شما مسیردهی کند. ممکن است شرایط خدمات را نقض کند → مسدود شدن حساب. با مسئولیت خود استفاده کنید.", - "Endpoint is exposed without an API key.": "Endpoint بدون کلید API در معرض دسترسی است." + "✓ Confirm Add": "✓ تأیید افزودن", + "📝 Configure providers in dashboard or use environment variables": "📝 ارائه‌دهندگان را در داشبورد پیکربندی کنید یا از متغیرهای محیطی استفاده کنید", + "🔐 OAuth required. Add now and authenticate after Apply; tool list will be discovered after first connect.": "🔐 نیاز به OAuth. اکنون اضافه کنید و پس از اعمال، احراز هویت کنید؛ لیست ابزارها پس از اولین اتصال کشف می‌شود." } From d6761c6fb003faf537c198ae206d4e9b9864dd65 Mon Sep 17 00:00:00 2001 From: ann Date: Thu, 16 Jul 2026 15:29:02 +0700 Subject: [PATCH 19/25] feat(xai): add Grok Imagine video generation (/v1/videos) + CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Async video job proxy mirroring the existing image-generation layer split: Next routes → src/sse/handlers/videoGeneration.js (auth gate, account fallback loop, refresh persistence) → open-sse/handlers/videoCore.js (transparent upstream proxy, 401 refresh-once/retry-once, secret sanitization). - POST /v1/videos/{generations,edits,extensions}: byte-exact body forward (JSON + multipart), request_id passthrough, Idempotency-Key forwarded - GET /v1/videos/{request_id}: status/progress/video.url passthrough - Register grok-imagine-video (kind: "video"); add "video" to MODEL_TYPE_TO_KIND so video models stay out of chat lists (also fixes runwayml leak) - 9router xai video CLI: submit → poll → atomic MP4 download - No auto-retry of creation POSTs (billable jobs); rotate accounts only on 401/403/429; sanitize Bearer tokens + credential values from errors/logs Closes #1285 --- cli/cli.js | 18 ++ cli/src/cli/commands/xaiVideo.js | 300 ++++++++++++++++++++ open-sse/handlers/videoCore.js | 166 ++++++++++++ open-sse/providers/registry/xai.js | 6 +- skills/9router-video/SKILL.md | 76 ++++++ skills/README.md | 1 + src/app/api/v1/models/route.js | 1 + src/app/api/v1/videos/[id]/route.js | 17 ++ src/app/api/v1/videos/edits/route.js | 16 ++ src/app/api/v1/videos/extensions/route.js | 16 ++ src/app/api/v1/videos/generations/route.js | 16 ++ src/shared/components/Sidebar.js | 2 +- src/shared/constants/providers.js | 2 +- src/sse/handlers/videoGeneration.js | 223 +++++++++++++++ tests/unit/cli-xai-video.test.js | 273 +++++++++++++++++++ tests/unit/xai-video-core.test.js | 301 +++++++++++++++++++++ tests/unit/xai-video-handler.test.js | 221 +++++++++++++++ 17 files changed, 1652 insertions(+), 3 deletions(-) create mode 100644 cli/src/cli/commands/xaiVideo.js create mode 100644 open-sse/handlers/videoCore.js create mode 100644 skills/9router-video/SKILL.md create mode 100644 src/app/api/v1/videos/[id]/route.js create mode 100644 src/app/api/v1/videos/edits/route.js create mode 100644 src/app/api/v1/videos/extensions/route.js create mode 100644 src/app/api/v1/videos/generations/route.js create mode 100644 src/sse/handlers/videoGeneration.js create mode 100644 tests/unit/cli-xai-video.test.js create mode 100644 tests/unit/xai-video-core.test.js create mode 100644 tests/unit/xai-video-handler.test.js diff --git a/cli/cli.js b/cli/cli.js index 6a0fc064..8da59653 100755 --- a/cli/cli.js +++ b/cli/cli.js @@ -67,6 +67,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. @@ -139,6 +152,11 @@ Options: --skip-update Skip auto-update check -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/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} 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 }; diff --git a/open-sse/handlers/videoCore.js b/open-sse/handlers/videoCore.js new file mode 100644 index 00000000..98d60157 --- /dev/null +++ b/open-sse/handlers/videoCore.js @@ -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": "*", + }, + }), + }; +} diff --git a/open-sse/providers/registry/xai.js b/open-sse/providers/registry/xai.js index efe13bdd..53a73c07 100644 --- a/open-sse/providers/registry/xai.js +++ b/open-sse/providers/registry/xai.js @@ -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", diff --git a/skills/9router-video/SKILL.md b/skills/9router-video/SKILL.md new file mode 100644 index 00000000..37ea13b5 --- /dev/null +++ b/skills/9router-video/SKILL.md @@ -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: ) +``` + +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: " +# → {"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. diff --git a/skills/README.md b/skills/README.md index f9f06b90..f3c0062e 100644 --- a/skills/README.md +++ b/skills/README.md @@ -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 | diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index 6260ffa4..07a24b6c 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -94,6 +94,7 @@ const MODEL_TYPE_TO_KIND = { embedding: "embedding", stt: "stt", imageToText: "imageToText", + video: "video", }; function modelKind(model) { diff --git a/src/app/api/v1/videos/[id]/route.js b/src/app/api/v1/videos/[id]/route.js new file mode 100644 index 00000000..1473d6fe --- /dev/null +++ b/src/app/api/v1/videos/[id]/route.js @@ -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); +} diff --git a/src/app/api/v1/videos/edits/route.js b/src/app/api/v1/videos/edits/route.js new file mode 100644 index 00000000..776a5afb --- /dev/null +++ b/src/app/api/v1/videos/edits/route.js @@ -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"); +} diff --git a/src/app/api/v1/videos/extensions/route.js b/src/app/api/v1/videos/extensions/route.js new file mode 100644 index 00000000..4d9a124e --- /dev/null +++ b/src/app/api/v1/videos/extensions/route.js @@ -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"); +} diff --git a/src/app/api/v1/videos/generations/route.js b/src/app/api/v1/videos/generations/route.js new file mode 100644 index 00000000..e90630ef --- /dev/null +++ b/src/app/api/v1/videos/generations/route.js @@ -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"); +} diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index 77211f72..ef4fcba8 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -13,7 +13,7 @@ import { ConfirmModal } from "./Modal"; import NineRemotePromoModal from "./NineRemotePromoModal"; // 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" }; diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index 34356cb7..dd116d16 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -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" } }, ]; diff --git a/src/sse/handlers/videoGeneration.js b/src/sse/handlers/videoGeneration.js new file mode 100644 index 00000000..67142899 --- /dev/null +++ b/src/sse/handlers/videoGeneration.js @@ -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; +} diff --git a/tests/unit/cli-xai-video.test.js b/tests/unit/cli-xai-video.test.js new file mode 100644 index 00000000..a63afbe4 --- /dev/null +++ b/tests/unit/cli-xai-video.test.js @@ -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); + }); +}); diff --git a/tests/unit/xai-video-core.test.js b/tests/unit/xai-video-core.test.js new file mode 100644 index 00000000..6e70ab2e --- /dev/null +++ b/tests/unit/xai-video-core.test.js @@ -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"); + }); +}); diff --git a/tests/unit/xai-video-handler.test.js b/tests/unit/xai-video-handler.test.js new file mode 100644 index 00000000..563ee08c --- /dev/null +++ b/tests/unit/xai-video-handler.test.js @@ -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(); + }); +}); From 59b7828237126bc646343241adf5d5f498e57d11 Mon Sep 17 00:00:00 2001 From: ryanngit Date: Thu, 16 Jul 2026 15:28:25 +0700 Subject: [PATCH 20/25] fix(grok-cli): align Grok Build with current subscription protocol (#2590) --- open-sse/config/grokCli.js | 10 + open-sse/executors/grok-cli.js | 233 +++++++++++++++--- open-sse/handlers/chatCore.js | 4 +- open-sse/providers/registry/grok-cli.js | 46 ++-- open-sse/services/grokCliModels.js | 127 ++++++++++ open-sse/services/model.js | 8 +- open-sse/services/usage/grok-cli.js | 68 ++++- src/app/api/providers/[id]/models/route.js | 31 +++ src/app/api/v1/models/route.js | 27 +- tests/unit/grok-cli-executor.test.js | 225 ++++++++++++++++- tests/unit/grok-cli-models.test.js | 80 ++++++ tests/unit/grok-cli-usage.test.js | 49 ++++ tests/unit/openai-responses-multiturn.test.js | 15 +- 13 files changed, 839 insertions(+), 84 deletions(-) create mode 100644 open-sse/config/grokCli.js create mode 100644 open-sse/services/grokCliModels.js create mode 100644 tests/unit/grok-cli-models.test.js diff --git a/open-sse/config/grokCli.js b/open-sse/config/grokCli.js new file mode 100644 index 00000000..f2e024e4 --- /dev/null +++ b/open-sse/config/grokCli.js @@ -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 || "")); +} diff --git a/open-sse/executors/grok-cli.js b/open-sse/executors/grok-cli.js index 00684d56..59baa2e2 100644 --- a/open-sse/executors/grok-cli.js +++ b/open-sse/executors/grok-cli.js @@ -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"); diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 3206b5c7..594958e8 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -13,6 +13,7 @@ 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"; @@ -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}`, diff --git a/open-sse/providers/registry/grok-cli.js b/open-sse/providers/registry/grok-cli.js index bc769e1d..403c1abe 100644 --- a/open-sse/providers/registry/grok-cli.js +++ b/open-sse/providers/registry/grok-cli.js @@ -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" }, diff --git a/open-sse/services/grokCliModels.js b/open-sse/services/grokCliModels.js new file mode 100644 index 00000000..58f5c216 --- /dev/null +++ b/open-sse/services/grokCliModels.js @@ -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}` }; + } +} diff --git a/open-sse/services/model.js b/open-sse/services/model.js index 6558d707..5b88809c 100644 --- a/open-sse/services/model.js +++ b/open-sse/services/model.js @@ -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; } diff --git a/open-sse/services/usage/grok-cli.js b/open-sse/services/usage/grok-cli.js index b412b5ee..865baeb0 100644 --- a/open-sse/services/usage/grok-cli.js +++ b/open-sse/services/usage/grok-cli.js @@ -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: {}, }; } diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index 52f0291b..89b1ca4b 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -8,6 +8,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"; @@ -369,6 +371,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`; diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index 07a24b6c..6a05da37 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -12,7 +12,9 @@ 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 { 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 @@ -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) => { diff --git a/tests/unit/grok-cli-executor.test.js b/tests/unit/grok-cli-executor.test.js index e7c90522..519bf038 100644 --- a/tests/unit/grok-cli-executor.test.js +++ b/tests/unit/grok-cli-executor.test.js @@ -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 }, diff --git a/tests/unit/grok-cli-models.test.js b/tests/unit/grok-cli-models.test.js new file mode 100644 index 00000000..3abd3159 --- /dev/null +++ b/tests/unit/grok-cli-models.test.js @@ -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"); + }); +}); diff --git a/tests/unit/grok-cli-usage.test.js b/tests/unit/grok-cli-usage.test.js index 49bebb7a..0c52a2cd 100644 --- a/tests/unit/grok-cli-usage.test.js +++ b/tests/unit/grok-cli-usage.test.js @@ -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)", () => { diff --git a/tests/unit/openai-responses-multiturn.test.js b/tests/unit/openai-responses-multiturn.test.js index bdc41dce..e08a65ba 100644 --- a/tests/unit/openai-responses-multiturn.test.js +++ b/tests/unit/openai-responses-multiturn.test.js @@ -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); From 30d0f6d3d8c048322e1a1910b50c860c7cae8882 Mon Sep 17 00:00:00 2001 From: YasharSL <80866837+YasharSL@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:47:23 +0700 Subject: [PATCH 21/25] docs(README): Add Persian youtube video tutorial --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 0dee052a..a07dd18c 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,13 @@ Default URLs: 🇮🇩 Indonesia
Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB
by Krisswuh
+ + + این شکلی از هر API ای استفاده کن برای هوش مصنوعی +
+ 🇮🇷 Persian-فارسی
+ این شکلی از هر API ای استفاده کن برای هوش مصنوعی
by Matin SenPai
+ From 8b9cac180ee6ebe8b395af83433f92b4c6f10d3e Mon Sep 17 00:00:00 2001 From: Ella CEO Date: Thu, 16 Jul 2026 15:55:56 +0700 Subject: [PATCH 22/25] fix(alicode-intl): use DashScope compatible-mode endpoint so standard keys work Switch baseUrl from coding-intl.dashscope.aliyuncs.com (Coding Plan keys only) to dashscope-intl.aliyuncs.com/compatible-mode so ordinary DashScope API keys authenticate. Path /v1/chat/completions and preserveCacheControl quirk unchanged. Fixes #2591 --- open-sse/providers/registry/alicode-intl.js | 2 +- tests/unit/alicode-intl-endpoint-2591.test.js | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 tests/unit/alicode-intl-endpoint-2591.test.js diff --git a/open-sse/providers/registry/alicode-intl.js b/open-sse/providers/registry/alicode-intl.js index b2eca7d8..b93d1d89 100644 --- a/open-sse/providers/registry/alicode-intl.js +++ b/open-sse/providers/registry/alicode-intl.js @@ -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 }, }, diff --git a/tests/unit/alicode-intl-endpoint-2591.test.js b/tests/unit/alicode-intl-endpoint-2591.test.js new file mode 100644 index 00000000..7779328f --- /dev/null +++ b/tests/unit/alicode-intl-endpoint-2591.test.js @@ -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); + }); +}); From 6acc3bb9657d4decaaa20431942c6a2bf849db39 Mon Sep 17 00:00:00 2001 From: Tuan Do <63587709+doquoctuan@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:54:30 +0700 Subject: [PATCH 23/25] fix(anthropic): lowercase anthropic-version header key to prevent duplication on /v1/messages --- open-sse/providers/registry/anthropic.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/open-sse/providers/registry/anthropic.js b/open-sse/providers/registry/anthropic.js index 1f6a3494..f83937fe 100644 --- a/open-sse/providers/registry/anthropic.js +++ b/open-sse/providers/registry/anthropic.js @@ -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", }, }, From de680e789ff2cd6123fa6ba73aac6c610deb228c Mon Sep 17 00:00:00 2001 From: asynx6 Date: Thu, 16 Jul 2026 17:00:37 +0700 Subject: [PATCH 24/25] fix(providers): bulk-add API keys no longer overwrite existing keys Bulk-add named auto-generated keys by paste-line index, blind to existing connection names. The backend upserts apikey connections by exact name (connectionsRepo), so a colliding generated name silently replaced an existing key instead of inserting a new one. Add a collision-aware planner (src/shared/utils/bulkAdd.js) that gap-fills the smallest free " " against both existing connection names and names assigned earlier in the same batch, so a generated name is never reused and the backend always inserts. Applies to auto-named lines, custom name|apiKey lines, and Cloudflare name|apiKey|accountId lines. Wire the planner into AddApiKeyModal and pass existing connection names from the provider detail page. Add unit tests covering gap-fill, custom names, Cloudflare 3-part format, and robustness. --- CHANGELOG.md | 5 + cli/package.json | 2 +- package.json | 2 +- .../providers/[id]/AddApiKeyModal.js | 33 ++--- .../dashboard/providers/[id]/page.js | 1 + src/shared/utils/bulkAdd.js | 94 +++++++++++++++ tests/unit/bulk-add-names.test.js | 113 ++++++++++++++++++ 7 files changed, 228 insertions(+), 22 deletions(-) create mode 100644 src/shared/utils/bulkAdd.js create mode 100644 tests/unit/bulk-add-names.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cdd2373..12c1dd6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# v0.5.31 (2026-07-13) + +## Fixes +- **Providers**: bulk-add API keys no longer overwrite existing keys. Auto-generated `Key N` names are now gap-filled against existing connection names (and earlier entries in the same batch), so a generated name never collides with a saved one. Previously the bulk-add modal named keys by paste-line index, blind to existing names, and the backend upserts apikey connections by name — so a colliding generated name silently replaced an existing key instead of inserting a new one. Custom `name|apiKey` lines and Cloudflare `name|apiKey|accountId` lines get the same collision-free numbering. + # v0.5.30 (2026-07-10) ## Features diff --git a/cli/package.json b/cli/package.json index e72fd187..ac2196fd 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.5.30", + "version": "0.5.31", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/package.json b/package.json index 352b661f..9f714c29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.5.30", + "version": "0.5.31", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js index 0a272045..5d19bd10 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js @@ -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,38 +132,29 @@ 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; - 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) success++; @@ -409,6 +401,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, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 845eed85..dff51fe0 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -1690,6 +1690,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={() => { diff --git a/src/shared/utils/bulkAdd.js b/src/shared/utils/bulkAdd.js new file mode 100644 index 00000000..78f54264 --- /dev/null +++ b/src/shared/utils/bulkAdd.js @@ -0,0 +1,94 @@ +// 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 " " from the paste position, blind to existing +// names, so re-adding keys often silently replaced earlier ones. +// +// This planner gap-fills the smallest free " " 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 " ", 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 " " 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, 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 (const raw of lines) { + 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 " " 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 }; + if (parsed.providerSpecificData) entry.providerSpecificData = parsed.providerSpecificData; + out.push(entry); + } + return out; +} diff --git a/tests/unit/bulk-add-names.test.js b/tests/unit/bulk-add-names.test.js new file mode 100644 index 00000000..cd012dba --- /dev/null +++ b/tests/unit/bulk-add-names.test.js @@ -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 " " 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"); + }); +}); From bc252ea80298d4879dc6b3c69585af1610d2c76f Mon Sep 17 00:00:00 2001 From: decolua Date: Thu, 16 Jul 2026 18:13:51 +0700 Subject: [PATCH 25/25] # v0.5.35 (2026-07-16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- CHANGELOG.md | 30 +++++++++++++++++++++++++++--- cli/package.json | 2 +- open-sse/handlers/chatCore.js | 3 +++ package.json | 2 +- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12c1dd6c..fb1f69ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,38 @@ -# v0.5.31 (2026-07-13) +# 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. Auto-generated `Key N` names are now gap-filled against existing connection names (and earlier entries in the same batch), so a generated name never collides with a saved one. Previously the bulk-add modal named keys by paste-line index, blind to existing names, and the backend upserts apikey connections by name — so a colliding generated name silently replaced an existing key instead of inserting a new one. Custom `name|apiKey` lines and Cloudflare `name|apiKey|accountId` lines get the same collision-free numbering. +- **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 - **Perplexity**: add Agent API provider (#2492) - **Grok CLI**: add Grok CLI / Grok Build provider with OAuth device-code flow (#2502) -- **CLI tools**: add Grok Build setup — writes `[model.9router]` custom model to `~/.grok/config.toml` - **Featherless**: add OpenAI-compatible provider presets - **SearXNG**: configure endpoint via SEARXNG_URL env (#2499) - **Providers**: add max thinking level for gpt-5.6-sol (#2500) diff --git a/cli/package.json b/cli/package.json index ac2196fd..d7fd2054 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.5.31", + "version": "0.5.35", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 594958e8..47190acf 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -210,6 +210,9 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred } } 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 (tokenSaverEnabled && cavemanEnabled && cavemanLevel) { injectCaveman(translatedBody, finalFormat, cavemanLevel); diff --git a/package.json b/package.json index 9f714c29..ba2170c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.5.31", + "version": "0.5.35", "description": "9Router web dashboard", "private": true, "scripts": {