diff --git a/CHANGELOG.md b/CHANGELOG.md index 51d8e70c..ba02368a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ - **Dokploy**: deploy production Compose from GitHub Actions with serialized rollout tracking, terminal-status handling, health verification, and deployment summaries - **GitHub Actions**: remove unrelated Docker image publishing and GitBook Pages deployment workflows +# v0.5.40 (2026-07-20) + +## Features +- **i18n**: add Khmer (km) translations +- **CLI tools**: configure Grok Build subagent models +- **Kimi**: merge OAuth into dual-auth provider, add K3 / K2.7 models +- **Dashboard**: ProviderTopology flow animation + +## Fixes +- **DB**: resolve better-sqlite3 parameter binding crash +- **Translator**: pass `service_tier` through OpenAI → Responses conversion +- **Kiro**: map GPT-5.6 reasoning effort fields +- **Kiro**: validate terminal streams before emitting output +- **Kiro**: map GPT reasoning effort fields +- **Codex**: current `client_version` + refresh-aware model sync +- **Alicode-intl**: split into Coding Plan + Model Studio providers +- **Cursor**: HTTP/2 AgentService support + version bump 3.12.17 +- **Dashboard**: cut duplicate API/icon spam, lazy-load provider assets + # v0.5.35 (2026-07-16) ## Fixes @@ -14,7 +33,7 @@ - **User quota**: show remaining Orbit and Codex headroom in the users table with session and weekly usage details - **Orbit Provider**: add Anthropic-compatible API-key routing for Claude Opus 4.6–4.8 models - **xAI**: Grok Imagine video generation (`/v1/videos`) + CLI -- **CLI tools**: Grok Build setup — writes `[model.9router]` to `~/.grok/config.toml` +- **CLI tools**: Grok Build setup — choose separate main/general-purpose/explore/plan models and preserve each model's context window - **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 diff --git a/README.md b/README.md index a07dd18c..916298bf 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ npm install -g 9router **2. Connect a FREE provider (no signup needed):** -Dashboard → Providers → Connect **Kiro AI** (free Claude unlimited) or **OpenCode Free** (no auth) → Done! +Dashboard → Providers → Connect **Kiro AI** (~50 credits/month free: Claude 4.5 + GLM-5 + MiniMax) or **OpenCode Free** (no auth) → Done! **3. Use in your CLI tool:** @@ -127,14 +127,14 @@ Default URLs: - - + - - - - + + - - - - - - - - - + + + + + + -
- - Tiết kiệm chi phí LLM với 9Router -
- 🇻🇳 Tiếng Việt
- Tiết kiệm chi phí LLM cho OpenClaw với 9Router
by Mì AI
-
+ + + Tiết kiệm chi phí LLM với 9Router +
+ 🇻🇳 Tiếng Việt
+ Tiết kiệm chi phí LLM cho OpenClaw với 9Router
by Mì AI
+
9Router + Claude Code FREE Unlimited Setup
@@ -148,10 +148,7 @@ Default URLs: 🇺🇸 English
9Router + Claude Code FREE Setup
by Build AI With Hamid
+ 9Router Setup Tutorial
@@ -165,6 +162,8 @@ Default URLs: 🇺🇸 English
Claude Code FREE Forever — Unlimited Models
by Build AI With Hamid
Claude CLI Free Setup @@ -172,10 +171,7 @@ Default URLs: 🇺🇸 English
Claude CLI Free Setup with 9Router 🚀
by
CodeVerse Soban
+ Cài đặt OpenClaw Free A-Z
@@ -196,17 +192,15 @@ Default URLs: 🇮🇩 Indonesia
Koding 24 Jam Anti Rate Limit! Hemat Token AI 65% | Tutorial Quick Setup 9Router 🚀
by Krisswuh
+ Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB
🇮🇩 Indonesia
Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB
by Krisswuh
این شکلی از هر API ای استفاده کن برای هوش مصنوعی @@ -214,8 +208,17 @@ Default URLs: 🇮🇷 Persian-فارسی
این شکلی از هر API ای استفاده کن برای هوش مصنوعی
by
Matin SenPai
+ + Hướng Dẫn Setup OpenClaw + 9Router: Tạo Bot Zalo AI Tự Động Từ A-Z +
+ 🇻🇳 Tiếng Việt
+ Hướng Dẫn Setup OpenClaw + 9Router: Tạo Bot Zalo AI Tự Động Từ A-Z
by tuanminhhole
+
@@ -330,12 +333,12 @@ Default URLs: Kiro
Kiro AI
- Claude 4.5 + GLM-5 + MiniMax
Unlimited FREE
+ Claude 4.5 + GLM-5 + MiniMax
50 credits/month free
OpenCode Free
OpenCode Free
- No auth • Auto-fetch models
Unlimited FREE
+ No auth • Auto-fetch models
Free (model list varies)
Vertex AI
@@ -346,7 +349,11 @@ Default URLs: -> **Note:** iFlow, Qwen and Gemini CLI free tiers were discontinued in 2026. Use Kiro / OpenCode Free / Vertex instead. +> **Note:** iFlow, Qwen Code and Gemini CLI free tiers were discontinued in 2026. Use Kiro / OpenCode Free / Vertex instead. +> +> **Kiro AI** moved to a paid model in Sep 2025 — the free tier is now capped at **50 credits/month** (plus 500 trial credits for new accounts in the first 30 days). Paid tiers: Pro $20/mo (1,000 credits), Pro+ $40/mo (2,000), Pro Max $100/mo (5,000), Power $200/mo (10,000). +> **OpenCode Free** model list fluctuates over time (some models free only for limited promos) — subject to change without notice. +> **Vertex AI**: the $300 free credit for new GCP accounts is still valid, but since Mar 2026 the **Gemini API endpoint no longer consumes these credits** — call the **Vertex AI Studio** endpoint instead. ### 🔑 API Key Providers (40+) @@ -600,8 +607,8 @@ Seamless translation between formats: > The "cost" displayed in Usage Analytics is **for tracking and comparison purposes only**. > 9Router itself **never charges** you anything. You only pay providers directly (if using paid services). > -> **Example:** If your dashboard shows "$290 total cost" while using iFlow models, this represents -> what you would have paid using paid APIs directly. Your actual cost = **$0** (iFlow is free unlimited). +> **Example:** If your dashboard shows "$290 total cost" while using Kiro free models, this represents +> what you would have paid using paid APIs directly. Your actual cost = **$0** (Kiro free tier: ~50 credits/mo). > > Think of it as a "savings tracker" showing how much you're saving by using free models or > routing through 9Router! @@ -629,9 +636,9 @@ Seamless translation between formats: | **💰 CHEAP** | GLM-5.1 / GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup | | | MiniMax M2.7 | $0.2/1M | 5-hour rolling | Cheapest option | | | Kimi K2.5 | $9/mo flat | 10M tokens/mo | Predictable cost | -| **🆓 FREE** | Kiro AI | $0 | Unlimited | Claude 4.5 + GLM-5 + MiniMax free | -| | OpenCode Free | $0 | Unlimited | No auth, auto-fetch models | -| | Vertex AI | $300 credits | New GCP accounts | Gemini 3 Pro + DeepSeek + GLM-5 | + | **🆓 FREE** | Kiro AI | $0 | 50 credits/mo | Claude 4.5 + GLM-5 + MiniMax free (paid tiers above) | + | | OpenCode Free | $0 | Varies* | No auth, auto-fetch models (list changes over time) | + | | Vertex AI | $300 credits | New GCP accounts | Gemini 3 Pro + DeepSeek + GLM-5 (use Vertex AI Studio endpoint for free credits) | **💡 Pro Tip:** RTK + Kiro AI + OpenCode Free combo = **$0 cost + 20-40% token savings**! @@ -644,7 +651,7 @@ Seamless translation between formats: ✅ **9Router software = FREE forever** (open source, never charges) ✅ **Dashboard "costs" = Display/tracking only** (not actual bills) ✅ **You pay providers directly** (subscriptions or API fees) -✅ **FREE providers stay FREE** (iFlow, Kiro, Qwen = $0 unlimited) +✅ **FREE providers stay FREE** (Kiro ~50 credits/mo, OpenCode Free, Vertex $300 credits = $0 within free-tier limits) — note iFlow/Qwen/Gemini CLI free tiers were discontinued in 2026 ❌ **9Router never sends invoices** or charges your card **How Cost Display Works:** @@ -660,7 +667,7 @@ Dashboard Display: • Display Cost: $290 Reality Check: -• Provider: iFlow (FREE unlimited) +• Provider: Kiro (free tier: ~50 credits/mo) • Actual Payment: $0.00 • What $290 Means: Amount you SAVED by using free models! ``` @@ -700,7 +707,7 @@ vs. $20 + hitting limits = frustration ``` Combo: "free-forever" - 1. kr/claude-sonnet-4.5 (Claude 4.5 free unlimited) + 1. kr/claude-sonnet-4.5 (Claude 4.5 free via Kiro, ~50 credits/mo) 2. kr/glm-5 (GLM-5 free via Kiro) 3. oc/ (OpenCode Free, no auth) @@ -720,7 +727,7 @@ Combo: "always-on" 2. cx/gpt-5.5 (second subscription) 3. glm/glm-5.1 (cheap, resets daily) 4. minimax/MiniMax-M2.7 (cheapest, 5h reset) - 5. kr/claude-sonnet-4.5 (free unlimited) + 5. kr/claude-sonnet-4.5 (free via Kiro, ~50 credits/mo) Result: 5 layers of fallback = zero downtime Monthly cost: $20-200 (subscriptions) + $10-20 (backup) @@ -754,7 +761,7 @@ The dashboard tracks your token usage and displays **estimated costs** as if you **Example:** - **Dashboard shows:** "$290 total cost" -- **Reality:** You're using iFlow (FREE unlimited) +- **Reality:** You're using Kiro free models (~50 credits/mo) - **Your actual cost:** **$0.00** - **What $290 means:** Amount you **saved** by using free models instead of paid APIs! @@ -780,21 +787,21 @@ The cost display is a "savings tracker" to help you understand your usage patter
🆓 Are FREE providers really unlimited? -**Yes!** The current FREE providers (Kiro, OpenCode Free, Vertex) are genuinely free with **no hidden charges**. +**Mostly!** The current FREE providers (Kiro, OpenCode Free, Vertex) are genuinely free, but free tiers have limits: These are free services offered by those respective companies: -- **Kiro AI**: Free unlimited Claude 4.5 + GLM-5 + MiniMax via AWS Builder ID / Google / GitHub OAuth -- **OpenCode Free**: No-auth passthrough proxy, models auto-fetched from `opencode.ai/zen/v1/models` -- **Vertex AI**: $300 free credits for new Google Cloud accounts (90 days) +- **Kiro AI**: ~50 credits/month free (plus 500 trial credits for new accounts in the first 30 days) via AWS Builder ID / Google / GitHub OAuth. Paid tiers available above that. +- **OpenCode Free**: No-auth passthrough proxy, models auto-fetched from `opencode.ai/zen/v1/models`. The free model list fluctuates over time (some models free only for limited promos) — subject to change without notice. +- **Vertex AI**: $300 free credits for new Google Cloud accounts (90 days). Since Mar 2026 the Gemini API endpoint no longer consumes these credits — use the **Vertex AI Studio** endpoint instead. -9Router just routes your requests to them - there's no "catch" or future billing. They're truly free services, and 9Router makes them easy to use with fallback support. +9Router just routes your requests to them - there's no "catch" or future billing from 9Router itself. They're truly free services, and 9Router makes them easy to use with fallback support. **Discontinued free tiers (no longer recommended):** - ❌ **iFlow**: Was free unlimited, now changed to paid (2026) -- ❌ **Qwen Code**: Free OAuth tier discontinued by Alibaba on 2026-04-15 -- ❌ **Gemini CLI**: Still works, but using it with non-CLI tools (Claude, Codex, Cursor...) may result in account bans — only use if you stick to Gemini CLI itself +- ❌ **Qwen Code**: Free OAuth tier fully discontinued by Alibaba on 2026-04-15 +- ❌ **Gemini CLI**: Service fully shut down by Google on 2026-06-18 (replaced by the closed-source Antigravity CLI). Discontinued — do not use.
@@ -806,12 +813,12 @@ These are free services offered by those respective companies: 1. **Start with 100% free combo:** ``` - 1. gc/gemini-3-flash (180K/month free from Google) - 2. if/kimi-k2-thinking (unlimited free from iFlow) - 3. qw/qwen3-coder-plus (unlimited free from Qwen) + 1. kr/glm-5 (GLM-5 free via Kiro, ~50 credits/mo) + 2. OpenCode Free models (no auth, auto-fetched) + 3. Vertex AI Gemini 3 Pro (using the Vertex AI Studio endpoint with $300 credits) ``` - **Cost: $0/month** + **Cost: $0/month** (within Kiro's free credit cap; OpenCode/Vertex subject to their free-tier limits) 2. **Add cheap backup** only if you need it: @@ -1036,7 +1043,7 @@ Monthly cost example (100M tokens): ``` Name: free-combo Models: - 1. kr/claude-sonnet-4.5 (Claude 4.5 free unlimited) + 1. kr/claude-sonnet-4.5 (Claude 4.5 free via Kiro, ~50 credits/mo) 2. kr/glm-5 (GLM-5 free via Kiro) 3. vertex/gemini-3.1-pro-preview ($300 free credits) @@ -1301,7 +1308,7 @@ Notes: - `kimi/kimi-k2.5` - `kimi/kimi-k2.5-thinking` -**Kiro (`kr/`)** - FREE unlimited: +**Kiro (`kr/`)** - Free (~50 credits/month, paid tiers above): - `kr/claude-sonnet-4.5` - `kr/claude-haiku-4.5` diff --git a/README.zh-CN.md b/README.zh-CN.md index 8ba77ffb..1b2bedd2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -82,7 +82,7 @@ npm install -g 9router **2. 连接免费提供商(无需注册):** -控制面板 → 提供商 → 连接 **Kiro AI**(免费 Claude 无限量)或 **OpenCode Free**(无需认证)→ 完成! +控制面板 → 提供商 → 连接 **Kiro AI**(约 50 积分/月免费:Claude 4.5 + GLM-5 + MiniMax)或 **OpenCode Free**(无需认证)→ 完成! **3. 在 CLI 工具中使用:** @@ -279,12 +279,12 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run Kiro
Kiro AI
- Claude 4.5 + GLM-5 + MiniMax
无限免费
+ Claude 4.5 + GLM-5 + MiniMax
每月 50 积分免费
OpenCode Free
OpenCode Free
- 无需认证 • 自动获取模型
无限免费
+ 无需认证 • 自动获取模型
免费(模型列表会变)
Vertex AI
@@ -295,7 +295,11 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run -> **注意:** iFlow、Qwen 和 Gemini CLI 的免费等级已于 2026 年停止。请改用 Kiro / OpenCode Free / Vertex。 +> **注意:** iFlow、Qwen Code 和 Gemini CLI 的免费等级已于 2026 年停止。请改用 Kiro / OpenCode Free / Vertex。 +> +> **Kiro AI** 于 2025 年 9 月转为付费模式 — 免费等级现在上限为**每月 50 积分**(新账户前 30 天另加 500 试用积分)。付费档位:Pro $20/月(1,000 积分)、Pro+ $40/月(2,000)、Pro Max $100/月(5,000)、Power $200/月(10,000)。 +> **OpenCode Free** 的模型列表会随时间变化(部分模型仅限时免费)— 可能随时变更,恕不另行通知。 +> **Vertex AI**:新 GCP 账户的 $300 免费额度仍然有效,但自 2026 年 3 月起 **Gemini API 端点不再消耗这些额度** — 请改用 **Vertex AI Studio** 端点。 ### 🔑 API Key 提供商(40+) @@ -500,7 +504,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run > 使用分析中显示的"成本"**仅用于追踪和比较目的**。 > 9Router 本身**永远不会向你收费**。你只直接向提供商付款(如果使用付费服务)。 > -> **示例:** 如果你的控制面板显示使用 iFlow 模型时"总成本 $290",这代表你如果直接使用付费 API 需要支付的金额。你的实际成本 = **$0**(iFlow 免费无限量)。 +> **示例:** 如果你的控制面板显示使用 Kiro 免费模型时"总成本 $290",这代表你如果直接使用付费 API 需要支付的金额。你的实际成本 = **$0**(Kiro 免费等级:约 50 积分/月)。 > > 把它想象成一个"节省追踪器",展示你通过使用免费模型或通过 9Router 路由节省了多少钱! @@ -527,9 +531,9 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run | **💰 低价** | GLM-5.1 / GLM-4.7 | $0.6/1M | 每日 10AM | 预算备份 | | | MiniMax M2.7 | $0.2/1M | 5小时滚动 | 最便宜选项 | | | Kimi K2.5 | $9/月固定 | 10M tokens/月 | 可预测成本 | -| **🆓 免费** | Kiro AI | $0 | 无限量 | Claude 4.5 + GLM-5 + MiniMax 免费 | -| | OpenCode Free | $0 | 无限量 | 无需认证,自动获取模型 | -| | Vertex AI | $300 额度 | 新 GCP 账户 | Gemini 3 Pro + DeepSeek + GLM-5 | + | **🆓 免费** | Kiro AI | $0 | 50 积分/月 | Claude 4.5 + GLM-5 + MiniMax 免费(之上为付费档位) | + | | OpenCode Free | $0 | varies* | 无需认证,自动获取模型(列表会变化) | + | | Vertex AI | $300 额度 | 新 GCP 账户 | Gemini 3 Pro + DeepSeek + GLM-5(使用 Vertex AI Studio 端点消耗免费额度) | **💡 专业提示:** RTK + Kiro AI + OpenCode Free 组合 = **$0 成本 + 节省 20-40% tokens**! @@ -542,7 +546,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run ✅ **9Router 软件 = 永久免费**(开源,绝不收费) ✅ **控制面板"成本" = 仅用于显示/追踪**(不是实际账单) ✅ **你直接向提供商付款**(订阅或 API 费用) -✅ **免费提供商保持免费**(iFlow、Kiro、Qwen = $0 无限量) +✅ **免费提供商保持免费**(Kiro 约 50 积分/月、OpenCode Free、Vertex $300 额度 = 在免费额度内 $0)— 注意 iFlow/Qwen/Gemini CLI 免费等级已于 2026 年停止 ❌ **9Router 永不发送发票** 或扣款 **成本显示如何工作:** @@ -557,7 +561,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run • 显示成本:$290 实际检查: -• 提供商:iFlow(免费无限量) +• 提供商:Kiro(免费等级:约 50 积分/月) • 实际支付:$0.00 • $290 意味着什么:通过使用免费模型节省的金额! ``` @@ -565,7 +569,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run **付款规则:** - **订阅提供商**(Claude Code、Codex):通过他们的网站直接付款 - **低价提供商**(GLM、MiniMax):直接付款,9Router 只做路由 -- **免费提供商**(iFlow、Kiro、Qwen):真正的永久免费,无隐藏费用 +- **免费提供商**(Kiro、OpenCode Free、Vertex):真正的免费,在免费额度内无隐藏费用 - **9Router**:从不收取任何费用,永远不会 --- @@ -594,7 +598,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run **解决方案:** ``` 组合:"free-forever" - 1. kr/claude-sonnet-4.5 (Claude 4.5 免费无限量) + 1. kr/claude-sonnet-4.5 (通过 Kiro 免费使用 Claude 4.5,约 50 积分/月) 2. kr/glm-5 (通过 Kiro 免费使用 GLM-5) 3. oc/ (OpenCode Free,无需认证) @@ -613,7 +617,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run 2. cx/gpt-5.5 (第二个订阅) 3. glm/glm-5.1 (低价,每日重置) 4. minimax/MiniMax-M2.7 (最便宜,5小时重置) - 5. kr/claude-sonnet-4.5 (免费无限量) + 5. kr/claude-sonnet-4.5 (通过 Kiro 免费使用,约 50 积分/月) 结果:5 层切换 = 零停机时间 月成本:$20-200(订阅)+ $10-20(备份) @@ -645,7 +649,7 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run **示例:** - **控制面板显示:** "$290 总成本" -- **实际情况:** 你在使用 iFlow(免费无限量) +- **实际情况:** 你在使用 Kiro 免费模型(约 50 积分/月) - **你的实际成本:** **$0.00** - **$290 的含义:** 你通过使用免费模型而不是付费 API **节省**的金额! @@ -670,19 +674,19 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run
🆓 免费提供商真的是无限量的吗? -**是的!** 当前的免费提供商(Kiro、OpenCode Free、Vertex)是真正的免费,**无隐藏费用**。 +**基本上是!** 当前的免费提供商(Kiro、OpenCode Free、Vertex)是真正的免费,但免费等级有上限: 这些是各公司提供的免费服务: -- **Kiro AI**:通过 AWS Builder ID / Google / GitHub OAuth 免费无限量使用 Claude 4.5 + GLM-5 + MiniMax -- **OpenCode Free**:无认证直连代理,模型从 `opencode.ai/zen/v1/models` 自动获取 -- **Vertex AI**:新 Google Cloud 账户可获得 $300 免费额度(90 天) +- **Kiro AI**:通过 AWS Builder ID / Google / GitHub OAuth 使用,免费等级约**每月 50 积分**(新账户前 30 天另加 500 试用积分)。之上提供付费档位。 +- **OpenCode Free**:无认证直连代理,模型从 `opencode.ai/zen/v1/models` 自动获取。免费模型列表会随时间变化(部分模型仅限时免费)— 可能随时变更。 +- **Vertex AI**:新 Google Cloud 账户可获得 $300 免费额度(90 天)。自 2026 年 3 月起 Gemini API 端点不再消耗这些额度 — 请改用 **Vertex AI Studio** 端点。 9Router 只是路由你的请求到它们 — 没有"陷阱"或未来的计费。它们是真正的免费服务,9Router 让它们易于使用并支持切换。 **已停止的免费等级(不再推荐):** - ❌ **iFlow**:曾是免费无限量,现在改为付费(2026) -- ❌ **Qwen Code**:阿里巴巴于 2026-04-15 停止免费 OAuth 等级 -- ❌ **Gemini CLI**:仍可用,但与非 CLI 工具(Claude、Codex、Cursor...)一起使用可能会导致账户被封 — 仅在你坚持使用 Gemini CLI 本身时才使用 +- ❌ **Qwen Code**:阿里巴巴于 2026-04-15 完全停止免费 OAuth 等级 +- ❌ **Gemini CLI**:Google 已于 2026-06-18 完全停止服务(由闭源的 Antigravity CLI 取代)。已停止 — 请勿使用。
@@ -693,11 +697,11 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run 1. **从 100% 免费组合开始:** ``` - 1. gc/gemini-3-flash (Google 每月 180K 免费) - 2. if/kimi-k2-thinking (iFlow 无限量免费) - 3. qw/qwen3-coder-plus (Qwen 无限量免费) + 1. kr/glm-5 (通过 Kiro 免费使用 GLM-5,约 50 积分/月) + 2. OpenCode Free 模型(无认证,自动获取) + 3. Vertex AI Gemini 3 Pro(使用 Vertex AI Studio 端点 + $300 额度) ``` - **成本:$0/月** + **成本:$0/月**(在 Kiro 免费积分上限内;OpenCode/Vertex 受各自免费等级限制) 2. **仅在需要时添加低价备份:** ``` @@ -918,7 +922,7 @@ Vertex 合作伙伴(通过 Vertex 提供 Anthropic / DeepSeek / GLM / Qwen) ``` 名称:free-combo 模型: - 1. kr/claude-sonnet-4.5 (Claude 4.5 免费无限量) + 1. kr/claude-sonnet-4.5 (通过 Kiro 免费使用 Claude 4.5,约 50 积分/月) 2. kr/glm-5 (通过 Kiro 免费使用 GLM-5) 3. vertex/gemini-3.1-pro-preview ($300 免费额度) @@ -1168,7 +1172,7 @@ docker stop 9router && docker rm 9router - `kimi/kimi-k2.5` - `kimi/kimi-k2.5-thinking` -**Kiro(`kr/`)** - 免费无限量: +**Kiro(`kr/`)** - 免费(约 50 积分/月,之上为付费档位): - `kr/claude-sonnet-4.5` - `kr/claude-haiku-4.5` - `kr/glm-5` diff --git a/cli/cli.js b/cli/cli.js index 7110a642..703039dd 100755 --- a/cli/cli.js +++ b/cli/cli.js @@ -3,6 +3,7 @@ const { spawn, exec, execSync } = require("child_process"); const path = require("path"); const fs = require("fs"); +const https = require("https"); const net = require("net"); const os = require("os"); @@ -25,6 +26,42 @@ function waitServerReady(port, { timeoutMs = 15000, intervalMs = 150 } = {}) { }); } +// Native spinner - no external dependency +function createSpinner(text) { + const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let i = 0; + let interval = null; + let currentText = text; + return { + start() { + if (process.stdout.isTTY) { + process.stdout.write(`\r${frames[0]} ${currentText}`); + interval = setInterval(() => { + process.stdout.write(`\r${frames[i++ % frames.length]} ${currentText}`); + }, 80); + } + return this; + }, + stop() { + if (interval) { + clearInterval(interval); + interval = null; + } + if (process.stdout.isTTY) { + process.stdout.write("\r\x1b[K"); + } + }, + succeed(msg) { + this.stop(); + console.log(`✅ ${msg}`); + }, + fail(msg) { + this.stop(); + console.log(`❌ ${msg}`); + } + }; +} + const pkg = require("./package.json"); const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRuntime"); const { ensureTrayRuntime } = require("./hooks/trayRuntime"); @@ -53,6 +90,7 @@ try { ensureTrayRuntime({ silent: true }); } catch {} // Configuration constants const APP_NAME = pkg.name; // Use from package.json +const INSTALL_CMD_LATEST = `npm i -g ${APP_NAME}@latest --prefer-online`; const DEFAULT_PORT = 20128; const DEFAULT_HOST = "0.0.0.0"; @@ -81,6 +119,7 @@ const PROCESS_IDENTIFIERS = [ let port = DEFAULT_PORT; let host = DEFAULT_HOST; let noBrowser = false; +let skipUpdate = false; let showLog = false; let trayMode = false; @@ -95,6 +134,8 @@ for (let i = 0; i < args.length; i++) { noBrowser = true; } else if (args[i] === "--log" || args[i] === "-l") { showLog = true; + } else if (args[i] === "--skip-update") { + skipUpdate = true; } else if (args[i] === "--tray" || args[i] === "-t") { trayMode = true; process.env.TRAY_MODE = "1"; @@ -108,6 +149,7 @@ Options: -n, --no-browser Don't open browser automatically -l, --log Show server logs (default: hidden) -t, --tray Run in system tray mode (background) + --skip-update Skip auto-update check -h, --help Show this help message -v, --version Show version @@ -123,9 +165,26 @@ Commands: } } +// Auto-relaunch after update: detached process has no TTY → fallback to tray +if (skipUpdate && !trayMode && !process.stdin.isTTY) { + trayMode = true; + process.env.TRAY_MODE = "1"; +} + // Always use Node.js runtime with absolute path const RUNTIME = process.execPath; +// Compare semver versions: returns 1 if a > b, -1 if a < b, 0 if equal +function compareVersions(a, b) { + const partsA = a.split(".").map(Number); + const partsB = b.split(".").map(Number); + for (let i = 0; i < 3; i++) { + if (partsA[i] > partsB[i]) return 1; + if (partsA[i] < partsB[i]) return -1; + } + return 0; +} + // Get app data dir (matches app/src/lib/dataDir.js convention) function getAppDataDir() { return process.platform === "win32" @@ -150,14 +209,54 @@ function killByPidFile(pidFile) { } catch { } } +// Kill tunnel processes (cloudflared/tailscale) by their PID files +function killTunnelByPidFile() { + const tunnelDir = path.join(getAppDataDir(), "tunnel"); + killByPidFile(path.join(tunnelDir, "cloudflared.pid")); + killByPidFile(path.join(tunnelDir, "tailscale.pid")); +} + +// Kill cloudflared whose --url targets this app's port (covers stale PID file case) +function killCloudflaredByAppPort(appPort) { + if (!appPort) return []; + const portMatchers = [`localhost:${appPort}`, `127.0.0.1:${appPort}`]; + const pids = []; + try { + if (process.platform === "win32") { + const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"cloudflared.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`; + const output = execSync(psCmd, { encoding: "utf8", windowsHide: true, timeout: 5000 }); + const lines = output.split("\n").slice(1).filter(l => l.trim()); + lines.forEach(line => { + if (portMatchers.some(m => line.includes(m))) { + const match = line.match(/^"(\d+)"/); + if (match && match[1]) pids.push(match[1]); + } + }); + } else { + const output = execSync("ps -eo pid,command 2>/dev/null", { encoding: "utf8", timeout: 5000 }); + output.split("\n").forEach(line => { + if (line.includes("cloudflared") && portMatchers.some(m => line.includes(m))) { + const parts = line.trim().split(/\s+/); + const pid = parts[0]; + if (pid && !isNaN(pid)) pids.push(pid); + } + }); + } + } catch { } + return pids; +} + // Kill all 9router processes function killAllAppProcesses(appPort) { return new Promise((resolve) => { try { - // MITM runs on a separate process and does not block the critical path. + // Background: MITM + tunnel/cloudflared run on separate ports/processes — + // killing them doesn't free the app port, so don't block the critical path. // Server-side MITM manager has stale-lock recovery and starts deferred (~3s). setImmediate(() => { try { killProxyByPidFile(); } catch {} + try { killTunnelByPidFile(); } catch {} + try { killCloudflaredByAppPort(appPort); } catch {} }); const platform = process.platform; @@ -356,6 +455,55 @@ function isRestrictedEnvironment() { return null; } +// Check if new version available, return latest version or null +function checkForUpdate() { + return new Promise((resolve) => { + if (skipUpdate) { + resolve(null); + return; + } + + const spinner = createSpinner("Checking for updates...").start(); + let resolved = false; + + const safetyTimeout = setTimeout(() => { + if (!resolved) { + resolved = true; + spinner.stop(); + resolve(null); + } + }, 8000); + + const done = (version) => { + if (resolved) return; + resolved = true; + clearTimeout(safetyTimeout); + spinner.stop(); + resolve(version); + }; + + const req = https.get(`https://registry.npmjs.org/${pkg.name}/latest`, { timeout: 3000 }, (res) => { + let data = ""; + res.on("data", chunk => data += chunk); + res.on("end", () => { + try { + const latest = JSON.parse(data); + if (latest.version && compareVersions(latest.version, pkg.version) > 0) { + done(latest.version); + } else { + done(null); + } + } catch (e) { + done(null); + } + }); + }); + + req.on("error", () => done(null)); + req.on("timeout", () => { req.destroy(); done(null); }); + }); +} + // Open browser function openBrowser(url) { const platform = process.platform; @@ -390,24 +538,39 @@ if (!fs.existsSync(serverPath)) { process.exit(1); } +// Start server immediately; run update check in parallel (not on the critical path). +const updatePromise = checkForUpdate(); killAllAppProcesses(port) .then(() => killProcessOnPort(port)) - .then(() => startServer()); + .then(() => startServer(updatePromise)); // Show interface selection menu -async function showInterfaceMenu() { +async function showInterfaceMenu(latestVersion) { const { selectMenu } = require("./src/cli/utils/input"); const { clearScreen } = require("./src/cli/utils/display"); + const { getEndpoint } = require("./src/cli/utils/endpoint"); + clearScreen(); const displayHost = getDisplayHost(); - const serverUrl = `http://${displayHost}:${port}`; + // Detect tunnel/local mode for server URL display + let serverUrl; + try { + const { endpoint, tunnelEnabled } = await getEndpoint(port); + serverUrl = tunnelEnabled ? endpoint.replace(/\/v1$/, "") : `http://${displayHost}:${port}`; + } catch (e) { + serverUrl = `http://${displayHost}:${port}`; + } const subtitle = `🚀 Server: \x1b[32m${serverUrl}\x1b[0m`; const menuItems = []; + if (latestVersion) { + menuItems.push({ label: `Update to v${latestVersion} (current: v${pkg.version})`, icon: "⬆" }); + } + menuItems.push( { label: "Web UI (Open in Browser)", icon: "🌐" }, { label: "Terminal UI (Interactive CLI)", icon: "💻" }, @@ -417,16 +580,21 @@ async function showInterfaceMenu() { const selected = await selectMenu(`Choose Interface (v${pkg.version})`, menuItems, 0, subtitle); - if (selected === 0) return "web"; - if (selected === 1) return "terminal"; - if (selected === 2) return "hide"; + const offset = latestVersion ? 1 : 0; + + if (latestVersion && selected === 0) return "update"; + if (selected === offset) return "web"; + if (selected === offset + 1) return "terminal"; + if (selected === offset + 2) return "hide"; return "exit"; } const MAX_RESTARTS = 2; const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s -function startServer() { +function startServer(updatePromise) { + // Accept either a Promise (parallel update check) or a resolved value. + const latestVersionPromise = Promise.resolve(updatePromise); const displayHost = getDisplayHost(); const url = `http://${displayHost}:${port}/dashboard`; // Surface real network exposure when bound to all interfaces (default 0.0.0.0). @@ -444,7 +612,7 @@ function startServer() { function spawnServer() { serverStartTime = Date.now(); crashLog = []; - const child = spawn(RUNTIME, ["--max-old-space-size=6144", serverPath], { + const child = spawn(RUNTIME, ["--dns-result-order=ipv4first", "--max-old-space-size=6144", serverPath], { cwd: standaloneDir, stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"], detached: true, @@ -480,6 +648,8 @@ function startServer() { } catch (e) { } // Kill MIT server (privileged process) via PID file killProxyByPidFile(); + // Kill cloudflared/tailscale via PID file (only this app's tunnel) + killTunnelByPidFile(); // Kill server process directly if (server.pid) { process.kill(server.pid, "SIGKILL"); @@ -556,14 +726,28 @@ function startServer() { // Wait for server to be ready, then show interface menu loop + tray waitServerReady(port).then(async () => { + // Resolve parallel update check (already running); don't block server start on it. + const latestVersion = await latestVersionPromise; // Start tray icon alongside TUI initTrayIcon(); try { while (true) { - const choice = await showInterfaceMenu(); + const choice = await showInterfaceMenu(latestVersion); - if (choice === "web") { + if (choice === "update") { + isShuttingDown = true; + const { clearScreen } = require("./src/cli/utils/display"); + clearScreen(); + console.log(`\n⬆ Update v${pkg.version} → v${latestVersion}\n`); + console.log(`Run this after exit:\n`); + console.log(` \x1b[33m${INSTALL_CMD_LATEST}\x1b[0m\n`); + cleanup(); + await killAllAppProcesses(port); + await killProcessOnPort(port); + setTimeout(() => process.exit(0), 200); + return; + } else if (choice === "web") { openBrowser(url); // Wait for user to come back const { pause } = require("./src/cli/utils/input"); @@ -601,7 +785,7 @@ function startServer() { // Windows/Linux: spawn detached bgProcess (systray works fine in child) console.log(`\n⏳ Starting background process... (tray icon will appear in ~3s)`); - const bgProcess = spawn(process.execPath, [__filename, "--tray", "-p", port.toString()], { + const bgProcess = spawn(process.execPath, ["--dns-result-order=ipv4first", __filename, "--tray", "--skip-update", "-p", port.toString()], { detached: true, stdio: "ignore", windowsHide: true, diff --git a/cli/package.json b/cli/package.json index d7fd2054..cb24c4f0 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.5.35", + "version": "0.5.40", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/cli/src/cli/menus/providers.js b/cli/src/cli/menus/providers.js index 57013466..291ae674 100644 --- a/cli/src/cli/menus/providers.js +++ b/cli/src/cli/menus/providers.js @@ -131,7 +131,7 @@ const APIKEY_PROVIDERS = { openrouter: { id: "openrouter", name: "OpenRouter" }, glm: { id: "glm", name: "GLM Coding" }, minimax: { id: "minimax", name: "Minimax Coding" }, - kimi: { id: "kimi", name: "Kimi Coding" }, + kimi: { id: "kimi", name: "Kimi" }, openai: { id: "openai", name: "OpenAI" }, anthropic: { id: "anthropic", name: "Anthropic" }, gemini: { id: "gemini", name: "Gemini" }, diff --git a/open-sse/config/appConstants.js b/open-sse/config/appConstants.js index cc46697c..2934fc88 100644 --- a/open-sse/config/appConstants.js +++ b/open-sse/config/appConstants.js @@ -1,6 +1,7 @@ -import { platform, arch } from "os"; +import { platform, arch, hostname } from "os"; import { PROVIDERS, PROVIDER_OAUTH } from "./providers.js"; import { ANTIGRAVITY_IDE_USER_AGENT } from "../providers/shared.js"; +import { createRequire } from "module"; // === Gemini CLI === derive từ registry gemini-cli.transport export const GEMINI_CLI_VERSION = PROVIDERS["gemini-cli"]?.cliVersion; @@ -171,12 +172,44 @@ export const OAUTH_ENDPOINTS = { github: { token: PROVIDER_OAUTH["github"]?.tokenUrl, auth: PROVIDER_OAUTH["github"]?.authorizeUrl, deviceCode: PROVIDER_OAUTH["github"]?.deviceCodeUrl }, }; -// Generate Kimi OAuth custom headers -export function buildKimiHeaders() { +let _appVersion; +function getAppPackageVersion() { + if (_appVersion) return _appVersion; + try { + const require = createRequire(import.meta.url); + _appVersion = require("../../package.json").version || "0.0.0"; + } catch { + _appVersion = process.env.npm_package_version || "0.0.0"; + } + return _appVersion; +} + +// Kimi Code OAuth / API headers (CLIProxyAPI internal/auth/kimi commonHeaders parity). +// deviceId must stay stable per connection for the whole OAuth session. +export function buildKimiHeaders(deviceId) { + const osName = platform(); + const architecture = arch(); + let deviceModel = `${osName} ${architecture}`; + if (osName === "darwin") deviceModel = `macOS ${architecture}`; + else if (osName === "win32") deviceModel = `Windows ${architecture}`; + else if (osName === "linux") deviceModel = `Linux ${architecture}`; + + let deviceName = "unknown"; + try { + deviceName = hostname() || "unknown"; + } catch { + deviceName = "unknown"; + } + + const resolvedId = (typeof deviceId === "string" && deviceId.trim()) + ? deviceId.trim() + : `kimi-${Date.now()}`; + return { "X-Msh-Platform": "9router", - "X-Msh-Version": "2.1.2", - "X-Msh-Device-Model": typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown", - "X-Msh-Device-Id": `kimi-${Date.now()}` + "X-Msh-Version": getAppPackageVersion(), + "X-Msh-Device-Name": deviceName, + "X-Msh-Device-Model": deviceModel, + "X-Msh-Device-Id": resolvedId, }; } diff --git a/open-sse/config/kiroConstants.js b/open-sse/config/kiroConstants.js index 93b8f456..511ae919 100644 --- a/open-sse/config/kiroConstants.js +++ b/open-sse/config/kiroConstants.js @@ -8,8 +8,8 @@ * - `-agentic` model suffix detection + chunked-write system prompt * - reasoning / thinking trigger detection (Anthropic-Beta header, * Claude `thinking`, OpenAI `reasoning_effort`, AMP/Cursor magic tag) - * - the `enabled` system-prompt injection - * that turns Kiro reasoning on + * - schema-specific native effort fields for supported GPT and Claude models + * - legacy `` system-prompt injection for other models * * Kiro upstream does not advertise `-agentic` model IDs; they are a 9router * fiction. The suffix is stripped before the request leaves this process. @@ -109,6 +109,7 @@ export function resolveKiroThinkingBudget(body, headers, model) { const cfg = extractThinking(body); if (cfg) { if (cfg.mode === "none") return null; + if (cfg.mode === "level" && cfg.level === "disabled") return null; if (cfg.mode === "budget") return cfg.budget; if (cfg.mode === "level") return effortToBudget(cfg.level) ?? KIRO_THINKING_BUDGET_DEFAULT; return KIRO_THINKING_BUDGET_DEFAULT; @@ -144,9 +145,30 @@ export function extractKiroEffortLevel(body) { return null; } -export function buildKiroAdditionalModelRequestFields(body) { - const effort = extractKiroEffortLevel(body); +function extractKiroGptEffortLevel(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 === "max") return "xhigh"; + // Kiro CLI does not advertise an explicit GPT "none" wire value; omit it. + if (["low", "medium", "high", "xhigh"].includes(normalized)) { + return normalized; + } + return null; +} + +export function buildKiroAdditionalModelRequestFields(body, effortPath = "output_config") { + const effort = effortPath === "reasoning" + ? extractKiroGptEffortLevel(body) + : extractKiroEffortLevel(body); if (!effort) return undefined; + if (effortPath === "reasoning") { + // Mirrors Kiro CLI/KAS buildEffortRequestFields("reasoning") for GPT. + return { reasoning: { effort } }; + } // Mirrors Kiro CLI/KAS buildEffortRequestFields("output_config"). return { thinking: { type: "adaptive", display: "summarized" }, @@ -154,12 +176,15 @@ export function buildKiroAdditionalModelRequestFields(body) { }; } -export function supportsKiroAdditionalModelRequestFields(model) { - if (typeof model !== "string") return false; +export function resolveKiroEffortPath(model) { + if (typeof model !== "string") return null; const normalized = model.toLowerCase().replace(/-/g, "."); - if (!normalized.includes("claude")) return false; + if (/(?:^|[/.])gpt[/.]5[/.]6(?:[/.]|$)/.test(normalized)) { + return "reasoning"; + } + if (!normalized.includes("claude")) return null; const match = normalized.match(/(?:^|[/.])claude(?:[/.][a-z]+)*[/.](\d+)(?:[/.](\d+))?(?:[/.]|$)/); - if (!match) return false; + if (!match) return null; const [, majorText, minorText] = match; const major = Number(majorText); const minor = minorText === undefined ? null : Number(minorText); @@ -167,12 +192,24 @@ export function supportsKiroAdditionalModelRequestFields(model) { // 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))); + return major < 4 || (major === 4 && (minor === null || minor <= 5 || dateSuffixMinor)) + ? null + : "output_config"; +} + +export function supportsKiroAdditionalModelRequestFields(model) { + return resolveKiroEffortPath(model) !== null; +} + +export function usesKiroNativeGptEffort(body, model) { + return resolveKiroEffortPath(model) === "reasoning" + && extractKiroGptEffortLevel(body) !== null; } export function buildKiroAdditionalModelRequestFieldsForModel(body, model) { - if (!supportsKiroAdditionalModelRequestFields(model)) return undefined; - return buildKiroAdditionalModelRequestFields(body); + const effortPath = resolveKiroEffortPath(model); + if (!effortPath) return undefined; + return buildKiroAdditionalModelRequestFields(body, effortPath); } /** diff --git a/open-sse/executors/cursor.js b/open-sse/executors/cursor.js index fe06d80d..5023aa45 100644 --- a/open-sse/executors/cursor.js +++ b/open-sse/executors/cursor.js @@ -1,8 +1,11 @@ import { BaseExecutor } from "./base.js"; -import { PROVIDERS } from "../config/providers.js"; +import { PROVIDERS, PROVIDER_OAUTH } from "../config/providers.js"; import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { generateCursorBody, + encodeField, + wrapConnectRPCFrame, + decodeMessage, parseConnectRPCFrame, extractTextFromResponse } from "../utils/cursorProtobuf.js"; @@ -13,6 +16,7 @@ import { chatChunkSse } from "../utils/sse.js"; import { FORMATS } from "../translator/formats.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import zlib from "zlib"; +import crypto from "crypto"; // Detect cloud environment const isCloudEnv = () => { @@ -38,6 +42,130 @@ const COMPRESS_FLAG = { GZIP_TRAILER: 0x03 }; +const AGENT_RUN_PATH = "/agent.v1.AgentService/Run"; +const PROTOBUF_LEN = 2; +const PROTOBUF_VARINT = 0; + +function concatBuffers(...parts) { + const length = parts.reduce((total, part) => total + part.length, 0); + const result = new Uint8Array(length); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + return result; +} + +const agentString = (field, value) => encodeField(field, PROTOBUF_LEN, value); +const agentMessage = (field, value) => encodeField(field, PROTOBUF_LEN, value); +const agentBool = (field, value) => encodeField(field, PROTOBUF_VARINT, value ? 1 : 0); + +function textFromContent(content) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter((part) => part?.type === "text" && typeof part.text === "string") + .map((part) => part.text) + .join("\n"); +} + +function isAgentTextRequest(body) { + // Many compatible clients always attach their built-in tool schemas, even + // for a normal text turn. Cursor's retired ChatService rejects those + // requests; AgentService can still answer the text turn, so ignore schemas + // here. A real tool-call/result conversation is kept on the legacy path + // until its AgentService tool protocol is implemented. + return Array.isArray(body?.messages) && body.messages.every((message) => { + if (message?.tool_calls?.length || message?.role === "tool") return false; + return typeof message?.content === "string" + || Array.isArray(message?.content) && message.content.every((part) => part?.type === "text"); + }); +} + +function encodeHistoryMessage(message) { + const content = textFromContent(message?.content); + if (!content) return null; + + // ConversationHistoryMessage.user / .assistant -> repeated content -> text. + const text = agentString(1, content); + if (message.role === "assistant") { + return agentMessage(2, agentMessage(1, agentMessage(1, text))); + } + return agentMessage(1, agentMessage(1, agentMessage(1, text))); +} + +function buildAgentRunFrame(messages, model) { + const system = messages + .filter((message) => message?.role === "system") + .map((message) => textFromContent(message.content)) + .filter(Boolean) + .join("\n\n"); + const chatMessages = messages.filter((message) => message?.role !== "system"); + const currentIndex = [...chatMessages].map((message) => message?.role).lastIndexOf("user"); + const current = currentIndex >= 0 ? chatMessages[currentIndex] : chatMessages.at(-1); + const history = chatMessages + .slice(0, currentIndex >= 0 ? currentIndex : -1) + .map(encodeHistoryMessage) + .filter(Boolean); + const userText = textFromContent(current?.content) || "Continue."; + + // agent.v1.UserMessageAction.user_message and its optional history. + const userMessage = concatBuffers( + agentString(1, userText), + agentString(2, crypto.randomUUID()), + ); + const conversationHistory = history.length + ? concatBuffers(...history.map((entry) => agentMessage(1, entry))) + : null; + const userAction = concatBuffers( + agentMessage(1, userMessage), + ...(conversationHistory ? [agentMessage(7, conversationHistory)] : []), + ); + const conversationAction = agentMessage(1, userAction); + const requestedModel = concatBuffers(agentString(1, model), agentBool(7, true)); + const runRequest = concatBuffers( + // An empty ConversationStateStructure starts a fresh local agent session. + agentMessage(1, new Uint8Array()), + agentMessage(2, conversationAction), + ...(system ? [agentString(8, system)] : []), + agentMessage(9, requestedModel), + ); + + // agent.v1.AgentClientMessage.run_request. + return wrapConnectRPCFrame(agentMessage(1, runRequest)); +} + +function extractAgentString(message, field) { + const value = message?.get(field)?.[0]?.value; + return value ? Buffer.from(value).toString("utf8") : ""; +} + +function decodeAgentFrames(buffer, onFrame) { + let pending = Buffer.from(buffer || []); + while (pending.length >= 5) { + const flags = pending[0]; + const length = pending.readUInt32BE(1); + if (pending.length < 5 + length) break; + let payload = pending.subarray(5, 5 + length); + pending = pending.subarray(5 + length); + if (flags & COMPRESS_FLAG.GZIP) { + payload = zlib.gunzipSync(payload); + } + if (!(flags & COMPRESS_FLAG.TRAILER)) onFrame(payload); + } + return pending; +} + +function createRequestContextResponse() { + // AgentService asks every run for client context. 9router has no IDE file + // context, so acknowledge with an empty RequestContext. + const requestContextSuccess = agentMessage(1, new Uint8Array()); + const requestContextResult = agentMessage(1, requestContextSuccess); + const execClientMessage = agentMessage(10, requestContextResult); + return wrapConnectRPCFrame(agentMessage(2, execClientMessage)); +} + const CURSOR_STREAM_DEBUG = process.env.CURSOR_STREAM_DEBUG === "1"; const debugLog = (...args) => { if (CURSOR_STREAM_DEBUG) console.log(...args); @@ -253,7 +381,293 @@ export class CursorExecutor extends BaseExecutor { }); } + /** + * AgentService (agent.api5.cursor.sh) is HTTP/2-only. Node's fetch/undici speaks + * HTTP/1.1 and fails with HTTPParserError on the h2 preface — use http2 duplex. + */ + openAgentHttp2Stream(url, headers, signal) { + if (!http2) { + throw new Error("HTTP/2 is required for Cursor AgentService (endpoint is h2-only)"); + } + + const urlObj = new URL(url); + const client = http2.connect(`https://${urlObj.host}`); + const chunkQueue = []; + let waiting = null; + let ended = false; + let streamError = null; + let req = null; + + const wake = (result) => { + if (!waiting) return; + const resolve = waiting; + waiting = null; + resolve(result); + }; + + const fail = (error) => { + if (streamError) return; + streamError = error; + ended = true; + wake(null); + }; + + const close = () => { + try { req?.destroy(); } catch {} + try { client.close(); } catch {} + }; + + client.on("error", fail); + + req = client.request({ + ":method": "POST", + ":path": urlObj.pathname, + ":authority": urlObj.host, + ":scheme": "https", + ...headers, + }); + + req.on("error", fail); + req.on("data", (chunk) => { + if (waiting) wake({ value: chunk, done: false }); + else chunkQueue.push(chunk); + }); + req.on("end", () => { + ended = true; + wake({ value: undefined, done: true }); + }); + + if (signal) { + const onAbort = () => { + fail(new Error("Request aborted")); + close(); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + + const responseHeaders = new Promise((resolve, reject) => { + const onEarlyError = (error) => reject(error); + client.once("error", onEarlyError); + req.once("error", onEarlyError); + req.once("response", (hdrs) => { + client.off("error", onEarlyError); + req.off("error", onEarlyError); + resolve(hdrs); + }); + }); + + return { + responseHeaders, + write(frame) { + if (req && !req.destroyed) req.write(Buffer.from(frame)); + }, + end() { + try { if (req && !req.destroyed) req.end(); } catch {} + }, + close, + async read() { + if (chunkQueue.length) return { value: chunkQueue.shift(), done: false }; + if (ended) { + if (streamError) throw streamError; + return { value: undefined, done: true }; + } + const result = await new Promise((resolve) => { waiting = resolve; }); + if (streamError) throw streamError; + return result || { value: undefined, done: true }; + }, + }; + } + + async executeAgent({ model, body, stream, credentials, signal }) { + const agentEndpoint = PROVIDER_OAUTH.cursor?.agentEndpoint; + if (!agentEndpoint) throw new Error("Cursor AgentService endpoint is not configured"); + + const url = `${agentEndpoint}${AGENT_RUN_PATH}`; + const headers = this.buildHeaders(credentials); + const requestController = new AbortController(); + if (signal?.addEventListener) { + signal.addEventListener("abort", () => requestController.abort(signal.reason), { once: true }); + } + + let session; + try { + session = this.openAgentHttp2Stream(url, headers, requestController.signal); + session.write(buildAgentRunFrame(body.messages || [], model)); + } catch (error) { + throw new Error(`Cursor AgentService request failed: ${error.message}`); + } + + let responseHeaders; + try { + responseHeaders = await session.responseHeaders; + } catch (error) { + session.close(); + throw new Error(`Cursor AgentService request failed: ${error.message}`); + } + + const status = Number(responseHeaders[":status"] || 0); + if (status !== 200) { + let errorText = ""; + try { + while (true) { + const { done, value } = await session.read(); + if (done) break; + errorText += Buffer.from(value).toString("utf8"); + } + } catch {} + session.close(); + return { + response: new Response(JSON.stringify({ + error: { message: `Cursor AgentService ${status}: ${errorText || "request failed"}`, type: "api_error" }, + }), { status: status || HTTP_STATUS.SERVER_ERROR, headers: { "Content-Type": "application/json" } }), + url, + headers, + transformedBody: body, + responseFormat: FORMATS.OPENAI, + }; + } + + // The Claude SSE translator derives Anthropic's message ID by stripping + // `chatcmpl-`. Keep the remaining ID in Anthropic's required `msg_` form + // so strict clients such as Claude Code accept the completed stream. + const responseId = `chatcmpl-msg_${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + let pending = Buffer.alloc(0); + let finished = false; + + const consume = async (onEvent) => { + try { + while (!finished) { + const { done, value } = await session.read(); + if (done) break; + pending = Buffer.concat([pending, Buffer.from(value)]); + pending = decodeAgentFrames(pending, (payload) => { + const serverMessage = decodeMessage(payload); + + // agent.v1.AgentServerMessage.interaction_update + if (serverMessage.has(1)) { + const update = decodeMessage(serverMessage.get(1)[0].value); + if (update.has(1)) { + const textDelta = extractAgentString(decodeMessage(update.get(1)[0].value), 1); + if (textDelta) onEvent({ type: "text", value: textDelta }); + } + // Cursor's AgentService emits internal reasoning without the + // cryptographic signature required by Anthropic thinking blocks. + // Forwarding it makes strict Anthropic clients (Claude Code) + // discard or wait on an otherwise complete response. Keep the + // reasoning upstream-only and emit the normal answer text. + if (update.has(14)) { + finished = true; + onEvent({ type: "done" }); + } + } + + // AgentService requests IDE context before producing a response. + // Return an empty context; 9router is not coupled to an editor. + if (serverMessage.has(2)) { + const execRequest = decodeMessage(serverMessage.get(2)[0].value); + if (execRequest.has(10)) { + session.write(createRequestContextResponse()); + } else { + finished = true; + onEvent({ type: "error", value: "Cursor AgentService requested an unsupported IDE tool" }); + onEvent({ type: "done" }); + } + } + }); + } + } finally { + try { session.end(); } catch {} + try { session.close(); } catch {} + if (!finished) onEvent({ type: "done" }); + } + }; + + if (stream === false) { + let content = ""; + let reasoning = ""; + let agentError = null; + await consume((event) => { + if (event.type === "text") content += event.value; + else if (event.type === "thinking") reasoning += event.value; + else if (event.type === "error") agentError = event.value; + }); + if (agentError) { + return { + response: new Response(JSON.stringify({ error: { message: agentError, type: "api_error" } }), { + status: HTTP_STATUS.BAD_REQUEST, + headers: { "Content-Type": "application/json" }, + }), + url, + headers, + transformedBody: body, + responseFormat: FORMATS.OPENAI, + }; + } + return { + response: new Response(JSON.stringify({ + id: responseId, + object: "chat.completion", + created, + model, + choices: [{ index: 0, message: { role: "assistant", content: content || null, ...(reasoning ? { reasoning_content: reasoning } : {}) }, finish_reason: "stop" }], + usage: estimateUsage(body, content.length, FORMATS.OPENAI), + }), { headers: { "Content-Type": "application/json" } }), + url, + headers, + transformedBody: body, + responseFormat: FORMATS.OPENAI, + }; + } + + const encoder = new TextEncoder(); + const responseStream = new ReadableStream({ + start(controller) { + consume((event) => { + if (event.type === "text") { + controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { content: event.value } }))); + } else if (event.type === "thinking") { + controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { reasoning_content: event.value } }))); + } else if (event.type === "error") { + controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { content: `\n[${event.value}]` } }))); + } else if (event.type === "done") { + controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: {}, finishReason: "stop" }))); + controller.enqueue(encoder.encode(SSE_DONE)); + controller.close(); + } + }).catch((error) => controller.error(error)); + }, + cancel() { + requestController.abort(); + }, + }); + + return { + response: new Response(responseStream, { headers: SSE_HEADERS }), + url, + headers, + transformedBody: body, + responseFormat: FORMATS.OPENAI, + }; + } + async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + if (isAgentTextRequest(body)) { + try { + return await this.executeAgent({ model, body, stream, credentials, signal }); + } catch (error) { + return { + response: new Response(JSON.stringify({ + error: { message: error.message, type: "connection_error", code: "" }, + }), { status: HTTP_STATUS.SERVER_ERROR, headers: { "Content-Type": "application/json" } }), + url: `${PROVIDER_OAUTH.cursor?.agentEndpoint || ""}${AGENT_RUN_PATH}`, + headers: {}, + transformedBody: body, + }; + } + } + const url = this.buildUrl(); const headers = this.buildHeaders(credentials); const transformedBody = this.transformRequest(model, body, stream, credentials); diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index 96a230b3..48be8350 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -38,7 +38,8 @@ function applyAuth(headers, desc, credentials) { // Provider-specific header quirks kept as small hooks (not pure auth). const HEADER_HOOKS = { - kimiHeaders: (h) => Object.assign(h, buildKimiHeaders()), + // Stable device_id from OAuth connection (CLIProxyAPI KimiTokenStorage.DeviceID) + kimiHeaders: (h, c) => Object.assign(h, buildKimiHeaders(c?.providerSpecificData?.deviceId)), clineHeaders: (h, c) => Object.assign(h, buildClineHeaders(c.apiKey || c.accessToken)), kilocodeOrg: (h, c) => { if (c.providerSpecificData?.orgId) h["X-Kilocode-OrganizationID"] = c.providerSpecificData.orgId; }, claudeOverlay: (h) => { @@ -227,7 +228,8 @@ export class DefaultExecutor extends BaseExecutor { kiro: () => this.refreshKiro(credentials.refreshToken, proxyOptions), cline: () => this.refreshCline(credentials.refreshToken, proxyOptions), clinepass: () => this.refreshCline(credentials.refreshToken, proxyOptions), - "kimi-coding": () => this.refreshKimiCoding(credentials.refreshToken, proxyOptions), + kimi: () => this.refreshKimi(credentials, proxyOptions), + "kimi-coding": () => this.refreshKimi(credentials, proxyOptions), kilocode: () => this.refreshKilocode(credentials.refreshToken, proxyOptions) }; @@ -307,16 +309,20 @@ export class DefaultExecutor extends BaseExecutor { return { accessToken, refreshToken: data?.refreshToken || refreshToken, expiresIn }; } - async refreshKimiCoding(refreshToken, proxyOptions = null) { - const kimiHeaders = buildKimiHeaders(); - const response = await proxyAwareFetch(PROVIDERS["kimi-coding"].refreshUrl, { + // CLIProxyAPI DeviceFlowClient.RefreshToken — form body + X-Msh-* headers + stable device_id + async refreshKimi(credentials, proxyOptions = null) { + const refreshToken = credentials.refreshToken; + const cfg = PROVIDERS.kimi || PROVIDERS["kimi-coding"]; + if (!cfg?.refreshUrl || !cfg?.clientId) return null; + const kimiHeaders = buildKimiHeaders(credentials?.providerSpecificData?.deviceId); + const response = await proxyAwareFetch(cfg.refreshUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", ...kimiHeaders }, - body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: PROVIDERS["kimi-coding"].clientId }) + body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: cfg.clientId }) }, proxyOptions); if (!response.ok) return null; const tokens = await response.json(); diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index 556e0c72..90ec713e 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -5,6 +5,207 @@ import { v4 as uuidv4 } from "uuid"; import { refreshKiroToken } from "../services/tokenRefresh.js"; import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js"; import { getCapabilitiesForModel } from "../providers/capabilities.js"; +import { STREAM_FIRST_CHUNK_TIMEOUT_MS } from "../config/runtimeConfig.js"; + +const KIRO_REPAIR_BUFFER_MAX_BYTES = 8 * 1024 * 1024; +const KIRO_REPAIR_HEARTBEAT_MS = 10_000; +const KIRO_SHORT_FINAL_MAX_CHARS = 800; +const EVENTSTREAM_MAX_MESSAGE_BYTES = 24 * 1024 * 1024; +const EVENTSTREAM_MAX_HEADERS_BYTES = 128 * 1024; +const KIRO_EVENT_TYPES = new Set([ + "assistantResponseEvent", + "reasoningContentEvent", + "codeEvent", + "toolUseEvent", + "messageStopEvent", + "metadataEvent", + "MetadataEvent", + "contextUsageEvent", + "meteringEvent", + "metricsEvent" +]); +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const CRC32_TABLE = Uint32Array.from({ length: 256 }, (_, index) => { + let value = index; + for (let bit = 0; bit < 8; bit++) { + value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0); + } + return value >>> 0; +}); + +const REPAIR_INSTRUCTIONS = Object.freeze({ + tool: "Retry the previous response because its Kiro tool_call wrapper was malformed. If you use the wrapper tool named tool_call, its input must contain a non-empty name and an arguments field.", + ellipsis: "Retry the previous response because it ended with only an ellipsis. Return the complete final answer, not only ... or ….", + short_final: "Retry the previous response because its final only announced a future action. Complete the check now and return the result or a concrete blocker." +}); +const SHORT_FUTURE_ACTION = /^(?:(?:(?:現在|接著|接下來|下一步)[,,::\s]*(?:我(?:只)?(?:會|要|將|再)?\s*)?|我只再)(?:補|查|確認|驗證|追(?:查|蹤)?|繼續|檢查|測試)|我(?:會|要|將)(?:再|重新)?(?:補(?:齊|查)?|抓取|查(?:詢)?|確認|驗證|追(?:查|蹤)?|繼續|檢查|測試)|(?:(?:next|now|then)\b[\s,:-]*)?(?:i(?:'ll| will| am going to| need to)|let me)\s+(?:verify|check|confirm|validate|investigate|trace|continue|follow up|test)\b)/iu; +// Keep this tied to the observed whole-response signature. Broader Chinese +// result/progress heuristics create false positives for completed findings. +const OBSERVED_TRAILING_FUTURE_ACTION = /^目前證據顯示[\s\S]{1,700}[。.!?;;]\s*最後補查\s+504\s+access\s+log[,,]\s*確認\s+host[//]路徑與是否為集中流量[。.!]?$/iu; +const ENGLISH_FUTURE_ACTION = /^(?:(?:next|now|then)\b[\s,:-]*)?(?:i(?:'ll| will| am going to| need to)|let me)\s+(?:verify|check|confirm|validate|investigate|trace|continue|follow up|test)\b/iu; +const ENGLISH_RESULT_CLAUSE = /(?:[:;\n]|[.!?]\s+\S|\b(?:status|checksum|response|deployment)\s+(?:is|are|was|were|matches?|equals?|returned)\b)/iu; +const CHINESE_FUTURE_ACTION = /^(?:(?:現在|接著|接下來|下一步)[,,::\s]*(?:我(?:只)?(?:會|要|將|再)?\s*)?|我只再|我(?:會|要|將)(?:再|重新)?)(?:補|抓取|查|確認|驗證|追|繼續|檢查|測試)/u; +const CHINESE_RESULT_CLAUSE = /(?:[。!?]\s*\S|(?:版本|狀態|回應|結果|部署|校驗碼)(?:是|為|等於|顯示))/u; +const USER_WAIT = /(?:請(?:你|先)|你(?:先|需要|可以|提供|確認|批准|允許)|等待(?:你|使用者)|等你|核准|同意|授權|\b(?:after|when|once)\s+you\b|\byour\s+(?:approval|confirmation|permission|input)\b|\bwait(?:ing)?\s+for\s+you\b|\bplease\s+(?:approve|confirm|provide|send)\b)/iu; +const COMPLETED_FINAL = /(?:已(?:經)?完成|完成(?:了|驗證|確認)|修復完成|確認無誤|驗證(?:完成|通過)|測試(?:均)?通過|結論|總結|\b(?:done|completed|fixed|verified|confirmed|passed|in conclusion|summary)\b|\b(?:is|are) complete\b)/iu; +const RESULT_EVIDENCE = /(?:顯示|發現|因此|成功|失敗|正常|無錯誤|沒有錯誤|\b(?:found|shows?|showed|because|therefore|succeeded|failed|healthy|green|no errors?)\b)/iu; + +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function envPositiveInt(name, fallback) { + const parsed = Number.parseInt(process.env?.[name] || "", 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function concatChunks(chunks, totalBytes) { + const output = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +function makeAbortError(reason) { + const error = new Error(reason?.message || reason || "Request aborted"); + error.name = "AbortError"; + return error; +} + +async function readWithTimeout(reader, signal, timeoutMs, message) { + if (signal?.aborted) throw makeAbortError(signal.reason); + let timeout; + let abortHandler; + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + const abortPromise = new Promise((_, reject) => { + abortHandler = () => reject(makeAbortError(signal.reason)); + signal?.addEventListener("abort", abortHandler, { once: true }); + }); + try { + return await Promise.race([reader.read(), timeoutPromise, abortPromise]); + } finally { + clearTimeout(timeout); + signal?.removeEventListener?.("abort", abortHandler); + } +} + +async function readResponsePrefix(response, signal, maxBytes, timeoutMs) { + const reader = response?.body?.getReader?.(); + if (!reader) return ""; + const chunks = []; + let totalBytes = 0; + try { + while (totalBytes < maxBytes) { + const { done, value } = await readWithTimeout( + reader, + signal, + timeoutMs, + "Kiro retry error body stalled" + ); + if (done) break; + const remaining = maxBytes - totalBytes; + const chunk = value.byteLength > remaining ? value.slice(0, remaining) : value; + chunks.push(chunk); + totalBytes += chunk.byteLength; + if (value.byteLength > remaining) break; + } + } finally { + await reader.cancel("bounded Kiro retry error body").catch(() => {}); + } + return decoder.decode(concatChunks(chunks, totalBytes)); +} + +function appendRepairInstruction(body, kind) { + const repaired = structuredClone(body || {}); + const instruction = REPAIR_INSTRUCTIONS[kind] || "Retry the previous incomplete Kiro response."; + repaired.systemPrompt = repaired.systemPrompt + ? `${repaired.systemPrompt}\n\n${instruction}` + : instruction; + return repaired; +} + +function normalizeStopReason(value) { + const reason = String(value || "").trim().replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase().replace(/[\s-]+/g, "_"); + if (["endturn", "end_turn", "stop", "stop_sequence"].includes(reason)) return "end_turn"; + if (["tooluse", "tool_use", "tool_calls"].includes(reason)) return "tool_use"; + if (["maxtokens", "max_tokens", "max_output_tokens", "length"].includes(reason)) return "max_tokens"; + return reason || null; +} + +function stopDisposition(stopReason, hasToolCalls) { + if (["malformed_model_output", "invalid_model_output"].includes(stopReason)) return "retryable_protocol_failure"; + if (["cancelled", "pause_turn", "model_context_window_exceeded"].includes(stopReason)) return "terminal_incomplete"; + if (stopReason === "refusal" || /(?:content.*filter|guardrail|safety|policy|blocked)/u.test(stopReason)) return "terminal_refusal"; + if (stopReason === "max_tokens") return hasToolCalls ? "terminal_incomplete" : "length"; + if (stopReason && !["end_turn", "tool_use"].includes(stopReason)) return "unknown_failure"; + if (hasToolCalls || stopReason === "tool_use") return "tool_use"; + if (!stopReason || stopReason === "end_turn") return "complete"; + return "unknown_failure"; +} + +function mergeStopReason(current, incoming) { + if (!incoming) return current; + if (!current) return incoming; + const severity = (reason) => { + const disposition = stopDisposition(reason, false); + if (disposition === "terminal_refusal") return 6; + if (disposition === "terminal_incomplete") return 5; + if (disposition === "unknown_failure") return 4; + if (disposition === "retryable_protocol_failure") return 3; + if (disposition === "length") return 2; + return 1; + }; + return severity(incoming) > severity(current) ? incoming : current; +} + +function isEllipsisOnly(value) { + return ["...", "…"].includes(String(value || "").trim()); +} + +function isShortFutureAction(value) { + const text = String(value || "").trim().replaceAll("’", "'"); + if (OBSERVED_TRAILING_FUTURE_ACTION.test(text)) return true; + if (ENGLISH_FUTURE_ACTION.test(text) && ENGLISH_RESULT_CLAUSE.test(text)) return false; + if (CHINESE_FUTURE_ACTION.test(text) && CHINESE_RESULT_CLAUSE.test(text)) return false; + return text.length > 0 && text.length <= KIRO_SHORT_FINAL_MAX_CHARS && + SHORT_FUTURE_ACTION.test(text) && !USER_WAIT.test(text) && + !COMPLETED_FINAL.test(text) && !RESULT_EVIDENCE.test(text); +} + +function encodeSSEError(code, message, details) { + return encoder.encode(`data: ${JSON.stringify({ error: { + message, + type: "upstream_error", + code, + ...(details ? { details } : {}) + } })}\n\ndata: [DONE]\n\n`); +} + +function inspectSSEChunk(chunk, state) { + for (const line of decoder.decode(chunk).split("\n")) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6).trim(); + if (!data || data === "[DONE]") continue; + try { + const event = JSON.parse(data); + if (event.error) state.error = event.error; + for (const choice of event.choices || []) { + const delta = choice.delta || {}; + if (typeof delta.content === "string") state.content += delta.content; + if (typeof delta.reasoning_content === "string") state.reasoning += delta.reasoning_content; + if (delta.tool_calls?.length) state.hasToolCalls = true; + } + } catch { /* a malformed SSE line is diagnosed by the transformer */ } + } +} /** * KiroExecutor - Executor for Kiro AI (AWS CodeWhisperer) @@ -111,393 +312,776 @@ export class KiroExecutor extends BaseExecutor { */ async execute(args) { const result = await super.execute(args); - if (result?.response?.ok) { - result.response = this.transformEventStreamToSSE(result.response, args.model); - } + if (result?.response?.ok) this.attachIntegrityGate(result, args); return result; } - /** - * Transform AWS EventStream binary response to SSE text stream - * Using TransformStream instead of ReadableStream.pull() to avoid Workers timeout - */ - transformEventStreamToSSE(response, model) { - let buffer = new Uint8Array(0); - let chunkIndex = 0; + attachIntegrityGate(result, args) { + const abortController = new AbortController(); + const maxBytes = envPositiveInt("KIRO_TOOL_CALL_REPAIR_BUFFER_MAX_BYTES", KIRO_REPAIR_BUFFER_MAX_BYTES); + const legacyTimeout = envPositiveInt("KIRO_TOOL_CALL_REPAIR_TIMEOUT_MS", STREAM_FIRST_CHUNK_TIMEOUT_MS); + const ttftTimeoutMs = envPositiveInt("KIRO_TOOL_CALL_REPAIR_TTFT_TIMEOUT_MS", legacyTimeout); + const stallTimeoutMs = envPositiveInt("KIRO_TOOL_CALL_REPAIR_STALL_TIMEOUT_MS", legacyTimeout); + const repairEnabled = args.credentials?.providerSpecificData?.kiroToolCallRepair !== false && + process.env.KIRO_TOOL_CALL_REPAIR !== "false"; + const forwardAbort = () => abortController.abort(args.signal?.reason); + args.signal?.addEventListener("abort", forwardAbort, { once: true }); + let open = true; + let heartbeatTimer; + + const stream = new ReadableStream({ + start: async (controller) => { + const heartbeat = () => { + if (!open) return; + try { + controller.enqueue(encoder.encode(": kiro-validation\n\n")); + } catch { + open = false; + } + }; + heartbeat(); + heartbeatTimer = setInterval(heartbeat, KIRO_REPAIR_HEARTBEAT_MS); + + try { + const bytes = await this.runIntegrityRecovery(result.response, args, { + signal: abortController.signal, + maxBytes, + ttftTimeoutMs, + stallTimeoutMs, + repairEnabled + }); + if (abortController.signal.aborted) throw makeAbortError(abortController.signal.reason); + controller.enqueue(bytes); + controller.close(); + } catch (error) { + if (open && error.name === "AbortError") { + controller.error(error); + } else if (open && error.name !== "AbortError") { + controller.enqueue(encodeSSEError( + "kiro_integrity_gate_failed", + error.message || "Kiro integrity validation failed" + )); + controller.close(); + } + } finally { + open = false; + clearInterval(heartbeatTimer); + args.signal?.removeEventListener?.("abort", forwardAbort); + } + }, + cancel(reason) { + open = false; + clearInterval(heartbeatTimer); + abortController.abort(reason || "client cancelled"); + } + }); + + result.response = new Response(stream, { + status: result.response.status, + statusText: result.response.statusText, + headers: { ...SSE_HEADERS } + }); + } + + async runIntegrityRecovery(rawResponse, args, options) { + const first = await this.readRecoverableIntegrityAttempt( + rawResponse, + args.model, + options, + "initial" + ); + if (first.kind === "complete") return first.bytes; + if (first.kind === "terminal_stop" || first.kind === "upstream_error") { + return this.integrityFailureSSE(first); + } + if (first.kind === "invalid_tool" && !options.repairEnabled) { + return encodeSSEError("invalid_kiro_tool_call", first.message, first.diagnostics); + } + + const repairKind = ["ellipsis", "short_final", "invalid_tool"].includes(first.kind) + ? first.kind + : null; + const repairBody = repairKind + ? appendRepairInstruction(args.body, repairKind === "invalid_tool" ? "tool" : repairKind) + : structuredClone(args.body || {}); + + const retry = await BaseExecutor.prototype.execute.call(this, { + ...args, + body: repairBody, + signal: options.signal + }); + if (!retry?.response?.ok) { + let body = ""; + try { + body = await readResponsePrefix( + retry?.response, + options.signal, + Math.min(options.maxBytes, 4096), + options.stallTimeoutMs + ); + } catch (error) { + if (error.name === "AbortError") throw error; + } + return encodeSSEError( + "kiro_integrity_retry_upstream_error", + body || `Kiro integrity retry failed with HTTP ${retry?.response?.status || 502}`, + { status: retry?.response?.status || 502 } + ); + } + + const second = await this.readRecoverableIntegrityAttempt( + retry.response, + args.model, + options, + "retry" + ); + if (second.kind === "complete") return second.bytes; + if (second.kind === "terminal_stop" || second.kind === "upstream_error") { + return this.integrityFailureSSE(second); + } + const code = second.kind === "ellipsis" + ? "kiro_ellipsis_retry_failed" + : second.kind === "short_final" + ? "kiro_short_final_retry_failed" + : second.kind === "invalid_tool" + ? "kiro_tool_call_repair_retry_failed" + : "kiro_missing_terminal_retry_failed"; + return encodeSSEError( + code, + `Kiro integrity validation failed after one bounded retry: ${second.message || second.kind}`, + { attempts: [first.diagnostics, second.diagnostics].filter(Boolean) } + ); + } + + integrityFailureSSE(attempt) { + const disposition = attempt.diagnostics?.stop_disposition; + const code = attempt.diagnostics?.terminal_provenance === "integrity_buffer_exceeded" + ? "kiro_integrity_buffer_exceeded" + : attempt.kind === "upstream_error" + ? "kiro_upstream_eventstream_error" + : disposition === "terminal_refusal" + ? "kiro_terminal_refusal" + : disposition === "terminal_incomplete" + ? "kiro_terminal_incomplete" + : "kiro_unknown_stop_reason"; + return encodeSSEError(code, attempt.message || "Kiro stream ended with a terminal failure", attempt.diagnostics); + } + + async readRecoverableIntegrityAttempt(rawResponse, model, options, attempt) { + try { + return await this.readIntegrityAttempt(rawResponse, model, options, attempt); + } catch (error) { + if (error.name === "AbortError") throw error; + return { + kind: "missing_terminal", + message: error.message || "Kiro transport read failed", + diagnostics: { + attempt, + terminal_provenance: "transport_read_error", + transport_state: "upstream_error", + stop_reason: null, + stop_disposition: "terminal_incomplete", + response_state: "no_semantic_output", + event_counts: {}, + incomplete_frame_bytes: 0 + } + }; + } + } + + async readIntegrityAttempt(rawResponse, model, options, attempt) { + let diagnostics; + const transformed = this.transformEventStreamToSSE(rawResponse, model, { + maxToolBytes: Math.max(1, Math.floor(options.maxBytes / 2)), + onTerminalState: (value) => { + diagnostics = value; + } + }); + const reader = transformed.body.getReader(); + const chunks = []; + let totalBytes = 0; + let sawChunk = false; + const output = { content: "", reasoning: "", hasToolCalls: false, error: null }; + + try { + while (true) { + const timeoutMs = sawChunk ? options.stallTimeoutMs : options.ttftTimeoutMs; + const phase = sawChunk ? "stalled" : "timed out before first chunk"; + const { done, value } = await readWithTimeout( + reader, + options.signal, + timeoutMs, + `Kiro integrity validation ${phase}` + ); + if (done) break; + sawChunk = true; + totalBytes += value.byteLength; + if (totalBytes > options.maxBytes) { + await reader.cancel("kiro_integrity_buffer_exceeded").catch(() => {}); + return { + kind: "terminal_stop", + message: `Kiro integrity buffer exceeded ${options.maxBytes} bytes`, + diagnostics: { terminal_provenance: "integrity_buffer_exceeded" } + }; + } + chunks.push(value); + inspectSSEChunk(value, output); + } + } catch (error) { + await reader.cancel(error.message).catch(() => {}); + throw error; + } + + const safeDiagnostics = { + attempt, + terminal_provenance: diagnostics?.terminal_provenance || "missing_terminal_diagnostics", + transport_state: diagnostics?.transport_state || "unknown", + stop_reason: diagnostics?.stop_reason || null, + stop_disposition: diagnostics?.stop_disposition || "terminal_incomplete", + response_state: diagnostics?.response_state || "no_semantic_output", + event_counts: diagnostics?.event_counts || {}, + incomplete_frame_bytes: diagnostics?.incomplete_frame_bytes || 0 + }; + if (safeDiagnostics.stop_disposition === "retryable_protocol_failure") { + const kind = safeDiagnostics.terminal_provenance === "invalid_tool_call" + ? "invalid_tool" + : "retryable_stop"; + return { kind, message: output.error?.message, diagnostics: safeDiagnostics }; + } + if (safeDiagnostics.stop_disposition === "terminal_incomplete" || + safeDiagnostics.stop_disposition === "terminal_refusal" || + safeDiagnostics.stop_disposition === "unknown_failure") { + const kind = safeDiagnostics.terminal_provenance === "upstream_eventstream_error" + ? "upstream_error" + : safeDiagnostics.terminal_provenance === "integrity_buffer_exceeded" + ? "terminal_stop" + : ["metadata_stop_reason", "message_stop_event"].includes(safeDiagnostics.terminal_provenance) + ? "terminal_stop" + : "missing_terminal"; + return { kind, message: output.error?.message, diagnostics: safeDiagnostics }; + } + if (output.error) { + return { kind: "missing_terminal", message: output.error.message, diagnostics: safeDiagnostics }; + } + if (!output.hasToolCalls) { + if (isEllipsisOnly(output.content) || + (!output.content.trim() && isEllipsisOnly(output.reasoning))) { + return { kind: "ellipsis", diagnostics: safeDiagnostics }; + } + if (isShortFutureAction(output.content)) { + return { kind: "short_final", diagnostics: safeDiagnostics }; + } + } + return { kind: "complete", bytes: concatChunks(chunks, totalBytes), diagnostics: safeDiagnostics }; + } + + transformEventStreamToSSE(response, model, options = {}) { const responseId = `chatcmpl-${Date.now()}`; const created = Math.floor(Date.now() / 1000); const capabilityModel = resolveKiroModel(model).upstream; const contextWindow = getCapabilitiesForModel("kiro", capabilityModel).contextWindow || 200000; + const eventCounts = {}; const state = { - endDetected: false, - finishEmitted: false, + buffer: new Uint8Array(0), + chunkIndex: 0, + toolCounter: 0, + tools: new Map(), + bufferedToolBytes: 0, + hasText: false, + hasReasoning: false, + hasCode: false, hasToolCalls: false, - hasReasoningContent: false, - reasoningChunkCount: 0, - toolCallIndex: 0, - seenToolIds: new Map(), - inThinking: false + sawToolUse: false, + explicitStop: false, + stopReason: null, + terminalProvenance: null, + transportState: "consuming_response", + totalContentLength: 0, + contextUsagePercentage: 0, + hasContextUsage: false, + hasMetering: false, + usage: null, + inThinking: false, + toolValidationError: null, + validatedFrames: 0, + finished: false }; - const transformStream = new TransformStream({ - async transform(chunk, controller) { - // Track output so we can emit a keepalive if this frame yields no chunk. - const enqueueCountBefore = chunkIndex; - // Append to buffer - const newBuffer = new Uint8Array(buffer.length + chunk.length); - newBuffer.set(buffer); - newBuffer.set(chunk, buffer.length); - buffer = newBuffer; + const diagnostics = (overrides = {}) => ({ + terminal_provenance: state.terminalProvenance || "clean_eventstream_eof", + transport_state: state.transportState, + stop_reason: state.stopReason, + stop_disposition: stopDisposition(state.stopReason, state.hasToolCalls), + response_state: state.hasToolCalls + ? "valid_tool" + : state.hasText || state.hasReasoning || state.hasCode + ? "text_reasoning" + : state.explicitStop + ? "explicit_stop" + : "no_semantic_output", + event_counts: { ...eventCounts }, + incomplete_frame_bytes: state.buffer.byteLength, + ...overrides + }); + const sseChunk = (delta, finishReason = null, usage) => encoder.encode(`data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta, finish_reason: finishReason }], + ...(usage ? { usage } : {}) + })}\n\n`); + const emitDelta = (controller, delta) => { + if (state.chunkIndex === 0) delta = { role: "assistant", ...delta }; + state.chunkIndex++; + controller.enqueue(sseChunk(delta)); + }; + const fail = (controller, provenance, code, message, extra = {}) => { + state.finished = true; + state.terminalProvenance = provenance; + state.transportState = extra.transport_state || "corrupt_frame"; + const detail = diagnostics({ + stop_disposition: extra.stop_disposition || "terminal_incomplete", + ...extra + }); + options.onTerminalState?.(detail); + controller.enqueue(encodeSSEError(code, message, detail)); + }; + const assertToolBufferBound = () => { + if (state.bufferedToolBytes <= (options.maxToolBytes || KIRO_REPAIR_BUFFER_MAX_BYTES / 2)) return; + const error = new Error("Kiro buffered tool input exceeded the integrity memory bound"); + error.code = "KIRO_BUFFER_EXCEEDED"; + throw error; + }; + const appendToolInput = (tool, input) => { + if (input === undefined) return; + if (typeof input === "string") { + if (tool.inputKind && tool.inputKind !== "string") throw new Error("Kiro tool input changed fragment type"); + tool.inputKind = "string"; + tool.inputChunks ||= []; + tool.inputChunks.push(input); + state.bufferedToolBytes += encoder.encode(input).byteLength; + } else if (input && typeof input === "object" && !Array.isArray(input)) { + if (tool.inputKind && tool.inputKind !== "object") throw new Error("Kiro tool input changed fragment type"); + tool.inputKind = "object"; + state.bufferedToolBytes -= tool.inputBytes || 0; + tool.inputObject = input; + tool.inputBytes = encoder.encode(JSON.stringify(input)).byteLength; + state.bufferedToolBytes += tool.inputBytes; + } else { + throw new Error("Kiro tool input must be a JSON object"); + } + assertToolBufferBound(); + }; + const parsedToolInput = (tool) => { + if (!tool.inputKind) throw new Error("Kiro tool call is missing input"); + if (tool.inputKind === "object") return tool.inputObject; + try { + const input = JSON.parse(tool.inputChunks.join("")); + if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("not an object"); + return input; + } catch (error) { + throw new Error(`Kiro tool input must be valid object JSON (${error.message})`); + } + }; + const emitTools = (controller) => { + for (const tool of state.tools.values()) { + const input = parsedToolInput(tool); + if (tool.name === "tool_call") { + if (typeof input.name !== "string" || !input.name.trim()) { + throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool name"); + } + if (!Object.prototype.hasOwnProperty.call(input, "arguments")) { + throw new Error("Invalid Kiro tool_call payload: missing nested MCP tool arguments"); + } + } + const index = state.toolCounter++; + emitDelta(controller, { + tool_calls: [{ + index, + id: tool.id, + type: "function", + function: { name: tool.name, arguments: "" } + }] + }); + emitDelta(controller, { + tool_calls: [{ index, function: { arguments: JSON.stringify(input) } }] + }); + state.hasToolCalls = true; + } + state.tools.clear(); + state.bufferedToolBytes = 0; + if (state.stopReason === "tool_use" && !state.hasToolCalls) { + throw new Error("Kiro tool_use stop reason did not include a complete tool call"); + } + }; + const processEvent = (event, controller) => { + const messageType = event.headers[":message-type"]; + if (messageType === "error" || messageType === "exception") { + fail( + controller, + "upstream_eventstream_error", + "kiro_upstream_eventstream_error", + event.payload?.message || `Kiro upstream sent an EventStream ${messageType}`, + { transport_state: "upstream_error" } + ); + return false; + } - // Parse events from buffer - let iterations = 0; - const maxIterations = 1000; - while (buffer.length >= 16 && iterations < maxIterations) { - iterations++; - const view = new DataView(buffer.buffer, buffer.byteOffset); - const totalLength = view.getUint32(0, false); - - if (totalLength < 16 || totalLength > buffer.length || buffer.length < totalLength) break; - - const eventData = buffer.slice(0, totalLength); - buffer = buffer.slice(totalLength); - - const event = parseEventFrame(eventData); - if (!event) continue; - - const eventType = event.headers[":event-type"] || ""; - - // Track total content length for token estimation - if (!state.totalContentLength) state.totalContentLength = 0; - if (!state.contextUsagePercentage) state.contextUsagePercentage = 0; - - // Handle assistantResponseEvent - if (eventType === "assistantResponseEvent" && event.payload?.content) { - let content = event.payload.content; - - // Kiro Claude models can leak blocks into the content stream. - // We strip these literal tags to prevent duplication, as the reasoning - // is already routed correctly via reasoningContentEvent. - if (state.inThinking) { - if (content.includes("")) { - state.inThinking = false; - const after = content.split("").slice(1).join(""); - content = after.startsWith("\n") ? after.substring(1) : after; - } else { - content = ""; // Drop entirely while inside thinking block - } - } else if (content.includes("")) { + const eventType = event.headers[":event-type"] || ""; + const eventCountKey = KIRO_EVENT_TYPES.has(eventType) ? eventType : "other"; + eventCounts[eventCountKey] = (eventCounts[eventCountKey] || 0) + 1; + if (eventType === "assistantResponseEvent" && typeof event.payload?.content === "string") { + let content = event.payload.content; + if (state.inThinking) { + const end = content.indexOf(""); + if (end < 0) content = ""; + else { + state.inThinking = false; + content = content.slice(end + 11).replace(/^\n/u, ""); + } + } else { + const start = content.indexOf(""); + if (start >= 0) { + const end = content.indexOf("", start + 10); + if (end < 0) { state.inThinking = true; - if (content.includes("")) { - state.inThinking = false; - const before = content.split("")[0]; - const after = content.split("").slice(1).join(""); - content = before + (after.startsWith("\n") ? after.substring(1) : after); - } else { - content = content.split("")[0]; - } + content = content.slice(0, start); + } else { + content = content.slice(0, start) + content.slice(end + 11).replace(/^\n/u, ""); } - - if (!content && state.hasReasoningContent) { - // If we stripped everything, skip emitting an empty content chunk - continue; - } - - state.totalContentLength += content.length; - - const chunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: chunkIndex === 0 - ? { role: "assistant", content } - : { content }, - finish_reason: null - }] - }; - chunkIndex++; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)); - } - - // Handle reasoningContentEvent (Kiro thinking / reasoning) - // Kiro returns reasoning as a separate event when the request system - // prompt contains enabled. Surface it - // as OpenAI delta.reasoning_content so downstream translators can map - // it back to Claude thinking blocks / Anthropic reasoning, etc. - if (eventType === "reasoningContentEvent") { - const reasoning = event.payload?.reasoningContentEvent || event.payload || {}; - const reasoningText = (typeof reasoning === "string") - ? reasoning - : (reasoning.text || reasoning.content || ""); - if (reasoningText) { - state.hasReasoningContent = true; - state.totalContentLength += reasoningText.length; - - const reasoningDelta = state.reasoningChunkCount === 0 && chunkIndex === 0 - ? { role: "assistant", reasoning_content: reasoningText } - : { reasoning_content: reasoningText }; - - const chunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: reasoningDelta, - finish_reason: null - }] - }; - chunkIndex++; - state.reasoningChunkCount++; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)); - } - } - - // Handle codeEvent - if (eventType === "codeEvent" && event.payload?.content) { - const chunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: { content: event.payload.content }, - finish_reason: null - }] - }; - chunkIndex++; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)); - } - - // Handle toolUseEvent - if (eventType === "toolUseEvent" && event.payload) { - state.hasToolCalls = true; - const toolUse = event.payload; - const toolUses = Array.isArray(toolUse) ? toolUse : [toolUse]; - - for (const singleToolUse of toolUses) { - const toolCallId = singleToolUse.toolUseId || `call_${Date.now()}`; - const toolName = singleToolUse.name || ""; - const toolInput = singleToolUse.input; - - let toolIndex; - const isNewTool = !state.seenToolIds.has(toolCallId); - - if (isNewTool) { - toolIndex = state.toolCallIndex++; - state.seenToolIds.set(toolCallId, toolIndex); - - const startChunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: { - ...(chunkIndex === 0 ? { role: "assistant" } : {}), - tool_calls: [{ - index: toolIndex, - id: toolCallId, - type: "function", - function: { - name: toolName, - arguments: "" - } - }] - }, - finish_reason: null - }] - }; - chunkIndex++; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(startChunk)}\n\n`)); - } else { - toolIndex = state.seenToolIds.get(toolCallId); - } - - if (toolInput !== undefined) { - let argumentsStr; - - if (typeof toolInput === 'string') { - argumentsStr = toolInput; - } else if (typeof toolInput === 'object') { - argumentsStr = JSON.stringify(toolInput); - } else { - continue; - } - - const argsChunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: { - tool_calls: [{ - index: toolIndex, - function: { - arguments: argumentsStr - } - }] - }, - finish_reason: null - }] - }; - chunkIndex++; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(argsChunk)}\n\n`)); - } - } - } - - // Handle messageStopEvent - if (eventType === "messageStopEvent") { - const chunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: {}, - finish_reason: state.hasToolCalls ? "tool_calls" : "stop" - }] - }; - state.finishEmitted = true; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)); - } - - // Handle contextUsageEvent to extract contextUsagePercentage - if (eventType === "contextUsageEvent" && event.payload?.contextUsagePercentage) { - state.contextUsagePercentage = event.payload.contextUsagePercentage; - // Mark that we received context usage event - state.hasContextUsage = true; - } - - // Handle meteringEvent - mark that we received it - if (eventType === "meteringEvent") { - state.hasMeteringEvent = true; - } - - // Handle metricsEvent for token usage - if (eventType === "metricsEvent") { - // Extract usage data from metricsEvent payload - const metrics = event.payload?.metricsEvent || event.payload; - if (metrics && typeof metrics === 'object') { - const inputTokens = metrics.inputTokens || 0; - const outputTokens = metrics.outputTokens || 0; - // ponytail: Amazon Q upstream does not expose cache fields today, - // but pick up cache_read_input_tokens / cache_creation_input_tokens - // if the event shape grows them so cost tracking stays accurate. - const cachedTokens = metrics.cacheReadInputTokens || metrics.cache_read_input_tokens || 0; - const cacheCreationInputTokens = metrics.cacheCreationInputTokens || metrics.cache_creation_input_tokens || 0; - - if (inputTokens > 0 || outputTokens > 0) { - state.usage = { - prompt_tokens: inputTokens, - completion_tokens: outputTokens, - total_tokens: inputTokens + outputTokens - }; - // Kiro is Claude-backed: inputTokens EXCLUDES cache (Claude convention), - // not inclusive like OpenAI's cached_tokens. Emit cache_read_input_tokens - // (not cached_tokens) so canonicalizeUsage takes the Claude fold path and - // correctly adds cache back into prompt_tokens instead of undercharging. - if (cachedTokens > 0) state.usage.cache_read_input_tokens = cachedTokens; - if (cacheCreationInputTokens > 0) state.usage.cache_creation_input_tokens = cacheCreationInputTokens; - } - } - } - - // Emit final chunk only after receiving BOTH meteringEvent AND contextUsageEvent - if (state.hasMeteringEvent && state.hasContextUsage && !state.finishEmitted) { - state.finishEmitted = true; - - // Estimate tokens if not available from events - if (!state.usage) { - // Estimate output tokens from content length - const estimatedOutputTokens = state.totalContentLength > 0 - ? Math.max(1, Math.floor(state.totalContentLength / 4)) - : 0; - - // Estimate input tokens from contextUsagePercentage - const estimatedInputTokens = state.contextUsagePercentage > 0 - ? Math.floor(state.contextUsagePercentage * contextWindow / 100) - : 0; - - state.usage = { - prompt_tokens: estimatedInputTokens, - completion_tokens: estimatedOutputTokens, - total_tokens: estimatedInputTokens + estimatedOutputTokens - }; - } - - const finishChunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: {}, - finish_reason: state.hasToolCalls ? "tool_calls" : "stop" - }] - }; - - // Include usage in final chunk if available - if (state.usage) { - finishChunk.usage = state.usage; - } - - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`)); } } - - if (iterations >= maxIterations) { - console.warn("[Kiro] Max iterations reached in event parsing"); + if (content || !state.hasReasoning) { + state.hasText ||= content.length > 0; + state.totalContentLength += content.length; + emitDelta(controller, { content }); } - - // No client chunk produced this frame — emit an SSE comment keepalive - // so the stall watchdog sees upstream activity (ignored by parser/client). - if (chunkIndex === enqueueCountBefore && !state.finishEmitted) { - controller.enqueue(new TextEncoder().encode(": ka\n\n")); - } - }, - - flush(controller) { - // Emit finish chunk if not already sent - if (!state.finishEmitted) { - state.finishEmitted = true; - const finishChunk = { - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [{ - index: 0, - delta: {}, - finish_reason: state.hasToolCalls ? "tool_calls" : "stop" - }] + } else if (eventType === "reasoningContentEvent") { + const value = event.payload?.reasoningContentEvent || event.payload || {}; + const content = typeof value === "string" ? value : value.text || value.content || ""; + if (content) { + state.hasReasoning = true; + state.totalContentLength += content.length; + emitDelta(controller, { reasoning_content: content }); + } + } else if (eventType === "codeEvent" && typeof event.payload?.content === "string") { + state.hasCode = true; + state.totalContentLength += event.payload.content.length; + emitDelta(controller, { content: event.payload.content }); + } else if (eventType === "toolUseEvent") { + state.sawToolUse = true; + if (state.toolValidationError) return true; + const values = Array.isArray(event.payload) ? event.payload : [event.payload]; + if (!values[0]) throw new Error("Kiro toolUseEvent is empty"); + for (const value of values) { + const name = typeof value?.name === "string" ? value.name.trim() : ""; + if (!name) throw new Error("Kiro toolUseEvent is missing a tool name"); + let id; + if (value.toolUseId == null) { + id = `call_${created}_${state.tools.size + 1}`; + } else if (typeof value.toolUseId !== "string" || !value.toolUseId.trim()) { + throw new Error("Kiro toolUseEvent has an invalid toolUseId"); + } else { + id = value.toolUseId; + } + let tool = state.tools.get(id); + if (!tool) { + tool = { id, name }; + state.tools.set(id, tool); + state.bufferedToolBytes += encoder.encode(id).byteLength + encoder.encode(name).byteLength + 32; + assertToolBufferBound(); + } else if (tool.name !== name) { + throw new Error("Kiro tool name changed between fragments"); + } + appendToolInput(tool, value.input); + } + } else if (eventType === "messageStopEvent") { + state.explicitStop = true; + const reason = normalizeStopReason( + event.payload?.stopReason ?? event.payload?.stop_reason + ) || (state.sawToolUse ? "tool_use" : "end_turn"); + const merged = mergeStopReason(state.stopReason, reason); + if (merged !== state.stopReason) state.terminalProvenance = "message_stop_event"; + state.stopReason = merged; + } else if (eventType === "metadataEvent" || eventType === "MetadataEvent") { + const metadata = event.payload?.metadataEvent || event.payload?.metadata || event.payload; + const reason = normalizeStopReason(metadata?.stopReason ?? metadata?.stop_reason); + if (reason) { + state.explicitStop = true; + const merged = mergeStopReason(state.stopReason, reason); + if (merged !== state.stopReason) state.terminalProvenance = "metadata_stop_reason"; + state.stopReason = merged; + } + } else if (eventType === "contextUsageEvent") { + const percentage = Number(event.payload?.contextUsagePercentage); + if (Number.isFinite(percentage)) { + state.contextUsagePercentage = percentage; + state.hasContextUsage = true; + } + } else if (eventType === "meteringEvent") { + state.hasMetering = true; + const metering = event.payload?.meteringEvent || event.payload || {}; + const credits = Number(metering.usage); + if (Number.isFinite(credits)) { + state.usage = { + ...(state.usage || {}), + kiro_credits: credits, + kiro_credit_unit: typeof metering.unit === "string" ? metering.unit : "credit" }; - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`)); } + } else if (eventType === "metricsEvent") { + const metrics = event.payload?.metricsEvent || event.payload || {}; + const prompt = Number(metrics.inputTokens) || 0; + const completion = Number(metrics.outputTokens) || 0; + if (prompt || completion) { + state.usage = { + ...(state.usage || {}), + prompt_tokens: prompt, + completion_tokens: completion, + total_tokens: prompt + completion + }; + const cacheRead = Number(metrics.cacheReadInputTokens || metrics.cache_read_input_tokens) || 0; + const cacheCreate = Number(metrics.cacheCreationInputTokens || metrics.cache_creation_input_tokens) || 0; + if (cacheRead) state.usage.cache_read_input_tokens = cacheRead; + if (cacheCreate) state.usage.cache_creation_input_tokens = cacheCreate; + } + } + return true; + }; + const processBytes = (chunk, controller) => { + const combinedLength = state.buffer.byteLength + chunk.byteLength; + if (combinedLength > (options.maxRawBytes || EVENTSTREAM_MAX_MESSAGE_BYTES)) { + fail( + controller, + "corrupt_eventstream_frame", + "kiro_missing_terminal", + "Kiro EventStream buffered bytes exceed the protocol bound" + ); + return false; + } + if (state.buffer.byteLength === 0) { + state.buffer = chunk; + } else { + const joined = new Uint8Array(combinedLength); + joined.set(state.buffer); + joined.set(chunk, state.buffer.byteLength); + state.buffer = joined; + } - // Send final done message - controller.enqueue(new TextEncoder().encode(SSE_DONE)); + while (state.buffer.byteLength >= 12) { + const view = new DataView(state.buffer.buffer, state.buffer.byteOffset); + if (view.getUint32(8, false) !== crc32(state.buffer.subarray(0, 8))) { + fail(controller, "corrupt_eventstream_frame", "kiro_missing_terminal", "Kiro EventStream prelude CRC mismatch"); + return false; + } + const totalLength = view.getUint32(0, false); + const headersLength = view.getUint32(4, false); + if (totalLength < 16 || totalLength > EVENTSTREAM_MAX_MESSAGE_BYTES || + headersLength > EVENTSTREAM_MAX_HEADERS_BYTES || headersLength > totalLength - 16) { + fail(controller, "corrupt_eventstream_frame", "kiro_missing_terminal", "Kiro EventStream frame bounds are invalid"); + return false; + } + if (state.buffer.byteLength < totalLength) break; + const frame = state.buffer.slice(0, totalLength); + state.buffer = state.buffer.slice(totalLength); + let event; + try { + event = parseEventFrame(frame); + } catch (error) { + fail(controller, "corrupt_eventstream_frame", "kiro_missing_terminal", error.message); + return false; + } + state.transportState = "valid_complete_frame"; + state.validatedFrames++; + try { + if (!processEvent(event, controller)) return false; + } catch (error) { + const bufferExceeded = error.code === "KIRO_BUFFER_EXCEEDED"; + if (!bufferExceeded) { + state.toolValidationError ||= error.message; + state.tools.clear(); + state.bufferedToolBytes = 0; + continue; + } + fail( + controller, + "integrity_buffer_exceeded", + "kiro_integrity_buffer_exceeded", + error.message, + { + transport_state: state.transportState, + stop_disposition: "terminal_incomplete" + } + ); + return false; + } + } + return true; + }; + const finish = (controller) => { + if (state.finished) return; + if (state.buffer.byteLength) { + fail( + controller, + "incomplete_eventstream_frame", + "kiro_missing_terminal", + "Kiro EventStream ended with a truncated frame", + { transport_state: "incomplete_frame" } + ); + return; + } + state.transportState = "clean_eof"; + const declaredDisposition = stopDisposition(state.stopReason, state.sawToolUse); + if (["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(declaredDisposition)) { + const code = declaredDisposition === "retryable_protocol_failure" + ? "kiro_retryable_protocol_failure" + : declaredDisposition === "terminal_refusal" + ? "kiro_terminal_refusal" + : declaredDisposition === "terminal_incomplete" + ? "kiro_terminal_incomplete" + : "kiro_unknown_stop_reason"; + fail( + controller, + state.terminalProvenance || "metadata_stop_reason", + code, + `Kiro ended with non-success stop reason: ${state.stopReason}`, + { transport_state: state.transportState, stop_disposition: declaredDisposition } + ); + return; + } + if (state.toolValidationError) { + fail( + controller, + "invalid_tool_call", + "invalid_kiro_tool_call", + state.toolValidationError, + { transport_state: state.transportState, stop_disposition: "retryable_protocol_failure" } + ); + return; + } + try { + emitTools(controller); + } catch (error) { + fail( + controller, + "invalid_tool_call", + "invalid_kiro_tool_call", + error.message, + { transport_state: state.transportState, stop_disposition: "retryable_protocol_failure" } + ); + return; + } + + const hasOutput = state.hasText || state.hasReasoning || state.hasCode || state.hasToolCalls; + if (!hasOutput && !state.explicitStop) { + fail( + controller, + "empty_response_eof", + "kiro_missing_terminal", + "Kiro EventStream ended without model output", + { transport_state: state.transportState } + ); + return; + } + + const disposition = stopDisposition(state.stopReason, state.hasToolCalls); + if (["retryable_protocol_failure", "terminal_incomplete", "terminal_refusal", "unknown_failure"].includes(disposition)) { + const code = disposition === "retryable_protocol_failure" + ? "kiro_retryable_protocol_failure" + : disposition === "terminal_refusal" + ? "kiro_terminal_refusal" + : disposition === "terminal_incomplete" + ? "kiro_terminal_incomplete" + : "kiro_unknown_stop_reason"; + fail( + controller, + state.terminalProvenance || "metadata_stop_reason", + code, + `Kiro ended with non-success stop reason: ${state.stopReason}`, + { transport_state: state.transportState, stop_disposition: disposition } + ); + return; + } + + if (state.hasMetering && state.hasContextUsage && !state.usage?.total_tokens) { + const completion = state.totalContentLength + ? Math.max(1, Math.floor(state.totalContentLength / 4)) + : 0; + const prompt = Math.floor(state.contextUsagePercentage * contextWindow / 100); + state.usage = { + ...(state.usage || {}), + prompt_tokens: prompt, + completion_tokens: completion, + total_tokens: prompt + completion + }; + } + const finishReason = state.hasToolCalls + ? "tool_calls" + : disposition === "length" + ? "length" + : "stop"; + controller.enqueue(sseChunk({}, finishReason, state.usage)); + controller.enqueue(encoder.encode(SSE_DONE)); + state.finished = true; + options.onTerminalState?.(diagnostics({ + terminal_provenance: state.terminalProvenance || "clean_eventstream_eof", + transport_state: state.transportState, + stop_disposition: disposition + })); + }; + + if (!response.body) { + const detail = diagnostics({ + terminal_provenance: "missing_response_body", + transport_state: "missing_body", + stop_disposition: "terminal_incomplete" + }); + options.onTerminalState?.(detail); + return new Response(encodeSSEError( + "kiro_missing_terminal", + "Kiro response did not include an EventStream body", + detail + ), { status: response.status, headers: { ...SSE_HEADERS } }); + } + + const reader = response.body.getReader(); + const stream = new ReadableStream({ + start: async (controller) => { + try { + while (!state.finished) { + const { done, value } = await reader.read(); + if (done) break; + const chunksBefore = state.chunkIndex; + const framesBefore = state.validatedFrames; + if (!processBytes(value, controller)) { + await reader.cancel("invalid Kiro EventStream").catch(() => {}); + break; + } + if (state.validatedFrames > framesBefore && state.chunkIndex === chunksBefore) { + controller.enqueue(encoder.encode(": kiro-upstream\n\n")); + } + } + finish(controller); + controller.close(); + } catch (error) { + if (!state.finished) { + fail( + controller, + "upstream_read_error", + "kiro_missing_terminal", + error.message || "Kiro EventStream read failed", + { transport_state: "upstream_error" } + ); + } + controller.close(); + } + }, + cancel(reason) { + return reader.cancel(reason); } }); - - // Pipe response body through transform stream - if (!response.body) { - return new Response(SSE_DONE, { status: response.status, headers: { "Content-Type": "text/event-stream" } }); - } - const transformedStream = response.body.pipeThrough(transformStream); - - return new Response(transformedStream, { + return new Response(stream, { status: response.status, statusText: response.statusText, headers: { ...SSE_HEADERS } @@ -527,65 +1111,90 @@ export class KiroExecutor extends BaseExecutor { /** * Parse AWS EventStream frame */ + function parseEventFrame(data) { + if (!(data instanceof Uint8Array) || data.byteLength < 16) { + throw new Error("AWS EventStream frame is shorter than 16 bytes"); + } + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const totalLength = view.getUint32(0, false); + const headersLength = view.getUint32(4, false); + if (totalLength !== data.byteLength) { + throw new Error("AWS EventStream frame length does not match its prelude"); + } + if (totalLength > EVENTSTREAM_MAX_MESSAGE_BYTES || + headersLength > EVENTSTREAM_MAX_HEADERS_BYTES || + headersLength > totalLength - 16) { + throw new Error("AWS EventStream frame bounds are invalid"); + } + if (view.getUint32(8, false) !== crc32(data.subarray(0, 8))) { + throw new Error("AWS EventStream prelude CRC mismatch"); + } + if (view.getUint32(totalLength - 4, false) !== crc32(data.subarray(0, totalLength - 4))) { + throw new Error("AWS EventStream message CRC mismatch"); + } + + const headers = Object.create(null); + const names = new Set(); + let offset = 12; + const headerEnd = offset + headersLength; + const requireBytes = (count) => { + if (offset + count > headerEnd) { + throw new Error("AWS EventStream header exceeds its declared bounds"); + } + }; + + while (offset < headerEnd) { + requireBytes(1); + const nameLength = data[offset++]; + requireBytes(nameLength + 1); + const name = decoder.decode(data.subarray(offset, offset + nameLength)); + offset += nameLength; + if (names.has(name)) throw new Error(`AWS EventStream contains duplicate header: ${name}`); + names.add(name); + const type = data[offset++]; + + if (type === 0 || type === 1) { + headers[name] = type === 0; + } else if (type === 2) { + requireBytes(1); + headers[name] = view.getInt8(offset); + offset += 1; + } else if (type === 3) { + requireBytes(2); + headers[name] = view.getInt16(offset, false); + offset += 2; + } else if (type === 4) { + requireBytes(4); + headers[name] = view.getInt32(offset, false); + offset += 4; + } else if (type === 5 || type === 8) { + requireBytes(8); + offset += 8; + } else if (type === 6 || type === 7) { + requireBytes(2); + const valueLength = view.getUint16(offset, false); + offset += 2; + requireBytes(valueLength); + const bytes = data.subarray(offset, offset + valueLength); + headers[name] = type === 7 ? decoder.decode(bytes) : bytes; + offset += valueLength; + } else if (type === 9) { + requireBytes(16); + offset += 16; + } else { + throw new Error(`AWS EventStream header ${name} has unknown type ${type}`); + } + } + + const payloadBytes = data.subarray(headerEnd, totalLength - 4); + if (payloadBytes.byteLength === 0) return { headers, payload: null }; + const payloadText = decoder.decode(payloadBytes); + if (!payloadText.trim()) return { headers, payload: null }; try { - const view = new DataView(data.buffer, data.byteOffset); - const headersLength = view.getUint32(4, false); - - // Parse headers - const headers = {}; - let offset = 12; // After prelude - const headerEnd = 12 + headersLength; - - while (offset < headerEnd && offset < data.length) { - const nameLen = data[offset]; - offset++; - if (offset + nameLen > data.length) break; - - const name = new TextDecoder().decode(data.slice(offset, offset + nameLen)); - offset += nameLen; - - const headerType = data[offset]; - offset++; - - if (headerType === 7) { // String type - const valueLen = (data[offset] << 8) | data[offset + 1]; - offset += 2; - if (offset + valueLen > data.length) break; - - const value = new TextDecoder().decode(data.slice(offset, offset + valueLen)); - offset += valueLen; - headers[name] = value; - } else { - break; - } - } - - // Parse payload - const payloadStart = 12 + headersLength; - const payloadEnd = data.length - 4; // Exclude message CRC - - let payload = null; - if (payloadEnd > payloadStart) { - const payloadStr = new TextDecoder().decode(data.slice(payloadStart, payloadEnd)); - - // Skip empty or whitespace-only payloads - if (!payloadStr || !payloadStr.trim()) { - return { headers, payload: null }; - } - - try { - payload = JSON.parse(payloadStr); - } catch (parseError) { - // Log parse error for debugging - console.warn(`[Kiro] Failed to parse payload: ${parseError.message} | payload: ${payloadStr.substring(0, 100)}`); - payload = { raw: payloadStr }; - } - } - - return { headers, payload }; - } catch { - return null; + return { headers, payload: JSON.parse(payloadText) }; + } catch (error) { + throw new Error(`AWS EventStream payload is not valid JSON (${error.message})`); } } diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 47190acf..4e3475f4 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -291,12 +291,16 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Execute request let providerResponse, providerUrl, providerHeaders, finalBody; + // Most executors return their registry format. Cursor AgentService is an + // exception: it is decoded by the executor into OpenAI-compatible output. + let providerResponseFormat = targetFormat; try { const result = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions }); providerResponse = result.response; providerUrl = result.url; providerHeaders = result.headers; finalBody = result.transformedBody; + providerResponseFormat = result.responseFormat || targetFormat; reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); } catch (error) { trackPendingRequest(model, provider, connectionId, false, true); @@ -335,7 +339,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred } try { const retryResult = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions }); - if (retryResult.response.ok) { providerResponse = retryResult.response; providerUrl = retryResult.url; } + if (retryResult.response.ok) { + providerResponse = retryResult.response; + providerUrl = retryResult.url; + providerResponseFormat = retryResult.responseFormat || targetFormat; + } } catch { log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); } } else { log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`); @@ -382,14 +390,14 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // True non-streaming response if (!stream) { - const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, reqLogger, toolNameMap, trackDone, appendLog }); + const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, reqLogger, toolNameMap, trackDone, appendLog }); streamController.handleComplete(); return result; } // Streaming response const { onStreamComplete, streamDetailId } = buildOnStreamComplete({ ...sharedCtx }); - return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId }); + return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId }); } export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) { diff --git a/open-sse/handlers/chatCore/sseToJsonHandler.js b/open-sse/handlers/chatCore/sseToJsonHandler.js index f25986c5..9638824b 100644 --- a/open-sse/handlers/chatCore/sseToJsonHandler.js +++ b/open-sse/handlers/chatCore/sseToJsonHandler.js @@ -40,15 +40,21 @@ function pickAssistantMessageForChatCompletion(output) { */ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { const chunks = []; + let streamError = null; for (const line of String(rawSSE || "").split("\n")) { const trimmed = line.trim(); if (!trimmed.startsWith("data:")) continue; const payload = trimmed.slice(5).trim(); if (!payload || payload === "[DONE]") continue; - try { chunks.push(JSON.parse(payload)); } catch { /* ignore malformed lines */ } + try { + const chunk = JSON.parse(payload); + if (chunk?.error) streamError = chunk.error; + else chunks.push(chunk); + } catch { /* ignore malformed lines */ } } + if (streamError) return { error: streamError }; if (chunks.length === 0) return null; const first = chunks[0]; @@ -196,6 +202,12 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr const sseText = await providerResponse.text(); const parsed = parseSSEToOpenAIResponse(sseText, model); if (!parsed) return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request"); + if (parsed.error) { + return createErrorResult( + HTTP_STATUS.BAD_GATEWAY, + parsed.error.message || "Upstream SSE stream failed" + ); + } if (onRequestSuccess) await onRequestSuccess(); diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 6e54a683..5bd7f6d8 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -96,10 +96,23 @@ export const MODEL_CAPABILITIES = { // Qwen plain coder/text (no vision) — registry "vision-model" / "coder-model" aliases "vision-model": { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 }, "coder-model": { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 }, + + // Kimi flagship + coding (platform + Kimi Code ids) — vision/video native + "kimi-k3": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 131072 }, + "k3": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 131072 }, + "kimi-for-coding": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 }, + "kimi-for-coding-highspeed": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 }, + "kimi-k2.7-code": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 }, + "kimi-k2.7-code-highspeed": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 }, }; const KIRO_GPT_5_6_CAPABILITIES = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 }; +// Codex OAuth (ChatGPT backend) — per-model context window reported by upstream +// (lower than OpenAI API's 1.05M). Sol differs from Terra/Luna. #2720 +const CODEX_GPT_56_SOL_CAPS = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 372000, maxOutput: 128000 }; +const CODEX_GPT_56_DEFAULT_CAPS = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 }; + /** * Provider-specific capability overrides. Keyed by provider alias/id. */ @@ -113,6 +126,14 @@ 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 }, }, + "codex": { + "gpt-5.6-sol": CODEX_GPT_56_SOL_CAPS, + "gpt-5.6-sol-review": CODEX_GPT_56_SOL_CAPS, + "gpt-5.6-terra": CODEX_GPT_56_DEFAULT_CAPS, + "gpt-5.6-terra-review": CODEX_GPT_56_DEFAULT_CAPS, + "gpt-5.6-luna": CODEX_GPT_56_DEFAULT_CAPS, + "gpt-5.6-luna-review": CODEX_GPT_56_DEFAULT_CAPS, + }, "kiro": { "gpt-5.6-sol": KIRO_GPT_5_6_CAPABILITIES, "gpt-5.6-terra": KIRO_GPT_5_6_CAPABILITIES, @@ -222,7 +243,9 @@ export const PATTERN_CAPABILITIES = [ { pattern: "*qwen*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } }, // ── Kimi (enabled→reasoning_effort; K2.7-code cannot disable) ───── - { pattern: "*kimi*k2.7*code*", caps: { vision: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 262144 } }, + { pattern: "*kimi*k3*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 131072 } }, + { pattern: "*kimi*for-coding*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 } }, + { pattern: "*kimi*k2.7*code*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 } }, { pattern: "*kimi*k2*", caps: { vision: true, reasoning: true, thinkingFormat: "kimi", contextWindow: 262144, maxOutput: 262144 } }, { pattern: "*kimi*", caps: { reasoning: true, thinkingFormat: "kimi", contextWindow: 262144 } }, diff --git a/open-sse/providers/pricing.js b/open-sse/providers/pricing.js index 943365a1..c2831fdb 100644 --- a/open-sse/providers/pricing.js +++ b/open-sse/providers/pricing.js @@ -75,10 +75,18 @@ export const MODEL_PRICING = { "qwen3-coder-flash": { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 }, // === Kimi === + // Official platform.kimi.ai: cache-hit / cache-miss / output per 1M tokens + "kimi-k3": { input: 3.00, output: 15.00, cached: 0.30, reasoning: 15.00, cache_creation: 3.00 }, + "k3": { input: 3.00, output: 15.00, cached: 0.30, reasoning: 15.00, cache_creation: 3.00 }, + "kimi-k2.7-code": { input: 0.95, output: 4.00, cached: 0.19, reasoning: 4.00, cache_creation: 0.95 }, + "kimi-k2.7-code-highspeed": { input: 1.90, output: 8.00, cached: 0.38, reasoning: 8.00, cache_creation: 1.90 }, + "kimi-for-coding": { input: 0.95, output: 4.00, cached: 0.19, reasoning: 4.00, cache_creation: 0.95 }, + "kimi-for-coding-highspeed": { input: 1.90, output: 8.00, cached: 0.38, reasoning: 8.00, cache_creation: 1.90 }, "kimi-k2": { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 }, "kimi-k2-thinking": { input: 1.50, output: 6.00, cached: 0.75, reasoning: 9.00, cache_creation: 1.50 }, "kimi-k2.5": { input: 1.20, output: 4.80, cached: 0.60, reasoning: 7.20, cache_creation: 1.20 }, "kimi-k2.5-thinking": { input: 1.80, output: 7.20, cached: 0.90, reasoning: 10.80, cache_creation: 1.80 }, + "kimi-k2.6": { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 }, "kimi-latest": { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 }, // === DeepSeek === @@ -185,6 +193,7 @@ export const PATTERN_PRICING = [ // --- Kimi --- { pattern: "kimi-*-thinking", pricing: { input: 1.80, output: 7.20, cached: 0.90, reasoning: 10.80, cache_creation: 1.80 } }, + { pattern: "kimi-k3*", pricing: { input: 3.00, output: 15.00, cached: 0.30, reasoning: 15.00, cache_creation: 3.00 } }, { pattern: "kimi-k2*", pricing: { input: 1.20, output: 4.80, cached: 0.60, reasoning: 7.20, cache_creation: 1.20 } }, { pattern: "kimi-*", pricing: { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 } }, diff --git a/open-sse/providers/registry/alicode-intl.js b/open-sse/providers/registry/alicode-intl.js index b93d1d89..45d4f21d 100644 --- a/open-sse/providers/registry/alicode-intl.js +++ b/open-sse/providers/registry/alicode-intl.js @@ -3,18 +3,18 @@ export default { priority: 10, alias: "alicode-intl", display: { - name: "Alibaba Intl", + name: "Alibaba Coding", icon: "cloud", color: "#FF6A00", textIcon: "ALi", - website: "https://modelstudio.console.alibabacloud.com", + website: "https://www.alibabacloud.com/product/coding", notice: { - apiKeyUrl: "https://modelstudio.console.alibabacloud.com/?apiKey=1", + apiKeyUrl: "https://www.alibabacloud.com/product/coding", }, }, category: "apikey", transport: { - baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", + baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions", headers: {}, quirks: { preserveCacheControl: true }, }, diff --git a/open-sse/providers/registry/alims-intl.js b/open-sse/providers/registry/alims-intl.js new file mode 100644 index 00000000..66e045fc --- /dev/null +++ b/open-sse/providers/registry/alims-intl.js @@ -0,0 +1,32 @@ +// Model Studio Intl — standard DashScope API keys (sk-...), NOT Coding Plan keys. +// Sibling of alicode-intl (Coding Plan). Two key types use two different hosts. +export default { + id: "alims-intl", + priority: 11, + alias: "alims-intl", + display: { + name: "Alibaba Studio", + icon: "cloud", + color: "#FF6A00", + textIcon: "ALi", + website: "https://modelstudio.console.alibabacloud.com", + notice: { + apiKeyUrl: "https://modelstudio.console.alibabacloud.com/?apiKey=1", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", + headers: {}, + quirks: { preserveCacheControl: true }, + }, + models: [ + { id: "qwen3.5-plus", name: "Qwen3.5 Plus" }, + { id: "kimi-k2.5", name: "Kimi K2.5" }, + { id: "glm-5", name: "GLM 5" }, + { id: "MiniMax-M2.5", name: "MiniMax M2.5" }, + { id: "qwen3-coder-next", name: "Qwen3 Coder Next" }, + { id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" }, + { id: "glm-4.7", name: "GLM 4.7" }, + ], +}; diff --git a/open-sse/providers/registry/cursor.js b/open-sse/providers/registry/cursor.js index ca0ecdb1..b8529373 100644 --- a/open-sse/providers/registry/cursor.js +++ b/open-sse/providers/registry/cursor.js @@ -23,7 +23,7 @@ export default { "Content-Type": "application/connect+proto", "User-Agent": "connect-es/1.6.1", }, - clientVersion: "3.1.0", + clientVersion: "3.12.17", }, models: [ { id: "default", name: "Auto (Server Picks)" }, @@ -44,11 +44,11 @@ export default { oauth: { apiEndpoint: "https://api2.cursor.sh", chatEndpoint: "/aiserver.v1.ChatService/StreamUnifiedChatWithTools", - modelsEndpoint: "/aiserver.v1.AiService/GetDefaultModelNudgeData", + modelsEndpoint: "/agent.v1.AgentService/GetUsableModels", api3Endpoint: "https://api3.cursor.sh", agentEndpoint: "https://agent.api5.cursor.sh", agentNonPrivacyEndpoint: "https://agentn.api5.cursor.sh", - clientVersion: "3.1.0", + clientVersion: "3.12.17", clientType: "ide", dbKeys: { accessToken: "cursorAuth/accessToken", diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index 17c67b84..265dc741 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -52,53 +52,53 @@ import p49 from "./jina-ai.js"; import p50 from "./jina-reader.js"; import p51 from "./kilocode.js"; import p52 from "./kimchi.js"; -import p53 from "./kimi-coding.js"; -import p54 from "./kimi.js"; -import p55 from "./kiro.js"; -import p56 from "./linkup.js"; -import p57 from "./local-device.js"; -import p58 from "./mimo-free.js"; -import p59 from "./minimax-cn.js"; -import p60 from "./minimax.js"; -import p61 from "./mistral.js"; -import p62 from "./mmf.js"; -import p63 from "./nanobanana.js"; -import p64 from "./nebius.js"; -import p65 from "./nvidia.js"; -import p66 from "./ollama-local.js"; -import p67 from "./ollama.js"; -import p68 from "./openai.js"; -import p69 from "./opencode-go.js"; -import p70 from "./opencode.js"; -import p71 from "./openrouter.js"; -import p72 from "./perplexity-web.js"; -import p73 from "./perplexity.js"; -import p74 from "./perplexity-agent.js"; -import p75 from "./playht.js"; -import p76 from "./qoder.js"; -import p77 from "./qwen.js"; -import p78 from "./recraft.js"; -import p79 from "./runwayml.js"; -import p80 from "./sdwebui.js"; -import p81 from "./searchapi.js"; -import p82 from "./searxng.js"; -import p83 from "./serper.js"; -import p84 from "./siliconflow.js"; -import p85 from "./stability-ai.js"; -import p86 from "./tavily.js"; -import p87 from "./together.js"; -import p88 from "./topaz.js"; -import p89 from "./tortoise.js"; -import p90 from "./venice.js"; -import p91 from "./vercel-ai-gateway.js"; -import p92 from "./vertex-partner.js"; -import p93 from "./vertex.js"; -import p94 from "./volcengine-ark.js"; -import p95 from "./voyage-ai.js"; -import p96 from "./xai.js"; -import p97 from "./xiaomi-mimo.js"; -import p98 from "./xiaomi-tokenplan.js"; -import p99 from "./youcom.js"; +import p53 from "./kimi.js"; +import p54 from "./kiro.js"; +import p55 from "./linkup.js"; +import p56 from "./local-device.js"; +import p57 from "./mimo-free.js"; +import p58 from "./minimax-cn.js"; +import p59 from "./minimax.js"; +import p60 from "./mistral.js"; +import p61 from "./mmf.js"; +import p62 from "./nanobanana.js"; +import p63 from "./nebius.js"; +import p64 from "./nvidia.js"; +import p65 from "./ollama-local.js"; +import p66 from "./ollama.js"; +import p67 from "./openai.js"; +import p68 from "./opencode-go.js"; +import p69 from "./opencode.js"; +import p70 from "./openrouter.js"; +import p71 from "./perplexity-web.js"; +import p72 from "./perplexity.js"; +import p73 from "./perplexity-agent.js"; +import p74 from "./playht.js"; +import p75 from "./qoder.js"; +import p76 from "./qwen.js"; +import p77 from "./recraft.js"; +import p78 from "./runwayml.js"; +import p79 from "./sdwebui.js"; +import p80 from "./searchapi.js"; +import p81 from "./searxng.js"; +import p82 from "./serper.js"; +import p83 from "./siliconflow.js"; +import p84 from "./stability-ai.js"; +import p85 from "./tavily.js"; +import p86 from "./together.js"; +import p87 from "./topaz.js"; +import p88 from "./tortoise.js"; +import p89 from "./venice.js"; +import p90 from "./vercel-ai-gateway.js"; +import p91 from "./vertex-partner.js"; +import p92 from "./vertex.js"; +import p93 from "./volcengine-ark.js"; +import p94 from "./voyage-ai.js"; +import p95 from "./xai.js"; +import p96 from "./xiaomi-mimo.js"; +import p97 from "./xiaomi-tokenplan.js"; +import p98 from "./youcom.js"; +import p99 from "./alims-intl.js"; import p100 from "./orbit-provider.js"; export default [ @@ -202,5 +202,5 @@ export default [ p97, p98, p99, - p100 + p100, ]; diff --git a/open-sse/providers/registry/kimi-coding.js b/open-sse/providers/registry/kimi-coding.js deleted file mode 100644 index 15705a86..00000000 --- a/open-sse/providers/registry/kimi-coding.js +++ /dev/null @@ -1,65 +0,0 @@ -import { CLAUDE_API_HEADERS, KIMI_CODING_BASE_URL } from "../shared.js"; - -export default { - id: "kimi-coding", - hidden: true, - priority: 120, - alias: "kmc", - display: { - name: "Kimi Coding", - icon: "psychology", - color: "#1E40AF", - textIcon: "KC", - website: "https://kimi.moonshot.cn", - notice: { - signupUrl: "https://kimi.moonshot.cn", - }, - }, - category: "oauth", - transport: { - baseUrl: "https://api.kimi.com/coding/v1/messages", - format: "claude", - urlSuffix: "?beta=true", - headers: { ...CLAUDE_API_HEADERS }, - clientId: "17e5f671-d194-4dfb-9706-5516cb48c098", - tokenUrl: "https://auth.kimi.com/api/oauth/token", - refreshUrl: "https://auth.kimi.com/api/oauth/token", - auth: { - combined: true, - header: "x-api-key", - scheme: "raw", - hooks: [ - "kimiHeaders", - ], - }, - }, - // Multi-endpoint: pick the transport matching client sourceFormat to skip translation. - transports: [ - { - format: "openai", - baseUrl: "https://api.kimi.com/coding/v1/chat/completions", - auth: { combined: true, header: "Authorization", scheme: "bearer", hooks: ["kimiHeaders"] }, - }, - { - format: "claude", - baseUrl: "https://api.kimi.com/coding/v1/messages", - urlSuffix: "?beta=true", - headers: { ...CLAUDE_API_HEADERS }, - auth: { combined: true, header: "x-api-key", scheme: "raw", hooks: ["kimiHeaders"] }, - }, - ], - models: [ - { id: "kimi-k2.6", name: "Kimi K2.6" }, - { id: "kimi-k2.5", name: "Kimi K2.5" }, - { id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" }, - { id: "kimi-latest", name: "Kimi Latest" }, - ], - oauth: { - deviceCodeUrl: "https://auth.kimi.com/api/oauth/device_authorization", - tokenUrl: "https://auth.kimi.com/api/oauth/token", - refreshLeadMs: 300000, - }, - features: { - usage: true, - }, -}; diff --git a/open-sse/providers/registry/kimi.js b/open-sse/providers/registry/kimi.js index e22286b4..88bfde3e 100644 --- a/open-sse/providers/registry/kimi.js +++ b/open-sse/providers/registry/kimi.js @@ -1,9 +1,14 @@ -import { CLAUDE_API_HEADERS, KIMI_CODING_BASE_URL } from "../shared.js"; +import { CLAUDE_API_HEADERS } from "../shared.js"; +// Dual auth (same pattern as xai): OAuth = Kimi Code subscription (device code), +// API key = platform.moonshot / api.kimi.com. Transport is shared. +// CLIProxyAPI parity: client_id, auth.kimi.com device+token, X-Msh-* headers, device_id. export default { id: "kimi", priority: 170, alias: "kimi", + // Legacy id + short alias from former kimi-coding registry entry + aliases: ["kimi-coding", "kmc"], display: { name: "Kimi", icon: "psychology", @@ -12,18 +17,25 @@ export default { website: "https://kimi.moonshot.cn", notice: { apiKeyUrl: "https://platform.moonshot.ai/console/api-keys", + signupUrl: "https://www.kimi.com/code", }, }, - category: "apikey", + category: "oauth", + authModes: ["oauth", "apikey"], + hasOAuth: true, transport: { baseUrl: "https://api.kimi.com/coding/v1/messages", format: "claude", urlSuffix: "?beta=true", headers: { ...CLAUDE_API_HEADERS }, + clientId: "17e5f671-d194-4dfb-9706-5516cb48c098", + tokenUrl: "https://auth.kimi.com/api/oauth/token", + refreshUrl: "https://auth.kimi.com/api/oauth/token", auth: { combined: true, header: "x-api-key", scheme: "raw", + hooks: ["kimiHeaders"], }, }, // Multi-endpoint: pick the transport matching client sourceFormat to skip translation. @@ -31,26 +43,47 @@ export default { { format: "openai", baseUrl: "https://api.kimi.com/coding/v1/chat/completions", - auth: { combined: true, header: "Authorization", scheme: "bearer" }, + auth: { combined: true, header: "Authorization", scheme: "bearer", hooks: ["kimiHeaders"] }, }, { format: "claude", baseUrl: "https://api.kimi.com/coding/v1/messages", urlSuffix: "?beta=true", headers: { ...CLAUDE_API_HEADERS }, - auth: { combined: true, header: "x-api-key", scheme: "raw" }, + auth: { combined: true, header: "x-api-key", scheme: "raw", hooks: ["kimiHeaders"] }, }, ], models: [ + // Flagship K3 — platform.kimi.ai id `kimi-k3`, Kimi Code OAuth id `k3` (up to 1M) + { id: "kimi-k3", name: "Kimi K3" }, + { id: "k3", name: "Kimi K3 (Code)" }, + // Kimi Code subscription stable ids (map to K2.7 Code backend) + { id: "kimi-for-coding", name: "Kimi for Coding" }, + { id: "kimi-for-coding-highspeed", name: "Kimi for Coding Highspeed" }, + // Pay-as-you-go platform ids + { id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }, + { id: "kimi-k2.7-code-highspeed", name: "Kimi K2.7 Code Highspeed" }, { id: "kimi-k2.6", name: "Kimi K2.6" }, { id: "kimi-k2.5", name: "Kimi K2.5" }, { id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" }, { id: "kimi-latest", name: "Kimi Latest" }, ], - serviceKinds: ["llm","webSearch"], + serviceKinds: ["llm", "webSearch"], searchViaChat: { - defaultModel: "kimi-k2.5", + defaultModel: "kimi-k3", endpoint: "https://api.moonshot.cn/v1/chat/completions", - pricingUrl: "https://platform.moonshot.ai/docs/pricing/chat", + pricingUrl: "https://platform.kimi.ai/docs/pricing/chat", + }, + oauth: { + clientId: "17e5f671-d194-4dfb-9706-5516cb48c098", + deviceCodeUrl: "https://auth.kimi.com/api/oauth/device_authorization", + tokenUrl: "https://auth.kimi.com/api/oauth/token", + refreshUrl: "https://auth.kimi.com/api/oauth/token", + // CLIProxyAPI refreshThresholdSeconds = 300 + refreshLeadMs: 300000, + authorizeDeviceUrl: "https://www.kimi.com/code/authorize_device", + }, + features: { + usage: true, }, }; diff --git a/open-sse/services/cursorModels.js b/open-sse/services/cursorModels.js new file mode 100644 index 00000000..93e4330b --- /dev/null +++ b/open-sse/services/cursorModels.js @@ -0,0 +1,187 @@ +/** + * Cursor live model catalog fetcher. + * + * Cursor exposes the account-specific model picker through the AgentService + * `GetUsableModels` Connect RPC. Unlike the static provider registry, this + * includes models newly enabled for the account and omits unavailable ones. + */ + +import crypto from "crypto"; +import http2 from "http2"; +import { PROVIDER_OAUTH } from "../providers/index.js"; +import { buildCursorHeaders } from "../utils/cursorChecksum.js"; +import { decodeMessage } from "../utils/cursorProtobuf.js"; + +const FETCH_TIMEOUT_MS = 10_000; +const CACHE_TTL_MS = 5 * 60 * 1000; + +// agent.v1.ModelDetails protobuf field numbers. +const MODEL_ID_FIELD = 1; +const DISPLAY_MODEL_ID_FIELD = 3; +const DISPLAY_NAME_FIELD = 4; +const DISPLAY_NAME_SHORT_FIELD = 5; +const RESPONSE_MODELS_FIELD = 1; + +/** @type {Map} */ +const catalogCache = new Map(); + +function getCursorModelsUrl() { + const config = PROVIDER_OAUTH.cursor; + if (!config?.agentEndpoint || !config?.modelsEndpoint) return null; + return `${config.agentEndpoint.replace(/\/$/, "")}${config.modelsEndpoint}`; +} + +function cacheKey(credentials) { + const seed = [ + credentials?.providerSpecificData?.machineId, + credentials?.accessToken, + ].filter(Boolean).join(":"); + if (!seed) return "cursor-anonymous"; + return crypto.createHash("sha256").update(`cursor:${seed}`).digest("hex"); +} + +function firstString(fields, fieldNumber) { + const value = fields.get(fieldNumber)?.[0]?.value; + if (!value || typeof value === "number") return ""; + return Buffer.from(value).toString("utf8"); +} + +/** + * Decode Cursor's `agent.v1.GetUsableModelsResponse` protobuf payload. + * The response contains repeated `agent.v1.ModelDetails` messages in field 1. + */ +export function parseCursorUsableModels(payload) { + const response = decodeMessage(payload); + const seen = new Set(); + const models = []; + + for (const entry of response.get(RESPONSE_MODELS_FIELD) || []) { + if (!entry?.value || typeof entry.value === "number") continue; + const detail = decodeMessage(entry.value); + const id = firstString(detail, MODEL_ID_FIELD).trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + + const name = ( + firstString(detail, DISPLAY_NAME_FIELD) + || firstString(detail, DISPLAY_NAME_SHORT_FIELD) + || firstString(detail, DISPLAY_MODEL_ID_FIELD) + || id + ).trim(); + models.push({ id, name }); + } + + return models; +} + +/** + * agent.api5.cursor.sh is HTTP/2-only; Node fetch/undici cannot speak h2. + * Unary GetUsableModels uses an unframed protobuf body (application/proto). + */ +function http2PostProto(url, headers, body, signal, timeoutMs) { + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const client = http2.connect(`https://${urlObj.host}`); + const chunks = []; + let responseHeaders = {}; + let settled = false; + + const finish = (fn) => (...args) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + try { client.close(); } catch {} + fn(...args); + }; + + const timeoutId = setTimeout(finish(() => { + reject(new Error("Cursor GetUsableModels timed out")); + }), timeoutMs); + + client.on("error", finish(reject)); + + const req = client.request({ + ":method": "POST", + ":path": urlObj.pathname, + ":authority": urlObj.host, + ":scheme": "https", + ...headers, + }); + + req.on("response", (hdrs) => { responseHeaders = hdrs; }); + req.on("data", (chunk) => { chunks.push(chunk); }); + req.on("end", finish(() => { + resolve({ + status: Number(responseHeaders[":status"] || 0), + body: Buffer.concat(chunks), + }); + })); + req.on("error", finish(reject)); + + if (signal) { + const onAbort = finish(() => reject(new Error("Request aborted"))); + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + + req.end(body && body.length ? Buffer.from(body) : undefined); + }); +} + +async function fetchCursorCatalog(credentials, signal) { + const accessToken = credentials?.accessToken; + const machineId = credentials?.providerSpecificData?.machineId; + const url = getCursorModelsUrl(); + if (!accessToken || !machineId || !url) return null; + + const headers = { + ...buildCursorHeaders(accessToken, machineId, credentials?.providerSpecificData?.ghostMode !== false), + // Connect unary calls use an unframed protobuf body, unlike Cursor chat's + // streaming `application/connect+proto` endpoint. + accept: "application/proto", + "content-type": "application/proto", + }; + delete headers["connect-accept-encoding"]; + delete headers["connect-protocol-version"]; + + const response = await http2PostProto(url, headers, new Uint8Array(), signal, FETCH_TIMEOUT_MS); + if (response.status !== 200) { + const error = new Error(`Cursor GetUsableModels returned ${response.status}`); + error.status = response.status; + throw error; + } + + return parseCursorUsableModels(new Uint8Array(response.body)); +} + +/** + * Resolve the live Cursor catalog for the authenticated account. + * Returns null on any failure so callers can fall back to static models. + */ +export async function resolveCursorModels(credentials, options = {}) { + if (!credentials?.accessToken || !credentials?.providerSpecificData?.machineId) { + options.log?.debug?.("CURSOR_MODELS", "No Cursor access token or machine ID; skipping live fetch"); + return null; + } + + const key = cacheKey(credentials); + const now = Date.now(); + if (!options.forceRefresh) { + const cached = catalogCache.get(key); + if (cached?.expiresAt > now) return { models: cached.models }; + } + + try { + const models = await fetchCursorCatalog(credentials, options.signal); + if (!models?.length) return null; + catalogCache.set(key, { expiresAt: now + CACHE_TTL_MS, models }); + return { models }; + } catch (error) { + options.log?.warn?.("CURSOR_MODELS", `Live model fetch failed: ${error?.message || error}`); + return null; + } +} + +export function clearCursorModelCache() { + catalogCache.clear(); +} diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index c8d264c6..55f4582e 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -3,6 +3,7 @@ import { OAUTH_ENDPOINTS, REFRESH_LEAD_MS } from "../config/appConstants.js"; import { refreshXaiToken, refreshAccessToken, + refreshKimiToken, refreshClaudeOAuthToken, refreshGoogleToken, refreshQwenToken, @@ -18,6 +19,7 @@ import { // Re-export all provider refresh functions (preserves public API for all consumers) export { refreshAccessToken, + refreshKimiToken, refreshClaudeOAuthToken, refreshGoogleToken, refreshQwenToken, @@ -44,7 +46,10 @@ export function isUnrecoverableRefreshError(result) { } export function getRefreshLeadMs(provider) { - return REFRESH_LEAD_MS[provider] || TOKEN_EXPIRY_BUFFER_MS; + if (REFRESH_LEAD_MS[provider]) return REFRESH_LEAD_MS[provider]; + // Legacy id after kimi-coding → kimi merge + if (provider === "kimi-coding" && REFRESH_LEAD_MS.kimi) return REFRESH_LEAD_MS.kimi; + return TOKEN_EXPIRY_BUFFER_MS; } export function parseVertexSaJson(apiKey) { @@ -133,6 +138,9 @@ const REFRESH_HANDLERS = { "grok-cli": (c, log) => refreshXaiToken(c.refreshToken, log), gcli: (c, log) => refreshXaiToken(c.refreshToken, log), "codebuddy-cn": (c, log) => refreshCodebuddyToken(c.refreshToken, log), + // Kimi Code OAuth (merged into id `kimi`); legacy id still routes here + kimi: (c, log) => refreshKimiToken(c.refreshToken, c, log), + "kimi-coding": (c, log) => refreshKimiToken(c.refreshToken, c, log), vertex: vertexRefreshHandler, "vertex-partner": vertexRefreshHandler }; diff --git a/open-sse/services/tokenRefresh/providers.js b/open-sse/services/tokenRefresh/providers.js index 54c2fa0b..3fc8c4b2 100644 --- a/open-sse/services/tokenRefresh/providers.js +++ b/open-sse/services/tokenRefresh/providers.js @@ -1,5 +1,5 @@ import { PROVIDERS, PROVIDER_OAUTH } from "../../config/providers.js"; -import { OAUTH_ENDPOINTS, GITHUB_COPILOT } from "../../config/appConstants.js"; +import { OAUTH_ENDPOINTS, GITHUB_COPILOT, buildKimiHeaders } from "../../config/appConstants.js"; import { proxyAwareFetch } from "../../utils/proxyFetch.js"; import { dedupRefresh } from "./dedup.js"; import { buildExternalIdpRefreshParams } from "../../../src/lib/oauth/kiroExternalIdp.js"; @@ -91,6 +91,52 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo }, log); } +// CLIProxyAPI DeviceFlowClient.RefreshToken: form body (no client_secret) + X-Msh-* headers +export async function refreshKimiToken(refreshToken, credentials, log) { + const config = PROVIDERS.kimi; + if (!config?.refreshUrl || !config?.clientId) { + log?.warn?.("TOKEN_REFRESH", "No Kimi refresh URL/clientId configured"); + return null; + } + if (!refreshToken) return null; + + return dedupRefresh("kimi", refreshToken, async () => { + try { + const headers = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + ...buildKimiHeaders(credentials?.providerSpecificData?.deviceId), + }; + const response = await fetch(config.refreshUrl, { + method: "POST", + headers, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: config.clientId, + }), + }); + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", `Failed to refresh token for kimi`, { + status: response.status, + error: errorText, + }); + return null; + } + const tokens = await response.json(); + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || refreshToken, + expiresIn: tokens.expires_in, + }; + } catch (error) { + log?.error?.("TOKEN_REFRESH", `Error refreshing token for kimi`, { error: error.message }); + return null; + } + }, log); +} + export async function refreshClaudeOAuthToken(refreshToken, log) { if (!refreshToken) return null; return dedupRefresh("claude", refreshToken, async () => { diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index bcb8b97f..98531c73 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -33,6 +33,7 @@ import { KIRO_AGENTIC_SYSTEM_PROMPT, resolveDefaultProfileArn, buildKiroAdditionalModelRequestFieldsForModel, + usesKiroNativeGptEffort, } from "../../config/kiroConstants.js"; import { DEFAULT_IMAGE_MIME } from "../schema/index.js"; import { ROLE, CLAUDE_BLOCK } from "../schema/index.js"; @@ -390,6 +391,8 @@ export function claudeToKiroRequest(model, body, stream, credentials) { const { upstream: upstreamModel, agentic } = resolveKiroModel(model); const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model); + const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel); + const usesNativeGptEffort = usesKiroNativeGptEffort(body, upstreamModel); // Guard 1: no client tools → flatten all tool interactions to text. if (!clientProvidedTools) { @@ -421,7 +424,9 @@ export function claudeToKiroRequest(model, body, stream, credentials) { // enforce top-level systemPrompt for direct calls. const timestamp = new Date().toISOString(); const systemPromptParts = []; - if (thinkingBudget !== null) systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget)); + if (thinkingBudget !== null && !usesNativeGptEffort) { + systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget)); + } if (agentic) systemPromptParts.push(KIRO_AGENTIC_SYSTEM_PROMPT); const systemInstruction = extractClaudeSystemText(body.system); if (systemInstruction) systemPromptParts.push(systemInstruction); @@ -481,7 +486,6 @@ export function claudeToKiroRequest(model, body, stream, credentials) { if (profileArn) payload.profileArn = profileArn; if (systemPrompt) payload.systemPrompt = systemPrompt; - const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel); if (additionalModelRequestFields) { payload.additionalModelRequestFields = additionalModelRequestFields; } diff --git a/open-sse/translator/request/openai-responses.js b/open-sse/translator/request/openai-responses.js index 1fc05825..ecfa26d6 100644 --- a/open-sse/translator/request/openai-responses.js +++ b/open-sse/translator/request/openai-responses.js @@ -200,6 +200,9 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) delete result.include; delete result.prompt_cache_key; delete result.store; + if (typeof result.reasoning?.effort === "string") { + result.reasoning_effort = result.reasoning.effort; + } delete result.reasoning; delete result.client_metadata; @@ -377,6 +380,7 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) if (body.top_p !== undefined) result.top_p = body.top_p; if (body.reasoning !== undefined) result.reasoning = body.reasoning; if (body.reasoning_effort !== undefined) result.reasoning = { effort: body.reasoning_effort, summary: "auto" }; + if (body.service_tier !== undefined) result.service_tier = body.service_tier; return result; } diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index a2681b5a..069feffa 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -13,7 +13,8 @@ import { buildThinkingSystemPrefix, KIRO_AGENTIC_SYSTEM_PROMPT, resolveDefaultProfileArn, - buildKiroAdditionalModelRequestFieldsForModel + buildKiroAdditionalModelRequestFieldsForModel, + usesKiroNativeGptEffort } from "../../config/kiroConstants.js"; import { parseDataUri } from "../concerns/image.js"; import { DEFAULT_IMAGE_MIME } from "../schema/index.js"; @@ -511,12 +512,10 @@ function convertMessages(messages, tools, model) { * Kiro's 2-3 minute server timeout. The suffix is stripped before being * sent upstream. * - * 2. Thinking / reasoning. Kiro does not accept `thinking.type` or - * `reasoning_effort` natively. The only way to enable reasoning is to - * inject `enabled` into the user content - * sent upstream. Detection covers Anthropic-Beta header, Claude API + * 2. Thinking / reasoning. Detection covers Anthropic-Beta header, Claude API * `thinking`, OpenAI `reasoning_effort`, AMP/Cursor magic tags, and model - * name hints. + * name hints. Supported models receive Kiro's schema-specific effort fields; + * legacy prompt tags remain only for models that need them. */ export function openaiToKiroRequest(model, body, stream, credentials) { const messages = body.messages || []; @@ -527,6 +526,8 @@ export function openaiToKiroRequest(model, body, stream, credentials) { const { upstream: upstreamModel, agentic } = resolveKiroModel(model); const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model); + const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel); + const usesNativeGptEffort = usesKiroNativeGptEffort(body, upstreamModel); const { history, currentMessage } = convertMessages(messages, tools, upstreamModel); @@ -554,7 +555,7 @@ export function openaiToKiroRequest(model, body, stream, credentials) { // too because the CodeWhisperer surface does not always enforce top-level // systemPrompt for direct calls. const systemPromptParts = []; - if (thinkingBudget !== null) { + if (thinkingBudget !== null && !usesNativeGptEffort) { systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget)); } if (agentic) { @@ -612,7 +613,6 @@ export function openaiToKiroRequest(model, body, stream, credentials) { payload.profileArn = profileArn; } if (systemPrompt) payload.systemPrompt = systemPrompt; - const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel); if (additionalModelRequestFields) { payload.additionalModelRequestFields = additionalModelRequestFields; } diff --git a/open-sse/utils/cursorChecksum.js b/open-sse/utils/cursorChecksum.js index 961df7a1..81bac255 100644 --- a/open-sse/utils/cursorChecksum.js +++ b/open-sse/utils/cursorChecksum.js @@ -128,7 +128,8 @@ export function buildCursorHeaders(accessToken, machineId = null, ghostMode = tr "x-amzn-trace-id": `Root=${crypto.randomUUID()}`, "x-client-key": clientKey, "x-cursor-checksum": checksum, - "x-cursor-client-version": "3.1.0", + "x-cursor-client-version": "3.12.17", + "x-cursor-client-commit": "0fb762053c34788bb7760d5673f8a6d4c8589d50", "x-cursor-client-type": "ide", "x-cursor-client-os": os, "x-cursor-client-arch": arch, diff --git a/package.json b/package.json index ba2170c5..dc89873d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.5.35", + "version": "0.5.40", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/public/i18n/literals/km.json b/public/i18n/literals/km.json new file mode 100644 index 00000000..cb40c432 --- /dev/null +++ b/public/i18n/literals/km.json @@ -0,0 +1,1396 @@ +{ + "($/1M tokens). Example: An input rate of 2.50 means $2.50 per 1,000,000 input tokens.": "($/1M tokens)។ ឧទាហរណ៍៖ អត្រា input 2.50 មានន័យថា $2.50 ក្នុង input tokens ចំនួន 1,000,000", + "($/1M tokens). Example: Input rate of 2.50 means $2.50 per 1,000,000 input tokens.": "($/1M tokens)។ ឧទាហរណ៍៖ អត្រា Input 2.50 មានន័យថា $2.50 ក្នុង input tokens ចំនួន 1,000,000", + "(Caveman)": "(Caveman)", + "(Headroom)": "(Headroom)", + "(Ponytail)": "(Ponytail)", + "(RTK)": "(RTK)", + "(via inference test)": "(តាមរយៈការសាកល្បង inference)", + "+ Browse": "+ រុករក", + "+ Combo": "+ Combo", + "+ Custom": "+ ផ្ទាល់ខ្លួន", + "+ Save current as...": "+ រក្សាទុកបច្ចុប្បន្នជា...", + "-compatible models manually or import them from the /models endpoint.": "-compatible ដោយដៃ ឬនាំចូលពួកវាពី Endpoint /models។", + ". Click \"Apply\" to auto-configure.": "។ ចុច \"អនុវត្ត\" ដើម្បីកំណត់រចនាសម្ព័ន្ធដោយស្វ័យប្រវត្តិ។", + "1. CLI & SDKs": "1. CLI និង SDKs", + "1. Client Request (Input)": "1. សំណើរបស់ Client (Input)", + "1. Generates SSL cert & adds to system keychain": "1. បង្កើត SSL certificate ហើយបន្ថែមទៅ system keychain", + "2. 9Router Hub": "2. 9Router Hub", + "2. Provider Request (Translated)": "2. សំណើទៅអ្នកផ្តល់សេវា (បានបកប្រែ)", + "2. Redirects": "2. បញ្ជូនបន្ត", + "24h": "24 ម៉ោង", + "3. AI Providers": "3. អ្នកផ្តល់សេវា AI", + "3. Maps Antigravity models to any provider via 9Router": "3. ផ្គូផ្គងម៉ូដែល Antigravity ទៅអ្នកផ្តល់សេវាណាមួយតាមរយៈ 9Router", + "3. Provider Response (Raw)": "3. ការឆ្លើយតបពីអ្នកផ្តល់សេវា (ដើម)", + "30D": "30 ថ្ងៃ", + "4. Client Response (Final)": "4. ការឆ្លើយតបទៅ Client (ចុងក្រោយ)", + "60D": "60 ថ្ងៃ", + "7D": "7 ថ្ងៃ", + "9Router (Entry)": "9Router (ច្រកចូល)", + "9Router Base URL": "9Router Base URL", + ": Account | Workers Scripts | Edit": "៖ គណនី | Workers Scripts | កែសម្រួល", + ": 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.": "Proxy សម្រាប់ AI Endpoint ជាមួយផ្ទាំងគ្រប់គ្រងលើវេប — ជាកំណែ JavaScript របស់ 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": "អ្នកផ្តល់សេវាដែលត្រូវគ្នាជាមួយ 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": "ID គណនី", + "Account Resources": "ធនធានគណនី", + "Accounts per page": "ចំនួនគណនីក្នុងមួយទំព័រ", + "Action": "សកម្មភាព", + "Activate": "ធ្វើឱ្យសកម្ម", + "Active": "សកម្ម", + "Active All": "ធ្វើឱ្យសកម្មទាំងអស់", + "Active:": "សកម្ម៖", + "Add": "បន្ថែម", + "Add API Key": "បន្ថែម API Key", + "Add Anthropic Compatible": "បន្ថែម Anthropic Compatible", + "Add Connection": "បន្ថែមការតភ្ជាប់", + "Add Custom Embedding": "បន្ថែម 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": "បន្ថែមម៉ូដែលទៅ Combo", + "Add New Provider": "បន្ថែមអ្នកផ្តល់សេវាថ្មី", + "Add OpenAI Compatible": "បន្ថែម OpenAI Compatible", + "Add Provider": "បន្ថែមអ្នកផ្តល់សេវា", + "Add Proxy Pool": "បន្ថែម Proxy Pool", + "Add 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", + "Administrator required — restart 9Router as Administrator to use MITM": "ត្រូវការសិទ្ធិ Administrator — ចាប់ផ្តើម 9Router ឡើងវិញជា Administrator ដើម្បីប្រើ MITM", + "After authorization, copy the full URL from your browser address bar.": "បន្ទាប់ពីផ្តល់សិទ្ធិ សូមចម្លង URL ពេញពីរបារអាសយដ្ឋានរបស់ browser។", + "After authorization, copy the full URL from your browser.": "បន្ទាប់ពីផ្តល់សិទ្ធិ សូមចម្លង URL ពេញពី browser របស់អ្នក។", + "After installation, run": "បន្ទាប់ពីដំឡើង សូមដំណើរការ", + "After login, you'll need to copy the callback URL from your browser and paste it back here.": "បន្ទាប់ពីចូល អ្នកត្រូវចម្លង callback URL ពី browser ហើយបិទភ្ជាប់វានៅទីនេះ។", + "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": "អនុញ្ញាតឱ្យចូលប្រើផ្ទាំងគ្រប់គ្រងតាមរយៈ 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 បញ្ជូនបន្តទៅ 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": "ឈ្មោះ App", + "Apply": "អនុវត្ត", + "Apply Proxy": "អនុវត្ត Proxy", + "Applying...": "កំពុងអនុវត្ត...", + "Are you sure you want to close the proxy server?": "តើអ្នកប្រាកដថាចង់បិទម៉ាស៊ីនមេ Proxy មែនទេ?", + "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": "Ping ស្វ័យប្រវត្តិ", + "Auto-refresh": "ផ្ទុកឡើងវិញដោយស្វ័យប្រវត្តិ", + "Auto:": "ស្វ័យប្រវត្តិ៖", + "Automatically switch between providers when limits are hit.": "ប្តូររវាងអ្នកផ្តល់សេវាដោយស្វ័យប្រវត្តិ នៅពេលឈានដល់កម្រិតកំណត់។", + "Available": "មាន", + "Available Models": "ម៉ូដែលដែលមាន", + "Azure Endpoint": "Azure Endpoint", + "Azure OpenAI Configuration": "ការកំណត់រចនាសម្ព័ន្ធ Azure OpenAI", + "BXAuth=xxx; ...": "BXAuth=xxx; ...", + "Back": "ត្រឡប់ក្រោយ", + "Back to CLI Tools": "ត្រឡប់ទៅឧបករណ៍ CLI", + "Back to Providers": "ត្រឡប់ទៅអ្នកផ្តល់សេវា", + "Base URL": "Base URL", + "Batch Import": "នាំចូលជាបាច់", + "Batch Import Proxies": "នាំចូល 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, ប្រើ stdlib ឡើងវិញ និងលុបជំនួសការបន្ថែម", + "Binary File": "ឯកសារ Binary", + "Blog": "ប្លុក", + "Both": "ទាំងពីរ", + "Browse & edit files": "រុករក និងកែសម្រួលឯកសារ", + "Browse MCP Marketplace": "រុករក MCP Marketplace", + "Browse source, README, and examples.": "រុករក source, README និងឧទាហរណ៍។", + "Browser Control (Browser MCP)": "ការគ្រប់គ្រង Browser (Browser MCP)", + "Bulk Add": "បន្ថែមជាច្រើន", + "CLI Support": "ការគាំទ្រ CLI", + "CLI Tools": "ឧបករណ៍ CLI", + "CLI on the host →": "CLI នៅលើ host →", + "CLIProxyAPI Auth JSON": "CLIProxyAPI Auth JSON", + "Cache Creation": "ការបង្កើត Cache", + "Cache Creation:": "ការបង្កើត Cache៖", + "Cached": "បាន Cache", + "Cached Tokens": "Tokens ដែលបាន Cache", + "Cached Tokens:": "Tokens ដែលបាន Cache៖", + "Cached input tokens (typically 50% of input rate)": "Input Tokens ដែលបាន Cache (ជាទូទៅ 50% នៃអត្រា input)", + "Cached:": "បាន Cache៖", + "Calls per account before switching": "ចំនួន Call ក្នុងមួយគណនី មុនពេលប្តូរ", + "Calls per combo model before switching": "ចំនួន Call ក្នុងមួយម៉ូដែល Combo មុនពេលប្តូរ", + "Cancel": "បោះបង់", + "Capacity auto-switch": "ប្តូរដោយស្វ័យប្រវត្តិតាមសមត្ថភាព", + "Cert": "Certificate", + "Change Log": "កំណត់ហេតុផ្លាស់ប្តូរ", + "Changelog": "កំណត់ហេតុផ្លាស់ប្តូរ", + "Chat": "ជជែក", + "Chat / code-gen via OpenAI or Anthropic format with streaming.": "ជជែក / បង្កើតកូដតាមទម្រង់ OpenAI ឬ Anthropic ជាមួយ 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...", + "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 (inference ពីភាគីទីបី)", + "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 ID", + "Client Request": "សំណើរបស់ Client", + "Client Response": "ការឆ្លើយតបទៅ Client", + "Client Secret": "Client Secret", + "Cline - Manual Configuration": "Cline - កំណត់រចនាសម្ព័ន្ធដោយដៃ", + "Cline AI Coding Assistant": "ជំនួយការសរសេរកូដ AI Cline", + "Cline not detected locally": "រកមិនឃើញ Cline នៅលើម៉ាស៊ីន", + "Close": "បិទ", + "Close Proxy": "បិទ Proxy", + "Close provider filter": "បិទតម្រងអ្នកផ្តល់សេវា", + "Close reset credit expiry modal": "បិទផ្ទាំងកំណត់ថ្ងៃផុតកំណត់ Credit ឡើងវិញ", + "Close test results": "បិទលទ្ធផលសាកល្បង", + "Closing in": "នឹងបិទក្នុង", + "Cloud Sync": "សមកាលកម្ម Cloud", + "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": "កំណត់ថ្ងៃផុតកំណត់ Credit របស់ 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.": "Hostnames/domains បំបែកដោយសញ្ញាក្បៀស សម្រាប់រំលង Proxy។", + "Comma-separated hosts/domains to bypass proxy": "Hosts/domains បំបែកដោយសញ្ញាក្បៀស សម្រាប់រំលង Proxy", + "Company": "ក្រុមហ៊ុន", + "Complete the authorization in the popup window.": "បញ្ចប់ការផ្តល់សិទ្ធិនៅក្នុងផ្ទាំង popup។", + "Completion/response tokens": "Tokens សម្រាប់ Completion/response", + "Compress LLM output": "បង្រួម Output របស់ LLM", + "Compress context": "បង្រួម Context", + "Compress prompts via /v1/compress before routing to the model": "បង្រួម Prompts តាម /v1/compress មុនពេលបញ្ជូនទៅម៉ូដែល", + "Compress tool output": "បង្រួម Output របស់ឧបករណ៍", + "Compress tool output to reduce token usage.": "បង្រួម 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 ដើម្បីបញ្ជូនសំណើ jcode ទាំងអស់តាមស្រទាប់បង្កើនប្រសិទ្ធភាពរបស់ 9router។", + "Configure CLI tools": "កំណត់រចនាសម្ព័ន្ធឧបករណ៍ CLI", + "Configure a new AI provider to use with your applications.": "កំណត់រចនាសម្ព័ន្ធអ្នកផ្តល់សេវា AI ថ្មី ដើម្បីប្រើជាមួយកម្មវិធីរបស់អ្នក។", + "Configure pricing rates for cost tracking and calculations": "កំណត់អត្រាតម្លៃសម្រាប់តាមដាន និងគណនាចំណាយ", + "Configure providers and API keys via web interface": "កំណត់អ្នកផ្តល់សេវា និង API Keys តាមរយៈផ្ទៃប្រើប្រាស់លើវេប", + "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.": "ភ្ជាប់ទៅអ្នកផ្តល់សេវាដោយ OAuth ដើម្បីតាមដានកម្រិត API quota និងការប្រើប្រាស់របស់អ្នក។", + "Connect via OAuth or API keys. Securely manage credentials.": "ភ្ជាប់តាម OAuth ឬ API Keys។ គ្រប់គ្រងព័ត៌មានសម្ងាត់ដោយសុវត្ថិភាព។", + "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": "កំណត់ហេតុ Console", + "Contact": "ទំនាក់ទំនង", + "Content": "ខ្លឹមសារ", + "Continue": "បន្ត", + "Continue AI Assistant": "ជំនួយការ AI Continue", + "Continue to summary": "បន្តទៅសេចក្តីសង្ខេប", + "Continue with GitHub": "បន្តជាមួយ GitHub", + "Continue with Google": "បន្តជាមួយ Google", + "Cookie": "Cookie", + "Cookie Auth": "ការផ្ទៀងផ្ទាត់ដោយ Cookie", + "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": "ចម្លង Link ហើយបិទភ្ជាប់ទៅ AI របស់អ្នក ដើម្បីប្រើ 9Router — មិនចាំបាច់ដំឡើង", + "Copy combo name": "ចម្លងឈ្មោះ Combo", + "Copy install command": "ចម្លង 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 និងអត្រាតម្លៃ។ ចំណាយនៃសំណើនីមួយៗកំណត់ដោយ៖ (input_tokens × input_rate) + (output_tokens × output_rate) + (cached_tokens × cached_rate)", + "Could not read Cursor database automatically.": "មិនអាចអានមូលដ្ឋានទិន្នន័យ Cursor ដោយស្វ័យប្រវត្តិបានទេ។", + "Create": "បង្កើត", + "Create API Key": "បង្កើត API Key", + "Create Combo": "បង្កើត Combo", + "Create Cowork Combo": "បង្កើត Cowork Combo", + "Create Key": "បង្កើត Key", + "Create Provider": "បង្កើតអ្នកផ្តល់សេវា", + "Create Token": "បង្កើត Token", + "Create a": "បង្កើត", + "Create a proxy pool entry, then assign it to connections.": "បង្កើតធាតុ Proxy Pool បន្ទាប់មកកំណត់វាទៅការតភ្ជាប់។", + "Create model combos with fallback support": "បង្កើត Combo ម៉ូដែលដែលគាំទ្រ Fallback", + "Create your first API key to get started": "បង្កើត API Key ដំបូងរបស់អ្នកដើម្បីចាប់ផ្តើម", + "Created": "បានបង្កើត", + "Creating...": "កំពុងបង្កើត...", + "Current": "បច្ចុប្បន្ន", + "Current Password": "ពាក្យសម្ងាត់បច្ចុប្បន្ន", + "Current Pricing Overview": "ទិដ្ឋភាពទូទៅនៃតម្លៃបច្ចុប្បន្ន", + "Current password": "ពាក្យសម្ងាត់បច្ចុប្បន្ន", + "Current: Keeps": "បច្ចុប្បន្ន៖ រក្សា", + "Currently using accounts in priority order (Fill First).": "បច្ចុប្បន្នកំពុងប្រើគណនីតាមលំដាប់អាទិភាព (Fill First)។", + "Cursor AI Code Editor": "កម្មវិធីកែកូដ AI Cursor", + "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)": "អ្នកផ្តល់សេវាផ្ទាល់ខ្លួន (OpenAI/Anthropic Compatible)", + "Custom Token": "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": "ប្តូរវេនអ្នកផ្តល់សេវាក្នុង 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.": "ទិន្នន័យហូរយ៉ាងរលូនពីកម្មវិធីរបស់អ្នក តាមស្រទាប់បញ្ជូនផ្លូវឆ្លាតវៃ ទៅអ្នកផ្តល់សេវាដែលសមស្របបំផុតសម្រាប់ការងារ។", + "Data flows seamlessly through our intelligent routing system": "ទិន្នន័យហូរយ៉ាងរលូនតាមប្រព័ន្ធបញ្ជូនផ្លូវឆ្លាតវៃរបស់យើង", + "Database Location": "ទីតាំងមូលដ្ឋានទិន្នន័យ", + "Database backup downloaded": "បានទាញយកការបម្រុងទុកមូលដ្ឋានទិន្នន័យ", + "Database imported successfully": "បាននាំចូលមូលដ្ឋានទិន្នន័យដោយជោគជ័យ", + "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 Coding Agent សម្រាប់ Terminal (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 ដំណើរការលើបណ្តាញ Edge សកលដែលមានប្រសិទ្ធភាពខ្ពស់", + "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": "ដាក់ Relays ច្រើនឱ្យដំណើរការ ដើម្បីទទួលបាន IP ចម្រុះអតិបរមា", + "Deploy multiple relays on different accounts for more IP diversity": "ដាក់ Relays ច្រើនលើគណនីផ្សេងៗ ដើម្បីទទួលបាន IP ចម្រុះកាន់តែច្រើន", + "Deploying... (may take ~1 min)": "កំពុងដាក់ឱ្យដំណើរការ... (អាចចំណាយពេលប្រហែល 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.": "ដាក់ Cloudflare Worker ជា Proxy Relay ឱ្យដំណើរការ។ សំណើទៅអ្នកផ្តល់សេវា AI ទាំងអស់នឹងត្រូវបញ្ជូនតាមបណ្តាញ Edge សកលរបស់ 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 ទៅបណ្តាញ Edge សកលរបស់ Deno Deploy។ សំណើទៅអ្នកផ្តល់សេវា AI ទាំងអស់ត្រូវបានបញ្ជូនតាម Edge របស់ Deno ដើម្បីលាក់ IP ពិតរបស់អ្នក។", + "Deploys an edge relay function to Vercel that proxies requests through Vercel's network.": "ដាក់មុខងារ Edge Relay ទៅ Vercel ដែលបញ្ជូនសំណើជា Proxy តាមបណ្តាញរបស់ 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.": "ដាក់មុខងារ Edge Relay ទៅ Vercel។ សំណើទៅអ្នកផ្តល់សេវា AI ទាំងអស់នឹងត្រូវបញ្ជូនតាមបណ្តាញ Edge របស់ Vercel ដើម្បីលាក់ IP ពិតរបស់អ្នកពីអ្នកផ្តល់សេវា។", + "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": "បិទអ្នកផ្តល់សេវា", + "Disable this model": "បិទម៉ូដែលនេះ", + "Disabled": "បានបិទ", + "Disabling...": "កំពុងបិទ...", + "Disconnected from server": "បានផ្តាច់ពីម៉ាស៊ីនមេ", + "Dismiss notification": "បិទការជូនដំណឹង", + "Display Name": "ឈ្មោះបង្ហាញ", + "Display language": "ភាសាបង្ហាញ", + "Docs": "ឯកសារ", + "Documentation": "ឯកសារ", + "Domain:": "Domain៖", + "Donate": "បរិច្ចាគ", + "Done": "រួចរាល់", + "Download": "ទាញយក", + "Download Backup": "ទាញយកការបម្រុងទុក", + "Drag to reorder": "អូសដើម្បីរៀបលំដាប់ឡើងវិញ", + "Easy Setup": "ការរៀបចំងាយស្រួល", + "Edit": "កែសម្រួល", + "Edit Combo": "កែសម្រួល Combo", + "Edit Connection": "កែសម្រួលការតភ្ជាប់", + "Edit 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 ដើម្បីកែសម្រួលការផ្គូផ្គងម៉ូដែល", + "Enable Observability": "បើក Observability", + "Enable OpenAI API": "បើក OpenAI API", + "Enable Tunnel": "បើក Tunnel", + "Enable connections that still have quota on the current page": "បើកការតភ្ជាប់ដែលនៅមាន Quota លើទំព័របច្ចុប្បន្ន", + "Enable provider": "បើកអ្នកផ្តល់សេវា", + "Enable proxy for OAuth + provider outbound requests.": "បើក Proxy សម្រាប់ OAuth និងសំណើចេញទៅអ្នកផ្តល់សេវា។", + "Encrypted": "បានអ៊ិនគ្រីប", + "End Date": "កាលបរិច្ឆេទបញ្ចប់", + "End-to-end TLS via Cloudflare": "TLS ពីចុងដល់ចុងតាមរយៈ Cloudflare", + "Endpoint": "Endpoint", + "Endpoint & Key": "Endpoint និង Key", + "Endpoint is exposed without an API key.": "Endpoint ត្រូវបានបើកឱ្យចូលប្រើដោយគ្មាន API Key។", + "Enter current password": "បញ្ចូលពាក្យសម្ងាត់បច្ចុប្បន្ន", + "Enter model id": "បញ្ចូល ID ម៉ូដែល", + "Enter model id (provider-specific)": "បញ្ចូល 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.": "បញ្ចូល ID ម៉ូដែលឱ្យត្រឹមត្រូវតាមដែល 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 ឱ្យមានប្រសិទ្ធភាព។", + "Everything you need to manage your AI infrastructure in one place, built for scale.": "អ្វីៗគ្រប់យ៉ាងដែលអ្នកត្រូវការ ដើម្បីគ្រប់គ្រងហេដ្ឋារចនាសម្ព័ន្ធ AI នៅកន្លែងតែមួយ និងត្រៀមសម្រាប់ការពង្រីក។", + "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.": "បើក 9Router នៅលើម៉ាស៊ីនរបស់អ្នកឱ្យចូលបានពី Internet។ មិនត្រូវការ Port Forwarding ឬ Static IP ទេ។ ចែករំលែក Endpoint URL ជាមួយក្រុមរបស់អ្នក ឬប្រើវាក្នុង Cursor, Cline និងឧបករណ៍ AI ផ្សេងទៀតពីគ្រប់ទីកន្លែង។", + "Factory Droid - Manual Configuration": "Factory Droid - កំណត់រចនាសម្ព័ន្ធដោយដៃ", + "Factory Droid AI Assistant": "ជំនួយការ AI 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.": "ឱ្យសំណើបរាជ័យ ប្រសិនបើមិនអាចភ្ជាប់ទៅ Proxy ជំនួសឱ្យការប្តូរទៅការតភ្ជាប់ផ្ទាល់។", + "Failed": "បរាជ័យ", + "Failed to apply settings": "មិនអាចអនុវត្តការកំណត់បានទេ", + "Failed to create combo": "មិនអាចបង្កើត Combo បានទេ", + "Failed to load changelog:": "មិនអាចផ្ទុកកំណត់ហេតុផ្លាស់ប្តូរ៖", + "Failed to load 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 បានទេ", + "Fallback": "Fallback", + "Fallback — tries models in order (next on failure)": "Fallback — សាកល្បងម៉ូដែលតាមលំដាប់ (ទៅបន្ទាប់នៅពេលបរាជ័យ)", + "Fallback — try in order": "Fallback — សាកល្បងតាមលំដាប់", + "Features": "មុខងារ", + "Fetch Qoder Models": "ទាញយកម៉ូដែល Qoder", + "Fetching...": "កំពុងទាញយក...", + "Files": "ឯកសារ", + "Filter accounts by status": "តម្រងគណនីតាមស្ថានភាព", + "Filter naming": "តម្រងការដាក់ឈ្មោះ", + "Filter naming requests": "តម្រងសំណើដាក់ឈ្មោះ", + "Filter quota providers": "តម្រងអ្នកផ្តល់សេវាតាម Quota", + "Find MCPs →": "ស្វែងរក MCPs →", + "Find your Account ID in the right sidebar of": "ស្វែងរក ID គណនីរបស់អ្នកនៅរបារចំហៀងខាងស្តាំនៃ", + "Find your Account ID in the right sidebar of dash.cloudflare.com": "ស្វែងរក ID គណនីរបស់អ្នកនៅរបារចំហៀងខាងស្តាំនៃ dash.cloudflare.com", + "First Page": "ទំព័រដំបូង", + "Flush Interval (ms)": "ចន្លោះពេល Flush (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។", + "Free": "ឥតគិតថ្លៃ", + "Free & Free Tier Providers": "អ្នកផ្តល់សេវាឥតគិតថ្លៃ និង Free Tier", + "Free Providers": "អ្នកផ្តល់សេវាឥតគិតថ្លៃ", + "Free Tier": "Free Tier", + "Free Tier Providers": "អ្នកផ្តល់សេវា Free Tier", + "Free tier: 100,000 requests per day": "Free Tier៖ 100,000 សំណើក្នុងមួយថ្ងៃ", + "Free tier: 100GB bandwidth/month, 500K edge invocations": "Free Tier៖ Bandwidth 100GB/ខែ និង Edge Invocations 500K", + "Free tier: 1M requests & 100GiB outbound traffic per month": "Free Tier៖ 1M សំណើ និង Traffic ចេញ 100GiB ក្នុងមួយខែ", + "Fresh API key obtained": "បានទទួល API Key ថ្មី", + "Full shell access": "ចូលប្រើ Shell ពេញលេញ", + "Fusion": "Fusion", + "Fusion — panel + judge": "Fusion — Panel + Judge", + "Fusion — queries all models in parallel, then a judge synthesizes one answer": "Fusion — សួរម៉ូដែលទាំងអស់ស្របពេលគ្នា បន្ទាប់មក 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.": "ចាប់ផ្តើមក្នុងពេលប៉ុន្មានវិនាទី។ គ្រាន់តែដំឡើង បើក និងបញ្ជូនផ្លូវ។", + "Get token →": "ទទួលបាន 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 Access Tokens", + "GitLab Applications": "GitLab Applications", + "GitLab Base URL": "GitLab Base URL", + "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:": "ដាក់ម៉ូដែលជាក្រុមក្រោមឈ្មោះតែមួយ បន្ទាប់មកជ្រើសយុទ្ធសាស្ត្រសម្រាប់ 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": "ការបញ្ជូនផ្លូវសកលប្រសិទ្ធភាពខ្ពស់ និងការលាក់ IP តាមរយៈ Cloudflare Workers", + "High-performance Rust-based coding agent harness": "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 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.": "ប្រសិនបើអ្នកផ្តល់សេវាមិនមាន Endpoint /models សូមបញ្ចូល 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.": "Pools អសកម្មត្រូវបានរំលងដោយការដោះស្រាយនៅពេលដំណើរការ។", + "Inc. All rights reserved.": "Inc. រក្សាសិទ្ធិគ្រប់យ៉ាង។", + "Initializing...": "កំពុងចាប់ផ្តើម...", + "Input": "Input", + "Input Cost": "ចំណាយ Input", + "Input Tokens": "Input Tokens", + "Input Tokens:": "Input Tokens៖", + "Input:": "Input៖", + "Install 9Router": "ដំឡើង 9Router", + "Install 9Router, configure your providers via web dashboard, and start routing AI requests.": "ដំឡើង 9Router កំណត់អ្នកផ្តល់សេវារបស់អ្នកតាមផ្ទាំងគ្រប់គ្រងលើវេប ហើយចាប់ផ្តើមបញ្ជូនសំណើ AI។", + "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:": "Command ដំឡើង៖", + "Install jcode to enable automatic configuration:": "ដំឡើង jcode ដើម្បីបើកការកំណត់រចនាសម្ព័ន្ធដោយស្វ័យប្រវត្តិ៖", + "Install the Amp CLI using the package manager supported by your environment.": "ដំឡើង Amp CLI ដោយប្រើ Package Manager ដែល Environment របស់អ្នកគាំទ្រ។", + "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": "ចាប់ Traffic របស់ឧបករណ៍ CLI ហើយបញ្ជូនតាម 9Router", + "Intercepts Antigravity traffic via DNS redirect, letting you reroute models through 9Router.": "ចាប់ Traffic របស់ Antigravity តាម DNS Redirect ដើម្បីឱ្យអ្នកអាចបញ្ជូនម៉ូដែលឡើងវិញតាម 9Router។", + "Intercepts Claude Code's topic-naming requests and returns a fake response locally, saving API tokens.": "ចាប់សំណើដាក់ឈ្មោះប្រធានបទរបស់ Claude Code ហើយត្រឡប់ការឆ្លើយតបក្លែងក្លាយនៅលើម៉ាស៊ីន ដើម្បីសន្សំ API Tokens។", + "Invalid": "មិនត្រឹមត្រូវ", + "Invalid password": "ពាក្យសម្ងាត់មិនត្រឹមត្រូវ", + "Issuer URL": "Issuer URL", + "JSON Response": "ការឆ្លើយតប JSON", + "Join developers who are streamlining their AI integrations with 9Router. Open source and free to start.": "ចូលរួមជាមួយអ្នកអភិវឌ្ឍន៍ដែលកំពុងសម្រួលការរួមបញ្ចូល AI របស់ពួកគេជាមួយ 9Router។ Open Source និងចាប់ផ្តើមដោយឥតគិតថ្លៃ។", + "Judge": "Judge", + "Just now": "អម្បាញ់មិញ", + "KB per field": "KB ក្នុងមួយ Field", + "Keep the legacy password login.": "រក្សាការចូលដោយពាក្យសម្ងាត់ចាស់។", + "Key Name": "ឈ្មោះ Key", + "KiRo dashboard": "ផ្ទាំងគ្រប់គ្រង KiRo", + "Kill & Start": "បញ្ឈប់ និងចាប់ផ្តើម", + "Kill this process to start MITM Server?": "បញ្ឈប់ Process នេះ ដើម្បីចាប់ផ្តើមម៉ាស៊ីនមេ MITM មែនទេ?", + "Kilo Code - Manual Configuration": "Kilo Code - កំណត់រចនាសម្ព័ន្ធដោយដៃ", + "Kilo Code AI Assistant": "ជំនួយការ AI 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។ សូមបិទភ្ជាប់ 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": "សង្ខេប", + "Leave blank to keep existing secret": "ទុកឱ្យទទេ ដើម្បីរក្សា Secret ដែលមានស្រាប់", + "Leave blank to use": "ទុកឱ្យទទេ ដើម្បីប្រើ", + "Leave empty for public PKCE app": "ទុកឱ្យទទេសម្រាប់ Public PKCE App", + "Leave empty to inherit existing env proxy (if any).": "ទុកឱ្យទទេ ដើម្បីទទួល Proxy ពី Environment ដែលមានស្រាប់ (បើមាន)។", + "Legacy manual proxy fields are still accepted by API for backward compatibility.": "API នៅតែទទួលយក Fields Proxy ដោយដៃចាស់ ដើម្បីរក្សាភាពត្រូវគ្នាជាមួយកំណែមុន។", + "Legacy:": "កំណែចាស់៖", + "Legal": "ផ្នែកច្បាប់", + "Live server console output": "Console Output ផ្ទាល់ពីម៉ាស៊ីនមេ", + "Load": "ផ្ទុក", + "Loading logs...": "កំពុងផ្ទុកកំណត់ហេតុ...", + "Loading models from provider...": "កំពុងផ្ទុកម៉ូដែលពីអ្នកផ្តល់សេវា...", + "Loading pricing data...": "កំពុងផ្ទុកទិន្នន័យតម្លៃ...", + "Loading registry...": "កំពុងផ្ទុក Registry...", + "Loading reset credits...": "កំពុងផ្ទុក Credit កំណត់ឡើងវិញ...", + "Loading...": "កំពុងផ្ទុក...", + "Local": "លើម៉ាស៊ីន", + "Local Mode": "របៀបលើម៉ាស៊ីន", + "Local Mode - All data stored on your machine": "របៀបលើម៉ាស៊ីន - ទិន្នន័យទាំងអស់រក្សាទុកនៅលើម៉ាស៊ីនរបស់អ្នក", + "Local Plugins": "Plugins លើម៉ាស៊ីន", + "Locked. Retry in": "បានចាក់សោ។ ព្យាយាមម្តងទៀតក្នុង", + "Login": "ចូល", + "Login Button Label": "ស្លាកប៊ូតុងចូល", + "Login URL": "URL សម្រាប់ចូល", + "Login to your account": "ចូលគណនីរបស់អ្នក", + "Login with your GitHub account (manual callback).": "ចូលដោយគណនី GitHub របស់អ្នក (Callback ដោយដៃ)។", + "Login with your Google account (manual callback).": "ចូលដោយគណនី Google របស់អ្នក (Callback ដោយដៃ)។", + "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 License", + "MITM": "MITM", + "MITM Proxy": "MITM Proxy", + "MITM Server": "ម៉ាស៊ីនមេ MITM", + "MITM Tools": "ឧបករណ៍ MITM", + "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.": "គ្រប់គ្រង Proxies ដែលអាចប្រើឡើងវិញសម្រាប់ការតភ្ជាប់នីមួយៗ និងភ្ជាប់ពួកវាទៅការតភ្ជាប់អ្នកផ្តល់សេវា។", + "Manage your AI provider connections": "គ្រប់គ្រងការតភ្ជាប់អ្នកផ្តល់សេវា AI របស់អ្នក", + "Manage your Embedding providers": "គ្រប់គ្រងអ្នកផ្តល់សេវា Embedding របស់អ្នក", + "Manage your Image to Text providers": "គ្រប់គ្រងអ្នកផ្តល់សេវា រូបភាពទៅអត្ថបទ របស់អ្នក", + "Manage your Music providers": "គ្រប់គ្រងអ្នកផ្តល់សេវាតន្ត្រីរបស់អ្នក", + "Manage your Speech To Text providers": "គ្រប់គ្រងអ្នកផ្តល់សេវា Speech To Text របស់អ្នក", + "Manage your Text To Speech providers": "គ្រប់គ្រងអ្នកផ្តល់សេវា Text To Speech របស់អ្នក", + "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": "គ្រប់គ្រងការកំណត់រចនាសម្ព័ន្ធ Proxy Pool របស់អ្នក", + "Manual / current endpoint": "Endpoint ដោយដៃ / បច្ចុប្បន្ន", + "Manual Callback Required": "ត្រូវការ Callback ដោយដៃ", + "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 ទៅ Aliases របស់ 9Router ក្នុង Config លើម៉ាស៊ីនរបស់អ្នក។", + "Mask (URL)": "Mask (URL)", + "Max JSON Size (KB)": "ទំហំ JSON អតិបរមា (KB)", + "Max Records": "ចំនួន Records អតិបរមា", + "Maximum request detail records to keep (older records are auto-deleted)": "ចំនួន Records ព័ត៌មានលម្អិតសំណើអតិបរមាដែលត្រូវរក្សា (Records ចាស់នឹងត្រូវលុបដោយស្វ័យប្រវត្តិ)", + "Maximum size for each JSON field (request/response) before truncation": "ទំហំអតិបរមាសម្រាប់ JSON Field នីមួយៗ (សំណើ/ការឆ្លើយតប) មុនពេលកាត់ខ្លី", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "រយៈពេលរង់ចាំអតិបរមាមុនពេល Flush Buffer (ការពារការបាត់ទិន្នន័យនៅពេល Traffic ទាប)", + "Media Providers": "អ្នកផ្តល់សេវាមេឌៀ", + "Menu": "ម៉ឺនុយ", + "Message AI": "ផ្ញើសារទៅ AI", + "Messages": "សារ", + "Messages API": "Messages API", + "MiniMax": "MiniMax", + "Model": "ម៉ូដែល", + "Model Fallback": "Fallback ម៉ូដែល", + "Model ID": "ID ម៉ូដែល", + "Model ID (from OpenRouter)": "ID ម៉ូដែល (ពី OpenRouter)", + "Model ID (optional)": "ID ម៉ូដែល (ជាជម្រើស)", + "Model Status": "ស្ថានភាពម៉ូដែល", + "Model combos": "Combo ម៉ូដែល", + "Model combos with fallback": "Combo ម៉ូដែលជាមួយ 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 ការប្រើ Token និងកំណត់ហេតុសំណើរបស់អ្នក", + "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 Keys — បង្កើតមួយនៅទំព័រ Keys", + "No API keys yet": "មិនទាន់មាន API Keys", + "No MCPs added": "មិនទាន់បានបន្ថែម MCPs", + "No Providers Connected": "មិនមានអ្នកផ្តល់សេវាដែលបានភ្ជាប់", + "No Proxy": "គ្មាន Proxy", + "No active connections found for this group.": "រកមិនឃើញការតភ្ជាប់សកម្មសម្រាប់ក្រុមនេះ។", + "No active 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": "មិនទាន់បានបន្ថែមអ្នកផ្តល់សេវាដែលត្រូវគ្នា", + "No connections": "មិនមានការតភ្ជាប់", + "No connections yet": "មិនទាន់មានការតភ្ជាប់", + "No console logs yet.": "មិនទាន់មាន Console Logs។", + "No conversations yet.": "មិនទាន់មានការសន្ទនា។", + "No custom providers": "មិនមានអ្នកផ្តល់សេវាផ្ទាល់ខ្លួន", + "No custom providers — use buttons above to add OpenAI/Anthropic compatible endpoints": "មិនមានអ្នកផ្តល់សេវាផ្ទាល់ខ្លួន — ប្រើប៊ូតុងខាងលើដើម្បីបន្ថែម Endpoints ដែលត្រូវគ្នាជាមួយ OpenAI/Anthropic", + "No data for this period": "មិនមានទិន្នន័យសម្រាប់រយៈពេលនេះ", + "No key configured": "មិនទាន់បានកំណត់ Key", + "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": "មិនត្រូវការ Port Forwarding", + "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": "មិនទាន់មានធាតុ Proxy Pool", + "No proxy:": "គ្មាន Proxy៖", + "No quota data available": "មិនមានទិន្នន័យ Quota", + "No request details found": "រកមិនឃើញព័ត៌មានលម្អិតសំណើ", + "No requests yet.": "មិនទាន់មានសំណើ។", + "No reset credit details returned for this account.": "មិនមានព័ត៌មានលម្អិត Credit កំណត់ឡើងវិញសម្រាប់គណនីនេះទេ។", + "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": "AI Agent ដែលអាចកែលម្អខ្លួនឯងរបស់ Nous Research", + "Number of items to accumulate before writing to database (higher = better performance)": "ចំនួនធាតុដែលត្រូវប្រមូល មុនពេលសរសេរទៅមូលដ្ឋានទិន្នន័យ (កាន់តែខ្ពស់ = ប្រសិទ្ធភាពកាន់តែល្អ)", + "OAuth": "OAuth", + "OAuth & API Keys": "OAuth និង API Keys", + "OAuth Account": "គណនី OAuth", + "OAuth App": "OAuth App", + "OAuth Providers": "អ្នកផ្តល់សេវា OAuth", + "OAuth required": "ត្រូវការ OAuth", + "OK": "ជោគជ័យ", + "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 ត្រូវបានបើក ប៉ុន្តែ Fields របស់ Issuer/Client មិនទាន់បានកំណត់ទេ។ ការចូលដោយពាក្យសម្ងាត់នៅតែអាចប្រើសម្រាប់ការសង្គ្រោះ។", + "OIDC only": "តែ OIDC", + "Observability": "Observability", + "Office Proxy": "Office Proxy", + "Ollama Host URL": "Ollama Host URL", + "One Endpoint for": "Endpoint តែមួយសម្រាប់", + "One key per line. Format:": "មួយ Key ក្នុងមួយបន្ទាត់។ ទម្រង់៖", + "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.": "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": "ជំនួយការ AI Open Claw", + "Open Claw CLI not detected locally": "រកមិនឃើញ Open Claw CLI នៅលើម៉ាស៊ីន", + "Open Claw CLI not installed": "មិនទាន់បានដំឡើង Open Claw CLI", + "Open Continue configuration file": "បើកឯកសារ Config របស់ 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 ក្នុង browser របស់អ្នក", + "OpenAI / ElevenLabs / Edge / Google / Deepgram voices.": "សំឡេង OpenAI / ElevenLabs / Edge / Google / Deepgram។", + "OpenAI Codex CLI": "OpenAI Codex CLI", + "OpenAI Compatible (Prod)": "OpenAI Compatible (Production)", + "OpenAI Compatible Details": "ព័ត៌មានលម្អិត OpenAI Compatible", + "OpenAI Intermediate": "OpenAI Intermediate", + "OpenAI Response": "ការឆ្លើយតប OpenAI", + "OpenCode - Manual Configuration": "OpenCode - កំណត់រចនាសម្ព័ន្ធដោយដៃ", + "OpenCode AI Terminal Assistant": "ជំនួយការ AI Terminal 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 គាំទ្រម៉ូដែលណាមួយ។ បន្ថែមម៉ូដែល និងបង្កើត Aliases ដើម្បីចូលប្រើបានរហ័ស។", + "Optional SSO via Authentik/Keycloak/Google": "SSO ជាជម្រើសតាមរយៈ Authentik/Keycloak/Google", + "Or paste callback URL manually": "ឬបិទភ្ជាប់ Callback URL ដោយដៃ", + "Organization": "អង្គភាព", + "Organization Domain": "Domain របស់អង្គភាព", + "Organization ID": "ID អង្គភាព", + "Organization Token": "Token អង្គភាព", + "Organization Tokens": "Tokens អង្គភាព", + "Other": "ផ្សេងទៀត", + "Our engine analyzes the prompt and routes through your subscription, cheap, and free provider tiers with automatic fallback.": "Engine របស់យើងវិភាគ Prompt ហើយបញ្ជូនផ្លូវតាម Subscription របស់អ្នក អ្នកផ្តល់សេវាតម្លៃថោក និង Free Tier ជាមួយ Fallback ដោយស្វ័យប្រវត្តិ។", + "Our engine analyzes the prompt, checks provider health, and routes for lowest latency or cost.": "Engine របស់យើងវិភាគ Prompt ពិនិត្យស្ថានភាពអ្នកផ្តល់សេវា ហើយបញ្ជូនផ្លូវដើម្បីទទួលបានរយៈពេលឆ្លើយតប ឬចំណាយទាបបំផុត។", + "Out": "ចេញ", + "Outbound Proxy": "Proxy ចេញ", + "Output": "Output", + "Output Cost": "ចំណាយ Output", + "Output Format": "ទម្រង់ Output", + "Output Tokens": "Output Tokens", + "Output Tokens:": "Output Tokens៖", + "Output:": "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)": "បិទភ្ជាប់បញ្ជី Proxy (មួយក្នុងមួយបន្ទាត់)", + "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 (មិនមាន Refresh)។", + "Paste external_idp auth JSON from CLIProxyAPI/Kiro Microsoft login.": "បិទភ្ជាប់ external_idp auth JSON ពីការចូល CLIProxyAPI/Kiro Microsoft។", + "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។ ទទួលយកតែ Token Endpoints សម្រាប់ការចូល Microsoft ប៉ុណ្ណោះ។", + "Paste the URL from your browser address bar": "បិទភ្ជាប់ URL ពីរបារអាសយដ្ឋានរបស់ browser", + "Paste the command into your terminal and press Enter.": "បិទភ្ជាប់ Command ទៅក្នុង 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": "ជ្រើសម៉ូដែលដែលសំយោគចម្លើយពី Panel", + "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": "សូមបញ្ចូល Proxy URL ដើម្បីសាកល្បង", + "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:": "Pool៖", + "Popup blocked? Enter URL manually": "Popup ត្រូវបានទប់ស្កាត់? បញ្ចូល URL ដោយដៃ", + "Port 443 Already In Use": "Port 443 កំពុងត្រូវបានប្រើ", + "Port 443 is currently used by another process:": "Port 443 បច្ចុប្បន្នកំពុងត្រូវបានប្រើដោយ Process មួយទៀត៖", + "Powerful Features": "មុខងារដ៏មានអានុភាព", + "Prefix": "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": "Production Key", + "Project Name": "ឈ្មោះ Project", + "Prompt": "Prompt", + "Provider": "អ្នកផ្តល់សេវា", + "Provider Details": "ព័ត៌មានលម្អិតអ្នកផ្តល់សេវា", + "Provider Limits": "កម្រិតអ្នកផ្តល់សេវា", + "Provider Response": "ការឆ្លើយតបពីអ្នកផ្តល់សេវា", + "Provider not found": "រកមិនឃើញអ្នកផ្តល់សេវា", + "Provider test failed": "ការសាកល្បងអ្នកផ្តល់សេវាបានបរាជ័យ", + "Provider:": "អ្នកផ្តល់សេវា៖", + "Providers": "អ្នកផ្តល់សេវា", + "Proxy": "Proxy", + "Proxy Action": "សកម្មភាព Proxy", + "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", + "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។ សូមដំឡើង Python ជាមុន ឬប្រើ Proxy URL ខាងក្រៅ។", + "Python ≥ 3.10 required for local managed mode. Install Python first, or use an external proxy URL.": "របៀបគ្រប់គ្រងលើម៉ាស៊ីនត្រូវការ Python ≥ 3.10។ សូមដំឡើង Python ជាមុន ឬប្រើ Proxy URL ខាងក្រៅ។", + "Quota Tracker": "កម្មវិធីតាមដាន Quota", + "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 ដំណើរការជា Endpoint ដែលត្រូវគ្នាជាមួយ OpenAI។", + "Qwen OAuth free tier was discontinued on 2026-04-15. Use 9Router with alicode/openrouter/anthropic/gemini providers instead.": "Qwen OAuth Free Tier ត្រូវបានបញ្ឈប់នៅថ្ងៃទី 2026-04-15។ សូមប្រើ 9Router ជាមួយអ្នកផ្តល់សេវា alicode/openrouter/anthropic/gemini ជំនួសវិញ។", + "Rate Limited": "ត្រូវបានកម្រិតអត្រា", + "Read Documentation": "អានឯកសារ", + "Reading from AWS SSO cache": "កំពុងអានពី AWS SSO Cache", + "Reading from Cursor IDE database": "កំពុងអានពីមូលដ្ឋានទិន្នន័យ Cursor IDE", + "Ready": "រួចរាល់", + "Ready to Simplify Your AI Infrastructure?": "ត្រៀមខ្លួនសម្រួលហេដ្ឋារចនាសម្ព័ន្ធ AI របស់អ្នកហើយឬនៅ?", + "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": "Redirect URI", + "Ref Image (URL)": "រូបភាពយោង (URL)", + "Refresh": "ផ្ទុកឡើងវិញ", + "Refresh All": "ផ្ទុកទាំងអស់ឡើងវិញ", + "Refresh Token": "Refresh Token", + "Refresh all": "ផ្ទុកទាំងអស់ឡើងវិញ", + "Refresh quota": "ផ្ទុក 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": "សំណើដែលគ្មាន Key ត្រឹមត្រូវនឹងត្រូវបដិសេធ", + "Require API key": "ត្រូវការ API Key", + "Require OIDC for dashboard access.": "ត្រូវការ OIDC ដើម្បីចូលផ្ទាំងគ្រប់គ្រង។", + "Require login": "ត្រូវការចូល", + "Required for SSL certificate and DNS configuration": "ត្រូវការសម្រាប់ SSL Certificate និងការកំណត់ DNS", + "Required for SSL certificate and server startup": "ត្រូវការសម្រាប់ SSL Certificate និងការចាប់ផ្តើមម៉ាស៊ីនមេ", + "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.": "ចាំបាច់។ ប្រើជា Prefix អ្នកផ្តល់សេវាសម្រាប់ ID ម៉ូដែល។", + "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 ដើម្បីប្រើមុខងារនេះ។", + "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.": "ត្រូវការ Port ចេញ 7844 (TCP/UDP)។ ការតភ្ជាប់អាចចំណាយពេល 10-30 វិនាទី។", + "Reset": "កំណត់ឡើងវិញ", + "Reset Codex limit?": "កំណត់កម្រិត Codex ឡើងវិញមែនទេ?", + "Reset Password to Default": "កំណត់ពាក្យសម្ងាត់ទៅលំនាំដើម", + "Reset judge to Auto": "កំណត់ Judge ទៅស្វ័យប្រវត្តិ", + "Reset time": "ពេលវេលាកំណត់ឡើងវិញ", + "Reset to Defaults": "កំណត់ទៅលំនាំដើម", + "Reset to default": "កំណត់ទៅលំនាំដើម", + "Resources": "ធនធាន", + "Response": "ការឆ្លើយតប", + "Response Format": "ទម្រង់ការឆ្លើយតប", + "Responses": "ការឆ្លើយតប", + "Responses API": "Responses API", + "Restart": "ចាប់ផ្តើមឡើងវិញ", + "Restore model": "ស្តារម៉ូដែល", + "Resume key": "បន្ត Key", + "Retry": "ព្យាយាមម្តងទៀត", + "Risk Notice": "សេចក្តីជូនដំណឹងអំពីហានិភ័យ", + "Roo AI Assistant": "ជំនួយការ AI Roo", + "Rotate providers across requests instead of strict fallback order.": "ប្តូរវេនអ្នកផ្តល់សេវារវាងសំណើ ជំនួសឱ្យលំដាប់ Fallback តឹងរ៉ឹង។", + "Round Robin": "Round Robin", + "Round Robin — rotate": "Round Robin — ប្តូរវេន", + "Round Robin — rotates models across requests to spread load": "Round Robin — ប្តូរវេនម៉ូដែលរវាងសំណើ ដើម្បីចែកចាយបន្ទុក", + "Route AI requests through subscription, cheap, and free tiers with auto-fallback. One endpoint for Claude, GPT, Gemini, and more.": "បញ្ជូនសំណើ AI តាម Subscription, Tier តម្លៃថោក និងឥតគិតថ្លៃ ជាមួយ Fallback ដោយស្វ័យប្រវត្តិ។ Endpoint តែមួយសម្រាប់ Claude, GPT, Gemini និងផ្សេងទៀត។", + "Route Requests": "បញ្ជូនសំណើ", + "Routing Strategy": "យុទ្ធសាស្ត្របញ្ជូនផ្លូវ", + "Rows:": "ជួរដេក៖", + "Run": "ដំណើរការ", + "Run npx command to start the server instantly": "ដំណើរការ Command npx ដើម្បីចាប់ផ្តើមម៉ាស៊ីនមេភ្លាមៗ", + "Run this command in your terminal, then click": "ដំណើរការ Command នេះក្នុង terminal របស់អ្នក បន្ទាប់មកចុច", + "Running": "កំពុងដំណើរការ", + "Running on your machine": "កំពុងដំណើរការលើម៉ាស៊ីនរបស់អ្នក", + "Runtime": "Runtime", + "SSE URL": "SSE URL", + "START HERE": "ចាប់ផ្តើមទីនេះ", + "Save": "រក្សាទុក", + "Save Changes": "រក្សាទុកការផ្លាស់ប្តូរ", + "Save Config": "រក្សាទុក Config", + "Save Mappings": "រក្សាទុកការផ្គូផ្គង", + "Save auth mode": "រក្សាទុករបៀបផ្ទៀងផ្ទាត់", + "Save current Base URL and API key as a browser-local preset": "រក្សាទុក Base URL និង API Key បច្ចុប្បន្នជា Preset ក្នុង browser", + "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": "ស្វែងរក 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": "ជ្រើស Endpoint", + "Select Judge Model": "ជ្រើសម៉ូដែល Judge", + "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": "ជ្រើសម៉ូដែល Subagent សម្រាប់ Codex", + "Select Subagent Model for OpenCode": "ជ្រើសម៉ូដែល Subagent សម្រាប់ OpenCode", + "Select a provider": "ជ្រើសអ្នកផ្តល់សេវា", + "Select all": "ជ្រើសទាំងអស់", + "Select language": "ជ្រើសភាសា", + "Select models to add": "ជ្រើសម៉ូដែលដើម្បីបន្ថែម", + "Select one or more connections, then click Proxy Action.": "ជ្រើសការតភ្ជាប់មួយ ឬច្រើន បន្ទាប់មកចុច សកម្មភាព Proxy។", + "Select to pre-fill, then edit model ID in the input": "ជ្រើសដើម្បីបំពេញជាមុន បន្ទាប់មកកែ ID ម៉ូដែលក្នុង Input", + "Select your": "ជ្រើសរើសរបស់អ្នក", + "Selected connections have mixed proxy bindings": "ការតភ្ជាប់ដែលបានជ្រើសមានការភ្ជាប់ Proxy ចម្រុះ", + "Selected only": "តែអ្វីដែលបានជ្រើស", + "Selected provider": "អ្នកផ្តល់សេវាដែលបានជ្រើស", + "Selecting None will unbind selected connections from proxy pool.": "ការជ្រើស គ្មាន នឹងដកការតភ្ជាប់ដែលបានជ្រើសចេញពី 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.": "សេវាកំពុងដំណើរការក្នុង terminal។ អ្នកអាចបិទទំព័រវេបនេះបាន។ ការបិទនឹងបញ្ឈប់សេវា។", + "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.": "ការរៀបចំ + បញ្ជីមុខងារទាំងអស់។ ចាប់ផ្តើមទីនេះ — គ្របដណ្តប់ Base URL, ការផ្ទៀងផ្ទាត់, ការរកឃើញម៉ូដែល និង Links ទៅមុខងារនីមួយៗ។", + "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!": "ផ្ទៃជជែកសាមញ្ញសម្រាប់ទាក់ទងជាមួយម៉ូដែល AI ណាមួយពីអ្នកផ្តល់សេវាដែលបានភ្ជាប់។ ជ្រើសម៉ូដែល ហើយចាប់ផ្តើមជជែក!", + "Single": "តែមួយ", + "Single API endpoint for all major AI providers. Simplify your integration.": "API Endpoint តែមួយសម្រាប់អ្នកផ្តល់សេវា AI សំខាន់ៗទាំងអស់។ ធ្វើឱ្យការរួមបញ្ចូលរបស់អ្នកកាន់តែងាយស្រួល។", + "Some models are not responding": "ម៉ូដែលមួយចំនួនមិនកំពុងឆ្លើយតប", + "Sort Codex quotas by remaining": "រៀបលំដាប់ Codex Quotas តាមចំនួននៅសល់", + "Sort accounts by earliest quota reset time": "រៀបលំដាប់គណនីតាមពេលកំណត់ Quota ឡើងវិញដែលឆាប់បំផុត", + "Source Body": "ខ្លឹមសារសំណើដើម", + "Sourcegraph Amp coding assistant CLI": "CLI ជំនួយការសរសេរកូដ Sourcegraph Amp", + "Special reasoning/thinking tokens (fallback to output rate)": "Tokens ពិសេសសម្រាប់ការគិតហេតុផល/ការគិត (Fallback ទៅអត្រា Output)", + "Speech To Text": "សំឡេងទៅអត្ថបទ", + "Speech-to-Text": "សំឡេងទៅអត្ថបទ", + "Standard prompt tokens": "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 នេះក្នុង browser របស់អ្នក", + "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", + "Sudo Password Required": "ត្រូវការពាក្យសម្ងាត់ Sudo", + "Sudo password is required": "ត្រូវការពាក្យសម្ងាត់ Sudo", + "Suggested free models (≥200k context):": "ម៉ូដែលឥតគិតថ្លៃដែលបានណែនាំ (Context ≥200k)៖", + "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": "គាំទ្រ Apps សកម្មរហូតដល់ 20 និង Custom Domains ចំនួន 50", + "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.": "ធ្វើសមកាលកម្មការកំណត់រវាងឧបករណ៍ ជាមួយ 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": "សំណើគោលដៅ", + "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%)": "System Prompt បែបសង្ខេប → កាត់បន្ថយ Output Tokens ប្រហែល 65% (រហូតដល់ 87%)", + "Test": "សាកល្បង", + "Test Again": "សាកល្បងម្តងទៀត", + "Test All": "សាកល្បងទាំងអស់", + "Test Example": "ឧទាហរណ៍សាកល្បង", + "Test Results": "លទ្ធផលសាកល្បង", + "Test all API Key connections": "សាកល្បងការតភ្ជាប់ API Key ទាំងអស់", + "Test all Compatible connections": "សាកល្បងការតភ្ជាប់ Compatible ទាំងអស់", + "Test all Free connections": "សាកល្បងការតភ្ជាប់ឥតគិតថ្លៃទាំងអស់", + "Test all Free provider connections": "សាកល្បងការតភ្ជាប់អ្នកផ្តល់សេវាឥតគិតថ្លៃទាំងអស់", + "Test all OAuth connections": "សាកល្បងការតភ្ជាប់ OAuth ទាំងអស់", + "Test connection": "សាកល្បងការតភ្ជាប់", + "Test model": "សាកល្បងម៉ូដែល", + "Test proxy": "សាកល្បង Proxy", + "Test proxy URL": "សាកល្បង Proxy URL", + "Testing...": "កំពុងសាកល្បង...", + "Text To Speech": "អត្ថបទទៅសំឡេង", + "Text To Speech combo": "Combo អត្ថបទទៅសំឡេង", + "Text to Image": "អត្ថបទទៅរូបភាព", + "Text to Image combo": "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 Tunnel នឹងត្រូវផ្តាច់។ ការចូលពីចម្ងាយតាម Tunnel URL នឹងឈប់ដំណើរការ។", + "The proxy server has been stopped.": "ម៉ាស៊ីនមេ Proxy ត្រូវបានបញ្ឈប់។", + "The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.": "សំណើត្រូវបានបំពេញភ្លាមៗដោយ 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។ ភ្ជាប់ បញ្ជូនផ្លូវ និងគ្រប់គ្រងអ្នកផ្តល់សេវា AI របស់អ្នកយ៉ាងងាយស្រួល។", + "The unified interface for modern AI infrastructure": "ផ្ទៃប្រើប្រាស់រួមសម្រាប់ហេដ្ឋារចនាសម្ព័ន្ធ AI ទំនើប", + "The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "ផ្ទៃប្រើប្រាស់រួមសម្រាប់ហេដ្ឋារចនាសម្ព័ន្ធ AI ទំនើប។ មានសុវត្ថិភាព អាចតាមដាន និងអាចពង្រីកបាន។", + "Theme": "រូបរាង", + "Thinking": "ការគិត", + "Thinking Process": "ដំណើរការគិត", + "This is the only time you will see this key. Store it securely.": "នេះជាលើកតែមួយដែលអ្នកនឹងឃើញ Key នេះ។ សូមរក្សាទុកវាឱ្យមានសុវត្ថិភាព។", + "This provider is ready to use.": "អ្នកផ្តល់សេវានេះរួចរាល់សម្រាប់ប្រើ។", + "This provider is ready to use. Optionally route requests through a proxy pool to bypass IP-based limits.": "អ្នកផ្តល់សេវានេះរួចរាល់សម្រាប់ប្រើ។ អ្នកអាចជ្រើសបញ្ជូនសំណើតាម Proxy Pool ដើម្បីរំលងកម្រិតផ្អែកលើ IP។", + "This value is write-only after saving.": "តម្លៃនេះអាចសរសេរបានតែប៉ុណ្ណោះ បន្ទាប់ពីរក្សាទុក។", + "Time": "ពេលវេលា", + "Timestamp": "ពេលវេលាកត់ត្រា", + "Timestamp:": "ពេលវេលាកត់ត្រា៖", + "To get a fresh API key, paste your browser cookie from": "ដើម្បីទទួលបាន API Key ថ្មី សូមបិទភ្ជាប់ browser cookie របស់អ្នកពី", + "Today": "ថ្ងៃនេះ", + "Toggle DNS to redirect": "បើក/បិទ DNS ដើម្បីបញ្ជូនបន្ត", + "Toggle auto-ping": "បើក/បិទ Ping ស្វ័យប្រវត្តិ", + "Token Saver": "Token Saver", + "Token Types:": "ប្រភេទ Token៖", + "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 និងមិនត្រូវបានរក្សាទុក។ អាចរកឃើញក្នុងការកំណត់អង្គភាព។", + "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 (Fallback ទៅអត្រា Input)", + "Tomorrow": "ថ្ងៃស្អែក", + "Tool not found or disabled.": "រកមិនឃើញឧបករណ៍ ឬឧបករណ៍ត្រូវបានបិទ។", + "Tools": "ឧបករណ៍", + "Tools:": "ឧបករណ៍៖", + "Total Cost": "ចំណាយសរុប", + "Total Input Tokens": "Input Tokens សរុប", + "Total Models": "ម៉ូដែលសរុប", + "Total Requests": "សំណើសរុប", + "Total Tokens": "Tokens សរុប", + "Total:": "សរុប៖", + "Track and manage your API quota limits": "តាមដាន និងគ្រប់គ្រងកម្រិត API Quota របស់អ្នក", + "Track token usage, costs, and performance across all providers.": "តាមដានការប្រើ Token ចំណាយ និងប្រសិទ្ធភាពនៅទូទាំងអ្នកផ្តល់សេវាទាំងអស់។", + "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.": "សាកល្បងតាមលំដាប់ (ពីលើចុះក្រោម) ឬប្តូរវេននៅពេលបើក Round Robin។", + "Trust Cert": "ទុកចិត្ត Certificate", + "Trusted": "បានទុកចិត្ត", + "Try Again": "ព្យាយាមម្តងទៀត", + "Tunnel": "Tunnel", + "Tunnel connected!": "បានភ្ជាប់ Tunnel!", + "Tunnel disabled": "Tunnel បានបិទ", + "Turn off Empty": "បិទធាតុដែលអស់", + "Turn on Available": "បើកធាតុដែលនៅមាន", + "Turn request detail recording on/off globally": "បើក/បិទការកត់ត្រាព័ត៌មានលម្អិតសំណើជាសកល", + "Twitter": "Twitter", + "Type": "ប្រភេទ", + "URL → markdown / text / HTML via Firecrawl, Jina, Tavily, Exa.": "URL → markdown / text / HTML តាម Firecrawl, Jina, Tavily, Exa។", + "Unavailable": "មិនមាន", + "Under": "ក្រោម", + "Unified Endpoint": "Endpoint រួម", + "Unknown": "មិនស្គាល់", + "Unselect all": "ដកការជ្រើសទាំងអស់", + "Update": "ធ្វើបច្ចុប្បន្នភាព", + "Update 9Router": "ធ្វើបច្ចុប្បន្នភាព 9Router", + "Update Password": "ធ្វើបច្ចុប្បន្នភាពពាក្យសម្ងាត់", + "Update now": "ធ្វើបច្ចុប្បន្នភាពឥឡូវនេះ", + "Upstream Auth Error": "កំហុសផ្ទៀងផ្ទាត់ពី Upstream", + "Upstream Unavailable": "Upstream មិនអាចប្រើបាន", + "Usage": "ការប្រើប្រាស់", + "Usage & Analytics": "ការប្រើប្រាស់ និងការវិភាគ", + "Usage / Limit": "ការប្រើប្រាស់ / កម្រិត", + "Usage Logs": "កំណត់ហេតុការប្រើប្រាស់", + "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.": "ប្រើ Aliases ម៉ូដែលរបស់ 9Router ដើម្បីរក្សាការផ្គូផ្គងឈ្មោះកាត់ Amp ឱ្យមានស្ថិរភាព នៅពេលអ្នកផ្តល់សេវាធ្វើបច្ចុប្បន្នភាព។", + "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 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.": "ប្រើ Proxy លើម៉ាស៊ីនសម្រាប់ ចាប់ផ្តើម/បញ្ឈប់ ឬ 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": "ប្រើប៊ូតុងខាងលើ ដើម្បីបន្ថែម Endpoints ដែលត្រូវគ្នាជាមួយ OpenAI ឬ Anthropic", + "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 បម្រើ Apps រាប់លាន — អ្នកផ្តល់សេវាមិនអាចទប់ស្កាត់ Vercel IPs ដោយមិនប៉ះពាល់ដល់ Traffic ស្របច្បាប់បានទេ", + "Verification URL": "URL ផ្ទៀងផ្ទាត់", + "Version": "កំណែ", + "Video": "វីដេអូ", + "View Codex reset credit expiry": "មើលថ្ងៃផុតកំណត់ Credit កំណត់ឡើងវិញរបស់ Codex", + "View Full Details": "មើលព័ត៌មានលម្អិតទាំងស្រុង", + "View on GitHub": "មើលនៅ GitHub", + "Visit the URL below and enter the code:": "ចូលទៅ URL ខាងក្រោម ហើយបញ្ចូល Code៖", + "Visit the login URL below and authorize:": "ចូលទៅ URL សម្រាប់ចូលខាងក្រោម ហើយផ្តល់សិទ្ធិ៖", + "Voice": "សំឡេង", + "Voice ID": "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៖", + "Windows: Run 9Router terminal as Administrator": "Windows៖ ដំណើរការ Terminal របស់ 9Router ជា Administrator", + "Windows: Run terminal (9Router) as Administrator to enable MITM": "Windows៖ ដំណើរការ Terminal (9Router) ជា Administrator ដើម្បីបើក 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": "Code របស់អ្នក", + "Your Kiro account via": "គណនី Kiro របស់អ្នកតាមរយៈ", + "Your OAuth application client ID": "Client ID នៃ OAuth Application របស់អ្នក", + "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.": "សំណើរបស់អ្នកចាប់ផ្តើមពីឧបករណ៍ដែលអ្នកពេញចិត្ត ឬ SDK រួមរបស់យើង។ គ្រាន់តែប្តូរ Base URL។", + "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": "បន្ថែម 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": "ដុល្លារក្នុងមួយលាន 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": "សំណើនីមួយៗគិតថ្លៃលើម៉ូដែល Panel ទាំងអស់ + 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", + "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 មានអង្គចងចាំតាមន័យ ក្រុម Agent ច្រើន និងប្រសិទ្ធភាពខ្ពស់បំផុត (RAM 27.8 MB, Boot 14ms)។", + "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": "ms / សរុប", + "name|apiKey": "name|apiKey", + "no_proxy:": "no_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": "Context ជាជម្រើសដើម្បីបង្កើនភាពត្រឹមត្រូវ", + "or VS Code extension marketplace.": "ឬទីផ្សារ Extension របស់ 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)": "សួរម៉ូដែលទាំងអស់ស្របពេលគ្នា បន្ទាប់មក Judge សំយោគជាចម្លើយតែមួយ។ គុណភាពល្អបំផុត ប៉ុន្តែចំណាយខ្ពស់បំផុត៖ សំណើនីមួយៗគិតថ្លៃលើម៉ូដែល Panel ទាំងអស់ + Judge (N+1 Calls)", + "records, batches every": "Records, បាច់រៀងរាល់", + "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.": "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)": "— សួរម៉ូដែលទាំងអស់ស្របពេលគ្នា បន្ទាប់មក Judge សំយោគជាចម្លើយតែមួយ។ គុណភាពល្អបំផុត ប៉ុន្តែចំណាយខ្ពស់បំផុត៖ សំណើនីមួយៗគិតថ្លៃលើម៉ូដែល Panel ទាំងអស់ + Judge (N+1 Calls)", + "— 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": "⚠️ 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 (Antigravity, GitHub Copilot, Kiro) តាម Local CA ដើម្បីបញ្ជូនសំណើទៅអ្នកផ្តល់សេវារបស់អ្នក។ អាចរំលោភ 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.": "⚠️ សេចក្តីជូនដំណឹងអំពីហានិភ័យ៖ អ្នកផ្តល់សេវានេះប្រើ Subscription/OAuth Session ដែលមិនមានអាជ្ញាបណ្ណផ្លូវការសម្រាប់ប្រើជា Proxy/Router។ គណនីអាចត្រូវបានដាក់កម្រិត ឬហាមឃាត់។ ប្រើដោយទទួលខុសត្រូវលើហានិភ័យដោយខ្លួនឯង។", + "✓ Confirm Add": "✓ បញ្ជាក់ការបន្ថែម", + "📝 Configure providers in dashboard or use environment variables": "📝 កំណត់អ្នកផ្តល់សេវាក្នុងផ្ទាំងគ្រប់គ្រង ឬប្រើ Environment Variables", + "🔐 OAuth required. Add now and authenticate after Apply; tool list will be discovered after first connect.": "🔐 ត្រូវការ OAuth។ បន្ថែមឥឡូវ ហើយផ្ទៀងផ្ទាត់បន្ទាប់ពីចុច អនុវត្ត; បញ្ជីឧបករណ៍នឹងត្រូវបានរកឃើញបន្ទាប់ពីការតភ្ជាប់លើកដំបូង។" +} diff --git a/public/providers/agentrouter.png b/public/providers/agentrouter.png new file mode 100644 index 00000000..6b5ca60d Binary files /dev/null and b/public/providers/agentrouter.png differ diff --git a/public/providers/codebuddy-cn.png b/public/providers/codebuddy-cn.png index eae3f4c1..c836282f 100644 Binary files a/public/providers/codebuddy-cn.png and b/public/providers/codebuddy-cn.png differ diff --git a/public/providers/commandcode.png b/public/providers/commandcode.png index ed7c8c99..3570fb00 100644 Binary files a/public/providers/commandcode.png and b/public/providers/commandcode.png differ diff --git a/public/providers/featherless.png b/public/providers/featherless.png new file mode 100644 index 00000000..b918a0ff Binary files /dev/null and b/public/providers/featherless.png differ diff --git a/public/providers/gitlab.png b/public/providers/gitlab.png new file mode 100644 index 00000000..9c790b1a Binary files /dev/null and b/public/providers/gitlab.png differ diff --git a/public/providers/longcat.png b/public/providers/longcat.png new file mode 100644 index 00000000..e72dffb9 Binary files /dev/null and b/public/providers/longcat.png differ diff --git a/public/providers/mmf.png b/public/providers/mmf.png new file mode 100644 index 00000000..3fbdd81d Binary files /dev/null and b/public/providers/mmf.png differ diff --git a/public/providers/morph.png b/public/providers/morph.png new file mode 100644 index 00000000..3034fa5e Binary files /dev/null and b/public/providers/morph.png differ diff --git a/public/providers/novita.png b/public/providers/novita.png new file mode 100644 index 00000000..d765fc56 Binary files /dev/null and b/public/providers/novita.png differ diff --git a/public/providers/perplexity-agent.png b/public/providers/perplexity-agent.png new file mode 100644 index 00000000..0b5851e5 Binary files /dev/null and b/public/providers/perplexity-agent.png differ diff --git a/public/providers/qoder.png b/public/providers/qoder.png index 41e81c1d..be87ec61 100644 Binary files a/public/providers/qoder.png and b/public/providers/qoder.png differ diff --git a/public/providers/reka.png b/public/providers/reka.png new file mode 100644 index 00000000..00016257 Binary files /dev/null and b/public/providers/reka.png differ diff --git a/public/providers/venice.png b/public/providers/venice.png new file mode 100644 index 00000000..38c528b5 Binary files /dev/null and b/public/providers/venice.png differ diff --git a/public/providers/vercel-ai-gateway.png b/public/providers/vercel-ai-gateway.png new file mode 100644 index 00000000..0319f0e6 Binary files /dev/null and b/public/providers/vercel-ai-gateway.png differ diff --git a/public/providers/vercel.png b/public/providers/vercel.png new file mode 100644 index 00000000..0319f0e6 Binary files /dev/null and b/public/providers/vercel.png differ diff --git a/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js b/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js index 47636dd5..b686138d 100644 --- a/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js +++ b/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js @@ -792,7 +792,7 @@ export default function BasicChatPageClient() {
{message.attachments.map((attachment) => ( - {attachment.name} + {attachment.name} ))}
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js index 48962cca..6a7339d3 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js @@ -38,15 +38,10 @@ export default function AntigravityToolCard({ }, [initialStatus]); useEffect(() => { - if (isExpanded && !status) { - fetchStatus(); - loadSavedMappings(); - fetchModelAliases(); - } - if (isExpanded) { - loadSavedMappings(); - fetchModelAliases(); - } + if (!isExpanded) return; + if (!status) fetchStatus(); + loadSavedMappings(); + fetchModelAliases(); }, [isExpanded]); const loadSavedMappings = async () => { @@ -243,6 +238,8 @@ export default function AntigravityToolCard({ className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} + loading="lazy" + decoding="async" />
@@ -467,15 +464,17 @@ export default function AntigravityToolCard({ {/* Model Select Modal */} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} - activeProviders={activeProviders} - modelAliases={modelAliases} - title={`Select model for ${currentEditingAlias}`} - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} + activeProviders={activeProviders} + modelAliases={modelAliases} + title={`Select model for ${currentEditingAlias}`} + /> + )} ); } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js index a5c323dd..2ab19967 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { Button, Card, ModelSelectModal } from "@/shared/components"; +import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import Image from "next/image"; import ApiKeySelect from "./ApiKeySelect"; @@ -332,21 +333,32 @@ export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, active className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} + loading="lazy" + decoding="async" /> ); } if (tool.icon) { return {tool.icon}; } + const iconSrc = getProviderIconSrc(toolId); + if (!iconSrc) { + return {(toolId || "?").slice(0, 2).toUpperCase()}; + } return ( {tool.name} { e.target.style.display = "none"; }} + onError={(e) => { + markProviderIconMissing(toolId); + e.target.style.display = "none"; + }} + loading="lazy" + decoding="async" /> ); }; @@ -377,18 +389,20 @@ export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, active
)} - setShowModelModal(false)} - onSelect={tool.modelSelection === "multiple" ? addSelectedModel : handleSelectModel} - selectedModel={modelValue} - activeProviders={activeProviders} - title={tool.modelSelection === "multiple" ? "Add Cursor custom model" : "Select Model"} - closeOnSelect={tool.modelSelection !== "multiple"} - addedModelValues={tool.modelSelection === "multiple" ? selectedModels : []} - allowDuplicates={tool.modelSelection === "multiple"} - availableModels={availableModels} - /> + {showModelModal && ( + setShowModelModal(false)} + onSelect={tool.modelSelection === "multiple" ? addSelectedModel : handleSelectModel} + selectedModel={modelValue} + activeProviders={activeProviders} + title={tool.modelSelection === "multiple" ? "Add Cursor custom model" : "Select Model"} + closeOnSelect={tool.modelSelection !== "multiple"} + addedModelValues={tool.modelSelection === "multiple" ? selectedModels : []} + allowDuplicates={tool.modelSelection === "multiple"} + availableModels={availableModels} + /> + )} ); } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/MitmLinkCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/MitmLinkCard.js index 865128e0..274e01d8 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/MitmLinkCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/MitmLinkCard.js @@ -22,6 +22,8 @@ export default function MitmLinkCard({ tool }) { className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} + loading="lazy" + decoding="async" />
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js index 8175cf2b..d37acfdb 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js @@ -146,6 +146,8 @@ export default function MitmToolCard({ className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} + loading="lazy" + decoding="async" />
@@ -307,16 +309,18 @@ export default function MitmToolCard({ )} {/* Model Select Modal */} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} - activeProviders={activeProviders} - modelAliases={modelAliases} - availableModels={availableModels} - title={`Select model for ${currentEditingAlias}`} - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} + activeProviders={activeProviders} + modelAliases={modelAliases} + availableModels={availableModels} + title={`Select model for ${currentEditingAlias}`} + /> + )} ); } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ToolSummaryCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ToolSummaryCard.js index 98740209..a59552bc 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ToolSummaryCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ToolSummaryCard.js @@ -12,7 +12,7 @@ export default function ToolSummaryCard({ toolId, tool }) {
{tool.image ? ( - {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" /> ) : tool.icon ? ( {tool.icon} ) : null} diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index b5c03d7f..7198e4ad 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -24,7 +24,7 @@ export default function CombosPage() { useEffect(() => { fetchData(); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, []); async function fetchData() { try { @@ -205,23 +205,27 @@ export default function CombosPage() { )} {/* Create Modal - Use key to force remount and reset state */} - setShowCreateModal(false)} - onSave={handleCreate} - availableModels={selectableModels} - /> + {showCreateModal && ( + setShowCreateModal(false)} + onSave={handleCreate} + availableModels={selectableModels} + /> + )} {/* Edit Modal - Use key to force remount and reset state */} - setEditingCombo(null)} - onSave={(data) => handleUpdate(editingCombo.id, data)} - availableModels={selectableModels} - /> + {editingCombo && ( + setEditingCombo(null)} + onSave={(data) => handleUpdate(editingCombo.id, data)} + availableModels={selectableModels} + /> + )} {/* Confirm Delete Modal */} {/* Judge model picker (single-select; combo members make natural judges too) */} - setShowJudgeSelect(false)} - onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }} - availableModels={availableModels} - title="Select Judge Model" - addedModelValues={judge ? [judge] : []} - closeOnSelect={true} - /> + {showJudgeSelect && ( + setShowJudgeSelect(false)} + onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }} + availableModels={availableModels} + title="Select Judge Model" + addedModelValues={judge ? [judge] : []} + closeOnSelect={true} + /> + )} ); } @@ -631,17 +637,19 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, availableModels = [], {/* Model Select Modal */} - setShowModelSelect(false)} - onSelect={handleAddModel} - onDeselect={handleDeselectModel} - availableModels={availableModels} - title="Add Model to Combo" - kindFilter={kindFilter} - addedModelValues={models} - closeOnSelect={false} - /> + {showModelSelect && ( + setShowModelSelect(false)} + onSelect={handleAddModel} + onDeselect={handleDeselectModel} + availableModels={availableModels} + title="Add Model to Combo" + kindFilter={kindFilter} + addedModelValues={models} + closeOnSelect={false} + /> + )} ); } diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js index 526217ee..7c6e490a 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js @@ -332,6 +332,8 @@ export function GenericExampleCard({ providerId, kind }) { className="max-h-40 rounded-lg border border-border object-contain bg-sidebar" onError={(e) => { e.currentTarget.style.display = "none"; }} onLoad={(e) => { e.currentTarget.style.display = "block"; }} + loading="lazy" + decoding="async" /> )}
@@ -365,6 +367,8 @@ export function GenericExampleCard({ providerId, kind }) { className="max-h-40 rounded-lg border border-border object-contain bg-sidebar" onError={(e) => { e.currentTarget.style.display = "none"; }} onLoad={(e) => { e.currentTarget.style.display = "block"; }} + loading="lazy" + decoding="async" /> )}
@@ -469,6 +473,8 @@ export function GenericExampleCard({ providerId, kind }) { src={`data:image/png;base64,${partialImage.b64_json}`} alt="Partial" className="max-w-full rounded-lg border border-border mt-1.5 opacity-80" + loading="lazy" + decoding="async" />
)} @@ -511,6 +517,8 @@ export function GenericExampleCard({ providerId, kind }) { src={binaryImageUrl || (result?.data?.data?.[0]?.b64_json ? `data:image/png;base64,${result.data.data[0].b64_json}` : result?.data?.data?.[0]?.url)} alt="Generated" className="max-w-full rounded-lg border border-border" + loading="lazy" + decoding="async" /> )} diff --git a/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js index 80aff003..2c6637e5 100644 --- a/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js @@ -352,7 +352,7 @@ export default function ComboDetailPage() { Download - Generated + Generated )} {testResult.audioUrl && ( @@ -388,18 +388,20 @@ export default function ComboDetailPage() { )} - setShowPicker(false)} - onSelect={handleAddModel} - onDeselect={handleDeselectModel} - activeProviders={connections} - modelAliases={modelAliases} - title={`Add ${kindLabel} Model`} - kindFilter={combo.kind} - addedModelValues={providers} - closeOnSelect={false} - /> + {showPicker && ( + setShowPicker(false)} + onSelect={handleAddModel} + onDeselect={handleDeselectModel} + activeProviders={connections} + modelAliases={modelAliases} + title={`Add ${kindLabel} Model`} + kindFilter={combo.kind} + addedModelValues={providers} + closeOnSelect={false} + /> + )} ); } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index e73aa531..e03e7619 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; +import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon"; import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS } from "@/shared/constants/providers"; import { getModelsByProviderId, getModelKind } from "@/shared/constants/models"; @@ -66,6 +67,7 @@ export default function ProviderDetailPage() { const [thinkingMode, setThinkingMode] = useState("auto"); const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} }); const [suggestedModels, setSuggestedModels] = useState([]); + const [liveModels, setLiveModels] = useState([]); const [kiloFreeModels, setKiloFreeModels] = useState([]); const [deletedModelIds, setDeletedModelIds] = useState([]); const [confirmState, setConfirmState] = useState(null); @@ -141,7 +143,10 @@ export default function ProviderDetailPage() { const isOAuth = !!OAUTH_PROVIDERS[providerId] || !!FREE_PROVIDERS[providerId] || authModes.includes("oauth"); const supportsApiKeyAuth = !!APIKEY_PROVIDERS[providerId] || authModes.includes("apikey"); const isFreeNoAuth = !!FREE_PROVIDERS[providerId]?.noAuth; - const models = getModelsByProviderId(providerId); + const staticModels = getModelsByProviderId(providerId); + const models = providerId === "cursor" && liveModels.length > 0 + ? liveModels + : staticModels; const providerAlias = getProviderAlias(providerId); const isOpenAICompatible = isOpenAICompatibleProvider(providerId); @@ -151,8 +156,12 @@ export default function ProviderDetailPage() { const oauthConnectionLabel = providerId === "xai" ? "Grok Build OAuth" : providerId === "grok-cli" ? "Grok CLI Device Login" + : providerId === "kimi" ? "Kimi Coding OAuth" : "OAuth"; - const apiKeyConnectionLabel = providerId === "xai" ? "xAI API Key" : "API Key"; + const apiKeyConnectionLabel = + providerId === "xai" ? "xAI API Key" + : providerId === "kimi" ? "Kimi API Key" + : "API Key"; // Resolve suffix "(level)" for a model when a thinking level is picked and the model supports it. const resolveThinkingSuffix = (modelId) => { if (!thinkingMode || thinkingMode === "auto") return null; @@ -427,6 +436,34 @@ export default function ProviderDetailPage() { fetchDeletedModels(); }, [fetchConnections, fetchAliases, fetchCustomModels, fetchDeletedModels]); + // Cursor's model availability is account-specific and changes frequently. + // Load the active account's live catalog for the dashboard; the static + // registry remains the fallback while the request is pending or unavailable. + useEffect(() => { + if (providerId !== "cursor") { + setLiveModels([]); + return; + } + + const connection = connections.find((item) => item.isActive !== false); + if (!connection?.id) { + setLiveModels([]); + return; + } + + let cancelled = false; + fetch(`/api/providers/${connection.id}/models`, { cache: "no-store" }) + .then(async (res) => ({ ok: res.ok, data: await res.json() })) + .then(({ ok, data }) => { + if (!cancelled && ok && Array.isArray(data.models) && data.models.length > 0) { + setLiveModels(data.models); + } + }) + .catch(() => {}); + + return () => { cancelled = true; }; + }, [providerId, connections]); + // Fetch suggested models from provider's public API (if configured) useEffect(() => { const fetcher = (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId] || FREE_TIER_PROVIDERS[providerId])?.modelsFetcher; @@ -1196,7 +1233,7 @@ export default function ProviderDetailPage() { if (isAnthropicCompatible) { return "/providers/anthropic-m.png"; } - return `/providers/${providerInfo.id}.png`; + return getProviderIconSrc(providerInfo.id); }; return ( @@ -1215,7 +1252,7 @@ export default function ProviderDetailPage() { className="flex size-12 shrink-0 items-center justify-center rounded-lg" style={{ backgroundColor: `${providerInfo.color}15` }} > - {headerImgError ? ( + {headerImgError || !getHeaderIconPath() ? ( {providerInfo.textIcon || providerInfo.id.slice(0, 2).toUpperCase()} @@ -1227,7 +1264,12 @@ export default function ProviderDetailPage() { height={48} className="max-h-12 max-w-12 rounded-lg object-contain" sizes="48px" - onError={() => setHeaderImgError(true)} + onError={() => { + markProviderIconMissing(providerInfo.id); + setHeaderImgError(true); + }} + loading="lazy" + decoding="async" /> )} diff --git a/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js b/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js index a6d9533e..9bdf4b41 100644 --- a/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js +++ b/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js @@ -116,26 +116,22 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri const [testingModelId, setTestingModelId] = useState(null); const [testError, setTestError] = useState(""); const [showAddCustomModel, setShowAddCustomModel] = useState(false); - const [connections, setConnections] = useState([]); const providerAlias = providerAliasOverride || getProviderAlias(providerId); const effectiveType = kindFilter || "llm"; const fetchData = useCallback(async () => { try { - const [aliasRes, connRes, customRes] = await Promise.all([ + const [aliasRes, customRes] = await Promise.all([ fetch("/api/models/alias"), - fetch("/api/providers", { cache: "no-store" }), fetch("/api/models/custom", { cache: "no-store" }), ]); const aliasData = await aliasRes.json(); - const connData = await connRes.json(); const customData = await customRes.json(); if (aliasRes.ok) setModelAliases(aliasData.aliases || {}); - if (connRes.ok) setConnections((connData.connections || []).filter((c) => c.provider === providerId)); if (customRes.ok) setCustomModels(customData.models || []); } catch (e) { console.log("ModelsCard fetch error:", e); } - }, [providerId]); + }, []); useEffect(() => { fetchData(); }, [fetchData]); @@ -242,7 +238,7 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri onSetAlias={(alias) => handleSetAlias(model.id, alias)} onDeleteAlias={() => handleDeleteAlias(existingAlias)} testStatus={modelTestResults[model.id]} - onTest={connections.length > 0 ? () => handleTestModel(model.id) : undefined} + onTest={() => handleTestModel(model.id)} isTesting={testingModelId === model.id} isFree={model.isFree} /> @@ -259,7 +255,7 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri onSetAlias={() => {}} onDeleteAlias={() => handleDeleteCustomModel(model.id)} testStatus={modelTestResults[model.id]} - onTest={connections.length > 0 ? () => handleTestModel(model.id) : undefined} + onTest={() => handleTestModel(model.id)} isTesting={testingModelId === model.id} isCustom /> diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index 7d52d30b..fb5e4557 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -10,6 +10,7 @@ import { Toggle, } from "@/shared/components"; import ProviderIcon from "@/shared/components/ProviderIcon"; +import { getProviderIconSrc } from "@/shared/utils/providerIcon"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; import { FREE_PROVIDERS, @@ -761,12 +762,12 @@ function ApiKeyProviderCard({ }; const getIconPath = () => { - if (isCompatible) + if (isCompatible && provider.apiType) return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png"; if (isAnthropicCompatible) return "/providers/anthropic-m.png"; - return `/providers/${provider.id}.png`; + return getProviderIconSrc(provider.id); }; return ( diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index f06ea169..9d01e0a8 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -1236,22 +1236,24 @@ export default function ProviderLimits() { /> )} {hiddenQuotaRows.length > 0 && ( -
- +
+ visibility_off - Hidden: - {hiddenQuotaRows.map((quotaRow) => ( - - ))} + Hidden: +
+ {hiddenQuotaRows.map((quotaRow) => ( + + ))} +
)}
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js b/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js index 4a435946..7db34791 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js @@ -7,21 +7,27 @@ import { Handle, Position, Controls, + BaseEdge, + getBezierPath, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { AI_PROVIDERS } from "@/shared/constants/providers"; +import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon"; // Force-stop FE animation if a provider stays active longer than this const FE_ACTIVE_TIMEOUT_MS = 60000; const FE_ACTIVE_TICK_MS = 1000; +// Kame + electric particles along active edges +const KAME_PARTICLE_COUNT = 6; +const SPARK_COUNT = 5; + function getProviderConfig(providerId) { return AI_PROVIDERS[providerId] || { color: "#6b7280", name: providerId }; } -// Use local provider images from /public/providers/ function getProviderImageUrl(providerId) { - return `/providers/${providerId}.png`; + return getProviderIconSrc(providerId); } // Custom provider node - rectangle with image + name @@ -47,8 +53,19 @@ function ProviderNode({ data }) { className="w-8 h-8 rounded-md flex items-center justify-center shrink-0" style={{ backgroundColor: `${color}15` }} > - {!imgError ? ( - {label} setImgError(true)} /> + {imageUrl && !imgError ? ( + {label} { + const m = imageUrl?.match(/^\/providers\/([^/]+)\.png$/i); + if (m) markProviderIconMissing(m[1]); + setImgError(true); + }} + /> ) : ( {textIcon} )} @@ -77,19 +94,34 @@ ProviderNode.propTypes = { data: PropTypes.object.isRequired, }; -// Center 9Router node +// Center 9Router node — pulse/glow on card only (no expanding rings) function RouterNode({ data }) { + const powering = (data.activeCount || 0) > 0; return ( -
+
- 9Router (Remake) - 9Router (Remake) + 9Router + + 9Router + {data.activeCount > 0 && ( - + {data.activeCount} )} @@ -101,7 +133,131 @@ RouterNode.propTypes = { data: PropTypes.object.isRequired, }; +// Active: electric kame beam (multi-layer stroke + sparks). Idle/last/error: solid BaseEdge. +function TopologyEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + style = {}, + data, +}) { + const [edgePath] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + const active = !!data?.active; + const stroke = style.stroke || "var(--color-border)"; + const filterId = `topo-electric-${id}`; + + if (!active) { + return ; + } + + return ( + + + + + + + + + + {/* Outer electric halo */} + + {/* Mid plasma */} + + {/* Hot white core */} + + {/* Energy orbs */} + {Array.from({ length: KAME_PARTICLE_COUNT }, (_, i) => ( + + + + ))} + {/* Electric sparks (short-lived blink along path) */} + {Array.from({ length: SPARK_COUNT }, (_, i) => ( + + + + + ))} + + ); +} + +TopologyEdge.propTypes = { + id: PropTypes.string, + sourceX: PropTypes.number, + sourceY: PropTypes.number, + targetX: PropTypes.number, + targetY: PropTypes.number, + sourcePosition: PropTypes.string, + targetPosition: PropTypes.string, + style: PropTypes.object, + data: PropTypes.object, +}; + const nodeTypes = { provider: ProviderNode, router: RouterNode }; +const edgeTypes = { topology: TopologyEdge }; // Place N nodes evenly along an ellipse around the router center. function buildLayout(providers, activeSet, lastSet, errorSet) { @@ -135,9 +291,9 @@ function buildLayout(providers, activeSet, lastSet, errorSet) { draggable: false, }); - const edgeStyle = (active, last, error, color) => { + const edgeStyle = (active, last, error) => { if (error) return { stroke: "#ef4444", strokeWidth: 2.5, opacity: 0.9 }; - if (active) return { stroke: "#22c55e", strokeWidth: 2.5, opacity: 0.9 }; + if (active) return { stroke: "#22d3ee", strokeWidth: 3.5, opacity: 1 }; if (last) return { stroke: "#f59e0b", strokeWidth: 2, opacity: 0.7 }; return { stroke: "var(--color-border)", strokeWidth: 1, opacity: 0.3 }; }; @@ -183,12 +339,15 @@ function buildLayout(providers, activeSet, lastSet, errorSet) { edges.push({ id: `e-${nodeId}`, + type: "topology", source: "router", sourceHandle, target: nodeId, targetHandle, - animated: active, - style: edgeStyle(active, last, error, config.color), + // Built-in animated uses stroke-dasharray (CPU-heavy); use particle beam instead + animated: false, + data: { active }, + style: edgeStyle(active, last, error), }); }); @@ -241,7 +400,7 @@ export default function ProviderTopology({ providers = [], activeRequests = [], const { nodes, edges } = useMemo( () => buildLayout(providers, activeSet, lastSet, errorSet), - [providers, activeSet, lastKey, errorKey] + [providers, activeSet, lastSet, errorSet] ); // Stable key — only remount when provider list changes @@ -289,6 +448,7 @@ export default function ProviderTopology({ providers = [], activeRequests = [], nodes={nodes} edges={edges} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} fitView fitViewOptions={fitOpts} minZoom={0.1} diff --git a/src/app/api/models/route.js b/src/app/api/models/route.js index 6b61e381..3afae60c 100644 --- a/src/app/api/models/route.js +++ b/src/app/api/models/route.js @@ -21,12 +21,21 @@ export async function GET() { }) .map((m) => { const fullModel = `${m.provider}/${m.model}`; + const providerAlias = getProviderAlias(m.provider) || m.provider; + const routedModel = `${providerAlias}/${m.model}`; const c = getCapabilitiesForModel(m.provider, m.model); return { ...m, fullModel, + routedModel, alias: modelAliases[fullModel] || m.model, - caps: { vision: c.vision, search: c.search, reasoning: c.reasoning }, + caps: { + vision: c.vision, + search: c.search, + reasoning: c.reasoning, + contextWindow: c.contextWindow, + maxOutput: c.maxOutput, + }, }; }); diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js index 9980d755..4275e4f3 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -157,6 +157,7 @@ export async function GET(request, { params }) { const noPkceDeviceProviders = [ "github", "kiro", + "kimi", "kimi-coding", "kilocode", "codebuddy-cn", @@ -291,10 +292,11 @@ export async function POST(request, { params }) { } // Providers that don't use PKCE for device code - const noPkceProviders = ["github", "kimi-coding", "kilocode", "codebuddy-cn"]; + const noPkceProviders = ["github", "kimi", "kimi-coding", "kilocode", "codebuddy-cn"]; let result; if (noPkceProviders.includes(provider)) { - result = await pollForToken(provider, deviceCode); + // kimi needs extraData._kimiDeviceId for stable X-Msh-Device-Id (CLIProxyAPI parity) + result = await pollForToken(provider, deviceCode, null, extraData); } else if (provider === "kiro") { // Kiro needs extraData (clientId, clientSecret) from device code response result = await pollForToken(provider, deviceCode, null, extraData); @@ -315,9 +317,10 @@ export async function POST(request, { params }) { } if (result.success) { - // Save to database + // Save to database (legacy kimi-coding OAuth → dual-auth kimi) + const providerId = provider === "kimi-coding" ? "kimi" : provider; const connection = await createProviderConnection({ - provider, + provider: providerId, authType: "oauth", ownerId: user.id, ...result.tokens, diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index 785c9c79..c41a1114 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -3,7 +3,7 @@ import { getProviderConnectionById } from "@/models"; import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; import { GEMINI_CONFIG } from "@/lib/oauth/constants/oauth"; -import { refreshGoogleToken, updateProviderCredentials } from "@/sse/services/tokenRefresh"; +import { refreshGoogleToken, refreshCodexToken, updateProviderCredentials } from "@/sse/services/tokenRefresh"; import { resolveOllamaLocalHost } from "open-sse/config/providers.js"; import { getModelsByProviderId } from "open-sse/config/providerModels.js"; import { resolveKiroModels } from "open-sse/services/kiroModels.js"; @@ -11,9 +11,17 @@ 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"; +import { resolveCursorModels } from "open-sse/services/cursorModels.js"; const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"; +// The /codex/models endpoint gates each entry by minimal_client_version against this +// value, and codex CLI's own manifest (openai/codex codex-rs/models-manager/models.json) +// already requires 0.144.0 for its newest models, so a stale client_version here comes +// back 200 with those entries quietly missing instead of erroring. +const CODEX_CLIENT_VERSION = "0.144.6"; +const CODEX_MODELS_URL = `https://chatgpt.com/backend-api/codex/models?client_version=${CODEX_CLIENT_VERSION}`; + const parseOpenAIStyleModels = (data) => { if (Array.isArray(data)) return data; return data?.data || data?.models || data?.results || []; @@ -158,12 +166,20 @@ const PROVIDER_MODELS_CONFIG = { parseResponse: (data) => data.data || [] }, codex: { - url: "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0", - method: "GET", - headers: { "Content-Type": "application/json", "Accept": "application/json" }, - authHeader: "Authorization", - authPrefix: "Bearer ", - parseResponse: parseCodexModels + customResolver: buildOAuthResolver({ + refreshFn: (conn) => refreshCodexToken(conn.refreshToken), + fetchFn: (token) => fetch(CODEX_MODELS_URL, { + method: "GET", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": `Bearer ${token}`, + "originator": "codex_cli_rs" + } + }), + parseFn: parseCodexModels, + errorLabel: "Failed to fetch Codex models" + }) }, antigravity: { url: "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:models", @@ -230,6 +246,14 @@ const PROVIDER_MODELS_CONFIG = { authPrefix: "Bearer ", parseResponse: (data) => data.data || [] }, + "alims-intl": { + url: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || [] + }, "volcengine-ark": createOpenAIModelsConfig("https://ark.cn-beijing.volces.com/api/coding/v3/models"), byteplus: createOpenAIModelsConfig("https://ark.ap-southeast.bytepluses.com/api/coding/v3/models"), @@ -270,6 +294,19 @@ const PROVIDER_MODELS_CONFIG = { }; } }, + cursor: { + customResolver: async (connection) => { + const result = await resolveCursorModels({ + accessToken: connection.accessToken, + providerSpecificData: connection.providerSpecificData || {}, + }, { forceRefresh: true, log: console }); + if (result?.models?.length) return { models: result.models }; + return { + models: getStaticProviderModels("cursor"), + warning: "Cursor returned no live models; falling back to static catalog.", + }; + }, + }, // Custom resolvers (non-OpenAI-shaped APIs / token-refresh flows) kiro: { diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index f97d03c7..d690e4e5 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -76,7 +76,8 @@ const OAUTH_TEST_CONFIG = { authPrefix: "Bearer ", refreshable: false, }, - "kimi-coding": { checkExpiry: true, refreshable: false }, + kimi: { checkExpiry: true, refreshable: true }, + "kimi-coding": { checkExpiry: true, refreshable: true }, cursor: { tokenExists: true }, kilocode: { url: `${KILOCODE_CONFIG.apiBaseUrl}/api/profile`, @@ -627,10 +628,13 @@ async function testApiKeyConnection(connection, effectiveProxy = null) { return { valid, error: valid ? null : "Invalid API key" }; } case "alicode": - case "alicode-intl": { - // Aliyun Coding Plan uses OpenAI-compatible API + case "alicode-intl": + case "alims-intl": { + // Aliyun Coding Plan uses OpenAI-compatible API; alims-intl uses Model Studio compatible-mode const aliBaseUrl = connection.provider === "alicode-intl" ? "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions" + : connection.provider === "alims-intl" + ? "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions" : "https://coding.dashscope.aliyuncs.com/v1/chat/completions"; const res = await fetchWithConnectionProxy(aliBaseUrl, { method: "POST", diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js index 2b41844f..24fb3431 100644 --- a/src/app/api/providers/validate/route.js +++ b/src/app/api/providers/validate/route.js @@ -311,11 +311,12 @@ export async function POST(request) { case "minimax": case "minimax-cn": case "alicode-intl": + case "alims-intl": case "alicode": case "agentrouter": { // Use baseUrl from PROVIDERS (DRY); separate openai-format vs claude-format flow const cfg = PROVIDERS[provider]; - const isOpenAiFormat = provider === "glm-cn" || provider === "alicode" || provider === "alicode-intl"; + const isOpenAiFormat = provider === "glm-cn" || provider === "alicode" || provider === "alicode-intl" || provider === "alims-intl"; if (isOpenAiFormat) { const testModel = getDefaultModel(provider); diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index 1f37fb24..e66e466a 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -13,6 +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 { resolveGrokCliModels } from "open-sse/services/grokCliModels.js"; +import { resolveCursorModels } from "open-sse/services/cursorModels.js"; import { updateProviderCredentials } from "@/sse/services/tokenRefresh"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js"; @@ -97,6 +98,13 @@ const LIVE_MODEL_RESOLVERS = { }); return result?.models?.length ? { models: result.models } : null; }, + cursor: async (conn) => { + const result = await resolveCursorModels({ + accessToken: conn.accessToken, + providerSpecificData: conn.providerSpecificData || {}, + }, { log: console }); + return result?.models?.length ? { models: result.models } : null; + } }; const parseOpenAIStyleModels = (data) => { diff --git a/src/app/globals.css b/src/app/globals.css index d81ab47d..985deecd 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -470,6 +470,68 @@ button:disabled, opacity: 1; } +/* Topology: router card pulse (no flying rings) + electric edge beam */ +@keyframes topology-router-pulse { + 0%, 100% { + transform: scale(1); + box-shadow: + 0 0 8px #fde047, + 0 0 20px color-mix(in srgb, var(--color-primary) 65%, transparent), + 0 0 32px rgba(34, 211, 238, 0.4), + inset 0 0 10px rgba(253, 224, 71, 0.3); + } + 50% { + transform: scale(1.06); + box-shadow: + 0 0 14px #fef08a, + 0 0 28px #facc15, + 0 0 44px rgba(34, 211, 238, 0.6), + inset 0 0 14px rgba(255, 255, 255, 0.25); + } +} +@keyframes topology-router-icon-shake { + 0%, 100% { transform: rotate(0deg) scale(1); filter: drop-shadow(0 0 2px #fde047); } + 25% { transform: rotate(-5deg) scale(1.08); filter: drop-shadow(0 0 6px #facc15); } + 75% { transform: rotate(5deg) scale(1.08); filter: drop-shadow(0 0 6px #22d3ee); } +} +@keyframes topology-router-label-flicker { + 0%, 100% { text-shadow: 0 0 6px #fde047, 0 0 12px #22d3ee; } + 50% { text-shadow: 0 0 10px #fff, 0 0 18px #facc15; } +} +@keyframes topology-edge-dash { + to { stroke-dashoffset: -36; } +} +@keyframes topology-edge-flicker { + 0%, 100% { opacity: 0.85; } + 40% { opacity: 1; } + 55% { opacity: 0.55; } + 70% { opacity: 1; } +} +.topology-router-core { + animation: topology-router-pulse 0.75s ease-in-out infinite; + border-color: #fde047 !important; +} +.topology-router-icon { + animation: topology-router-icon-shake 0.45s ease-in-out infinite; +} +.topology-router-label { + animation: topology-router-label-flicker 0.7s ease-in-out infinite; +} +.topology-router-badge { + box-shadow: 0 0 10px #fde047, 0 0 16px rgba(34, 211, 238, 0.6); +} +.topology-edge-kame { + stroke-dasharray: 8 6; + animation: topology-edge-dash 0.22s linear infinite; +} +.topology-edge-halo { + animation: topology-edge-flicker 0.18s steps(2) infinite; +} +.topology-edge-plasma { + stroke-dasharray: 14 10; + animation: topology-edge-dash 0.18s linear infinite, topology-edge-flicker 0.22s steps(3) infinite; +} + /* React Flow controls: match app theme */ .react-flow-controls-custom { background: var(--color-surface); diff --git a/src/i18n/config.js b/src/i18n/config.js index ef6d41cb..f30fee76 100644 --- a/src/i18n/config.js +++ b/src/i18n/config.js @@ -20,6 +20,7 @@ export const LOCALES = [ "uk", "tl", "id", + "km", "th", "hi", "bn", @@ -60,6 +61,7 @@ export const LOCALE_NAMES = { tl: "Tagalog", id: "Indonesia", th: "ไทย", + km: "ខ្មែរ", hi: "हिन्दी", bn: "বাংলা", ur: "اردو", @@ -141,6 +143,9 @@ export function normalizeLocale(locale) { if (locale === "th") { return "th"; } + if (locale === "km") { + return "km"; + } if (locale === "hi") { return "hi"; } diff --git a/src/lib/db/adapters/betterSqliteAdapter.js b/src/lib/db/adapters/betterSqliteAdapter.js index 4fc8a0ed..70a55a5f 100644 --- a/src/lib/db/adapters/betterSqliteAdapter.js +++ b/src/lib/db/adapters/betterSqliteAdapter.js @@ -40,9 +40,9 @@ export function createBetterSqliteAdapter(filePath) { return { driver: "better-sqlite3", - run(sql, params = []) { return prepare(sql).run(params); }, - get(sql, params = []) { return prepare(sql).get(params); }, - all(sql, params = []) { return prepare(sql).all(params); }, + run(sql, params = []) { return prepare(sql).run(...params); }, + get(sql, params = []) { return prepare(sql).get(...params); }, + all(sql, params = []) { return prepare(sql).all(...params); }, exec(sql) { return db.exec(sql); }, transaction(fn) { return db.transaction(fn)(); }, checkpoint() { try { db.pragma("wal_checkpoint(TRUNCATE)"); } catch {} }, diff --git a/src/lib/grokBuildConfig.js b/src/lib/grokBuildConfig.js new file mode 100644 index 00000000..c11147f1 --- /dev/null +++ b/src/lib/grokBuildConfig.js @@ -0,0 +1,247 @@ +export const GROK_MAIN_MODEL_SLOT = "9router"; +export const GROK_BUILTIN_DEFAULT = "grok-build"; +export const GROK_SUBAGENT_TYPES = ["general-purpose", "explore", "plan"]; + +const UNSET_SENTINEL = "__9router_unset__"; +const MODELS_SECTION = "models"; +const SUBAGENT_MODELS_SECTION = "subagents.models"; + +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +const tomlString = (value) => JSON.stringify(String(value)); + +const sectionRegExp = (section) => + new RegExp( + `^\\[${escapeRegExp(section)}\\][ \\t]*\\r?\\n((?:(?!\\[)[^\\r\\n]*\\r?\\n?)*)`, + "m", + ); + +const modelSlot = (type) => `${GROK_MAIN_MODEL_SLOT}-${type}`; + +const previousDefaultRegExp = /^# 9router-prev-default = "([^"]*)"[ \t]*\r?\n?/m; +const previousSubagentRegExp = (type) => + new RegExp( + `^# 9router-prev-subagent-${escapeRegExp(type)} = "([^"]*)"[ \\t]*\\r?\\n?`, + "m", + ); + +function getSectionField(toml, section, key) { + const match = toml.match(sectionRegExp(section)); + if (!match) return null; + const field = match[1].match( + new RegExp(`^[ \\t]*${escapeRegExp(key)}[ \\t]*=[ \\t]*"([^"]*)"`, "m"), + ); + return field ? field[1] : null; +} + +function getSectionNumber(toml, section, key) { + const match = toml.match(sectionRegExp(section)); + if (!match) return null; + const field = match[1].match( + new RegExp(`^[ \\t]*${escapeRegExp(key)}[ \\t]*=[ \\t]*([0-9]+(?:\\.[0-9]+)?)`, "m"), + ); + if (!field) return null; + const value = Number(field[1]); + return Number.isFinite(value) ? value : null; +} + +function setSectionField(toml, section, key, value) { + const match = toml.match(sectionRegExp(section)); + const line = `${key} = ${tomlString(value)}`; + if (!match) { + const prefix = toml.length > 0 && !toml.endsWith("\n") ? `${toml}\n` : toml; + return `${prefix}\n[${section}]\n${line}\n`; + } + + const body = match[1] || ""; + const fieldRegExp = new RegExp( + `^[ \\t]*${escapeRegExp(key)}[ \\t]*=[ \\t]*"[^"]*"`, + "m", + ); + const nextBody = fieldRegExp.test(body) + ? body.replace(fieldRegExp, line) + : `${line}\n${body}`; + return toml.replace(match[0], `[${section}]\n${nextBody}`); +} + +function deleteSectionField(toml, section, key) { + const match = toml.match(sectionRegExp(section)); + if (!match) return toml; + const fieldRegExp = new RegExp( + `^[ \\t]*${escapeRegExp(key)}[ \\t]*=[^\\r\\n]*\\r?\\n?`, + "m", + ); + const nextBody = (match[1] || "").replace(fieldRegExp, ""); + if (!nextBody.trim()) return toml.replace(match[0], "").replace(/\n{3,}/g, "\n\n"); + return toml.replace(match[0], `[${section}]\n${nextBody}`); +} + +function parseModelSection(toml, slot) { + const match = toml.match(sectionRegExp(`model.${slot}`)); + if (!match) return null; + const body = match[1] || ""; + const contextWindow = getSectionNumber(toml, `model.${slot}`, "context_window"); + return { + model: getSectionField(toml, `model.${slot}`, "model"), + base_url: getSectionField(toml, `model.${slot}`, "base_url"), + name: getSectionField(toml, `model.${slot}`, "name"), + api_key: getSectionField(toml, `model.${slot}`, "api_key"), + api_backend: getSectionField(toml, `model.${slot}`, "api_backend"), + context_window: Number.isFinite(contextWindow) && contextWindow > 0 ? contextWindow : null, + raw: body, + }; +} + +function buildModelSection({ slot, model, baseUrl, apiKey, contextWindow, name }) { + const lines = [ + `[model.${slot}]`, + `model = ${tomlString(model)}`, + `base_url = ${tomlString(baseUrl)}`, + `name = ${tomlString(name)}`, + `description = ${tomlString("Routed via 9Router gateway")}`, + `api_backend = "chat_completions"`, + ]; + if (apiKey) lines.push(`api_key = ${tomlString(apiKey)}`); + if (Number.isFinite(contextWindow) && contextWindow > 0) { + lines.push(`context_window = ${Math.floor(contextWindow)}`); + } + return `${lines.join("\n")}\n`; +} + +function upsertModelSection(toml, config) { + const regexp = sectionRegExp(`model.${config.slot}`); + const section = buildModelSection(config); + if (regexp.test(toml)) return toml.replace(regexp, section); + const prefix = toml.length > 0 && !toml.endsWith("\n") ? `${toml}\n` : toml; + return `${prefix}\n${section}`; +} + +function removeModelSection(toml, slot) { + return toml.replace(sectionRegExp(`model.${slot}`), "").replace(/\n{3,}/g, "\n\n"); +} + +function insertMarker(toml, marker) { + const mainSection = sectionRegExp(`model.${GROK_MAIN_MODEL_SLOT}`); + if (mainSection.test(toml)) { + return toml.replace(mainSection, (section) => `${marker}${section}`); + } + const prefix = toml.length > 0 && !toml.endsWith("\n") ? `${toml}\n` : toml; + return `${prefix}${marker}`; +} + +function rememberPreviousDefault(toml) { + if (previousDefaultRegExp.test(toml)) return toml; + const current = getSectionField(toml, MODELS_SECTION, "default"); + if (!current || current === GROK_MAIN_MODEL_SLOT) return toml; + return insertMarker(toml, `# 9router-prev-default = ${tomlString(current)}\n`); +} + +function restorePreviousDefault(toml) { + const previous = toml.match(previousDefaultRegExp)?.[1] || GROK_BUILTIN_DEFAULT; + let next = toml.replace(previousDefaultRegExp, ""); + if (getSectionField(next, MODELS_SECTION, "default") === GROK_MAIN_MODEL_SLOT) { + next = setSectionField(next, MODELS_SECTION, "default", previous); + } + return next; +} + +function rememberPreviousSubagent(toml, type) { + const regexp = previousSubagentRegExp(type); + if (regexp.test(toml)) return toml; + const current = getSectionField(toml, SUBAGENT_MODELS_SECTION, type); + const previous = current == null ? UNSET_SENTINEL : current; + return insertMarker( + toml, + `# 9router-prev-subagent-${type} = ${tomlString(previous)}\n`, + ); +} + +function restorePreviousSubagent(toml, type) { + const regexp = previousSubagentRegExp(type); + const previous = toml.match(regexp)?.[1] || UNSET_SENTINEL; + let next = toml.replace(regexp, ""); + if (getSectionField(next, SUBAGENT_MODELS_SECTION, type) !== modelSlot(type)) { + return next; + } + if (previous === UNSET_SENTINEL) { + return deleteSectionField(next, SUBAGENT_MODELS_SECTION, type); + } + return setSectionField(next, SUBAGENT_MODELS_SECTION, type, previous); +} + +export function parseGrokBuildConfig(toml) { + const subagentModels = {}; + const subagentMappings = {}; + for (const type of GROK_SUBAGENT_TYPES) { + const mapping = getSectionField(toml, SUBAGENT_MODELS_SECTION, type); + subagentMappings[type] = mapping; + subagentModels[type] = mapping === modelSlot(type) + ? parseModelSection(toml, mapping) + : null; + } + + return { + model: parseModelSection(toml, GROK_MAIN_MODEL_SLOT), + default: getSectionField(toml, MODELS_SECTION, "default"), + subagentModels, + subagentMappings, + }; +} + +/** + * Apply main model and optional per-type subagent overrides while preserving all unrelated TOML. + * `subagentModels === undefined` leaves existing subagent config untouched for API compatibility. + */ +export function applyGrokBuildConfig( + toml, + { baseUrl, apiKey, model, contextWindow, subagentModels }, +) { + let next = rememberPreviousDefault(toml); + next = upsertModelSection(next, { + slot: GROK_MAIN_MODEL_SLOT, + model, + baseUrl, + apiKey, + contextWindow, + name: "9Router", + }); + next = setSectionField(next, MODELS_SECTION, "default", GROK_MAIN_MODEL_SLOT); + + if (subagentModels && typeof subagentModels === "object") { + for (const type of GROK_SUBAGENT_TYPES) { + const selected = subagentModels[type]; + const slot = modelSlot(type); + if (selected?.model) { + next = rememberPreviousSubagent(next, type); + next = upsertModelSection(next, { + slot, + model: selected.model, + baseUrl, + apiKey, + contextWindow: selected.contextWindow, + name: `9Router ${type}`, + }); + next = setSectionField(next, SUBAGENT_MODELS_SECTION, type, slot); + } else { + next = restorePreviousSubagent(next, type); + next = removeModelSection(next, slot); + } + } + } + + return next; +} + +export function resetGrokBuildConfig(toml) { + let next = toml; + for (const type of GROK_SUBAGENT_TYPES) { + next = restorePreviousSubagent(next, type); + next = removeModelSection(next, modelSlot(type)); + } + next = removeModelSection(next, GROK_MAIN_MODEL_SLOT); + next = restorePreviousDefault(next); + return next.replace(/\n{3,}/g, "\n\n"); +} + +export function getGrokSubagentSlot(type) { + return GROK_SUBAGENT_TYPES.includes(type) ? modelSlot(type) : null; +} diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js index 1188b1fb..53015258 100644 --- a/src/lib/oauth/constants/oauth.js +++ b/src/lib/oauth/constants/oauth.js @@ -89,12 +89,18 @@ export const CURSOR_CONFIG = { }, }; -// Kimi Coding OAuth Configuration (Device Code Flow) -// clientId uses env override — dynamic, not stored in registry -export const KIMI_CODING_CONFIG = { - ...PROVIDER_OAUTH["kimi-coding"], - clientId: process.env.KIMI_CODING_OAUTH_CLIENT_ID || REGISTRY_PROVIDERS["kimi-coding"]?.clientId, +// Kimi Code OAuth (Device Code Flow) — merged into provider id `kimi` (dual auth) +// clientId: registry first, env override for forks +export const KIMI_CONFIG = { + ...PROVIDER_OAUTH["kimi"], + clientId: + process.env.KIMI_CODING_OAUTH_CLIENT_ID || + process.env.KIMI_OAUTH_CLIENT_ID || + REGISTRY_PROVIDERS["kimi"]?.clientId || + PROVIDER_OAUTH["kimi"]?.clientId, }; +// Back-compat alias for any remaining KIMI_CODING_CONFIG imports +export const KIMI_CODING_CONFIG = KIMI_CONFIG; // KiloCode OAuth Configuration (Custom Device Auth Flow) export const KILOCODE_CONFIG = { ...PROVIDER_OAUTH["kilocode"] }; @@ -134,7 +140,8 @@ export const PROVIDERS = { GITHUB: "github", KIRO: "kiro", CURSOR: "cursor", - KIMI_CODING: "kimi-coding", + KIMI: "kimi", + KIMI_CODING: "kimi", KILOCODE: "kilocode", CLINE: "cline", CLINEPASS: "clinepass", diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index d2d14daf..e995567e 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -20,7 +20,7 @@ import { KIRO_CONFIG, assertValidAwsRegion, CURSOR_CONFIG, - KIMI_CODING_CONFIG, + KIMI_CONFIG, KILOCODE_CONFIG, CLINE_CONFIG, CLINEPASS_CONFIG, @@ -1081,13 +1081,22 @@ const PROVIDERS = { }), }, - "kimi-coding": { - config: KIMI_CODING_CONFIG, + // Kimi Code device flow (CLIProxyAPI internal/auth/kimi). Id is `kimi`; + // `kimi-coding` remains an alias key so old UI/API routes still resolve. + kimi: { + config: KIMI_CONFIG, flowType: "device_code", requestDeviceCode: async (config) => { + const { buildKimiHeaders } = await import("open-sse/config/appConstants.js"); + const deviceId = crypto.randomUUID(); + const headers = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + ...buildKimiHeaders(deviceId), + }; const response = await fetch(config.deviceCodeUrl, { method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + headers, body: new URLSearchParams({ client_id: config.clientId }), }); if (!response.ok) { @@ -1095,21 +1104,30 @@ const PROVIDERS = { throw new Error(`Device code request failed: ${error}`); } const data = await response.json(); + const authorizeDeviceUrl = config.authorizeDeviceUrl || "https://www.kimi.com/code/authorize_device"; return { device_code: data.device_code, user_code: data.user_code, - verification_uri: data.verification_uri || "https://www.kimi.com/code/authorize_device", + verification_uri: data.verification_uri || authorizeDeviceUrl, verification_uri_complete: data.verification_uri_complete || - `https://www.kimi.com/code/authorize_device?user_code=${data.user_code}`, + `${authorizeDeviceUrl}?user_code=${data.user_code}`, expires_in: data.expires_in, interval: data.interval || 5, + _kimiDeviceId: deviceId, }; }, - pollToken: async (config, deviceCode) => { + pollToken: async (config, deviceCode, _codeVerifier, extraData) => { + const { buildKimiHeaders } = await import("open-sse/config/appConstants.js"); + const deviceId = extraData?._kimiDeviceId; + const headers = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + ...buildKimiHeaders(deviceId), + }; const response = await fetch(config.tokenUrl, { method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + headers, body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", client_id: config.clientId, @@ -1119,19 +1137,26 @@ const PROVIDERS = { let data; try { data = await response.json(); - } catch (e) { - const text = await response.text(); - data = { error: "invalid_response", error_description: text }; + } catch { + data = { error: "invalid_response", error_description: "non-json token response" }; } - return { ok: response.ok, data }; + // CLIProxyAPI: Kimi returns 200 for pending states with error field + if (data.error === "authorization_pending" || data.error === "slow_down") { + return { ok: true, data }; + } + if (data.access_token && deviceId) data._kimiDeviceId = deviceId; + return { ok: response.ok || !!data.access_token || !!data.error, data }; }, mapTokens: (tokens) => ({ accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresIn: tokens.expires_in, + providerSpecificData: { + authMethod: "device_code", + ...(tokens._kimiDeviceId ? { deviceId: tokens._kimiDeviceId } : {}), + }, }), }, - kilocode: { config: KILOCODE_CONFIG, flowType: "device_code", @@ -1520,7 +1545,9 @@ const PROVIDERS = { * Get provider handler */ export function getProvider(name) { - const provider = PROVIDERS[name]; + // Legacy kimi-coding → kimi (dual-auth merge) + const key = name === "kimi-coding" ? "kimi" : name; + const provider = PROVIDERS[key]; if (!provider) { throw new Error(`Unknown provider: ${name}`); } diff --git a/src/shared/components/ComboFormModal.js b/src/shared/components/ComboFormModal.js index ad3c9ce9..6e47a65a 100644 --- a/src/shared/components/ComboFormModal.js +++ b/src/shared/components/ComboFormModal.js @@ -176,12 +176,14 @@ export default function ComboFormModal({ isOpen, combo, onClose, onSave, activeP
- setShowModelSelect(false)} - onSelect={handleAddModel} onDeselect={handleDeselectModel} - activeProviders={activeProviders} modelAliases={modelAliases} - availableModels={availableModels} - title="Add Model to Combo" kindFilter={kindFilter} - addedModelValues={models} closeOnSelect={false} /> + {showModelSelect && ( + setShowModelSelect(false)} + onSelect={handleAddModel} onDeselect={handleDeselectModel} + activeProviders={activeProviders} modelAliases={modelAliases} + availableModels={availableModels} + title="Add Model to Combo" kindFilter={kindFilter} + addedModelValues={models} closeOnSelect={false} /> + )} ); } diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index e6f5bc61..7e6b0193 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -11,6 +11,7 @@ import ThemeToggle from "@/shared/components/ThemeToggle"; import { useHeaderSearchStore } from "@/store/headerSearchStore"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS } from "@/shared/constants/providers"; +import { getProviderIconSrc } from "@/shared/utils/providerIcon"; import { translate } from "@/i18n/runtime"; const getPageInfo = (pathname) => { @@ -29,7 +30,7 @@ const getPageInfo = (pathname) => { breadcrumbs: [ { label: "Media Providers", href: `/dashboard/media-providers/${kindId}` }, { label: kindConfig?.label || kindId, href: `/dashboard/media-providers/${kindId}` }, - { label: provider?.name || providerId, image: `/providers/${providerId}.png` }, + { label: provider?.name || providerId, image: getProviderIconSrc(providerId) }, ], }; } @@ -61,7 +62,7 @@ const getPageInfo = (pathname) => { { label: "Providers", href: "/dashboard/providers" }, { label: providerInfo.name, - image: `/providers/${providerInfo.id}.png`, + image: getProviderIconSrc(providerInfo.id), }, ], }; diff --git a/src/shared/components/LanguageSwitcher.js b/src/shared/components/LanguageSwitcher.js index 7697e649..bf5b255d 100644 --- a/src/shared/components/LanguageSwitcher.js +++ b/src/shared/components/LanguageSwitcher.js @@ -39,6 +39,7 @@ const getLocaleInfo = (locale) => { "tl": { name: "Tagalog", flag: "🇵🇭" }, "id": { name: "Indonesia", flag: "🇮🇩" }, "th": { name: "ไทย", flag: "🇹🇭" }, + "km": { name: "ខ្មែរ", flag: "🇰🇭" }, "hi": { name: "हिन्दी", flag: "🇮🇳" }, "bn": { name: "বাংলা", flag: "🇧🇩" }, "ur": { name: "اردو", flag: "🇵🇰" }, @@ -63,9 +64,9 @@ export default function LanguageSwitcher({ className = "", isOpen: controlledOpe const isControlled = typeof controlledOpen === "boolean"; const isOpen = isControlled ? controlledOpen : internalOpen; - const setIsOpen = (value) => { + const setIsOpen = (value, nextLocale = locale) => { if (isControlled) { - if (!value && onClose) onClose(locale); + if (!value && onClose) onClose(nextLocale); } else { setInternalOpen(value); } @@ -92,7 +93,6 @@ export default function LanguageSwitcher({ className = "", isOpen: controlledOpe if (nextLocale === locale || isPending) return; setIsPending(true); - setIsOpen(false); try { await fetch("/api/locale", { method: "POST", @@ -103,6 +103,7 @@ export default function LanguageSwitcher({ className = "", isOpen: controlledOpe // Reload translations without full page reload await reloadTranslations(); setLocale(nextLocale); + setIsOpen(false, nextLocale); } catch (err) { console.error("Failed to set locale:", err); } finally { diff --git a/src/shared/components/McpMarketplaceModal.js b/src/shared/components/McpMarketplaceModal.js index 5b18dfb5..826ad260 100644 --- a/src/shared/components/McpMarketplaceModal.js +++ b/src/shared/components/McpMarketplaceModal.js @@ -154,7 +154,7 @@ export default function McpMarketplaceModal({ isOpen, onClose, onAdd, addedNames
{s.iconUrl ? ( // eslint-disable-next-line @next/next/no-img-element - { e.target.style.display = "none"; }} /> + { e.target.style.display = "none"; }} loading="lazy" decoding="async" /> ) : (
)} diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js index f92f280a..60381ba4 100644 --- a/src/shared/components/ModelSelectModal.js +++ b/src/shared/components/ModelSelectModal.js @@ -50,6 +50,46 @@ export default function ModelSelectModal({ const [providerNodes, setProviderNodes] = useState([]); const [customModels, setCustomModels] = useState([]); const [deletedModels, setDeletedModels] = useState({}); + const [cursorModels, setCursorModels] = useState([]); + + // Cursor exposes the usable catalog per account. Keep the static catalog only + // as a fallback, since it quickly becomes stale and account entitlements vary. + const cursorConnectionIds = useMemo( + () => activeProviders + .filter((provider) => provider.provider === "cursor" && provider.id) + .map((provider) => provider.id), + [activeProviders], + ); + + useEffect(() => { + if (!isOpen || cursorConnectionIds.length === 0) { + setCursorModels([]); + return undefined; + } + + let cancelled = false; + Promise.all(cursorConnectionIds.map(async (connectionId) => { + const response = await fetch(`/api/providers/${connectionId}/models`, { cache: "no-store" }); + if (!response.ok) return []; + const data = await response.json(); + return Array.isArray(data.models) ? data.models : []; + })) + .then((modelLists) => { + if (cancelled) return; + const seen = new Set(); + setCursorModels(modelLists.flat().filter((model) => { + if (!model?.id || seen.has(model.id)) return false; + seen.add(model.id); + return true; + })); + }) + .catch((error) => { + console.warn("Unable to load Cursor models for selector:", error); + if (!cancelled) setCursorModels([]); + }); + + return () => { cancelled = true; }; + }, [isOpen, cursorConnectionIds]); const fetchCombos = async () => { try { @@ -315,7 +355,9 @@ export default function ModelSelectModal({ hasModels: mergedModels.length > 0, }; } else { - const hardcodedModels = getModelsByProviderId(providerId); + const hardcodedModels = providerId === "cursor" && cursorModels.length > 0 + ? cursorModels + : getModelsByProviderId(providerId); const hardcodedIds = new Set(hardcodedModels.map((m) => m.id)); // Custom models: if no hardcoded models (e.g. openrouter), show all aliases for this provider @@ -387,7 +429,7 @@ export default function ModelSelectModal({ }); return groups; - }, [availableModels, filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, deletedModels, kindFilter, activeProviders]); + }, [availableModels, filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, deletedModels, kindFilter, activeProviders, cursorModels]); // Filter combos by search query (and hide combos when kindFilter is set — combos are LLM-only by design) const filteredCombos = useMemo(() => { diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index bc2b246b..47e9de80 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -165,6 +165,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, "github", "qwen", "kiro", + "kimi", "kimi-coding", "kilocode", "codebuddy-cn", @@ -210,6 +211,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, _qoderMachineId: data._qoderMachineId, _qoderVerifier: data.codeVerifier, } + : (provider === "kimi" || provider === "kimi-coding") + ? { _kimiDeviceId: data._kimiDeviceId } : null; startPolling( data.device_code, diff --git a/src/shared/components/ProviderIcon.js b/src/shared/components/ProviderIcon.js index ca558508..f69342e3 100644 --- a/src/shared/components/ProviderIcon.js +++ b/src/shared/components/ProviderIcon.js @@ -2,18 +2,29 @@ import { useState } from "react"; import PropTypes from "prop-types"; +import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon"; + +function resolveSrc(src, providerId) { + if (providerId) return getProviderIconSrc(providerId); + if (!src) return null; + const m = String(src).match(/^\/providers\/([^/]+)\.png$/i); + if (m) return getProviderIconSrc(m[1]); + return src; +} export default function ProviderIcon({ src, + providerId, alt, size = 32, className = "", fallbackText = "?", fallbackColor, }) { + const effectiveSrc = resolveSrc(src, providerId); const [errored, setErrored] = useState(false); - if (!src || errored) { + if (!effectiveSrc || errored) { return ( setErrored(true)} + loading="lazy" + decoding="async" + onError={() => { + const m = effectiveSrc.match(/^\/providers\/([^/]+)\.png$/i); + if (m) markProviderIconMissing(m[1]); + if (providerId) markProviderIconMissing(providerId); + setErrored(true); + }} /> ); } ProviderIcon.propTypes = { src: PropTypes.string, + providerId: PropTypes.string, alt: PropTypes.string, size: PropTypes.number, className: PropTypes.string, diff --git a/src/shared/constants/locales.js b/src/shared/constants/locales.js index eff86544..80cf3763 100644 --- a/src/shared/constants/locales.js +++ b/src/shared/constants/locales.js @@ -22,6 +22,7 @@ export const LOCALE_FLAGS = { "tl": "🇵🇭", "id": "🇮🇩", "th": "🇹🇭", + "km": "🇰🇭", "hi": "🇮🇳", "bn": "🇧🇩", "ur": "🇵🇰", diff --git a/src/shared/hooks/useModelCaps.js b/src/shared/hooks/useModelCaps.js index 3dbdcdd4..92bcec21 100644 --- a/src/shared/hooks/useModelCaps.js +++ b/src/shared/hooks/useModelCaps.js @@ -1,44 +1,80 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js"; -// Fetch model capabilities once and expose a lookup by fullModel ("provider/model") or bare model id. +// Module cache: one /api/models fetch shared by every useModelCaps instance. +let cache = null; // { byFull, byId } | null +let inflight = null; + +function buildMaps(models) { + const byFull = {}; + const byId = {}; + for (const m of models || []) { + if (!m.caps) continue; + if (m.fullModel) byFull[m.fullModel] = m.caps; + if (m.routedModel) byFull[m.routedModel] = m.caps; + if (m.model) byId[m.model] = m.caps; + } + return { byFull, byId }; +} + +function loadModelCaps() { + if (cache) return Promise.resolve(cache); + if (inflight) return inflight; + inflight = fetch("/api/models") + .then(async (res) => { + if (!res.ok) throw new Error(`models ${res.status}`); + const data = await res.json(); + cache = buildMaps(data.models); + return cache; + }) + .catch(() => { + // Keep null so a later mount can retry + return { byFull: {}, byId: {} }; + }) + .finally(() => { inflight = null; }); + return inflight; +} + +// Resolve caps from a "provider/model" string or a bare model id. +function resolveCaps(byFull, byId, key) { + if (!key) return null; + if (byFull[key]) return byFull[key]; + const bare = key.includes("/") ? key.slice(key.indexOf("/") + 1) : key; + if (byId[bare]) return byId[bare]; + const provider = key.includes("/") ? key.slice(0, key.indexOf("/")) : null; + const c = getCapabilitiesForModel(provider, bare); + return { + vision: c.vision, + search: c.search, + reasoning: c.reasoning, + contextWindow: c.contextWindow, + maxOutput: c.maxOutput, + }; +} + export function useModelCaps() { - const [byFull, setByFull] = useState({}); - const [byId, setById] = useState({}); + const [byFull, setByFull] = useState(() => cache?.byFull || {}); + const [byId, setById] = useState(() => cache?.byId || {}); useEffect(() => { + if (cache) { + setByFull(cache.byFull); + setById(cache.byId); + return; + } let alive = true; - (async () => { - try { - const res = await fetch("/api/models"); - if (!res.ok) return; - const data = await res.json(); - const full = {}; - const id = {}; - for (const m of data.models || []) { - if (!m.caps) continue; - if (m.fullModel) full[m.fullModel] = m.caps; - if (m.model) id[m.model] = m.caps; - } - if (alive) { setByFull(full); setById(id); } - } catch { /* ignore */ } - })(); + loadModelCaps().then((maps) => { + if (alive) { setByFull(maps.byFull); setById(maps.byId); } + }); return () => { alive = false; }; }, []); - // Resolve caps from a "provider/model" string or a bare model id. - const getCaps = (key) => { - if (!key) return null; - if (byFull[key]) return byFull[key]; - const bare = key.includes("/") ? key.slice(key.indexOf("/") + 1) : key; - if (byId[bare]) return byId[bare]; - // Fallback: compute caps for dynamic models (passthrough/custom/suggested) not in static list - const provider = key.includes("/") ? key.slice(0, key.indexOf("/")) : null; - const c = getCapabilitiesForModel(provider, bare); - return { vision: c.vision, search: c.search, reasoning: c.reasoning }; - }; + const getCaps = useCallback( + (key) => resolveCaps(byFull, byId, key), + [byFull, byId], + ); return { getCaps }; } diff --git a/src/shared/utils/index.js b/src/shared/utils/index.js index f64461ab..72fc1a5a 100644 --- a/src/shared/utils/index.js +++ b/src/shared/utils/index.js @@ -1,6 +1,7 @@ // Shared Utils - Export all export { cn } from "./cn"; export * as api from "./api"; +export { getProviderIconSrc, markProviderIconMissing, resolveProviderIconId } from "./providerIcon"; import { v4 as uuidv4 } from "uuid"; diff --git a/src/shared/utils/providerIcon.js b/src/shared/utils/providerIcon.js new file mode 100644 index 00000000..32167d14 --- /dev/null +++ b/src/shared/utils/providerIcon.js @@ -0,0 +1,40 @@ +// Provider icon paths under /public/providers. +// Alias related brands; session-cache 404s so one miss never spams again. + +const ICON_ALIASES = { + "perplexity-agent": "perplexity", + "gitlab-duo": "gitlab", + "vercel-ai-gateway": "vercel", +}; + +// Runtime only — first 404 remembers id for the whole session +const failedIds = new Set(); + +function normalizeId(providerId) { + if (!providerId || typeof providerId !== "string") return ""; + return providerId.trim().toLowerCase(); +} + +/** Resolve icon file id (after alias). Empty if previously failed this session. */ +export function resolveProviderIconId(providerId) { + const id = normalizeId(providerId); + if (!id) return ""; + if (failedIds.has(id)) return ""; + const aliased = ICON_ALIASES[id] || id; + if (failedIds.has(aliased)) return ""; + return aliased; +} + +/** `/providers/{id}.png` or null when previously failed. */ +export function getProviderIconSrc(providerId) { + const id = resolveProviderIconId(providerId); + return id ? `/providers/${id}.png` : null; +} + +/** Call from img onError so later mounts skip the request. */ +export function markProviderIconMissing(providerId) { + const id = normalizeId(providerId); + if (id) failedIds.add(id); + const aliased = ICON_ALIASES[id]; + if (aliased) failedIds.add(aliased); +} diff --git a/tests/__baseline__/alias-baseline.json b/tests/__baseline__/alias-baseline.json index c104335e..6e189878 100644 --- a/tests/__baseline__/alias-baseline.json +++ b/tests/__baseline__/alias-baseline.json @@ -10,7 +10,7 @@ "kr": "kiro", "cu": "cursor", "kc": "kilocode", - "kmc": "kimi-coding", + "kmc": "kimi", "cl": "cline", "oc": "opencode", "ocg": "opencode-go", @@ -98,6 +98,7 @@ "idToAlias": { "alicode": "alicode", "alicode-intl": "alicode-intl", + "alims-intl": "alims-intl", "anthropic": "anthropic", "antigravity": "ag", "assemblyai": "assemblyai", @@ -117,6 +118,7 @@ "cursor": "cu", "deepgram": "deepgram", "deepseek": "deepseek", + "featherless": "featherless", "fireworks": "fireworks", "gemini": "gemini", "gemini-cli": "gc", @@ -132,7 +134,6 @@ "kilocode": "kc", "kimchi": "kimchi", "kimi": "kimi", - "kimi-coding": "kmc", "kiro": "kr", "mimo-free": "mmf", "minimax": "minimax", @@ -149,6 +150,7 @@ "opencode-go": "opencode-go", "openrouter": "openrouter", "perplexity": "perplexity", + "perplexity-agent": "perplexity-agent", "perplexity-web": "perplexity-web", "qoder": "qd", "qwen": "qw", @@ -167,6 +169,7 @@ "ag", "alicode", "alicode-intl", + "alims-intl", "anthropic", "assemblyai", "black-forest-labs", @@ -188,6 +191,7 @@ "edge-tts", "elevenlabs-tts-models", "fal-ai", + "featherless", "fireworks", "gc", "gcli", @@ -206,7 +210,6 @@ "kc", "kimchi", "kimi", - "kmc", "kr", "local-device", "minimax", @@ -226,6 +229,7 @@ "openrouter-tts-models", "openrouter-tts-voices", "perplexity", + "perplexity-agent", "perplexity-web", "qd", "qw", diff --git a/tests/__baseline__/oauth-urls-baseline.json b/tests/__baseline__/oauth-urls-baseline.json index e2049194..b4468678 100644 --- a/tests/__baseline__/oauth-urls-baseline.json +++ b/tests/__baseline__/oauth-urls-baseline.json @@ -35,14 +35,14 @@ "xai": "https://auth.x.ai/oauth2/token", "grok-cli": "https://auth.x.ai/oauth2/token", "cline": "https://api.cline.bot/api/v1/auth/token", - "kimi-coding": "https://auth.kimi.com/api/oauth/token" + "kimi": "https://auth.kimi.com/api/oauth/token" }, "authUrls": { "kiro": "https://prod.us-east-1.auth.desktop.kiro.dev" }, "refreshUrls": { "cline": "https://api.cline.bot/api/v1/auth/refresh", - "kimi-coding": "https://auth.kimi.com/api/oauth/token", + "kimi": "https://auth.kimi.com/api/oauth/token", "xai": "https://auth.x.ai/oauth2/token", "grok-cli": "https://auth.x.ai/oauth2/token" }, @@ -51,7 +51,7 @@ "codex": "app_EMoamEEZ73f0CkXaXp7hrann", "qwen": "f0304373b74a44d2b584a3fb70ca9e56", "iflow": "10009311001", - "kimi-coding": "17e5f671-d194-4dfb-9706-5516cb48c098", + "kimi": "17e5f671-d194-4dfb-9706-5516cb48c098", "grok-cli": "b1a00492-073a-47ea-816f-4c329264a828" } } \ No newline at end of file diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json index 8635e9e4..232c5d59 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -1,6 +1,6 @@ { "alicode-intl": { - "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 @@ -19,18 +19,17 @@ "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" } }, "antigravity": { "baseUrls": [ - "https://daily-cloudcode-pa.googleapis.com", - "https://daily-cloudcode-pa.sandbox.googleapis.com" + "https://cloudcode-pa.googleapis.com" ], "format": "antigravity", "headers": { - "User-Agent": "antigravity/1.107.0 darwin/arm64" + "User-Agent": "antigravity/ide/2.1.1 darwin/arm64" }, "retry": { "429": { @@ -232,7 +231,7 @@ "Content-Type": "application/connect+proto", "User-Agent": "connect-es/1.6.1" }, - "clientVersion": "3.1.0" + "clientVersion": "3.12.17" }, "deepgram": { "baseUrl": "https://api.deepgram.com/v1/listen", @@ -270,6 +269,11 @@ } ] }, + "featherless": { + "baseUrl": "https://api.featherless.ai/v1/chat/completions", + "validateUrl": "https://api.featherless.ai/v1/models", + "format": "openai" + }, "fireworks": { "baseUrl": "https://api.fireworks.ai/inference/v1/chat/completions", "validateUrl": "https://api.fireworks.ai/inference/v1/models", @@ -307,6 +311,7 @@ "github": { "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", @@ -398,17 +403,18 @@ "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", + "clientVersion": "0.2.99", + "clientIdentifier": "grok-shell", "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-shell/0.2.99 (linux; x86_64)", + "x-grok-client-identifier": "grok-shell", + "x-grok-client-version": "0.2.99" + }, + "usage": { + "url": "https://cli-chat-proxy.grok.com/v1/billing?format=credits", + "userUrl": "https://cli-chat-proxy.grok.com/v1/user?include=subscription" }, - "compactionAt": 400000, "retry": { "429": { "attempts": 2, @@ -477,7 +483,7 @@ "scheme": "bearer" } }, - "kimi-coding": { + "kimi": { "baseUrl": "https://api.kimi.com/coding/v1/messages", "format": "claude", "urlSuffix": "?beta=true", @@ -528,45 +534,6 @@ } ] }, - "kimi": { - "baseUrl": "https://api.kimi.com/coding/v1/messages", - "format": "claude", - "urlSuffix": "?beta=true", - "headers": { - "Anthropic-Version": "2023-06-01", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" - }, - "auth": { - "combined": true, - "header": "x-api-key", - "scheme": "raw" - }, - "transports": [ - { - "format": "openai", - "baseUrl": "https://api.kimi.com/coding/v1/chat/completions", - "auth": { - "combined": true, - "header": "Authorization", - "scheme": "bearer" - } - }, - { - "format": "claude", - "baseUrl": "https://api.kimi.com/coding/v1/messages", - "urlSuffix": "?beta=true", - "headers": { - "Anthropic-Version": "2023-06-01", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" - }, - "auth": { - "combined": true, - "header": "x-api-key", - "scheme": "raw" - } - } - ] - }, "kiro": { "baseUrl": "https://runtime.us-east-1.kiro.dev/generateAssistantResponse", "baseUrls": [ @@ -774,6 +741,11 @@ "validateUrl": "https://api.perplexity.ai/models", "format": "openai" }, + "perplexity-agent": { + "baseUrl": "https://api.perplexity.ai/v1/responses", + "validateUrl": "https://api.perplexity.ai/v1/models", + "format": "openai-responses" + }, "qoder": { "baseUrl": "https://api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation", "headers": {}, @@ -900,5 +872,13 @@ } } ] + }, + "alims-intl": { + "baseUrl": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", + "headers": {}, + "quirks": { + "preserveCacheControl": true + }, + "format": "openai" } } \ No newline at end of file diff --git a/tests/__baseline__/verify-oauth-urls.mjs b/tests/__baseline__/verify-oauth-urls.mjs index b0c6a8fa..d385d102 100644 --- a/tests/__baseline__/verify-oauth-urls.mjs +++ b/tests/__baseline__/verify-oauth-urls.mjs @@ -22,7 +22,7 @@ const resolved = { // Grok CLI injects oauth.tokenUrl onto PROVIDERS via OAUTH_INJECT_FIELDS "grok-cli": PROVIDERS["grok-cli"]?.tokenUrl, cline: PROVIDERS.cline?.tokenUrl, - "kimi-coding": PROVIDERS["kimi-coding"]?.tokenUrl, + kimi: PROVIDERS.kimi?.tokenUrl, }, authUrls: { qwen: PROVIDERS.qwen?.authUrl, @@ -31,7 +31,7 @@ const resolved = { }, refreshUrls: { cline: PROVIDERS.cline?.refreshUrl, - "kimi-coding": PROVIDERS["kimi-coding"]?.refreshUrl, + kimi: PROVIDERS.kimi?.refreshUrl, xai: PROVIDERS.xai?.refreshUrl, "grok-cli": PROVIDERS["grok-cli"]?.tokenUrl, }, @@ -40,7 +40,7 @@ const resolved = { codex: PROVIDERS.codex?.clientId, qwen: PROVIDERS.qwen?.clientId, iflow: PROVIDERS.iflow?.clientId, - "kimi-coding": PROVIDERS["kimi-coding"]?.clientId, + kimi: PROVIDERS.kimi?.clientId, "grok-cli": PROVIDERS["grok-cli"]?.clientId, }, }; diff --git a/tests/translator/bugs-kiro.test.js b/tests/translator/bugs-kiro.test.js index 7f3677fa..c3f777d4 100644 --- a/tests/translator/bugs-kiro.test.js +++ b/tests/translator/bugs-kiro.test.js @@ -5,8 +5,34 @@ import { translateRequest } from "../../open-sse/translator/index.js"; import { FORMATS } from "../../open-sse/translator/formats.js"; const O2K = (body) => translateRequest(FORMATS.OPENAI, FORMATS.KIRO, "m", body, true, null, "kiro"); +const R2K = (model, body) => translateRequest( + FORMATS.OPENAI_RESPONSES, + FORMATS.KIRO, + model, + body, + true, + null, + "kiro" +); describe("OpenAI → Kiro", () => { + it.each([ + ["high", "gpt-5.6-sol"], + ["medium", "gpt-5.6-terra"], + ["low", "gpt-5.6-luna"], + ])("preserves Responses reasoning.effort %s through the full Kiro route", (effort, model) => { + const out = R2K(model, { + input: "Use the requested effort", + reasoning: { effort }, + }); + + expect(out.additionalModelRequestFields).toEqual({ + reasoning: { effort }, + }); + expect(out.systemPrompt || "").not.toContain(""); + expect(out.systemPrompt || "").not.toContain(""); + }); + // openai-to-kiro.js — safeJSONParse guards bad tool-call JSON (fixed in PR #1582) it("malformed tool arguments do not throw the whole request", () => { expect(() => diff --git a/tests/translator/claude-kiro-direct.test.js b/tests/translator/claude-kiro-direct.test.js index 01e21709..3c2d964b 100644 --- a/tests/translator/claude-kiro-direct.test.js +++ b/tests/translator/claude-kiro-direct.test.js @@ -112,6 +112,59 @@ describe("Claude → Kiro (direct route)", () => { expect(out.systemPrompt).toContain("24576"); }); + it("maps Claude-format effort to GPT-5.6 reasoning fields without legacy prompt tags", () => { + const out = C2K({ + output_config: { effort: "low" }, + messages: [{ role: "user", content: "think lightly" }], + }, null, "gpt-5.6-sol"); + + expect(out.additionalModelRequestFields).toEqual({ + reasoning: { effort: "low" }, + }); + expect(out.systemPrompt || "").not.toContain(""); + expect(out.systemPrompt || "").not.toContain(""); + }); + + it.each(["auto", "minimal", "ultra"])( + "keeps the legacy thinking fallback for unsupported GPT-5.6 effort %s", + (effort) => { + const out = C2K({ + output_config: { effort }, + messages: [{ role: "user", content: "Use legacy thinking" }], + }, null, "gpt-5.6-sol"); + + expect(out.additionalModelRequestFields).toBeUndefined(); + expect(out.systemPrompt).toContain("enabled"); + expect(out.systemPrompt).toContain(""); + } + ); + + it.each(["none", "off", "disabled"])( + "keeps GPT-5.6 reasoning intentionally disabled for effort %s", + (effort) => { + const out = C2K({ + output_config: { effort }, + messages: [{ role: "user", content: "Do not reason" }], + }, null, "gpt-5.6-sol"); + + expect(out.additionalModelRequestFields).toBeUndefined(); + expect(out.systemPrompt || "").not.toContain(""); + expect(out.systemPrompt || "").not.toContain(""); + } + ); + + it("keeps explicit Claude effort ahead of an injected OpenAI effort", () => { + const out = C2K({ + output_config: { effort: "low" }, + reasoning_effort: "high", + messages: [{ role: "user", content: "honor the client effort" }], + }, null, "gpt-5.6-sol"); + + expect(out.additionalModelRequestFields).toEqual({ + reasoning: { effort: "low" }, + }); + }); + it("sends Claude system as top-level systemPrompt and keeps a user-content fallback", () => { const out = C2K({ system: "system-only instruction", diff --git a/tests/unit/alicode-intl-endpoint-2591.test.js b/tests/unit/alicode-intl-endpoint-2591.test.js index 7779328f..4b0c5213 100644 --- a/tests/unit/alicode-intl-endpoint-2591.test.js +++ b/tests/unit/alicode-intl-endpoint-2591.test.js @@ -1,24 +1,35 @@ -// #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". +// #2591 — Alibaba Intl key types split across two hosts: +// - alicode-intl: Coding Plan keys (sk-sp-...) → coding-intl.dashscope.aliyuncs.com +// - alims-intl: standard DashScope API keys (sk-...) → dashscope-intl.aliyuncs.com/compatible-mode +// The two key types are NOT interchangeable across hosts. Split into two providers +// so each key type reaches its own host. import { describe, it, expect } from "vitest"; import alicodeIntl from "../../open-sse/providers/registry/alicode-intl.js"; +import alimsIntl from "../../open-sse/providers/registry/alims-intl.js"; -describe("alicode-intl endpoint (issue #2591)", () => { - it("routes to the compatible-mode DashScope endpoint", () => { +describe("alicode-intl endpoint (Coding Plan keys)", () => { + it("routes to the coding-intl host for Coding Plan keys", () => { expect(alicodeIntl.id).toBe("alicode-intl"); expect(alicodeIntl.transport.baseUrl).toBe( - "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions" + "https://coding-intl.dashscope.aliyuncs.com/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); }); }); + +describe("alims-intl endpoint (standard DashScope keys)", () => { + it("routes to the compatible-mode DashScope endpoint for standard keys", () => { + expect(alimsIntl.id).toBe("alims-intl"); + expect(alimsIntl.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(alimsIntl.transport.baseUrl).not.toContain("coding-intl.dashscope.aliyuncs.com"); + }); +}); diff --git a/tests/unit/codex-refresh-token.test.js b/tests/unit/codex-refresh-token.test.js index 3b702af8..e6274c16 100644 --- a/tests/unit/codex-refresh-token.test.js +++ b/tests/unit/codex-refresh-token.test.js @@ -152,7 +152,8 @@ describe("Codex Refresh Token", () => { expect(getRefreshLeadMs("claude")).toBe(4 * 60 * 60 * 1000); // 4 hours expect(getRefreshLeadMs("iflow")).toBe(24 * 60 * 60 * 1000); // 24 hours expect(getRefreshLeadMs("qwen")).toBe(20 * 60 * 1000); // 20 minutes - expect(getRefreshLeadMs("kimi-coding")).toBe(5 * 60 * 1000); // 5 minutes + expect(getRefreshLeadMs("kimi")).toBe(5 * 60 * 1000); // 5 minutes + expect(getRefreshLeadMs("kimi-coding")).toBe(5 * 60 * 1000); // legacy alias expect(getRefreshLeadMs("antigravity")).toBe(5 * 60 * 1000); // 5 minutes }); diff --git a/tests/unit/cursor-agent-proto.test.js b/tests/unit/cursor-agent-proto.test.js new file mode 100644 index 00000000..2d571aba --- /dev/null +++ b/tests/unit/cursor-agent-proto.test.js @@ -0,0 +1,282 @@ +import { describe, expect, it } from "vitest"; +import { + decodeMessage, + encodeField, + encodeAgentValue, + decodeAgentValue, + encodeMcpToolDefinition, + encodeMcpTools, + decodeMcpArgs, + encodeMcpResultSuccess, + encodeMcpResultError, + encodeMcpResultToolNotFound, +} from "../../open-sse/utils/cursorProtobuf.js"; +import { + isAgentCapableRequest, + buildAgentRunFrame, +} from "../../open-sse/executors/cursor.js"; + +// AgentService (agent.v1) codec tests — validate the production implementation +// in cursorProtobuf.js + the executor's frame builders. Pure round-trip, no network. +// Field numbers verified against Cursor's agent.proto (extracted via @oh-my-pi). + +const LEN = 2; +// McpArgs.args map entry { field1: key, field2: Value } +const entry = (k, v) => Buffer.concat([ + Buffer.from(encodeField(2, LEN, + Buffer.concat([Buffer.from(encodeField(1, LEN, k)), Buffer.from(encodeField(2, LEN, encodeAgentValue(v)))]) + )), +]); + +describe("Cursor AgentService codec (cursorProtobuf.js)", () => { + describe("google.protobuf.Value round-trip", () => { + const cases = [ + ["null", null], + ["bool true", true], + ["bool false", false], + ["string", "hello"], + ["integer", 42], + ["float", 3.14], + ["empty object", {}], + ["flat object", { a: 1, b: "x", c: true }], + ["nested object", { outer: { inner: [1, 2, "three"] } }], + ["array of mixed", [1, "two", false, null]], + ["deeply nested", { a: { b: { c: { d: 1 } } } }], + ]; + for (const [label, value] of cases) { + it(`encodes/decodes ${label}`, () => { + expect(decodeAgentValue(encodeAgentValue(value))).toEqual(value); + }); + } + }); + + describe("McpToolDefinition", () => { + it("encodes name, description, input_schema (Value), provider, tool_name", () => { + const schema = { type: "object", properties: { city: { type: "string" } }, required: ["city"] }; + const def = encodeMcpToolDefinition({ function: { name: "get_weather", description: "Get weather", parameters: schema } }); + const msg = decodeMessage(def); + expect(Buffer.from(msg.get(1)[0].value).toString("utf8")).toBe("get_weather"); + expect(Buffer.from(msg.get(2)[0].value).toString("utf8")).toBe("Get weather"); + expect(Buffer.from(msg.get(4)[0].value).toString("utf8")).toBe("9router"); + expect(Buffer.from(msg.get(5)[0].value).toString("utf8")).toBe("get_weather"); + expect(decodeAgentValue(msg.get(3)[0].value)).toEqual(schema); + }); + + it("preserves nested JSON-schema types", () => { + const schema = { + type: "object", + properties: { + query: { type: "string", description: "search query" }, + opts: { type: "array", items: { type: "string" } }, + }, + required: ["query"], + }; + const def = encodeMcpToolDefinition({ function: { name: "search", parameters: schema } }); + const msg = decodeMessage(def); + expect(decodeAgentValue(msg.get(3)[0].value)).toEqual(schema); + }); + + it("accepts flat tool shape (no .function wrapper)", () => { + const def = encodeMcpToolDefinition({ name: "noop", description: "d", inputSchema: { type: "object" } }); + const msg = decodeMessage(def); + expect(Buffer.from(msg.get(1)[0].value).toString("utf8")).toBe("noop"); + }); + }); + + describe("encodeMcpTools", () => { + it("produces empty bytes for no tools", () => { + expect(encodeMcpTools([]).length).toBe(0); + expect(encodeMcpTools().length).toBe(0); + }); + + it("wraps multiple tool defs as repeated field 1", () => { + const tools = [ + { function: { name: "get_weather", parameters: { type: "object" } } }, + { function: { name: "calculate", parameters: { type: "object" } } }, + ]; + const mcpTools = encodeMcpTools(tools); + const inner = decodeMessage(mcpTools); + expect(inner.get(1).length).toBe(2); + }); + }); + + describe("McpArgs decode", () => { + it("decodes name, toolName, toolCallId, and typed args map", () => { + const argsBytes = Buffer.concat([ + entry("city", "Hanoi"), + entry("count", 5), + entry("flag", true), + entry("nested", { a: [1, 2] }), + ]); + const mcpArgs = Buffer.concat([ + Buffer.from(encodeField(1, LEN, "get_weather")), + argsBytes, + Buffer.from(encodeField(3, LEN, "call_abc")), + Buffer.from(encodeField(5, LEN, "get_weather")), + ]); + const decoded = decodeMcpArgs(mcpArgs); + expect(decoded.name).toBe("get_weather"); + expect(decoded.toolName).toBe("get_weather"); + expect(decoded.toolCallId).toBe("call_abc"); + expect(decoded.args).toEqual({ city: "Hanoi", count: 5, flag: true, nested: { a: [1, 2] } }); + }); + + it("handles empty args map", () => { + const mcpArgs = Buffer.concat([ + Buffer.from(encodeField(1, LEN, "noop")), + Buffer.from(encodeField(5, LEN, "noop")), + ]); + expect(decodeMcpArgs(mcpArgs).args).toEqual({}); + }); + }); + + describe("McpResult success", () => { + it("builds success with single text content", () => { + const bytes = encodeMcpResultSuccess({ textItems: ['{"temp":32}'], isError: false }); + const msg = decodeMessage(bytes); // McpResult level + expect(msg.has(1)).toBe(true); // success variant + const success = decodeMessage(msg.get(1)[0].value); + expect(success.get(1).length).toBe(1); + expect(success.get(2)[0].value).toBe(0); // is_error=false + const item = decodeMessage(success.get(1)[0].value); + const textContent = decodeMessage(item.get(1)[0].value); + expect(Buffer.from(textContent.get(1)[0].value).toString("utf8")).toBe('{"temp":32}'); + }); + + it("builds success with multiple text items", () => { + const bytes = encodeMcpResultSuccess({ textItems: ["line1", "line2"] }); + const success = decodeMessage(decodeMessage(bytes).get(1)[0].value); + expect(success.get(1).length).toBe(2); + }); + + it("marks is_error=true", () => { + const bytes = encodeMcpResultSuccess({ textItems: ["fail"], isError: true }); + const success = decodeMessage(decodeMessage(bytes).get(1)[0].value); + expect(success.get(2)[0].value).toBe(1); + }); + }); + + describe("McpResult image content", () => { + it("builds image item with raw bytes + mime type", () => { + const imgBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + const bytes = encodeMcpResultSuccess({ imageItems: [{ data: imgBytes, mimeType: "image/png" }] }); + const success = decodeMessage(decodeMessage(bytes).get(1)[0].value); + const item = decodeMessage(success.get(1)[0].value); + expect(item.has(2)).toBe(true); // image variant + const img = decodeMessage(item.get(2)[0].value); + expect(Buffer.from(img.get(1)[0].value)).toEqual(Buffer.from(imgBytes)); + expect(Buffer.from(img.get(2)[0].value).toString("utf8")).toBe("image/png"); + }); + + it("builds mixed text + image content", () => { + const imgBytes = new Uint8Array([1, 2, 3]); + const bytes = encodeMcpResultSuccess({ textItems: ["see image"], imageItems: [{ data: imgBytes, mimeType: "image/jpeg" }] }); + const success = decodeMessage(decodeMessage(bytes).get(1)[0].value); + expect(success.get(1).length).toBe(2); + expect(decodeMessage(success.get(1)[0].value).has(1)).toBe(true); // text + expect(decodeMessage(success.get(1)[1].value).has(2)).toBe(true); // image + }); + }); + + describe("McpResult error / toolNotFound", () => { + it("builds error result (field 2)", () => { + const bytes = encodeMcpResultError("tool crashed"); + const msg = decodeMessage(bytes); + expect(msg.has(2)).toBe(true); + const err = decodeMessage(msg.get(2)[0].value); + expect(Buffer.from(err.get(1)[0].value).toString("utf8")).toBe("tool crashed"); + }); + + it("builds toolNotFound result (field 5)", () => { + const bytes = encodeMcpResultToolNotFound("missing_tool"); + const msg = decodeMessage(bytes); + expect(msg.has(5)).toBe(true); + const tnf = decodeMessage(msg.get(5)[0].value); + expect(Buffer.from(tnf.get(1)[0].value).toString("utf8")).toBe("missing_tool"); + }); + }); +}); + +describe("Cursor AgentService executor helpers (cursor.js)", () => { + describe("isAgentCapableRequest", () => { + it("accepts plain text content", () => { + expect(isAgentCapableRequest({ messages: [{ role: "user", content: "hi" }] })).toBe(true); + }); + + it("accepts array text content", () => { + expect(isAgentCapableRequest({ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }] })).toBe(true); + }); + + it("accepts request with tools declared", () => { + expect(isAgentCapableRequest({ messages: [{ role: "user", content: "hi" }], tools: [{ function: { name: "t" } }] })).toBe(true); + }); + + it("accepts history with assistant tool_calls + tool results", () => { + expect(isAgentCapableRequest({ + messages: [ + { role: "user", content: "weather?" }, + { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "get_weather", arguments: "{}" } }] }, + { role: "tool", tool_call_id: "c1", content: "sunny" }, + { role: "user", content: "thanks" }, + ], + })).toBe(true); + }); + + it("rejects non-text (image) content", () => { + expect(isAgentCapableRequest({ messages: [{ role: "user", content: [{ type: "image_url" }] }] })).toBe(false); + }); + + it("rejects missing messages", () => { + expect(isAgentCapableRequest({})).toBe(false); + expect(isAgentCapableRequest(null)).toBe(false); + }); + }); + + describe("buildAgentRunFrame", () => { + // buildAgentRunFrame returns a wrapped Connect-RPC frame (5-byte header + AgentClientMessage). + const unwrap = (frame) => frame.subarray(5); + + it("encodes a text-only run request with system + model", () => { + const frame = unwrap(buildAgentRunFrame( + [{ role: "system", content: "be brief" }, { role: "user", content: "hi" }], + "gpt-5.2", + )); + const clientMsg = decodeMessage(frame); + expect(clientMsg.has(1)).toBe(true); // run_request + const run = decodeMessage(clientMsg.get(1)[0].value); + expect(run.has(2)).toBe(true); // action + expect(run.has(9)).toBe(true); // requested_model + }); + + it("encodes mcp_tools (field 4) when tools are provided", () => { + const tools = [{ function: { name: "get_weather", description: "weather", parameters: { type: "object", properties: { city: { type: "string" } } } } }]; + const frame = unwrap(buildAgentRunFrame([{ role: "user", content: "weather?" }], "gpt-5.2", tools)); + const run = decodeMessage(decodeMessage(frame).get(1)[0].value); + expect(run.has(4)).toBe(true); // mcp_tools + const mcpTools = decodeMessage(run.get(4)[0].value); + expect(mcpTools.get(1).length).toBe(1); + }); + + it("omits mcp_tools when no tools provided", () => { + const frame = unwrap(buildAgentRunFrame([{ role: "user", content: "hi" }], "gpt-5.2", [])); + const run = decodeMessage(decodeMessage(frame).get(1)[0].value); + expect(run.has(4)).toBe(false); + }); + + it("encodes conversation_history from prior turns including tool calls/results", () => { + const messages = [ + { role: "user", content: "weather in Tokyo?" }, + { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "get_weather", arguments: '{"city":"Tokyo"}' } }] }, + { role: "tool", tool_call_id: "c1", content: "18C cloudy" }, + { role: "user", content: "thanks" }, + ]; + const frame = unwrap(buildAgentRunFrame(messages, "gpt-5.2", [])); + const run = decodeMessage(decodeMessage(frame).get(1)[0].value); + const action = decodeMessage(run.get(2)[0].value); + const userAction = decodeMessage(action.get(1)[0].value); + expect(userAction.has(7)).toBe(true); // conversation_history (field 7) + const history = decodeMessage(userAction.get(7)[0].value); + expect(history.get(1).length).toBeGreaterThanOrEqual(2); // prior turns + }); + }); +}); diff --git a/tests/unit/cursor-models.test.js b/tests/unit/cursor-models.test.js new file mode 100644 index 00000000..23de871e --- /dev/null +++ b/tests/unit/cursor-models.test.js @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearCursorModelCache, + parseCursorUsableModels, + resolveCursorModels, +} from "../../open-sse/services/cursorModels.js"; + +const originalFetch = global.fetch; + +function varint(value) { + const bytes = []; + while (value >= 0x80) { + bytes.push((value & 0x7f) | 0x80); + value >>>= 7; + } + bytes.push(value); + return Uint8Array.from(bytes); +} + +function field(fieldNumber, value) { + return Uint8Array.from([(fieldNumber << 3) | 2, ...varint(value.length), ...value]); +} + +function text(value) { + return new TextEncoder().encode(value); +} + +function concat(...parts) { + const size = parts.reduce((sum, part) => sum + part.length, 0); + const result = new Uint8Array(size); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + return result; +} + +function model(id, name) { + return field(1, concat(field(1, text(id)), field(4, text(name)))); +} + +describe("Cursor live model catalog", () => { + beforeEach(() => { + clearCursorModelCache(); + }); + + afterEach(() => { + global.fetch = originalFetch; + clearCursorModelCache(); + }); + + it("decodes the GetUsableModels protobuf response", () => { + const payload = concat( + model("default", "Auto"), + model("gpt-5.3-codex", "GPT 5.3 Codex"), + model("gpt-5.3-codex", "Duplicate"), + ); + + expect(parseCursorUsableModels(payload)).toEqual([ + { id: "default", name: "Auto" }, + { id: "gpt-5.3-codex", name: "GPT 5.3 Codex" }, + ]); + }); + + it("fetches the account-specific catalog and caches it", async () => { + const payload = concat(model("claude-4.6-opus", "Claude 4.6 Opus")); + global.fetch = vi.fn().mockResolvedValue(new Response(payload, { status: 200 })); + const credentials = { + accessToken: "cursor-token", + providerSpecificData: { machineId: "machine-id" }, + }; + + await expect(resolveCursorModels(credentials)).resolves.toEqual({ + models: [{ id: "claude-4.6-opus", name: "Claude 4.6 Opus" }], + }); + await expect(resolveCursorModels(credentials)).resolves.toEqual({ + models: [{ id: "claude-4.6-opus", name: "Claude 4.6 Opus" }], + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith( + "https://agent.api5.cursor.sh/agent.v1.AgentService/GetUsableModels", + expect.objectContaining({ + method: "POST", + body: expect.any(Uint8Array), + headers: expect.objectContaining({ + "content-type": "application/proto", + accept: "application/proto", + }), + }), + ); + }); + + it("fails open when the Cursor catalog request fails", async () => { + global.fetch = vi.fn().mockResolvedValue(new Response("no", { status: 403 })); + + await expect(resolveCursorModels({ + accessToken: "cursor-token", + providerSpecificData: { machineId: "machine-id" }, + })).resolves.toBeNull(); + }); +}); diff --git a/tests/unit/grok-build-config.test.js b/tests/unit/grok-build-config.test.js new file mode 100644 index 00000000..6da793a9 --- /dev/null +++ b/tests/unit/grok-build-config.test.js @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vitest"; +import { + applyGrokBuildConfig, + getGrokSubagentSlot, + parseGrokBuildConfig, + resetGrokBuildConfig, +} from "../../src/lib/grokBuildConfig.js"; + +const BASE_CONFIG = `[cli] +installer = "internal" + +[ui] +yolo = false + +[models] +default = "grok-4.5" +default_reasoning_effort = "high" + +[subagents] +enabled = true + +[subagents.models] +general-purpose = "grok-4.5" +explore = "grok-build" +plan = "grok-4.5" + +[mcp_servers.example] +url = "https://example.com/mcp" +enabled = true +`; + +const APPLY_INPUT = { + baseUrl: "http://127.0.0.1:20128/v1", + apiKey: "sk-test", + model: "cx/gpt-5.6-sol", + contextWindow: 400000, + subagentModels: { + "general-purpose": { model: "cc/claude-sonnet-5", contextWindow: 1000000 }, + explore: { model: "gemini/gemini-3-flash", contextWindow: 1048576 }, + }, +}; + +describe("grokBuildConfig", () => { + it("creates independent main and per-type subagent model slots", () => { + const result = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT); + const parsed = parseGrokBuildConfig(result); + + expect(parsed.default).toBe("9router"); + expect(parsed.model).toMatchObject({ + model: "cx/gpt-5.6-sol", + base_url: "http://127.0.0.1:20128/v1", + context_window: 400000, + }); + expect(parsed.subagentMappings).toMatchObject({ + "general-purpose": "9router-general-purpose", + explore: "9router-explore", + plan: "grok-4.5", + }); + expect(parsed.subagentModels["general-purpose"]).toMatchObject({ + model: "cc/claude-sonnet-5", + context_window: 1000000, + }); + expect(parsed.subagentModels.explore).toMatchObject({ + model: "gemini/gemini-3-flash", + context_window: 1048576, + }); + expect(parsed.subagentModels.plan).toBeNull(); + }); + + it("preserves unrelated config sections", () => { + const result = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT); + expect(result).toContain("[cli]\ninstaller = \"internal\""); + expect(result).toContain("[ui]\nyolo = false"); + expect(result).toContain("default_reasoning_effort = \"high\""); + expect(result).toContain("[mcp_servers.example]"); + expect(result).toContain("url = \"https://example.com/mcp\""); + }); + + it("is idempotent and updates owned slots without duplicate sections", () => { + let result = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT); + result = applyGrokBuildConfig(result, { + ...APPLY_INPUT, + model: "cc/claude-opus-4.8", + contextWindow: 1000000, + subagentModels: { + ...APPLY_INPUT.subagentModels, + explore: { model: "mimo/mimo", contextWindow: 262144 }, + }, + }); + + expect(result.match(/^\[model\.9router\]$/gm)).toHaveLength(1); + expect(result.match(/^\[model\.9router-general-purpose\]$/gm)).toHaveLength(1); + expect(result.match(/^\[model\.9router-explore\]$/gm)).toHaveLength(1); + expect(result.match(/^# 9router-prev-subagent-explore/gm)).toHaveLength(1); + expect(parseGrokBuildConfig(result).model).toMatchObject({ + model: "cc/claude-opus-4.8", + context_window: 1000000, + }); + expect(parseGrokBuildConfig(result).subagentModels.explore).toMatchObject({ + model: "mimo/mimo", + context_window: 262144, + }); + }); + + it("blank override restores previous subagent mapping and removes owned slot", () => { + let result = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT); + result = applyGrokBuildConfig(result, { + ...APPLY_INPUT, + subagentModels: { + "general-purpose": APPLY_INPUT.subagentModels["general-purpose"], + // explore omitted => inherit / restore previous + }, + }); + + const parsed = parseGrokBuildConfig(result); + expect(parsed.subagentMappings.explore).toBe("grok-build"); + expect(parsed.subagentModels.explore).toBeNull(); + expect(result).not.toContain("[model.9router-explore]"); + expect(parsed.subagentMappings["general-purpose"]).toBe("9router-general-purpose"); + }); + + it("reset restores previous default and all previous subagent mappings", () => { + const applied = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT); + const reset = resetGrokBuildConfig(applied); + const parsed = parseGrokBuildConfig(reset); + + expect(parsed.default).toBe("grok-4.5"); + expect(parsed.model).toBeNull(); + expect(parsed.subagentMappings).toEqual({ + "general-purpose": "grok-4.5", + explore: "grok-build", + plan: "grok-4.5", + }); + expect(reset).not.toContain("[model.9router-"); + expect(reset).not.toContain("9router-prev-"); + expect(reset).toContain("[mcp_servers.example]"); + }); + + it("removes mappings that were originally unset", () => { + const config = `[models]\ndefault = "grok-build"\n\n[mcp_servers.x]\nenabled = true\n`; + const applied = applyGrokBuildConfig(config, { + ...APPLY_INPUT, + subagentModels: { + plan: { model: "cc/claude-sonnet-5", contextWindow: 1000000 }, + }, + }); + const reset = resetGrokBuildConfig(applied); + + expect(parseGrokBuildConfig(applied).subagentMappings.plan).toBe("9router-plan"); + expect(parseGrokBuildConfig(reset).subagentMappings.plan).toBeNull(); + expect(reset).not.toContain("[subagents.models]"); + expect(reset).toContain("[mcp_servers.x]"); + }); + + it("legacy callers without subagentModels leave existing overrides untouched", () => { + const applied = applyGrokBuildConfig(BASE_CONFIG, APPLY_INPUT); + const updatedMainOnly = applyGrokBuildConfig(applied, { + baseUrl: APPLY_INPUT.baseUrl, + apiKey: APPLY_INPUT.apiKey, + model: "gemini/gemini-3.1-pro", + contextWindow: 1048576, + }); + + const parsed = parseGrokBuildConfig(updatedMainOnly); + expect(parsed.model.model).toBe("gemini/gemini-3.1-pro"); + expect(parsed.subagentMappings.explore).toBe("9router-explore"); + expect(parsed.subagentModels.explore.model).toBe("gemini/gemini-3-flash"); + }); + + it("returns stable slot names only for supported subagent types", () => { + expect(getGrokSubagentSlot("general-purpose")).toBe("9router-general-purpose"); + expect(getGrokSubagentSlot("explore")).toBe("9router-explore"); + expect(getGrokSubagentSlot("plan")).toBe("9router-plan"); + expect(getGrokSubagentSlot("unknown")).toBeNull(); + }); +}); diff --git a/tests/unit/kiro-nonstream-error.test.js b/tests/unit/kiro-nonstream-error.test.js new file mode 100644 index 00000000..d36b1c0a --- /dev/null +++ b/tests/unit/kiro-nonstream-error.test.js @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/usageDb.js", () => ({ + appendRequestLog: vi.fn(async () => {}), + saveRequestDetail: vi.fn(async () => {}), + saveRequestUsage: vi.fn(async () => {}) +})); + +const { FORMATS } = await import("../../open-sse/translator/formats.js"); +const { + handleForcedSSEToJson, + parseSSEToOpenAIResponse +} = await import("../../open-sse/handlers/chatCore/sseToJsonHandler.js"); + +describe("Kiro non-streaming error propagation", () => { + it("prefers a terminal SSE error over earlier semantic chunks", () => { + const raw = [ + 'data: {"choices":[{"delta":{"content":"partial"},"finish_reason":null}]}', + 'data: {"error":{"message":"Kiro transport failed","code":"kiro_missing_terminal"}}', + "data: [DONE]" + ].join("\n\n"); + + expect(parseSSEToOpenAIResponse(raw, "kiro")).toEqual({ + error: { + message: "Kiro transport failed", + code: "kiro_missing_terminal" + } + }); + }); + + it("returns 502 instead of collapsing a failed Kiro SSE stream into stop", async () => { + const encoder = new TextEncoder(); + const raw = [ + 'data: {"choices":[{"delta":{"content":"partial"},"finish_reason":null}]}', + 'data: {"error":{"message":"Kiro stream ended incompletely","code":"kiro_missing_terminal"}}', + "data: [DONE]", + "" + ].join("\n\n"); + const result = await handleForcedSSEToJson({ + providerResponse: new Response(new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(raw)); + controller.close(); + } + }), { headers: { "content-type": "text/event-stream" } }), + sourceFormat: FORMATS.OPENAI, + provider: "kiro", + model: "kr/claude-opus-4.8", + body: { model: "kr/claude-opus-4.8", messages: [] }, + stream: false, + requestStartTime: Date.now(), + connectionId: "test-connection", + clientRawRequest: { endpoint: "/v1/chat/completions" }, + trackDone: vi.fn(), + appendLog: vi.fn() + }); + const json = await result.response.json(); + + expect(result.success).toBe(false); + expect(result.response.status).toBe(502); + expect(json.error.message).toContain("Kiro stream ended incompletely"); + expect(json).not.toHaveProperty("choices"); + }); +}); diff --git a/tests/unit/kiro-terminal-integrity.test.js b/tests/unit/kiro-terminal-integrity.test.js new file mode 100644 index 00000000..aaf66209 --- /dev/null +++ b/tests/unit/kiro-terminal-integrity.test.js @@ -0,0 +1,778 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const fetchMock = vi.fn(); +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: (...args) => fetchMock(...args) +})); + +const { KiroExecutor } = await import("../../open-sse/executors/kiro.js"); + +const encoder = new TextEncoder(); +const credentials = { + accessToken: "test-token", + providerSpecificData: { kiroToolCallRepair: true } +}; + +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) { + crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function encodeHeader(name, value) { + const nameBytes = encoder.encode(name); + const valueBytes = encoder.encode(value); + const bytes = new Uint8Array(1 + nameBytes.length + 3 + valueBytes.length); + let offset = 0; + bytes[offset++] = nameBytes.length; + bytes.set(nameBytes, offset); + offset += nameBytes.length; + bytes[offset++] = 7; + new DataView(bytes.buffer).setUint16(offset, valueBytes.length, false); + offset += 2; + bytes.set(valueBytes, offset); + return bytes; +} + +function concat(chunks) { + const output = new Uint8Array(chunks.reduce((size, chunk) => size + chunk.byteLength, 0)); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +function frameFromEntries(entries, payload) { + const headers = concat(entries.map(([name, value]) => encodeHeader(name, value))); + const payloadBytes = encoder.encode(JSON.stringify(payload)); + const totalLength = 12 + headers.byteLength + payloadBytes.byteLength + 4; + const frame = new Uint8Array(totalLength); + const view = new DataView(frame.buffer); + view.setUint32(0, totalLength, false); + view.setUint32(4, headers.byteLength, false); + frame.set(headers, 12); + frame.set(payloadBytes, 12 + headers.byteLength); + return checksum(frame); +} + +function frame(eventType, payload) { + return frameFromEntries([[":event-type", eventType]], payload); +} + +function checksum(bytes) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + view.setUint32(8, crc32(bytes.subarray(0, 8)), false); + view.setUint32(bytes.byteLength - 4, crc32(bytes.subarray(0, bytes.byteLength - 4)), false); + return bytes; +} + +function response(frames, status = 200) { + return new Response(new ReadableStream({ + start(controller) { + for (const value of frames) controller.enqueue(value); + controller.close(); + } + }), { status, statusText: status === 200 ? "OK" : "Upstream Error" }); +} + +function controlledResponse(frames = []) { + let controller; + const value = new Response(new ReadableStream({ + start(streamController) { + controller = streamController; + for (const item of frames) controller.enqueue(item); + } + }), { status: 200 }); + return { + value, + enqueue(item) { + controller.enqueue(item); + }, + close() { + controller.close(); + } + }; +} + +async function text(stream) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let output = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) return output + decoder.decode(); + output += decoder.decode(value, { stream: true }); + } +} + +async function execute(executor = new KiroExecutor(), overrides = {}) { + return executor.execute({ + model: "kr/claude-opus-4.8", + body: { systemPrompt: "base", conversationState: {} }, + stream: true, + credentials, + ...overrides + }); +} + +beforeEach(() => { + fetchMock.mockReset(); + delete process.env.KIRO_TOOL_CALL_REPAIR_BUFFER_MAX_BYTES; + delete process.env.KIRO_TOOL_CALL_REPAIR_TTFT_TIMEOUT_MS; + delete process.env.KIRO_TOOL_CALL_REPAIR_STALL_TIMEOUT_MS; +}); + +afterEach(() => { + delete process.env.KIRO_TOOL_CALL_REPAIR_BUFFER_MAX_BYTES; + delete process.env.KIRO_TOOL_CALL_REPAIR_TTFT_TIMEOUT_MS; + delete process.env.KIRO_TOOL_CALL_REPAIR_STALL_TIMEOUT_MS; +}); + +describe("Kiro terminal integrity recovery", () => { + it("keeps semantic output private behind a heartbeat until clean EOF", async () => { + const upstream = controlledResponse([ + frame("assistantResponseEvent", { content: "private until validated" }) + ]); + fetchMock.mockResolvedValueOnce(upstream.value); + + const result = await execute(); + const reader = result.response.body.getReader(); + expect(new TextDecoder().decode((await reader.read()).value)).toBe(": kiro-validation\n\n"); + + let settled = false; + const semantic = reader.read().then((value) => { + settled = true; + return value; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + upstream.close(); + expect(new TextDecoder().decode((await semantic).value)).toContain("private until validated"); + await reader.cancel(); + }); + + it("accepts CLI-compatible text and usage frames at clean EOF without messageStop", async () => { + fetchMock.mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "Complete answer." }), + frame("meteringEvent", { usage: 2, unit: "credit" }), + frame("contextUsageEvent", { contextUsagePercentage: 10 }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain("Complete answer."); + expect(body).toContain('"finish_reason":"stop"'); + expect(body).toContain('"kiro_credits":2'); + }); + + it("parses frames split across chunks and multiple frames in one chunk", async () => { + const first = frame("assistantResponseEvent", { content: "split " }); + const second = frame("assistantResponseEvent", { content: "boundaries" }); + const combined = concat([first, second]); + fetchMock.mockResolvedValueOnce(new Response(new ReadableStream({ + start(controller) { + controller.enqueue(combined.slice(0, 9)); + controller.enqueue(combined.slice(9, first.byteLength + 5)); + controller.enqueue(combined.slice(first.byteLength + 5)); + controller.close(); + } + }))); + + const body = await (await execute()).response.text(); + + expect(body).toContain('"content":"split "'); + expect(body).toContain('"content":"boundaries"'); + expect(body).toContain('"finish_reason":"stop"'); + }); + + it("accepts messageStop without semantic output as explicit completion", async () => { + fetchMock.mockResolvedValueOnce(response([frame("messageStopEvent", {})])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain('"finish_reason":"stop"'); + expect(body).not.toContain("kiro_missing_terminal"); + }); + + it.each(["...", "…"])("repairs exact ellipsis final %s without leaking it", async (ellipsis) => { + fetchMock + .mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: ellipsis })])) + .mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: "Recovered answer." })])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain("Recovered answer."); + expect(body).not.toContain(`"content":"${ellipsis}"`); + }); + + it.each([ + "接下來我只再確認部署結果。", + "我會重新抓取最新日誌並確認結果。", + "目前證據顯示只在 **03:48:30–03:49:00 TPE** 出現少量 NonKA 504;主池 106/106、副池 50/50,且兩池都沒有重啟。最後補查 504 access log,確認 host/路徑與是否為集中流量。", + "Next I'll verify the deployment logs.", + "Let me check the remaining failures." + ])("repairs conservative future-action final: %s", async (progress) => { + fetchMock + .mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: progress })])) + .mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: "Verification completed." })])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain("Verification completed."); + expect(body).not.toContain(progress); + }); + + it.each([ + "Working...", + "I'll check the logs. They show no errors and deployment succeeded.", + "Let me check: status is 200 and the checksum matches abc123.", + "我會檢查版本。版本是 1.2.3。", + "接下來請你先批准部署,我會等待你的確認。", + "已完成驗證,所有測試均通過。", + "目前證據顯示只有少量 504,且主副池均未重啟。", + "目前證據顯示只有少量 504。最後補查結果顯示沒有集中流量。", + "目前證據顯示只有少量 504。最後補查,結果顯示沒有集中流量。", + "目前證據顯示只有少量 504。最後補查:結果顯示沒有集中流量。", + "目前證據顯示只有少量 504。最後補查 504 access log,結果顯示沒有集中流量。", + "目前證據顯示只有少量 504。最後補查 504 access log,確認 host/路徑與有無集中流量:無集中流量。", + "目前證據顯示只有少量 504。最後補查 504 access log,確認 host/路徑與是否為集中流量(答案是否定的)。", + "目前證據顯示只有少量 504。最後補充兩點已確認的結果。", + "The verification is complete and all tests passed." + ])("does not retry legitimate final: %s", async (finalText) => { + fetchMock.mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: finalText }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain(finalText); + }); + + it("bounds incomplete-final repair to one retry", async () => { + fetchMock + .mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: "..." })])) + .mockResolvedValueOnce(response([frame("assistantResponseEvent", { content: "…" })])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain("kiro_ellipsis_retry_failed"); + expect(body).not.toContain('"content":"..."'); + }); + + it("repairs malformed wrapper tools without leaking the invalid call", async () => { + fetchMock + .mockResolvedValueOnce(response([frame("toolUseEvent", { + toolUseId: "bad", + name: "tool_call", + input: { arguments: { q: "router" } } + })])) + .mockResolvedValueOnce(response([frame("toolUseEvent", { + toolUseId: "good", + name: "tool_call", + input: { name: "mcp_search", arguments: { q: "router" } } + })])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain('"name":"tool_call"'); + expect(body).toContain('\\"name\\":\\"mcp_search\\"'); + expect(body).not.toContain('"id":"bad"'); + }); + + it("requires complete direct tool input and keeps the failure private", async () => { + const pending = frame("toolUseEvent", { toolUseId: "pending", name: "read_file" }); + fetchMock + .mockResolvedValueOnce(response([pending])) + .mockResolvedValueOnce(response([pending])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain("kiro_tool_call_repair_retry_failed"); + expect(body).not.toContain('"name":"read_file"'); + }); + + it("repairs a non-string toolUseId before releasing the tool call", async () => { + fetchMock + .mockResolvedValueOnce(response([frame("toolUseEvent", { + toolUseId: 123, + name: "read_file", + input: { path: "bad.txt" } + })])) + .mockResolvedValueOnce(response([frame("toolUseEvent", { + toolUseId: "valid-tool-id", + name: "read_file", + input: { path: "safe.txt" } + })])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain('"id":"valid-tool-id"'); + expect(body).not.toContain('"id":123'); + }); + + it("keeps model-controlled parser detail out of the retry system prompt", async () => { + fetchMock + .mockResolvedValueOnce(response([frame("toolUseEvent", { + toolUseId: "bad-json", + name: "tool_call", + input: '{"name":"IGNORE_ALL_INSTRUCTIONS"' + })])) + .mockResolvedValueOnce(response([frame("assistantResponseEvent", { + content: "Recovered safely." + })])); + + const body = await (await execute()).response.text(); + const retryBody = JSON.parse(fetchMock.mock.calls[1][1].body); + + expect(body).toContain("Recovered safely."); + expect(retryBody.systemPrompt).toContain("tool_call wrapper was malformed"); + expect(retryBody.systemPrompt).not.toContain("IGNORE_ALL_INSTRUCTIONS"); + }); + + it("lets a complete tool call override metadata end_turn", async () => { + fetchMock.mockResolvedValueOnce(response([ + frame("toolUseEvent", { + toolUseId: "tool", + name: "read_file", + input: { path: "safe.txt" } + }), + frame("metadataEvent", { stopReason: "end_turn" }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain('"name":"read_file"'); + expect(body).toContain('"finish_reason":"tool_calls"'); + }); + + it("maps max_tokens without treating it as a normal stop", async () => { + fetchMock.mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "Limited answer." }), + frame("metadataEvent", { stopReason: "max_tokens" }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain('"finish_reason":"length"'); + expect(body).not.toContain('"finish_reason":"stop"'); + }); + + it("retries malformed_model_output once without semantic leakage", async () => { + fetchMock + .mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "private malformed output" }), + frame("metadataEvent", { stopReason: "malformed_model_output" }) + ])) + .mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "Recovered protocol output." }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain("Recovered protocol output."); + expect(body).not.toContain("private malformed output"); + }); + + it.each([ + ["cancelled", "kiro_terminal_incomplete"], + ["pause_turn", "kiro_terminal_incomplete"], + ["content_filtered", "kiro_terminal_refusal"], + ["novel_reason", "kiro_unknown_stop_reason"] + ])("fails closed for stop reason %s", async (stopReason, code) => { + fetchMock.mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: `private-${stopReason}` }), + frame("metadataEvent", { stopReason }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain(code); + expect(body).not.toContain(`private-${stopReason}`); + expect(body).not.toContain('"finish_reason":"stop"'); + }); + + it.each([ + [ + frame("messageStopEvent", { stopReason: "content_filtered" }), + frame("metadataEvent", { stopReason: "end_turn" }) + ], + [ + frame("metadataEvent", { stopReason: "end_turn" }), + frame("messageStopEvent", { stopReason: "content_filtered" }) + ] + ])("preserves the most restrictive conflicting stop reason", async (...stopFrames) => { + fetchMock.mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "private filtered output" }), + ...stopFrames + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain("kiro_terminal_refusal"); + expect(body).not.toContain("private filtered output"); + }); + + it("prefers a non-retryable terminal reason over an earlier retryable reason", async () => { + fetchMock.mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "private malformed output" }), + frame("metadataEvent", { stopReason: "malformed_model_output" }), + frame("messageStopEvent", { stopReason: "cancelled" }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain("kiro_terminal_incomplete"); + expect(body).toContain('"stop_reason":"cancelled"'); + expect(body).not.toContain("private malformed output"); + }); + + it("preserves an authoritative refusal returned by the bounded retry", async () => { + fetchMock + .mockResolvedValueOnce(response([])) + .mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "private filtered retry" }), + frame("metadataEvent", { stopReason: "content_filtered" }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain("kiro_terminal_refusal"); + expect(body).not.toContain("kiro_missing_terminal_retry_failed"); + expect(body).not.toContain("private filtered retry"); + }); + + it.each([ + ["max_tokens", "kiro_terminal_incomplete"], + ["cancelled", "kiro_terminal_incomplete"], + ["content_filtered", "kiro_terminal_refusal"], + ["novel_reason", "kiro_unknown_stop_reason"] + ])("does not let a valid tool override failure stop reason %s", async (stopReason, code) => { + fetchMock.mockResolvedValueOnce(response([ + frame("toolUseEvent", { + toolUseId: "blocked-tool", + name: "read_file", + input: { path: "secret.txt" } + }), + frame("metadataEvent", { stopReason }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain(code); + expect(body).not.toContain('"name":"read_file"'); + }); + + it.each(["content_filtered", "cancelled", "max_tokens"])( + "classifies failure %s before validating a malformed deferred tool", + async (stopReason) => { + fetchMock.mockResolvedValueOnce(response([ + frame("toolUseEvent", { toolUseId: "bad-tool", name: "read_file" }), + frame("metadataEvent", { stopReason }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain(stopReason === "content_filtered" + ? "kiro_terminal_refusal" + : "kiro_terminal_incomplete"); + expect(body).not.toContain("kiro_tool_call_repair_retry_failed"); + expect(body).not.toContain('"name":"read_file"'); + } + ); + + it.each([ + ["content_filtered", [frame("toolUseEvent", { + toolUseId: 123, + name: "read_file", + input: { path: "bad.txt" } + })], "kiro_terminal_refusal"], + ["cancelled", [frame("toolUseEvent", { + toolUseId: "missing-name", + input: { path: "bad.txt" } + })], "kiro_terminal_incomplete"], + ["max_tokens", [ + frame("toolUseEvent", { toolUseId: "changing", name: "read_file" }), + frame("toolUseEvent", { toolUseId: "changing", name: "write_file" }) + ], "kiro_terminal_incomplete"] + ])("continues past eager tool-shape errors to authoritative stop %s", async (stopReason, toolFrames, code) => { + fetchMock.mockResolvedValueOnce(response([ + ...toolFrames, + frame("metadataEvent", { stopReason }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain(code); + expect(body).not.toContain("kiro_tool_call_repair_retry_failed"); + expect(body).not.toContain('"tool_calls"'); + }); + + it("retries a TTFT timeout once while preserving cancellation semantics", async () => { + process.env.KIRO_TOOL_CALL_REPAIR_TTFT_TIMEOUT_MS = "1"; + fetchMock + .mockResolvedValueOnce(controlledResponse().value) + .mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "Recovered after timeout." }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain("Recovered after timeout."); + }); + + it("treats validated non-semantic frames as watchdog activity", async () => { + process.env.KIRO_TOOL_CALL_REPAIR_TTFT_TIMEOUT_MS = "30"; + process.env.KIRO_TOOL_CALL_REPAIR_STALL_TIMEOUT_MS = "30"; + const upstream = controlledResponse(); + fetchMock.mockResolvedValueOnce(upstream.value); + setTimeout(() => upstream.enqueue(frame("meteringEvent", { usage: 1 })), 20); + setTimeout(() => upstream.enqueue(frame("contextUsageEvent", { contextUsagePercentage: 5 })), 40); + setTimeout(() => { + upstream.enqueue(frame("assistantResponseEvent", { content: "Completed after active frames." })); + upstream.close(); + }, 60); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain("Completed after active frames."); + }); + + it("retries a response-body read failure once", async () => { + fetchMock + .mockResolvedValueOnce(new Response(new ReadableStream({ + start(controller) { + controller.error(new Error("socket reset")); + } + }))) + .mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "Recovered after read failure." }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain("Recovered after read failure."); + expect(body).not.toContain("socket reset"); + }); + + it.each([ + ["message CRC", () => { + const corrupt = frame("assistantResponseEvent", { content: "corrupt CRC" }); + corrupt[corrupt.byteLength - 1] ^= 0xff; + return [corrupt]; + }], + ["prelude CRC", () => { + const corrupt = frame("assistantResponseEvent", { content: "corrupt prelude" }); + corrupt[8] ^= 0xff; + return [corrupt]; + }], + ["truncated frame", () => { + const truncated = frame("assistantResponseEvent", { content: "truncated" }); + return [truncated.slice(0, -3)]; + }], + ["out-of-bounds headers", () => { + const corrupt = frame("assistantResponseEvent", { content: "bad headers" }); + new DataView(corrupt.buffer).setUint32(4, corrupt.byteLength - 15, false); + return [checksum(corrupt)]; + }], + ["duplicate headers", () => [ + frameFromEntries([ + [":event-type", "assistantResponseEvent"], + [":event-type", "metadataEvent"] + ], { content: "duplicate" }) + ]] + ])("retries %s and releases only the valid attempt", async (_name, invalidFrames) => { + fetchMock + .mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "must stay private" }), + ...invalidFrames() + ])) + .mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "Recovered after validation." }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(body).toContain("Recovered after validation."); + expect(body).not.toContain("must stay private"); + }); + + it("reports corrupt-frame provenance when the bounded retry also fails", async () => { + const corruptFrame = () => { + const corrupt = frame("assistantResponseEvent", { content: "corrupt" }); + corrupt[corrupt.byteLength - 1] ^= 0xff; + return corrupt; + }; + fetchMock + .mockResolvedValueOnce(response([corruptFrame()])) + .mockResolvedValueOnce(response([corruptFrame()])); + + const body = await (await execute()).response.text(); + + expect(body).toContain("kiro_missing_terminal_retry_failed"); + expect(body).toContain('"terminal_provenance":"corrupt_eventstream_frame"'); + expect(body).toContain('"transport_state":"corrupt_frame"'); + }); + + it("caps diagnostic event-type cardinality", async () => { + let terminal; + const executor = new KiroExecutor(); + const frames = Array.from({ length: 100 }, (_, index) => + frame(`unknownEvent${index}`, { index }) + ); + frames.push(frame("assistantResponseEvent", { content: "done" })); + const transformed = executor.transformEventStreamToSSE( + response(frames), + "kr/claude-opus-4.8", + { onTerminalState: (value) => { terminal = value; } } + ); + + await transformed.text(); + + expect(terminal.event_counts).toEqual({ + other: 100, + assistantResponseEvent: 1 + }); + }); + + it("rejects a raw chunk before concatenating beyond the protocol bound", async () => { + let terminal; + const executor = new KiroExecutor(); + const transformed = executor.transformEventStreamToSSE( + response([new Uint8Array(65)]), + "kr/claude-opus-4.8", + { + maxRawBytes: 64, + onTerminalState: (value) => { terminal = value; } + } + ); + + const body = await transformed.text(); + + expect(body).toContain("buffered bytes exceed the protocol bound"); + expect(terminal.terminal_provenance).toBe("corrupt_eventstream_frame"); + }); + + it.each(["error", "exception"])("propagates EventStream %s without retry or leakage", async (messageType) => { + fetchMock.mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "must stay private" }), + frameFromEntries([ + [":message-type", messageType], + ...(messageType === "exception" ? [[":exception-type", "InternalServerException"]] : []) + ], { message: "upstream failed" }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain("kiro_upstream_eventstream_error"); + expect(body).toContain("upstream failed"); + expect(body).not.toContain("must stay private"); + }); + + it("surfaces retry HTTP failures as SSE after heartbeat commits headers", async () => { + fetchMock + .mockResolvedValueOnce(response([])) + .mockResolvedValueOnce(new Response("unauthorized", { + status: 401, + statusText: "Unauthorized" + })); + + const result = await execute(); + const body = await result.response.text(); + + expect(result.response.status).toBe(200); + expect(body).toContain("kiro_integrity_retry_upstream_error"); + expect(body).toContain("unauthorized"); + }); + + it("bounds the retry HTTP error body", async () => { + fetchMock + .mockResolvedValueOnce(response([])) + .mockResolvedValueOnce(new Response(`error-start-${"x".repeat(10_000)}-error-tail`, { + status: 401, + statusText: "Unauthorized" + })); + + const body = await (await execute()).response.text(); + + expect(body).toContain("error-start-"); + expect(body).not.toContain("error-tail"); + expect(body.length).toBeLessThan(5000); + }); + + it("propagates cancellation while validation is waiting for EOF", async () => { + const upstream = controlledResponse([ + frame("assistantResponseEvent", { content: "waiting" }) + ]); + fetchMock.mockResolvedValueOnce(upstream.value); + const abort = new AbortController(); + + const result = await execute(new KiroExecutor(), { signal: abort.signal }); + const reader = result.response.body.getReader(); + await reader.read(); + abort.abort("client cancelled"); + + await expect(reader.read()).rejects.toMatchObject({ name: "AbortError" }); + }); + + it("fails safely when the private gate exceeds its configured bound", async () => { + process.env.KIRO_TOOL_CALL_REPAIR_BUFFER_MAX_BYTES = "8"; + fetchMock.mockResolvedValueOnce(response([ + frame("assistantResponseEvent", { content: "larger than eight bytes" }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain("integrity buffer exceeded"); + expect(body).not.toContain("larger than eight bytes"); + }); + + it("counts deferred tool fragments against the private memory bound", async () => { + process.env.KIRO_TOOL_CALL_REPAIR_BUFFER_MAX_BYTES = "128"; + fetchMock.mockResolvedValueOnce(response([ + frame("toolUseEvent", { + toolUseId: "large-tool", + name: "read_file", + input: { path: "x".repeat(200) } + }) + ])); + + const body = await (await execute()).response.text(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(body).toContain("kiro_integrity_buffer_exceeded"); + expect(body).not.toContain('"name":"read_file"'); + }); +}); diff --git a/tests/unit/kiro-thinking-strip.test.js b/tests/unit/kiro-thinking-strip.test.js index 1fff6e9e..cc20aa47 100644 --- a/tests/unit/kiro-thinking-strip.test.js +++ b/tests/unit/kiro-thinking-strip.test.js @@ -32,10 +32,23 @@ function createMockFrame(eventType, payloadObj) { offset += headerValueBytes.length; buffer.set(payloadBytes, offset); - + + view.setUint32(8, crc32(buffer.subarray(0, 8)), false); + view.setUint32(totalLength - 4, crc32(buffer.subarray(0, totalLength - 4)), false); return buffer; } +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) { + crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + async function readAllSSE(stream) { const reader = stream.getReader(); const decoder = new TextDecoder(); @@ -130,14 +143,16 @@ describe("KiroExecutor thinking tag stripping", () => { expect(contentChunks.length).toBe(0); }); - it("emits a terminal chunk at messageStop before the upstream stream closes", async () => { + it("waits for clean EOF before emitting stop after messageStop", async () => { const executor = new KiroExecutor(); const f1 = createMockFrame("assistantResponseEvent", { content: "OK" }); const f2 = createMockFrame("messageStopEvent", {}); + let upstreamController; const readableStream = new ReadableStream({ start(controller) { + upstreamController = controller; controller.enqueue(f1); controller.enqueue(f2); } @@ -147,11 +162,16 @@ describe("KiroExecutor thinking tag stripping", () => { 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 }); + const { value } = await readNextWithTimeout(reader); + output += decoder.decode(value, { stream: true }); + expect(output).not.toContain("\"finish_reason\":\"stop\""); + + upstreamController.close(); + while (!output.includes("\"finish_reason\":\"stop\"")) { + const { value: nextValue, done } = await readNextWithTimeout(reader); + if (done) break; + output += decoder.decode(nextValue, { stream: true }); } - await reader.cancel(); expect(output).toContain("\"finish_reason\":\"stop\""); }); diff --git a/tests/unit/openai-to-kiro.test.js b/tests/unit/openai-to-kiro.test.js index 8b1c97e8..dbaf7c85 100644 --- a/tests/unit/openai-to-kiro.test.js +++ b/tests/unit/openai-to-kiro.test.js @@ -316,6 +316,102 @@ describe("openaiToKiroRequest", () => { }); }); + it.each([ + ["high", "gpt-5.6-sol"], + ["medium", "kiro/gpt-5.6-terra"], + ["low", "gpt-5.6-luna"], + ])("maps GPT-5.6 reasoning.effort %s without legacy prompt tags", (effort, model) => { + const body = { + reasoning: { effort }, + messages: [{ role: "user", content: "Use the requested effort" }] + }; + + const result = openaiToKiroRequest(model, body, true, {}); + + expect(result.additionalModelRequestFields).toEqual({ + reasoning: { effort }, + }); + expect(systemPromptOf(result)).not.toContain(""); + expect(systemPromptOf(result)).not.toContain(""); + expect(contentOf(result)).not.toContain(""); + expect(contentOf(result)).not.toContain(""); + }); + + it.each([ + ["xhigh", "gpt-5.6-terra", "xhigh"], + ["max", "gpt-5.6-sol", "xhigh"], + ])("preserves GPT-5.6 effort %s as supported wire effort %s", (effort, model, wireEffort) => { + const body = { + reasoning: { effort }, + messages: [{ role: "user", content: "Use extended effort" }] + }; + + const result = openaiToKiroRequest(model, body, true, {}); + + expect(result.additionalModelRequestFields).toEqual({ + reasoning: { effort: wireEffort }, + }); + expect(systemPromptOf(result)).not.toContain(""); + expect(systemPromptOf(result)).not.toContain(""); + }); + + it("omits GPT-5.6 effort fields and legacy prompt tags when effort is absent", () => { + const body = { + messages: [{ role: "user", content: "No explicit reasoning effort" }] + }; + + const result = openaiToKiroRequest("gpt-5.6-sol", body, true, {}); + + expect(result.additionalModelRequestFields).toBeUndefined(); + expect(systemPromptOf(result)).not.toContain(""); + expect(systemPromptOf(result)).not.toContain(""); + }); + + it.each(["auto", "minimal", "ultra"])( + "keeps the legacy thinking fallback for unsupported GPT-5.6 effort %s", + (effort) => { + const body = { + reasoning: { effort }, + messages: [{ role: "user", content: "Use legacy thinking" }] + }; + + const result = openaiToKiroRequest("gpt-5.6-luna", body, true, {}); + + expect(result.additionalModelRequestFields).toBeUndefined(); + expect(systemPromptOf(result)).toContain("enabled"); + expect(systemPromptOf(result)).toContain(""); + } + ); + + it.each(["none", "off", "disabled"])( + "keeps GPT-5.6 reasoning intentionally disabled for effort %s", + (effort) => { + const body = { + reasoning: { effort }, + messages: [{ role: "user", content: "Do not reason" }] + }; + + const result = openaiToKiroRequest("gpt-5.6-luna", body, true, {}); + + expect(result.additionalModelRequestFields).toBeUndefined(); + expect(systemPromptOf(result)).not.toContain(""); + expect(systemPromptOf(result)).not.toContain(""); + } + ); + + it("keeps the thinking-alias fallback when GPT effort is blank", () => { + const body = { + reasoning: { effort: "" }, + messages: [{ role: "user", content: "Use the thinking alias" }] + }; + + const result = openaiToKiroRequest("gpt-5.6-sol-thinking", body, true, {}); + + expect(result.additionalModelRequestFields).toBeUndefined(); + expect(systemPromptOf(result)).toContain("enabled"); + expect(systemPromptOf(result)).toContain(""); + }); + it("does not send additionalModelRequestFields for legacy Kiro model ids", () => { const body = { reasoning_effort: "high",