diff --git a/CHANGELOG.md b/CHANGELOG.md index fb1f69ad..b970869d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,60 @@ +# v0.5.45 (2026-07-30) + +## Features +- **Providers**: add Poolside (OpenAI-compatible) +- **Providers**: add api-airforce, baidu, bazaarlink, bluesminds, kilo-gateway, llm7, morph, sambanova, tencent +- **OAuth**: zed / trae / windsurf providers + harden callback proxies +- **CLI tools**: set Claude Code max context tokens +- **Qoder**: PAT auth + refresh model list +- **Gemini**: Gemini 3.6 Flash tier routing + Gemini 3.5 Flash Lite +- **Claude**: bump default Opus to `claude-opus-5` +- **Kiro**: add Claude Opus 5 models +- **Usage**: Kimi and DeepSeek usage handlers +- **Usage**: SuperGrok weekly pool via gRPC-web + +## Fixes +- **Refresh**: rotate `refresh_token` between retry attempts +- **Kiro**: canonicalize tool history and route API keys correctly +- **Kiro**: normalize dashboard thinking intensity models +- **Cursor**: stop leaking agent tool errors as text +- **Gemini**: fill empty tool schemas after `$ref` strip +- **Antigravity**: strip `stream_options` from non-stream requests +- **Jina-reader**: recover after transient errors, use JSON POST API +- **Usage**: record exact embedding tokens +- **Tunnel**: preserve successor cloudflared PID +- **Console-log**: initialize capture at server boot + prevent SSE proxy buffering +- **Dashboard**: count dual-auth, free-tier OAuth and API-key connections correctly +- **Dashboard**: flex quota rows, thin global scrollbars, no hidden-row overflow + +## Docs +- **i18n**: expand pt-BR translation to 986 terms +- README: Indonesian translation + +# 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) ## Features - **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..4458ba78 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ [🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com) -[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) +[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) • [🇮🇩 Indonesia](./i18n/README.id-ID.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 8da59653..703039dd 100755 --- a/cli/cli.js +++ b/cli/cli.js @@ -612,7 +612,7 @@ function startServer(updatePromise) { 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, @@ -785,7 +785,7 @@ function startServer(updatePromise) { // 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", "--skip-update", "-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..efb786e4 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.5.35", + "version": "0.5.45", "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..7e28ec64 100644 --- a/cli/src/cli/menus/providers.js +++ b/cli/src/cli/menus/providers.js @@ -53,6 +53,9 @@ const PROVIDER_MODELS = { { id: "glm-4.7" }, ], ag: [ + { id: "gemini-3.6-flash-high" }, + { id: "gemini-3.6-flash-medium" }, + { id: "gemini-3.6-flash-low" }, { id: "gemini-3-flash-agent" }, { id: "gemini-3.5-flash-low" }, { id: "gemini-3.5-flash-extra-low" }, @@ -95,6 +98,8 @@ const PROVIDER_MODELS = { { id: "claude-3-5-sonnet-20241022" }, ], gemini: [ + { id: "gemini-3.6-flash" }, + { id: "gemini-3.5-flash-lite" }, { id: "gemini-3-pro-preview" }, { id: "gemini-2.5-pro" }, { id: "gemini-2.5-flash" }, @@ -131,7 +136,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/i18n/README.id-ID.md b/i18n/README.id-ID.md new file mode 100644 index 00000000..dbfbd8f5 --- /dev/null +++ b/i18n/README.id-ID.md @@ -0,0 +1,951 @@ +
+ 9Router Dashboard + + # 9Router - Router AI Gratis + + **Jangan berhenti ngoding. Otomatis dialihkan ke model AI gratis & murah dengan smart fallback.** + + **Hubungkan semua tool AI coding (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) ke 40+ provider AI dan 100+ model.** + + [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) + [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) + [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + + [🚀 Mulai Cepat](#-mulai-cepat) • [💡 Fitur](#-fitur-utama) • [📖 Setup](#-panduan-setup) • [🌐 Website](https://9router.com) + + [🇻🇳 Tiếng Việt](./README.vi.md) • [🇨🇳 中文](./README.zh-CN.md) • [🇯🇵 日本語](./README.ja-JP.md) • [🇮🇩 Bahasa Indonesia](./README.id-ID.md) +
+ +--- + +## 🤔 Kenapa 9Router? + +**Berhenti buang-buang uang dan terhambat limit:** + +- ❌ Kuota langganan hangus tiap bulan tanpa terpakai +- ❌ Rate limit bikin ngoding berhenti di tengah jalan +- ❌ API mahal ($20–50/bulan per provider) +- ❌ Harus gonta-ganti provider secara manual + +**9Router menyelesaikan itu semua:** + +- ✅ **Maksimalkan langganan** - lacak kuota dan habiskan sebelum reset +- ✅ **Fallback otomatis** - langganan → murah → gratis, tanpa downtime +- ✅ **Multi-akun** - round-robin antar akun untuk tiap provider +- ✅ **Universal** - mendukung Claude Code, Codex, Gemini CLI, Cursor, Cline, dan tool CLI apa pun + +--- + +## 🔄 Cara Kerja + +``` +┌─────────────┐ +│ Tool CLI │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...) +│ kamu │ +└──────┬──────┘ + │ http://localhost:20128/v1 + ↓ +┌─────────────────────────────────────────┐ +│ 9Router (Smart Router) │ +│ • Konversi format (OpenAI ↔ Claude) │ +│ • Pelacakan kuota │ +│ • Refresh token otomatis │ +└──────┬──────────────────────────────────┘ + │ + ├─→ [Tier 1: Langganan] Claude Code, Codex, Gemini CLI + │ ↓ kuota habis + ├─→ [Tier 2: Murah] GLM ($0.6/1M), MiniMax ($0.2/1M) + │ ↓ batas budget tercapai + └─→ [Tier 3: Gratis] iFlow, Qwen, Kiro (unlimited) + +Hasil: ngoding tanpa berhenti, biaya minimum +``` + +--- + +## ⚡ Mulai Cepat + +**1. Install secara global:** + +```bash +npm install -g 9router +9router +``` + +🎉 Dashboard terbuka di `http://localhost:20128` + +**2. Hubungkan provider gratis (tanpa perlu daftar):** + +Dashboard → Providers → hubungkan **Claude Code** atau **Antigravity** → login OAuth → selesai! + +**3. Pakai di tool CLI kamu:** + +``` +Konfigurasi Claude Code/Codex/Gemini CLI/OpenClaw/Cursor/Cline: + Endpoint: http://localhost:20128/v1 + API Key: [salin dari dashboard] + Model: if/kimi-k2-thinking +``` + +**Cuma itu!** Mulai ngoding dengan model AI gratis. + +**Alternatif: jalankan dari source (repo ini):** + +Paket repo ini bersifat privat (`9router-app`), jadi menjalankan dari source/Docker adalah jalur yang diharapkan untuk pengembangan lokal. + +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev +``` + +Mode produksi: + +```bash +npm run build +PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start +``` + +URL default: +- Dashboard: `http://localhost:20128/dashboard` +- API kompatibel OpenAI: `http://localhost:20128/v1` + +--- + +## 🎥 Video Tutorial + +
+ +### 📺 Panduan Setup Lengkap - 9Router + Claude Code Gratis + +[![9Router + Claude Code Setup](https://img.youtube.com/vi/raEyZPg5xE0/maxresdefault.jpg)](https://www.youtube.com/watch?v=raEyZPg5xE0) + +**🎬 Tonton tutorial langkah demi langkah:** +- ✅ Install dan setup 9Router +- ✅ Konfigurasi Claude Sonnet 4.5 gratis +- ✅ Integrasi dengan Claude Code +- ✅ Demo live coding + +**⏱️ Durasi:** 20 menit | **👥 Dibuat oleh:** Developer Community + +[▶️ Tonton di YouTube](https://www.youtube.com/watch?v=o3qYCyjrFYg) + +
+ +--- + +## 🛠️ Tool CLI yang Didukung + +9Router bekerja mulus dengan semua tool AI coding utama: + +
+ + + + + + + + + + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ OpenClaw
+ OpenClaw +
+ Codex
+ Codex +
+ OpenCode
+ OpenCode +
+ Cursor
+ Cursor +
+ Antigravity
+ Antigravity +
+ Cline
+ Cline +
+ Continue
+ Continue +
+ Droid
+ Droid +
+ Roo
+ Roo +
+ Copilot
+ Copilot +
+ Kilo Code
+ Kilo Code +
+
+ +--- + +## 🌐 Provider yang Didukung + +### 🔐 Provider OAuth + +
+ + + + + + + + +
+ Claude Code
+ Claude-Code +
+ Antigravity
+ Antigravity +
+ Codex
+ Codex +
+ GitHub
+ GitHub +
+ Cursor
+ Cursor +
+
+ +### 🆓 Provider Gratis + +
+ + + + + + + +
+ iFlow
+ iFlow AI
+ 8+ model • unlimited +
+ Qwen
+ Qwen Code
+ 3+ model • unlimited +
+ Gemini CLI
+ Gemini CLI
+ 180 ribu request/bulan gratis +
+ Kiro
+ Kiro AI
+ Claude • unlimited +
+
+ +### 🔑 Provider API Key (40+) + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ OpenRouter
+ OpenRouter +
+ GLM
+ GLM +
+ Kimi
+ Kimi +
+ MiniMax
+ MiniMax +
+ OpenAI
+ OpenAI +
+ Anthropic
+ Anthropic +
+ Gemini
+ Gemini +
+ DeepSeek
+ DeepSeek +
+ Groq
+ Groq +
+ xAI
+ xAI +
+ Mistral
+ Mistral +
+ Perplexity
+ Perplexity +
+ Together
+ Together AI +
+ Fireworks
+ Fireworks +
+ Cerebras
+ Cerebras +
+ Cohere
+ Cohere +
+ NVIDIA
+ NVIDIA +
+ SiliconFlow
+ SiliconFlow +
+

...dan 20+ provider lain seperti Nebius, Chutes, Hyperbolic, serta endpoint custom yang kompatibel dengan OpenAI/Anthropic

+
+ +--- + +## 💡 Fitur Utama + +| Fitur | Ringkasan | Manfaat | +|-------|-----------|---------| +| 🎯 **Smart Fallback 3 Tingkat** | Routing otomatis: langganan → murah → gratis | Ngoding tanpa berhenti, zero downtime | +| 📊 **Pelacakan Kuota Real-time** | Hitungan token live + hitung mundur reset | Nilai langganan termanfaatkan maksimal | +| 🔄 **Konversi Format** | OpenAI ↔ Claude ↔ Gemini mulus | Bekerja dengan tool CLI apa pun | +| 👥 **Dukungan Multi-akun** | Beberapa akun per provider | Load balancing + redundansi | +| 🔄 **Auto Refresh Token** | Token OAuth diperbarui otomatis | Tidak perlu login ulang manual | +| 🎨 **Combo Kustom** | Buat kombinasi model tanpa batas | Fallback sesuai kebutuhanmu | +| 📝 **Log Request** | Log lengkap request/response | Troubleshooting jadi mudah | +| 💾 **Cloud Sync** | Sinkronkan pengaturan antar perangkat | Setup sama di mana pun | +| 📊 **Analitik Penggunaan** | Lacak token, biaya, dan tren | Optimalkan pengeluaran | +| 🌐 **Deploy di Mana Saja** | Localhost, VPS, Docker, Cloudflare Workers | Opsi deployment fleksibel | + +
+📖 Detail Fitur + +### 🎯 Smart Fallback 3 Tingkat + +Buat combo dengan fallback otomatis: + +``` +Combo: "my-coding-stack" + 1. cc/claude-opus-4-6 (langganan) + 2. glm/glm-4.7 (backup murah, $0.6/1M) + 3. if/kimi-k2-thinking (fallback gratis) + +→ Otomatis beralih saat kuota habis atau terjadi error +``` + +### 📊 Pelacakan Kuota Real-time + +- Konsumsi token per provider +- Hitung mundur reset (5 jam, harian, mingguan) +- Estimasi biaya untuk tier berbayar +- Laporan pengeluaran bulanan + +### 🔄 Konversi Format + +Konversi mulus antar format: +- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **OpenAI Responses** +- Tool CLI mengirim dalam format OpenAI → 9Router mengonversi → provider menerima dalam format nativenya +- Bekerja dengan semua tool yang mendukung custom OpenAI endpoint + +### 👥 Dukungan Multi-akun + +- Tambahkan beberapa akun per provider +- Round-robin otomatis atau routing berbasis prioritas +- Saat satu akun mencapai kuota, fallback ke akun berikutnya + +### 🔄 Auto Refresh Token + +- Token OAuth di-refresh otomatis sebelum kedaluwarsa +- Tidak perlu autentikasi ulang manual +- Pengalaman mulus di semua provider + +### 🎨 Combo Kustom + +- Buat kombinasi model tanpa batas +- Campur tier langganan, murah, dan gratis +- Beri nama combo agar mudah diakses +- Bagikan combo antar perangkat lewat cloud sync + +### 📝 Log Request + +- Log lengkap request/response dalam mode debug +- Lacak API call, header, dan payload +- Troubleshoot masalah integrasi +- Ekspor log untuk dianalisis + +### 💾 Cloud Sync + +- Sinkronkan provider, combo, dan pengaturan antar perangkat +- Sinkronisasi latar belakang otomatis +- Penyimpanan terenkripsi yang aman +- Akses setup dari mana saja + +#### Catatan tentang cloud runtime + +- Untuk produksi, disarankan memakai variabel cloud sisi server: + - `BASE_URL` (URL callback internal yang dipakai scheduler sinkronisasi) + - `CLOUD_URL` (base URL endpoint cloud sync) +- `NEXT_PUBLIC_BASE_URL` dan `NEXT_PUBLIC_CLOUD_URL` masih didukung untuk kompatibilitas/UI, tetapi runtime server memprioritaskan `BASE_URL`/`CLOUD_URL`. +- Request cloud sync memakai timeout + perilaku fail-fast untuk menghindari UI menggantung saat DNS/jaringan cloud tidak tersedia. + +### 📊 Analitik Penggunaan + +- Lacak pemakaian token per provider dan per model +- Estimasi biaya dan tren pengeluaran +- Laporan dan insight bulanan +- Optimalkan pengeluaran AI + +> **💡 PENTING - tentang biaya di dashboard:** +> +> "Biaya" yang ditampilkan pada analitik penggunaan **hanya untuk pelacakan dan perbandingan**. +> 9Router sendiri **tidak menagih apa pun**. Kamu hanya membayar langsung ke provider jika memakai layanan berbayar. +> +> **Contoh:** jika dashboard menampilkan "Total biaya $290" untuk pemakaian model iFlow, +> itu adalah jumlah yang seharusnya kamu bayar bila memakai API berbayar secara langsung. Biaya sebenarnya = **$0** (iFlow gratis tanpa batas). +> +> Anggap saja ini "pelacak penghematan" yang menunjukkan berapa banyak yang kamu hemat lewat model gratis dan routing 9Router! + +### 🌐 Deploy di Mana Saja + +- 💻 **Localhost** - default, jalan offline +- ☁️ **VPS/Cloud** - berbagi antar perangkat +- 🐳 **Docker** - deploy satu perintah +- 🚀 **Cloudflare Workers** - jaringan edge global + +
+ +--- + +## 💰 Ringkasan Harga + +| Tier | Provider | Biaya | Reset Kuota | Cocok Untuk | +|------|----------|-------|-------------|-------------| +| **💳 Langganan** | Claude Code (Pro) | $20/bulan | 5 jam + mingguan | Yang sudah punya langganan | +| | Codex (Plus/Pro) | $20-200/bulan | 5 jam + mingguan | Pengguna OpenAI | +| | Gemini CLI | **Gratis** | 180rb/bulan + 1rb/hari | Semua orang! | +| | GitHub Copilot | $10-19/bulan | Bulanan | Pengguna GitHub | +| **💰 Murah** | GLM-4.7 | $0.6/1M | Setiap hari jam 10.00 | Backup hemat | +| | MiniMax M2.1 | $0.2/1M | Rolling 5 jam | Opsi paling murah | +| | Kimi K2 | $9/bulan flat | 10 juta token/bulan | Biaya yang bisa diprediksi | +| **🆓 Gratis** | iFlow | $0 | Unlimited | 8 model gratis | +| | Qwen | $0 | Unlimited | 3 model gratis | +| | Kiro | $0 | Unlimited | Claude gratis | + +**💡 Tips pro:** combo Gemini CLI (180rb request/bulan gratis) + iFlow (gratis unlimited) = biaya $0! + +--- + +### 📊 Tentang Biaya dan Penagihan 9Router + +**Fakta soal penagihan 9Router:** + +✅ **Software 9Router = gratis selamanya** (open source, tanpa tagihan) +✅ **"Biaya" di dashboard = tampilan/pelacakan saja** (bukan tagihan sungguhan) +✅ **Pembayaran langsung ke provider** (langganan atau biaya API) +✅ **Provider gratis tetap gratis** (iFlow, Kiro, Qwen = $0 unlimited) +❌ **9Router tidak mengirim invoice** atau menagih kartumu + +**Cara kerja tampilan biaya:** + +Dashboard menampilkan **estimasi biaya** seandainya kamu memakai API berbayar secara langsung. Ini **bukan tagihan**, melainkan alat pembanding yang menunjukkan penghematanmu. + +**Contoh skenario:** +``` +Tampilan dashboard: +• Total request: 1.662 +• Total token: 47 juta +• Biaya tertampil: $290 + +Kenyataannya: +• Provider: iFlow (gratis unlimited) +• Yang benar-benar dibayar: $0.00 +• Arti $290: jumlah yang kamu hemat dengan memakai model gratis! +``` + +**Aturan pembayaran:** +- **Provider langganan** (Claude Code, Codex): bayar langsung di website masing-masing +- **Provider murah** (GLM, MiniMax): bayar langsung, 9Router hanya melakukan routing +- **Provider gratis** (iFlow, Kiro, Qwen): benar-benar gratis selamanya, tanpa biaya tersembunyi +- **9Router**: tidak menagih apa pun + +--- + +## 🎯 Studi Kasus + +### Kasus 1: "Saya punya langganan Claude Pro" + +**Masalah:** kuota hangus tanpa terpakai, kena rate limit saat ngoding berat + +**Solusi:** +``` +Combo: "maximize-claude" + 1. cc/claude-opus-4-6 (manfaatkan langganan semaksimal mungkin) + 2. glm/glm-4.7 (backup murah saat kuota habis) + 3. if/kimi-k2-thinking (fallback darurat gratis) + +Biaya bulanan: $20 (langganan) + ~$5 (backup) = total $25 +vs. $20 + kena limit = frustrasi +``` + +### Kasus 2: "Saya mau biaya nol" + +**Masalah:** tidak mampu bayar langganan, tapi butuh AI coding yang andal + +**Solusi:** +``` +Combo: "free-forever" + 1. gc/gemini-3-flash (180rb request/bulan gratis) + 2. if/kimi-k2-thinking (gratis unlimited) + 3. qw/qwen3-coder-plus (gratis unlimited) + +Biaya bulanan: $0 +Kualitas: model siap produksi +``` + +### Kasus 3: "Ngoding 24/7 tanpa terputus" + +**Masalah:** deadline mepet, downtime tidak dapat ditoleransi + +**Solusi:** +``` +Combo: "always-on" + 1. cc/claude-opus-4-6 (kualitas terbaik) + 2. cx/gpt-5.2-codex (langganan kedua) + 3. glm/glm-4.7 (murah, reset harian) + 4. minimax/MiniMax-M2.1 (paling murah, reset 5 jam) + 5. if/kimi-k2-thinking (gratis unlimited) + +Hasil: 5 lapis fallback = zero downtime +Biaya bulanan: $20-200 (langganan) + $10-20 (backup) +``` + +### Kasus 4: "Saya mau pakai AI gratis di OpenClaw" + +**Masalah:** butuh asisten AI di aplikasi pesan (WhatsApp, Telegram, Slack...), sepenuhnya gratis + +**Solusi:** +``` +Combo: "openclaw-free" + 1. if/glm-4.7 (gratis unlimited) + 2. if/minimax-m2.1 (gratis unlimited) + 3. if/kimi-k2-thinking (gratis unlimited) + +Biaya bulanan: $0 +Cara akses: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... +``` + +--- + +## ❓ FAQ + +
+📊 Kenapa dashboard menampilkan biaya yang besar? + +Dashboard melacak pemakaian token dan menampilkan **estimasi biaya** seandainya kamu memakai API berbayar secara langsung. Ini **bukan tagihan nyata**, melainkan acuan untuk melihat berapa banyak yang kamu hemat dengan memakai model gratis atau langganan yang sudah ada lewat 9Router. + +**Contoh:** +- **Tampilan dashboard:** "Total biaya $290" +- **Kenyataan:** sedang memakai iFlow (gratis unlimited) +- **Biaya sebenarnya:** **$0.00** +- **Arti $290:** jumlah yang **dihemat** karena memakai model gratis alih-alih API berbayar! + +Tampilan biaya adalah "pelacak penghematan" untuk memahami pola pemakaian dan peluang optimasi. + +
+ +
+💳 Apakah 9Router menagih saya? + +**Tidak.** 9Router adalah software open source gratis yang berjalan di komputermu sendiri. Tidak ada penagihan sama sekali. + +**Kamu membayar ke:** +- ✅ **Provider langganan** (Claude Code $20/bulan, Codex $20-200/bulan) → bayar langsung di website masing-masing +- ✅ **Provider murah** (GLM, MiniMax) → bayar langsung, 9Router hanya me-routing request +- ❌ **9Router sendiri** → **tidak menagih apa pun** + +9Router adalah proxy/router lokal. Ia tidak menyimpan informasi kartu kredit, tidak bisa mengirim invoice, dan tidak punya sistem penagihan. Sepenuhnya software gratis. + +
+ +
+🆓 Apakah provider gratis benar-benar unlimited? + +**Ya!** Provider yang ditandai gratis (iFlow, Kiro, Qwen) benar-benar unlimited dan **tanpa biaya tersembunyi**. + +Ini adalah layanan gratis yang disediakan masing-masing perusahaan: +- **iFlow**: akses gratis unlimited ke 8+ model via OAuth +- **Kiro**: model Claude gratis unlimited via AWS Builder ID +- **Qwen**: akses gratis unlimited ke model Qwen via device authentication + +9Router hanya me-routing request — tidak ada "jebakan" atau tagihan di kemudian hari. Layanannya memang gratis, dan 9Router membuatnya lebih mudah dipakai dengan dukungan fallback. + +**Catatan:** beberapa provider langganan (Antigravity, GitHub Copilot) punya masa preview gratis dan bisa jadi berbayar nanti, tetapi hal itu diumumkan secara jelas oleh provider tersebut, bukan oleh 9Router. + +
+ +
+💰 Bagaimana cara menekan biaya AI seminimal mungkin? + +**Strategi free-first:** + +1. **Mulai dari combo 100% gratis:** + ``` + 1. gc/gemini-3-flash (180rb/bulan gratis dari Google) + 2. if/kimi-k2-thinking (gratis unlimited dari iFlow) + 3. qw/qwen3-coder-plus (gratis unlimited dari Qwen) + ``` + **Biaya: $0/bulan** + +2. **Tambahkan backup murah hanya bila perlu:** + ``` + 4. glm/glm-4.7 ($0.6 per 1 juta token) + ``` + **Tambahan biaya: bayar sesuai pemakaian saja** + +3. **Gunakan provider langganan paling akhir:** + - Hanya jika kamu memang sudah punya + - 9Router memaksimalkan nilainya lewat pelacakan kuota + +**Hasil:** sebagian besar pengguna bisa jalan dengan $0/bulan hanya dengan tier gratis! + +
+ +
+📈 Bagaimana kalau pemakaian tiba-tiba melonjak? + +Smart fallback 9Router mencegah tagihan tak terduga: + +**Skenario:** kuota habis di tengah sprint coding + +**Tanpa 9Router:** +- ❌ Kena rate limit → kerja berhenti → frustrasi +- ❌ Atau: tagihan API mahal tanpa disengaja + +**Dengan 9Router:** +- ✅ Langganan mencapai batas → otomatis fallback ke tier murah +- ✅ Tier murah jadi mahal → otomatis fallback ke tier gratis +- ✅ Ngoding tidak berhenti → biaya tetap terprediksi + +**Kamu yang pegang kendali:** atur batas pengeluaran per provider di dashboard, dan 9Router akan mematuhinya. + +
+ +--- + +## 📖 Panduan Setup + +
+🔐 Provider Langganan (maksimalkan nilainya) + +### Claude Code (Pro/Max) + +```bash +Dashboard → Providers → hubungkan Claude Code +→ login OAuth → refresh token otomatis +→ pelacakan kuota 5 jam + mingguan + +Model: + cc/claude-opus-4-6 + cc/claude-sonnet-4-5-20250929 + cc/claude-haiku-4-5-20251001 +``` + +**Tips pro:** pakai Opus untuk tugas kompleks, Sonnet kalau mengutamakan kecepatan. 9Router melacak kuota per model! + +### OpenAI Codex (Plus/Pro) + +```bash +Dashboard → Providers → hubungkan Codex +→ login OAuth (port 1455) +→ reset 5 jam + mingguan + +Model: + cx/gpt-5.2-codex + cx/gpt-5.1-codex-max +``` + +### Gemini CLI (180rb request/bulan gratis!) + +```bash +Dashboard → Providers → hubungkan Gemini CLI +→ Google OAuth +→ 180rb/bulan + 1rb/hari + +Model: + gc/gemini-3-flash-preview + gc/gemini-2.5-pro +``` + +**Value terbaik:** free tier-nya besar sekali! Pakai ini sebelum tier berbayar. + +### GitHub Copilot + +```bash +Dashboard → Providers → hubungkan GitHub +→ OAuth via GitHub +→ reset bulanan (tanggal 1 tiap bulan) + +Model: + gh/gpt-5 + gh/claude-4.5-sonnet + gh/gemini-3-pro +``` + +
+ +
+💰 Provider Murah (backup) + +### GLM-4.7 (reset harian, $0.6/1M) + +1. Daftar: [Zhipu AI](https://open.bigmodel.cn/) +2. Ambil API key dari Coding Plan +3. Dashboard → tambahkan API key: + - Provider: `glm` + - API Key: `your-key` + +**Pemakaian:** `glm/glm-4.7` + +**Tips pro:** Coding Plan memberi kuota 3x lipat dengan biaya 1/7! Reset setiap hari jam 10.00. + +### MiniMax M2.1 (reset 5 jam, $0.20/1M) + +1. Daftar: [MiniMax](https://www.minimax.io/) +2. Ambil API key +3. Dashboard → tambahkan API key + +**Pemakaian:** `minimax/MiniMax-M2.1` + +**Tips pro:** opsi termurah dengan konteks panjang (1 juta token)! + +### Kimi K2 ($9/bulan flat) + +1. Berlangganan: [Moonshot AI](https://platform.moonshot.ai/) +2. Ambil API key +3. Dashboard → tambahkan API key + +**Pemakaian:** `kimi/kimi-latest` + +**Tips pro:** $9/bulan flat untuk 10 juta token = biaya efektif $0.90/1M! + +
+ +
+🆓 Provider Gratis (backup darurat) + +### iFlow (8 model gratis) + +```bash +Dashboard → hubungkan iFlow +→ login OAuth iFlow +→ pemakaian unlimited + +Model: + if/kimi-k2-thinking + if/qwen3-coder-plus + if/glm-4.7 + if/minimax-m2 + if/deepseek-r1 +``` + +### Qwen (3 model gratis) + +```bash +Dashboard → hubungkan Qwen +→ autentikasi device code +→ pemakaian unlimited + +Model: + qw/qwen3-coder-plus + qw/qwen3-coder-flash +``` + +### Kiro (Claude gratis) + +```bash +Dashboard → hubungkan Kiro +→ AWS Builder ID atau Google/GitHub +→ pemakaian unlimited + +Model: + kr/claude-sonnet-4.5 + kr/claude-haiku-4.5 +``` + +
+ +
+🎨 Membuat Combo + +### Contoh 1: maksimalkan langganan → backup murah + +``` +Dashboard → Combos → buat baru + +Nama: premium-coding +Model: + 1. cc/claude-opus-4-6 (langganan, utama) + 2. glm/glm-4.7 (backup murah, $0.6/1M) + 3. minimax/MiniMax-M2.1 (fallback termurah, $0.20/1M) + +Pemakaian di CLI: premium-coding + +Contoh biaya bulanan (100 juta token): + 80 juta lewat Claude (langganan): tambahan $0 + 15 juta lewat GLM: $9 + 5 juta lewat MiniMax: $1 + Total: $10 +``` + +### Contoh 2: combo 100% gratis + +``` +Nama: free-forever +Model: + 1. gc/gemini-3-flash (180rb request/bulan gratis) + 2. if/kimi-k2-thinking (gratis unlimited) + 3. qw/qwen3-coder-plus (gratis unlimited) + 4. kr/claude-sonnet-4.5 (gratis unlimited) + +Biaya bulanan: $0 +``` + +### Tips membuat combo + +- Urutkan dari kualitas/prioritas tertinggi ke fallback paling murah +- Selalu taruh minimal satu provider gratis di posisi terakhir +- Pakai nama combo yang deskriptif agar mudah dipilih dari CLI +- Aktifkan cloud sync agar combo ikut tersedia di perangkat lain + +
+ +--- + +## 🐳 Deployment + +
+Docker + +```bash +docker run -d \ + --name 9router \ + -p 20128:20128 \ + -v 9router-data:/app/data \ + -e PORT=20128 \ + -e BASE_URL=http://localhost:20128 \ + ghcr.io/decolua/9router:latest +``` + +Dashboard: `http://localhost:20128/dashboard` + +
+ +
+VPS / Cloud + +```bash +npm install -g 9router +PORT=20128 HOSTNAME=0.0.0.0 BASE_URL=https://your-domain.com 9router +``` + +Disarankan menaruhnya di belakang reverse proxy (Nginx/Caddy) dengan HTTPS, dan membatasi akses hanya untuk dirimu sendiri. + +
+ +
+Cloudflare Workers + +```bash +npm run build +npx wrangler deploy +``` + +Atur `BASE_URL` dan `CLOUD_URL` sebagai environment variable di dashboard Cloudflare. + +
+ +--- + +## 🧪 Troubleshooting + +| Masalah | Kemungkinan Penyebab | Solusi | +|---------|----------------------|--------| +| Tool CLI tidak bisa konek | Endpoint salah | Pastikan `http://localhost:20128/v1` | +| 401 / Unauthorized | API key salah | Salin ulang key dari dashboard | +| Model tidak ditemukan | Prefix provider salah | Pakai format `provider/model`, mis. `if/kimi-k2-thinking` | +| Selalu fallback ke gratis | Kuota langganan habis | Cek hitung mundur reset di dashboard | +| OAuth gagal | Port callback terpakai | Tutup proses lain (mis. port 1455 untuk Codex) | +| UI menggantung saat sync | DNS/jaringan cloud bermasalah | Cek `CLOUD_URL`; sync memakai timeout fail-fast | + +Aktifkan mode debug di dashboard untuk melihat log lengkap request/response. + +--- + +## 🤝 Kontribusi + +Kontribusi sangat diterima! + +1. Fork repo ini +2. Buat branch fitur (`git checkout -b feature/nama-fitur`) +3. Commit perubahanmu (`git commit -m 'feat: tambah fitur X'`) +4. Push ke branch (`git push origin feature/nama-fitur`) +5. Buka Pull Request + +--- + +## 📄 Lisensi + +MIT License — lihat [LICENSE](https://github.com/decolua/9router/blob/main/LICENSE) untuk detailnya. + +--- + +
+ +**Kalau 9Router membantumu, kasih ⭐ di [GitHub](https://github.com/decolua/9router)!** + +[🌐 Website](https://9router.com) • [📦 npm](https://www.npmjs.com/package/9router) • [🐛 Laporkan Bug](https://github.com/decolua/9router/issues) + +
diff --git a/open-sse/config/appConstants.js b/open-sse/config/appConstants.js index cc46697c..5e3ec4be 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; @@ -133,10 +134,19 @@ export const ANTIGRAVITY_HEADERS = { "User-Agent": ANTIGRAVITY_IDE_USER_AGENT }; -// Cloud Code Assist API +// Cloud Code Assist API endpoints differ by client ecosystem. export const CLOUD_CODE_API = { - loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", - onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", + "gemini-cli": { + loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", + }, + // Project discovery (loadCodeAssist/onboardUser) stays on PROD — the daily host + // rejects these auth/onboarding calls. Only chat traffic uses the daily host + // (see transport.apiEndpoint in registry/antigravity.js, set to bypass prod 429). + antigravity: { + loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", + }, }; export const LOAD_CODE_ASSIST_HEADERS = { @@ -171,12 +181,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..e6408da1 100644 --- a/open-sse/config/kiroConstants.js +++ b/open-sse/config/kiroConstants.js @@ -8,18 +8,24 @@ * - `-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. */ -import { extractThinking } from "../translator/concerns/thinkingUnified.js"; +import { extractThinking, parseSuffix } from "../translator/concerns/thinkingUnified.js"; import { effortToBudget } from "../translator/concerns/thinking.js"; export const KIRO_AGENTIC_SUFFIX = "-agentic"; export const KIRO_THINKING_SUFFIX = "-thinking"; +export const KIRO_TOOL_NAME_MAX_LENGTH = 64; +export const KIRO_TOOL_DESCRIPTION_MAX_LENGTH = 10237; +export const KIRO_TOOL_ID_MAX_LENGTH = 64; +export const KIRO_CODEWHISPERER_TARGET = + "AmazonCodeWhispererStreamingService.GenerateAssistantResponse"; +export const KIRO_ENDPOINT_FALLBACK_STATUSES = new Set([401, 403, 404]); // Public default CodeWhisperer profile ARNs (us-east-1), keyed by auth method. // Used when an account cannot resolve its own profileArn. Builder ID and social @@ -40,6 +46,39 @@ export function resolveDefaultProfileArn(authMethod) { export const KIRO_THINKING_BUDGET_DEFAULT = 16000; +/** + * Resolve a Kiro model after consuming the generic model(level) suffix. + * The suffix is a 9router request override, not part of Kiro's upstream model id. + */ +export function resolveKiroModelIntent(model) { + const { cleanModel, override } = parseSuffix(model); + return { + model: cleanModel, + ...resolveKiroModel(cleanModel), + thinkingOverride: override, + }; +} + +/** Apply a parsed model(level) override without mutating the caller's body. */ +export function applyKiroThinkingOverride(body, override) { + if (!override) return body; + + const next = { ...body }; + if (override.mode === "budget") { + delete next.output_config; + delete next.reasoning_effort; + delete next.reasoning; + next.thinking = { type: "enabled", budget_tokens: override.budget }; + return next; + } + + next.output_config = { + ...(body.output_config || {}), + effort: override.mode === "level" ? override.level : override.mode, + }; + return next; +} + export const KIRO_AGENTIC_SYSTEM_PROMPT = ` # CRITICAL: CHUNKED WRITE PROTOCOL (MANDATORY) @@ -109,6 +148,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 +184,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 +215,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 +231,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/config/providerModels.js b/open-sse/config/providerModels.js index 108806e8..860f7097 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -4,7 +4,6 @@ import REGISTRY from "../providers/registry/index.js"; import { PROVIDER_MODELS } from "../providers/index.js"; import { modelQuotaFamily, modelStrip, modelTargetFormat, normalizeModelId } from "../providers/models/schema.js"; import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js"; - export { PROVIDER_MODELS }; @@ -70,8 +69,13 @@ export function getModelUpstreamId(aliasOrId, modelId) { const baseId = suffix ? modelId.slice(0, sufMatch.index).trim() : modelId; const models = PROVIDER_MODELS[aliasOrId]; const found = findModel(models, baseId, aliasOrId); - if (found?.upstreamModelId) return found.upstreamModelId + suffix; - if (found?.id) return found.id + suffix; + const resolvedId = found?.upstreamModelId || found?.id; + if (resolvedId) { + const presetMatch = resolvedId.match(/\([^()]+\)\s*$/); + const presetSuffix = presetMatch?.[0] || ""; + const resolvedBase = presetSuffix ? resolvedId.slice(0, presetMatch.index).trim() : resolvedId; + return resolvedBase + (suffix || presetSuffix); + } if (aliasOrId === "cx" && typeof baseId === "string" && baseId.endsWith(CODEX_REVIEW_SUFFIX)) { return baseId.slice(0, -CODEX_REVIEW_SUFFIX.length) + suffix; } diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index f669e3a7..7f24fd85 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -136,6 +136,10 @@ export class AntigravityExecutor extends BaseExecutor { transformRequest(model, body, stream, credentials) { const projectId = credentials?.projectId || this.generateProjectId(); + // OpenAI clients may include stream_options even for non-streaming calls. + // Google generateContent rejects that combination before processing the request. + if (stream !== true) delete body.stream_options; + // ─── Image generation: completely different request structure ─── if (isImageModel(model)) { const imageConfig = parseImageConfig(model); @@ -264,7 +268,7 @@ export class AntigravityExecutor extends BaseExecutor { return { ...body, project: projectId, - model: model, + model: body.model || model, userAgent: "antigravity", requestType: "agent", requestId: buildIdeRequestId({ body, request: transformedRequest, credentials, model, requestType: "agent" }), diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index 69d7a185..4c1708c1 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -127,7 +127,7 @@ export class BaseExecutor { for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const url = this.buildUrl(model, stream, urlIndex, credentials); const transformedBody = this.transformRequest(model, body, stream, credentials); - const headers = this.buildHeaders(credentials, stream); + const headers = this.buildHeaders(credentials, stream, url); if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0; diff --git a/open-sse/executors/codebuddy-intl.js b/open-sse/executors/codebuddy-intl.js new file mode 100644 index 00000000..06fe4326 --- /dev/null +++ b/open-sse/executors/codebuddy-intl.js @@ -0,0 +1,30 @@ +import { DefaultExecutor } from "./default.js"; + +/** + * CodeBuddyIntlExecutor — talks to https://www.codebuddy.ai/v2/chat/completions + * + * Same OpenAI-compatible-but-stream-only gateway behavior as codebuddy-cn: + * non-stream requests are rejected, and reasoning is surfaced only when the + * request carries the IDE's OpenAI-style reasoning params. Force stream and + * mirror reasoning_summary exactly like CodeBuddyExecutor. + */ +export class CodeBuddyIntlExecutor extends DefaultExecutor { + constructor() { + super("codebuddy-intl"); + } + + transformRequest(model, body, stream, credentials) { + const transformed = super.transformRequest(model, body, stream, credentials); + transformed.stream = true; + + const eff = transformed.reasoning_effort; + if (eff === "none" || eff === "off") { + delete transformed.reasoning_effort; + } else if (eff) { + transformed.reasoning_summary = "auto"; + } + return transformed; + } +} + +export default CodeBuddyIntlExecutor; diff --git a/open-sse/executors/cursor.js b/open-sse/executors/cursor.js index fe06d80d..0aefc623 100644 --- a/open-sse/executors/cursor.js +++ b/open-sse/executors/cursor.js @@ -1,18 +1,22 @@ 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"; import { buildCursorHeaders } from "../utils/cursorChecksum.js"; import { estimateUsage } from "../utils/usageTracking.js"; import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js"; -import { chatChunkSse } from "../utils/sse.js"; +import { chatChunkSse, sseChunk } 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,304 @@ 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) => { + // A single read can carry several frames; once the turn is over the + // rest of the batch must not reach the already-closed controller. + if (finished) return; + 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 { + // Every other ExecServerMessage variant is an editor-backed tool + // (shell, read, write, …) that 9router cannot service. Fail the + // turn rather than narrating protocol state as assistant text. + debugLog(`[CURSOR AGENT] Unsupported exec request fields: ${[...execRequest.keys()].join(",")}`); + finished = true; + onEvent({ type: "error", value: "Cursor AgentService requested an unsupported IDE tool" }); + } + } + }); + } + } 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") { + // An SSE error frame, not a content delta: a protocol failure must not + // be rendered to the user as the assistant's reply, and downstream + // usage tracking must not record the turn as a success. + controller.enqueue(encoder.encode(sseChunk({ error: { message: event.value, type: "api_error" } }))); + controller.enqueue(encoder.encode(SSE_DONE)); + controller.close(); + } 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/devin-cli.js b/open-sse/executors/devin-cli.js new file mode 100644 index 00000000..7cb35ff1 --- /dev/null +++ b/open-sse/executors/devin-cli.js @@ -0,0 +1,847 @@ +/** + * DevinCliExecutor — routes completions through the official Devin CLI binary + * via the Agent Client Protocol (ACP) JSON-RPC 2.0 over stdio. + * + * Protocol flow: + * 1. Spawn `devin acp` (default agent = full built-in tools: fs/shell/search). + * Set CLI_DEVIN_AGENT_TYPE=summarizer for a tool-less, text-only mode. + * 2. Send: initialize → session/new (with model + cwd + mcpServers) → session/prompt. + * 3. Receive: session/update notifications (agent_message_chunk = reply text, + * tool_call/tool_call_update = built-in tool invocations, surfaced as text). + * When devin calls a client-tool from the exposed MCP ("Calling mcp_X from + * clientTools"), it is bridged to an OpenAI tool_use and the turn ends. + * 4. Emit deltas as OpenAI-compatible SSE chunks. + * 5. Kill subprocess on _cognition.ai/agent_stopped or error. + * + * Auth: noAuth — the subprocess inherits the parent env and uses credentials + * stored by `devin auth login` (~/.local/share/devin/credentials.toml). + * + * Binary discovery: CLI_DEVIN_BIN env → PATH lookup → platform installer paths. + */ + +import { spawn } from "node:child_process"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs"; +import { BaseExecutor } from "./base.js"; + +// ─── Binary discovery ──────────────────────────────────────────────────────── + +function resolveDevinBin() { + // 1. Explicit override + const envBin = process.env.CLI_DEVIN_BIN?.trim(); + if (envBin) return envBin; + + const isWin = process.platform === "win32"; + const home = os.homedir(); + + // 2. Known installer / package-manager locations. spawn uses shell:false on + // macOS/Linux, so process.env.PATH alone may miss ~/.local/bin, Homebrew, + // Scoop, etc. when the server runs detached (tray/daemon/launchd) without + // a login shell — probe these explicitly before falling back to PATH. + const candidates = isWin + ? [ + // Official installer: %LOCALAPPDATA%\devin\cli\bin\devin.exe + path.join(process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "devin", "cli", "bin", "devin.exe"), + path.join(home, ".local", "bin", "devin.exe"), + path.join(home, "scoop", "shims", "devin.exe"), + path.join(process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Programs", "devin", "devin.exe"), + ] + : [ + path.join(home, ".local", "share", "devin", "bin", "devin"), + path.join(home, ".devin", "bin", "devin"), + path.join(home, ".local", "bin", "devin"), // pipx / user install + "/opt/homebrew/bin/devin", // Homebrew (Apple Silicon) + "/usr/local/bin/devin", // Homebrew (Intel) / manual + "/usr/bin/devin", + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + + // 3. Fallback — rely on process.env.PATH + return isWin ? "devin.exe" : "devin"; +} + +// ─── ACP JSON-RPC helper ──────────────────────────────────────────────────── + +function rpc(method, params, id) { + const msg = { jsonrpc: "2.0", method, params }; + if (id !== undefined) msg.id = id; + return JSON.stringify(msg) + "\n"; +} + +// ─── Client-tools → MCP bridge ─────────────────────────────────────────────── +// devin only invokes built-in + MCP tools, not OpenAI function-calling schemas. +// body.tools are exposed as a stdio MCP server "clientTools" so devin can call +// them. When devin calls one, we emit OpenAI tool_use and end the turn; the +// client executes and returns tool_result on the next request. That next request +// re-spawns with the full history (including tool_calls + tool results) and +// seeds the MCP server with those results so a re-call gets the real data. +// Tool schemas via DEVIN_MCP_TOOLS; prior results via DEVIN_MCP_RESULTS. + +const CLIENT_TOOLS_MCP_SCRIPT = ` +import readline from "node:readline"; +const TOOLS = JSON.parse(process.env.DEVIN_MCP_TOOLS || "[]"); +const RESULTS = JSON.parse(process.env.DEVIN_MCP_RESULTS || "{}"); +const rl = readline.createInterface({ input: process.stdin }); +function send(o){ process.stdout.write(JSON.stringify(o) + "\\n"); } +rl.on("line", (line) => { + let m; try { m = JSON.parse(line); } catch { return; } + if (m.method === "initialize") { + send({ jsonrpc: "2.0", id: m.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "clientTools", version: "1.0" } } }); + } else if (m.method === "tools/list") { + send({ jsonrpc: "2.0", id: m.id, result: { tools: TOOLS } }); + } else if (m.method === "tools/call") { + const name = m.params?.name || ""; + const seeded = RESULTS[name]; + const text = seeded !== undefined + ? String(seeded) + : "(awaiting client tool_result)"; + process.stderr.write("[client-tools] tool_call name=" + name + " seeded=" + (seeded !== undefined) + "\\n"); + send({ jsonrpc: "2.0", id: m.id, result: { content: [{ type: "text", text }] } }); + } +}); +`.trimStart(); + +function ensureClientToolsScript() { + const scriptPath = path.join(os.tmpdir(), "9router-devin-client-tools.mjs"); + // Always rewrite so script upgrades land without a process restart. + fs.writeFileSync(scriptPath, CLIENT_TOOLS_MCP_SCRIPT); + return scriptPath; +} + +// Map OpenAI tools ([{type:"function",function:{name,description,parameters}}]) +// to MCP tool declarations ([{name,description,inputSchema}]). +// devin only discovers MCP tools whose name carries the `mcp_` prefix, so we +// add it here and strip it back when bridging the call to the client. +const MCP_TOOL_PREFIX = "mcp_"; +function toMcpToolName(name) { + return name.startsWith(MCP_TOOL_PREFIX) ? name : MCP_TOOL_PREFIX + name; +} +function fromMcpToolName(name) { + return name.startsWith(MCP_TOOL_PREFIX) ? name.slice(MCP_TOOL_PREFIX.length) : name; +} + +function buildClientToolsMcp(tools, resultMap) { + const mcpTools = []; + for (const t of tools) { + if (!t) continue; + const f = t.function || t; + if (!f?.name) continue; + mcpTools.push({ + name: toMcpToolName(f.name), + description: f.description || "", + inputSchema: f.parameters || f.input_schema || { type: "object", properties: {} }, + }); + } + if (!mcpTools.length) return null; + const env = { DEVIN_MCP_TOOLS: JSON.stringify(mcpTools) }; + if (resultMap && Object.keys(resultMap).length) { + env.DEVIN_MCP_RESULTS = JSON.stringify(resultMap); + } + return { + command: process.execPath, + args: [ensureClientToolsScript()], + env, + }; +} + +// Extract tool_result content keyed by MCP tool name (mcp_). +// Walks messages: assistant.tool_calls id→name, role=tool tool_call_id→content. +function extractClientToolResults(messages) { + const idToMcpName = new Map(); + const results = {}; + for (const m of messages) { + if (m?.role === "assistant" && Array.isArray(m.tool_calls)) { + for (const tc of m.tool_calls) { + const name = tc?.function?.name || tc?.name; + if (tc?.id && name) idToMcpName.set(tc.id, toMcpToolName(name)); + } + } + // Claude-style tool_use blocks in content + if (m?.role === "assistant" && Array.isArray(m.content)) { + for (const b of m.content) { + if (b?.type === "tool_use" && b.id && b.name) { + idToMcpName.set(b.id, toMcpToolName(b.name)); + } + } + } + if (m?.role === "tool" && m.tool_call_id) { + const mcpName = idToMcpName.get(m.tool_call_id); + if (mcpName) { + results[mcpName] = + typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""); + } + } + // Claude-style tool_result blocks in user content + if (m?.role === "user" && Array.isArray(m.content)) { + for (const b of m.content) { + if (b?.type === "tool_result" && b.tool_use_id) { + const mcpName = idToMcpName.get(b.tool_use_id); + if (mcpName) { + const c = b.content; + results[mcpName] = + typeof c === "string" ? c : JSON.stringify(c ?? ""); + } + } + } + } + } + return results; +} + +// Resolve workspace cwd from client request (Codex/CLI env context, body fields). +// Prefer an absolute existing path so agent file tools hit the user's project +// instead of os.tmpdir() (which made relative create/delete inconsistent). +function resolveWorkspaceCwd(body) { + const candidates = []; + const push = (v) => { + if (typeof v === "string" && v.trim()) candidates.push(v.trim()); + }; + push(body?.cwd); + push(body?.working_directory); + push(body?.workdir); + push(body?.workspace); + push(body?.metadata?.cwd); + push(body?.metadata?.working_directory); + + const scanText = (text) => { + if (typeof text !== "string") return; + for (const m of text.matchAll(/\s*([^<]+?)\s*<\/cwd>/gi)) push(m[1]); + }; + const scanMessages = (msgs) => { + if (!Array.isArray(msgs)) return; + for (const msg of msgs) { + if (!msg) continue; + if (typeof msg.content === "string") scanText(msg.content); + else if (Array.isArray(msg.content)) { + for (const p of msg.content) { + if (typeof p === "string") scanText(p); + else if (p && typeof p === "object") { + scanText(p.text); + scanText(p.input_text); + scanText(p.content); + } + } + } + // Responses API input items + if (typeof msg === "string") scanText(msg); + if (msg.type === "message" && Array.isArray(msg.content)) { + for (const p of msg.content) scanText(p?.text || p?.input_text); + } + } + }; + scanMessages(body?.messages); + scanMessages(body?.input); + + for (const c of candidates) { + try { + if (path.isAbsolute(c) && fs.existsSync(c) && fs.statSync(c).isDirectory()) { + return c; + } + } catch { + /* ignore */ + } + } + return os.tmpdir(); +} + +// ─── Multi-turn message → single prompt builder ───────────────────────────── + +function buildPromptText(messages) { + // Inline the whole conversation so the model has full context, including + // prior tool_calls / tool_results so it can continue after a client round-trip. + const lines = []; + for (const m of messages) { + const role = String(m.role || "user"); + let text = ""; + if (typeof m.content === "string") { + text = m.content; + } else if (Array.isArray(m.content)) { + for (const p of m.content) { + if (!p || typeof p !== "object") continue; + if (p.type === "text") text += String(p.text || ""); + else if (p.type === "tool_use") { + text += `\n[Tool call ${p.name} id=${p.id}]\n${JSON.stringify(p.input ?? {})}\n`; + } else if (p.type === "tool_result") { + const c = + typeof p.content === "string" ? p.content : JSON.stringify(p.content ?? ""); + text += `\n[Tool result id=${p.tool_use_id}]\n${c}\n`; + } + } + } + // OpenAI tool_calls on assistant messages + if (role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length) { + const parts = m.tool_calls.map((tc) => { + const name = tc.function?.name || tc.name || "tool"; + const args = tc.function?.arguments ?? tc.arguments ?? {}; + const argStr = typeof args === "string" ? args : JSON.stringify(args); + return `[Tool call ${name} id=${tc.id}]\n${argStr}`; + }); + text = [text, ...parts].filter(Boolean).join("\n\n"); + } + // OpenAI role=tool messages + if (role === "tool") { + const c = typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""); + text = `[Tool result id=${m.tool_call_id || ""}]\n${c}`; + } + if (!text.trim()) continue; + if (role === "system") { + lines.push(`[System]\n${text}`); + } else if (role === "assistant") { + lines.push(`[Assistant]\n${text}`); + } else if (role === "tool") { + lines.push(`[Tool]\n${text}`); + } else { + lines.push(`[User]\n${text}`); + } + } + return lines.join("\n\n") || "(empty)"; +} + +// ─── DevinCliExecutor ───────────────────────────────────────────────────────── + +export class DevinCliExecutor extends BaseExecutor { + constructor() { + super("devin-cli", { id: "devin-cli", baseUrl: "devin://acp/stdio" }); + } + + buildUrl() { + return "devin://acp/stdio"; + } + + buildHeaders() { + return {}; + } + + transformRequest() { + return null; + } + + async execute({ model, body, credentials, signal, log }) { + const b = body ?? {}; + const messages = Array.isArray(b.messages) + ? b.messages + : Array.isArray(b.input) + ? b.input + : []; + const promptText = buildPromptText(messages); + const workspaceCwd = resolveWorkspaceCwd(b); + const devinBin = resolveDevinBin(); + + log?.info?.( + "DEVIN", + `devin acp → model=${model}, bin=${devinBin}, cwd=${workspaceCwd}` + ); + + // Optional MCP servers via DEVIN_MCP_SERVERS (JSON object, devin config format): + // {"echo":{"command":"/abs/node","args":["/srv/echo.js"],"env":{"K":"V"}}} + // Plus body.tools (OpenAI schema) → exposed as a "clientTools" MCP + // server so devin can invoke client-defined tools (bridged back in Phase 2). + // When any are present, a throwaway XDG_CONFIG_HOME holds devin/config.json so + // the agent auto-connects them (session/new mcpServers alone doesn't spawn + // them — see ACP mcp/connect, still unstable). Cleaned up on finish. + // NOTE: this replaces the user's global devin MCP config for the subprocess. + let mcpConfigDir = null; + const mcpServers = {}; + const mcpJson = process.env.DEVIN_MCP_SERVERS?.trim(); + if (mcpJson) { + try { + Object.assign(mcpServers, JSON.parse(mcpJson)); + } catch (e) { + log?.info?.("DEVIN", `DEVIN_MCP_SERVERS parse failed: ${e.message}`); + } + } + const clientTools = Array.isArray(b.tools) ? b.tools.filter(Boolean) : []; + const clientToolResults = extractClientToolResults(messages); + const clientToolsMcp = buildClientToolsMcp(clientTools, clientToolResults); + const hasClientTools = !!clientToolsMcp; + if (clientToolsMcp) { + mcpServers["clientTools"] = clientToolsMcp; + const seeded = Object.keys(clientToolResults).length; + log?.info?.( + "DEVIN", + `exposing ${clientTools.length} client tool(s) as MCP` + + (seeded ? ` (seeded ${seeded} result(s))` : "") + ); + } + if (Object.keys(mcpServers).length) { + try { + mcpConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "devin-mcp-")); + const cfgDev = path.join(mcpConfigDir, "devin"); + fs.mkdirSync(cfgDev, { recursive: true }); + fs.writeFileSync( + path.join(cfgDev, "config.json"), + JSON.stringify({ mcpServers }) + ); + log?.info?.("DEVIN", `mcp config written → ${mcpConfigDir}`); + } catch (e) { + log?.info?.("DEVIN", `mcp config write failed: ${e.message}`); + mcpConfigDir = null; + } + } + const cleanupMcp = () => { + if (!mcpConfigDir) return; + try { + fs.rmSync(mcpConfigDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + mcpConfigDir = null; + }; + + const sseStream = new ReadableStream({ + start(controller) { + const enc = new TextEncoder(); + const emit = (data) => controller.enqueue(enc.encode(data)); + + // Inherit the parent environment so devin resolves stored CLI credentials + // (~/.local/share/devin/credentials.toml from `devin auth login`). Do NOT + // inject WINDSURF_API_KEY: this provider is noAuth, and a bogus/leaked key + // overrides stored creds and makes devin return -32000 "invalid api key". + const env = { ...process.env }; + // Auto-approve tool execution so the agent doesn't block waiting for a + // session/request_permission response we never send (default mode would + // hang the stream on the first shell/exec tool call). Override via env. + // WARNING: bypass lets the agent run shell/modify FS unattended — local only. + env.DEVIN_PERMISSION_MODE = process.env.DEVIN_PERMISSION_MODE || "bypass"; + if (mcpConfigDir) env.XDG_CONFIG_HOME = mcpConfigDir; + + // Agent type: default (omitted) = full agent with built-in tools + // (fs/shell/search) so the model can actually perform tasks. Override to + // `summarizer` (no tools, text-only) via CLI_DEVIN_AGENT_TYPE for a safer, + // tool-less mode. WARNING: the default agent can run shell commands and + // modify the filesystem on the host running 9router — only expose locally. + const agentType = process.env.CLI_DEVIN_AGENT_TYPE?.trim(); + const acpArgs = ["acp"]; + if (agentType) acpArgs.push("--agent-type", agentType); + + // Spawn in the client workspace cwd (from env context) so built-in + // file tools create/delete relative paths in the user's project. + // MCP config still comes from XDG_CONFIG_HOME (throwaway), not project .devin/. + const child = spawn(devinBin, acpArgs, { + env, + cwd: workspaceCwd, + stdio: ["pipe", "pipe", "pipe"], + // On Windows, devin.exe may need shell resolution + shell: process.platform === "win32", + }); + + let spawnError = null; + let stdinClosed = false; + + child.on("error", (err) => { + spawnError = err; + const msg = + err.message.includes("ENOENT") || err.message.includes("not found") + ? `Devin CLI not found: ${devinBin}. Install via https://cli.devin.ai or set CLI_DEVIN_BIN env var.` + : `Devin CLI spawn error: ${err.message}`; + emit( + `data: ${JSON.stringify({ error: { message: msg, type: "devin_cli_error", code: "spawn_failed" } })}\n\n` + ); + emit("data: [DONE]\n\n"); + controller.close(); + }); + + if (signal) { + signal.addEventListener("abort", () => { + if (!child.killed) child.kill("SIGTERM"); + }); + } + + // ── JSON-RPC state machine ────────────────────────────────────────── + let idCounter = 1; + let sessionId = null; + let initDone = false; + let sessionCreated = false; + let promptSent = false; + const responseId = `chatcmpl-devin-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + let roleEmitted = false; + let totalText = ""; + let finished = false; + + const sendRpc = (method, params) => { + if (stdinClosed || child.stdin.destroyed) return; + const id = idCounter++; + try { + child.stdin.write(rpc(method, params, id)); + } catch { + /* ignore write errors after close */ + } + return id; + }; + + // Emit a content delta as an OpenAI-compatible SSE chunk (handles the + // leading role chunk once). + const emitDelta = (delta) => { + if (!roleEmitted) { + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }], + })}\n\n` + ); + roleEmitted = true; + } + totalText += delta; + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content: delta }, finish_reason: null }], + })}\n\n` + ); + }; + + // Emit an OpenAI tool_call delta (function calling). Ends the turn with + // finish_reason "tool_calls" so the client executes and returns tool_result. + let toolUseEmitted = false; + // ACP tool_call is upsert-by-id: the first event has title, a later update + // may only carry rawInput (title omitted). Track pending client-tool calls. + const pendingClientTools = new Map(); // toolCallId → original tool name + const emitToolUse = (toolName, args, toolCallId) => { + const argsStr = typeof args === "string" ? args : JSON.stringify(args ?? {}); + if (!roleEmitted) { + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant", content: null }, finish_reason: null }], + })}\n\n` + ); + roleEmitted = true; + } + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: toolCallId, + type: "function", + function: { name: toolName, arguments: argsStr }, + }, + ], + }, + finish_reason: null, + }, + ], + })}\n\n` + ); + }; + + const finish = (error, finishReason = "stop") => { + if (finished) return; + finished = true; + + if (error) { + emit( + `data: ${JSON.stringify({ error: { message: error, type: "devin_cli_error" } })}\n\n` + ); + } else { + // Emit finish chunk + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: finishReason }], + usage: { + prompt_tokens: Math.ceil(promptText.length / 4), + completion_tokens: Math.ceil(totalText.length / 4), + total_tokens: Math.ceil((promptText.length + totalText.length) / 4), + estimated: true, + }, + })}\n\n` + ); + } + emit("data: [DONE]\n\n"); + + // Gracefully close stdin → devin will exit + try { + if (!stdinClosed) { + stdinClosed = true; + child.stdin.end(); + } + } catch { + /* ignore */ + } + + // Give it 2s to exit cleanly, then SIGKILL + const killTimer = setTimeout(() => { + if (!child.killed) child.kill("SIGKILL"); + }, 2000); + killTimer.unref?.(); + + controller.close(); + cleanupMcp(); + }; + + // ── stdout reader (NDJSON) ────────────────────────────────────────── + let buffer = ""; + + child.stdout.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + let nl; + // Each ACP message is a newline-terminated JSON line + while ((nl = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (!line) continue; + + let msg; + try { + msg = JSON.parse(line); + } catch { + continue; // ignore non-JSON lines (banner text, etc.) + } + + // ── Initialize response ─────────────────────────────────────── + if (!initDone && msg.result !== undefined && !msg.method) { + initDone = true; + // Create session with the client workspace cwd so agent file tools + // resolve relative paths against the project (not /tmp). + // `mcpServers` is required by devin 3000.2.x (must be a sequence); + // omitting it returns -32602 "Invalid params: missing field mcpServers". + sendRpc("session/new", { + cwd: workspaceCwd, + mcpServers: [], + model: model || undefined, + }); + continue; + } + + // ── session/new response → get sessionId ────────────────────── + if (initDone && !sessionCreated && msg.result !== undefined && !msg.method) { + const res = msg.result || {}; + sessionId = res.sessionId || null; + if (!sessionId) { + finish("Devin ACP: session/new returned no sessionId"); + return; + } + sessionCreated = true; + // Send the prompt. devin 3000.2.x expects `prompt` (a sequence), + // not `content` — using `content` returns -32602 "missing field prompt". + promptSent = true; + sendRpc("session/prompt", { + sessionId, + prompt: [{ type: "text", text: promptText }], + }); + continue; + } + + // ── session/prompt response (ack / final result) ──────────── + if (sessionCreated && promptSent && msg.result !== undefined && !msg.method) { + // Devin 3000.2.x only resolves session/prompt with the final result + // (stopReason) after streaming completes. Streaming notifications are + // handled below; nothing to do here unless we never streamed. + if (!roleEmitted) { + const res = msg.result || undefined; + const content = extractResultText(res); + if (content) { + totalText = content; + emitDelta(content); + } + const stopReason = (res && res.stopReason) || ""; + if (stopReason && stopReason !== "cancelled") { + finish(); + return; + } + } + continue; + } + + // ── Permission requests → auto-approve the first allow option ── + // Devi asks before running shell/exec tools; as a headless proxy we + // grant once. (DEVIN_PERMISSION_MODE=bypass usually prevents these, + // but some tool kinds still prompt, so handle them here too.) + if (msg.method === "session/request_permission" && msg.id !== undefined) { + const options = msg.params?.options || []; + const allow = + options.find((o) => /allow/i.test(String(o.kind || ""))) || options[0]; + if (allow) { + child.stdin.write( + JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result: { outcome: { outcome: "selected", optionId: allow.optionId } }, + }) + "\n" + ); + } + continue; + } + + // ── Agent stopped notification (devin 3000.2.x stop signal) ─── + if (msg.method === "_cognition.ai/agent_stopped" || msg.method === "$/agent_stopped") { + const cause = msg.params?.cause; + if (cause === "error") { + // devin uses errorMessage on this notification (not message/error). + const errText = + msg.params?.errorMessage || + msg.params?.message || + msg.params?.error || + "Devin agent error"; + finish(String(errText)); + } else { + finish(); + } + return; + } + + // ── Streaming notifications (session/update) ────────────────── + if (msg.method === "session/update" || msg.method === "$/update") { + const params = msg.params; + if (!params) continue; + + // devin 3000.2.x nests the payload under params.update.sessionUpdate; + // older devin used a flat params.type. + const update = params.update || {}; + const type = update.sessionUpdate || params.type; + const contentField = update.content !== undefined ? update.content : params.content; + const deltaText = + typeof contentField === "string" + ? contentField + : contentField?.text ?? params.delta ?? params.text ?? ""; + + // ── Client-tool bridge: devin calling a tool from our exposed MCP ── + // ACP title shape: "Calling mcp_ from clientTools". + // tool_call is upsert-by-id: title may only appear on the first event, + // rawInput on a later tool_call_update. Track pending ids so we don't + // require both fields on the same notification. + if ( + hasClientTools && + !toolUseEmitted && + (type === "tool_call" || type === "tool_call_update") + ) { + const tcId = update.toolCallId; + if (typeof update.title === "string" && update.title.startsWith("Calling mcp_") && /from clientTools\b/.test(update.title)) { + const nameMatch = update.title.match(/^Calling (mcp_\S+)\b/); + const mcpName = nameMatch ? nameMatch[1] : ""; + const origName = fromMcpToolName(mcpName); + if (tcId && origName) pendingClientTools.set(tcId, origName); + } + const origName = tcId ? pendingClientTools.get(tcId) : null; + if (origName && update.rawInput) { + toolUseEmitted = true; + pendingClientTools.delete(tcId); + emitToolUse(origName, update.rawInput, tcId || `call_${Date.now()}`); + finish(null, "tool_calls"); + return; + } + continue; + } + + if (type === "agent_message_chunk" || type === "message_delta" || type === "text_delta" || type === "content_delta") { + if (deltaText) emitDelta(deltaText); + } else if (type === "agent_thought_chunk") { + // Internal reasoning — not surfaced to the client. + } else if (type === "message_stop" || type === "stop" || type === "done") { + finish(); + return; + } else if (type === "error") { + finish(String(params.message || params.error || "Devin ACP error")); + return; + } + continue; + } + + // ── Error responses ─────────────────────────────────────────── + if (msg.error) { + finish(`Devin ACP error ${msg.error.code}: ${msg.error.message}`); + return; + } + } + }); + + child.stderr.on("data", (chunk) => { + log?.debug?.("DEVIN", `stderr: ${chunk.toString("utf8").slice(0, 200)}`); + }); + + child.on("close", (code) => { + if (!finished) { + if (code !== 0 && !spawnError) { + finish(roleEmitted ? undefined : `Devin CLI exited with code ${code}`); + } else { + finish(); + } + } else { + cleanupMcp(); + } + }); + + // ── Send initialize ─────────────────────────────────────────────── + sendRpc("initialize", { + protocolVersion: "0.3", + clientInfo: { name: "9router", version: "1.0" }, + capabilities: {}, + }); + }, + }); + + return { + response: new Response(sseStream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }), + url: "devin://acp/stdio", + headers: {}, + transformedBody: { + model, + cwd: workspaceCwd, + clientTools: clientTools.map((t) => t?.function?.name || t?.name).filter(Boolean), + clientToolResults: Object.keys(clientToolResults), + mcpServers: Object.keys(mcpServers), + promptLength: Array.isArray(body?.messages) + ? body.messages.length + : Array.isArray(body?.input) + ? body.input.length + : 0, + }, + }; + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// Extract text from a final ACP session/prompt result object across common shapes. +function extractResultText(result) { + // { message: { content: "..." } } + // { messages: [{ content: "..." }] } + // { content: "..." } + // { text: "..." } + if (typeof result.content === "string") return result.content; + if (typeof result.text === "string") return result.text; + const msg = result.message; + if (msg && typeof msg.content === "string") return msg.content; + const msgs = result.messages; + if (Array.isArray(msgs)) { + return msgs + .filter((m) => m.role === "assistant") + .map((m) => String(m.content || "")) + .join("\n"); + } + return ""; +} + +export default DevinCliExecutor; diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index b6091b9d..92e10464 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -20,7 +20,12 @@ import { CommandCodeExecutor } from "./commandcode.js"; import { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; import { MimoFreeExecutor } from "./mimo-free.js"; import { CodeBuddyExecutor } from "./codebuddy-cn.js"; +import { CodeBuddyIntlExecutor } from "./codebuddy-intl.js"; +import TraeExecutor from "./trae.js"; +import ZedExecutor from "./zed.js"; +import WindsurfExecutor from "./windsurf.js"; import { DefaultExecutor } from "./default.js"; +import { DevinCliExecutor } from "./devin-cli.js"; const executors = { antigravity: new AntigravityExecutor(), @@ -50,6 +55,11 @@ const executors = { "mimo-free": new MimoFreeExecutor(), mmf: new MimoFreeExecutor(), // Alias for mimo-free "codebuddy-cn": new CodeBuddyExecutor(), + "codebuddy-intl": new CodeBuddyIntlExecutor(), + trae: new TraeExecutor(), + zed: new ZedExecutor(), + windsurf: new WindsurfExecutor(), + "devin-cli": new DevinCliExecutor(), }; const defaultCache = new Map(); @@ -88,3 +98,8 @@ export { CommandCodeExecutor } from "./commandcode.js"; export { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; export { MimoFreeExecutor } from "./mimo-free.js"; export { CodeBuddyExecutor } from "./codebuddy-cn.js"; +export { CodeBuddyIntlExecutor } from "./codebuddy-intl.js"; +export { default as TraeExecutor } from "./trae.js"; +export { default as ZedExecutor } from "./zed.js"; +export { default as WindsurfExecutor } from "./windsurf.js"; +export { DevinCliExecutor } from "./devin-cli.js"; diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index 556e0c72..1205522b 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -1,10 +1,215 @@ import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; -import { resolveKiroModel } from "../config/kiroConstants.js"; +import { + KIRO_CODEWHISPERER_TARGET, + KIRO_ENDPOINT_FALLBACK_STATUSES, + resolveKiroModel, +} from "../config/kiroConstants.js"; 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) @@ -15,12 +220,17 @@ export class KiroExecutor extends BaseExecutor { super("kiro", PROVIDERS.kiro); } - buildHeaders(credentials, stream = true) { + buildHeaders(credentials, stream = true, url = "") { const headers = { ...this.config.headers, "Amz-Sdk-Request": "attempt=1; max=3", "Amz-Sdk-Invocation-Id": uuidv4() }; + if (url.includes("://codewhisperer.")) { + headers["X-Amz-Target"] = KIRO_CODEWHISPERER_TARGET; + } else { + delete headers["X-Amz-Target"]; + } // API-key auth: the key is stored as accessToken and sent as a bearer token // exactly like an OAuth access token, but with an extra `tokentype: API_KEY` @@ -35,8 +245,8 @@ export class KiroExecutor extends BaseExecutor { const apiKey = credentials?.apiKey || (isApiKey ? credentials?.accessToken : null); if (isApiKey && apiKey) { headers["Authorization"] = `Bearer ${apiKey}`; - headers["tokentype"] = "API_KEY"; - } else if (credentials.accessToken) { + headers["TokenType"] = "API_KEY"; + } else if (credentials?.accessToken) { headers["Authorization"] = `Bearer ${credentials.accessToken}`; if (isExternalIdp) { headers["TokenType"] = "EXTERNAL_IDP"; @@ -49,14 +259,14 @@ export class KiroExecutor extends BaseExecutor { /** * Auth-aware endpoint ordering. * - * API-key Kiro connections store a raw CodeWhisperer credential (validated - * against codewhisperer.us-east-1.amazonaws.com via ListAvailableProfiles). + * API-key Kiro connections use the Amazon Q surface. The legacy + * codewhisperer.* GenerateAssistantResponse endpoint can authenticate the key + * but rejects the same valid payload with REQUEST_BODY_INVALID. Since a 400 + * is terminal in BaseExecutor, putting CodeWhisperer first prevents the working + * q.* endpoint from ever being tried. Keep q.* first only for api_key accounts. + * * The Kiro IDE gateway (runtime.*.kiro.dev) expects Kiro OIDC/social tokens - * and rejects an `tokentype: API_KEY` token with 401/403 — which - * BaseExecutor.execute() returns immediately (only 429 / network errors fall - * through to the next host). So for api-key auth we must try the *.amazonaws.com - * CodeWhisperer hosts FIRST, mirroring the Kiro-Go reference fork which never - * routes api-key traffic through kiro.dev. External IdP enterprise tokens also + * and rejects TokenType=API_KEY. External IdP enterprise tokens instead * use the CodeWhisperer surface, with the `TokenType: EXTERNAL_IDP` header. * Other OAuth methods keep the default order (kiro.dev first) since their * tokens are what that gateway accepts. @@ -81,6 +291,14 @@ export class KiroExecutor extends BaseExecutor { const amazon = baseUrls.filter((u) => u.includes("amazonaws.com")).map(regionalize); const others = baseUrls.filter((u) => !u.includes("amazonaws.com")); + if (authMethod === "api_key") { + const q = amazon.filter((u) => u.includes("://q.")); + const remaining = amazon.filter((u) => !u.includes("://q.")); + return q.length > 0 + ? [...q, ...remaining, ...others] + : [...amazon, ...others]; + } + return amazon.length > 0 ? [...amazon, ...others] : baseUrls; } @@ -89,6 +307,14 @@ export class KiroExecutor extends BaseExecutor { return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl; } + // Retry only endpoint/auth-surface failures. Payload-invalid HTTP 400 must be + // terminal: sending the same malformed body to every surface cannot repair it. + shouldRetry(status, urlIndex) { + const hasFallback = urlIndex + 1 < this.getFallbackCount(); + return super.shouldRetry(status, urlIndex) + || (hasFallback && KIRO_ENDPOINT_FALLBACK_STATUSES.has(status)); + } + transformRequest(model, body, stream, credentials) { return body; } @@ -111,393 +337,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 +1136,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/executors/qoder.js b/open-sse/executors/qoder.js index ddaac8c0..7fa0e554 100644 --- a/open-sse/executors/qoder.js +++ b/open-sse/executors/qoder.js @@ -33,7 +33,11 @@ import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js"; import { resolveProviderTimeoutMs } from "../services/providerTimeout.js"; import { QODER_CHAT_URL_ENCODED, + QODER_JOB_TOKEN_EXCHANGE_URL, + QODER_USERINFO_URL, QODER_MODEL_MAP, + QODER_IDE_VERSION, + QODER_CLIENT_TYPE, } from "../shared/qoder/constants.js"; import { getQoderModelConfig, resolveQoderModels } from "../services/qoderModels.js"; @@ -221,8 +225,13 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio * Each upstream line looks like: * data: {"statusCodeValue":200,"body":"{\"choices\":[{\"delta\":{...}}]}"} * The inner body is an OpenAI streaming chunk (or "[DONE]"). We unwrap it - * and re-emit as `data: \n\n`. Errors become `data: [DONE]\n\n` plus - * a synthetic OpenAI error chunk. + * and re-emit as `data: \n\n`. Errors become a synthetic OpenAI error + * chunk + [DONE]. + * + * Critical: Qoder's SSE often keeps the socket open after the terminal + * [DONE]/error frame (agent keepalive). Non-streaming clients drain via + * response.text() which hangs until the socket closes — so on terminal + * events we cancel the upstream reader and close our stream immediately. */ function wrapQoderSSE(response, model) { if (!response.ok || !response.body) return response; @@ -231,15 +240,14 @@ function wrapQoderSSE(response, model) { const encoder = new TextEncoder(); let buffer = ""; let doneEmitted = false; + const reader = response.body.getReader(); - // Process one already-extracted SSE line (no trailing newline). Returns - // false when the line indicated end-of-stream so the caller can stop - // forwarding any remaining chunks after [DONE]. + // Process one already-extracted SSE line (no trailing newline). const processLine = (line, controller) => { const trimmed = line.replace(/\r$/, "").trim(); if (!trimmed) return; if (!trimmed.startsWith("data:")) return; - if (doneEmitted) return; // never forward chunks past stream end + if (doneEmitted) return; const data = trimmed.slice(5).trimStart(); if (data === "[DONE]") { @@ -272,47 +280,60 @@ function wrapQoderSSE(response, model) { doneEmitted = true; return; } - // Inner is an OpenAI-shaped chunk. Strip any embedded newlines so the - // SSE frame stays a single event (a literal "\n" inside `inner` would - // otherwise split the frame across multiple data: lines and downstream - // parsers would reassemble them as separate events). + // Strip embedded newlines so the SSE frame stays a single event. const sanitized = inner.replace(/\r?\n/g, ""); controller.enqueue(encoder.encode(`data: ${sanitized}\n\n`)); }; - const transform = new TransformStream({ - transform(chunk, controller) { - buffer += decoder.decode(chunk, { stream: true }); - let nl; - while ((nl = buffer.indexOf("\n")) !== -1) { - const line = buffer.slice(0, nl); - buffer = buffer.slice(nl + 1); - processLine(line, controller); + const stream = new ReadableStream({ + // Use start()+loop (not pull): a pull that buffers a partial line without + // enqueueing would never be re-invoked, hanging consumers like .text(). + async start(controller) { + try { + while (!doneEmitted) { + const { done, value } = await reader.read(); + if (done) { + buffer += decoder.decode(); + if (buffer.length > 0) { + processLine(buffer, controller); + buffer = ""; + } + break; + } + + buffer += decoder.decode(value, { stream: true }); + let nl; + while ((nl = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + processLine(line, controller); + if (doneEmitted) { + // Terminal frame received — drop upstream keepalive and end. + await reader.cancel().catch(() => {}); + controller.close(); + return; + } + } + } + } catch { + // fall through to terminal [DONE] + close + } finally { + if (!doneEmitted) { + try { + controller.enqueue(encoder.encode(SSE_DONE)); + doneEmitted = true; + } catch { /* already closed */ } + } + try { controller.close(); } catch { /* already closed */ } + await reader.cancel().catch(() => {}); } }, - flush(controller) { - // Finalize the decoder so any pending multi-byte sequence is - // released into `buffer` instead of being silently dropped. - buffer += decoder.decode(); - // Drain any trailing line that arrived without a terminating newline - // (e.g. upstream closed the socket immediately after the last write, - // or a CDN stripped the final CRLF). Without this, the chunk that - // carries finish_reason is silently lost. - if (buffer.length > 0) { - processLine(buffer, controller); - buffer = ""; - } - if (!doneEmitted) { - controller.enqueue(encoder.encode(SSE_DONE)); - doneEmitted = true; - } + cancel() { + return reader.cancel().catch(() => {}); }, }); - const transformed = response.body.pipeThrough(transform); - // Build a Response with passable headers; the streaming handler reads - // `.body` as a ReadableStream regardless of Content-Type. - return new Response(transformed, { + return new Response(stream, { status: response.status, statusText: response.statusText, headers: { @@ -322,6 +343,92 @@ function wrapQoderSSE(response, model) { }); } +// ── PAT (Personal Access Token) → job-token exchange ─────────────────────── +// PATs (pt-...) cannot sign COSY requests directly. Exchange them for a +// short-lived job token (jt-...) via /api/v1/jobToken/exchange (plain JSON, +// not COSY-signed), then resolve the userId from userinfo. Mirrors the +// official qodercli flow. Cached per-PAT until near-expiry. +const PAT_PREFIX = "pt-"; +const PAT_REFRESH_BUFFER_MS = 5 * 60 * 1000; +const patJobCache = new Map(); + +export function isQoderPat(token) { + return typeof token === "string" && token.startsWith(PAT_PREFIX); +} + +async function exchangeJobToken(pat, proxyOptions = null, signal = null) { + const res = await proxyAwareFetch( + QODER_JOB_TOKEN_EXCHANGE_URL, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": "qodercli/1.0.0", + "Cosy-Version": QODER_IDE_VERSION, + "Cosy-ClientType": QODER_CLIENT_TYPE, + }, + body: JSON.stringify({ personal_token: pat }), + signal, + }, + proxyOptions, + ); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`qoder PAT exchange failed: ${res.status} ${text.slice(0, 200)}`); + } + const data = await res.json(); + if (!data.token) throw new Error("qoder PAT exchange returned no job token"); + + let expiresAt = Date.now() + 24 * 60 * 60 * 1000; + if (data.expires_at) { + const parsed = Date.parse(data.expires_at); + if (!Number.isNaN(parsed)) expiresAt = parsed; + } else if (typeof data.expires_in === "number" && data.expires_in > 0) { + expiresAt = Date.now() + data.expires_in; + } + return { jobToken: data.token, jobRefreshToken: data.refresh_token || "", expiresAt }; +} + +async function fetchUserIdForJobToken(jobToken, proxyOptions = null, signal = null) { + try { + const res = await proxyAwareFetch( + QODER_USERINFO_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${jobToken}`, + Accept: "application/json", + "User-Agent": "qodercli/1.0.0", + }, + signal, + }, + proxyOptions, + ); + if (!res.ok) return ""; + const info = await res.json().catch(() => ({})); + return info.id || info.userId || info.user_id || ""; + } catch { + return ""; + } +} + +/** + * Exchange a PAT for a job token + userId, caching until near-expiry so repeat + * chat requests don't re-exchange. Returns { accessToken, userId }. + */ +async function resolvePatCredential(pat, proxyOptions = null, signal = null) { + const cached = patJobCache.get(pat); + if (cached && cached.expiresAt - Date.now() > PAT_REFRESH_BUFFER_MS) { + return cached; + } + const { jobToken, expiresAt } = await exchangeJobToken(pat, proxyOptions, signal); + const userId = await fetchUserIdForJobToken(jobToken, proxyOptions, signal); + const entry = { accessToken: jobToken, userId, expiresAt }; + patJobCache.set(pat, entry); + return entry; +} + export class QoderExecutor extends BaseExecutor { constructor() { super("qoder", PROVIDERS.qoder); @@ -339,6 +446,34 @@ export class QoderExecutor extends BaseExecutor { async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { const url = this.buildUrl(); + // PAT (pt-...) → exchange for short-lived job token + resolve userId so + // downstream COSY signing + catalog fetch work. Device tokens (dt-...) and + // job tokens (jt-...) skip this and are used directly. + const rawToken = credentials?.apiKey || credentials?.accessToken; + if (isQoderPat(rawToken)) { + try { + const resolved = await resolvePatCredential(rawToken, proxyOptions, signal); + credentials = { + ...credentials, + accessToken: resolved.accessToken, + apiKey: undefined, + providerSpecificData: { + authMethod: "pat", + ...(credentials?.providerSpecificData || {}), + userId: resolved.userId || credentials?.providerSpecificData?.userId || "", + machineId: credentials?.providerSpecificData?.machineId || "", + }, + }; + } catch (err) { + log?.error?.("QODER", `PAT exchange failed: ${err.message}`); + const fakeResp = new Response( + JSON.stringify({ error: { message: `qoder PAT exchange failed: ${err.message}` } }), + { status: 401, headers: { "Content-Type": "application/json" } }, + ); + return { response: fakeResp, url, headers: {}, transformedBody: body }; + } + } + const psd = credentials?.providerSpecificData || {}; if (!psd.userId) { // No user id → no way to sign. Surface a 401 so the dashboard nudges @@ -456,4 +591,6 @@ export const __test__ = { normalizeMessages, wrapQoderSSE, buildQoderRequestBody, + isQoderPat, + resolvePatCredential, }; diff --git a/open-sse/executors/trae.js b/open-sse/executors/trae.js new file mode 100644 index 00000000..59f29be3 --- /dev/null +++ b/open-sse/executors/trae.js @@ -0,0 +1,339 @@ +import { BaseExecutor } from "./base.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { PROVIDERS } from "../config/providers.js"; + +// Trae executor — SOLO remote agent API. +// +// Flow: +// 1. POST {base}/chat_sessions → { code:0, data:{ chat_session_id, message_id } } +// 2. GET {base}/chat_sessions/{id}/events?reply_to_message_id={message_id} +// → text/event-stream. Assistant text streams in `plan_item` events under +// the `thought` field (cumulative per plan-item id). `token_usage` carries +// usage; `done` ends the turn; `error` carries upstream errors. +// +// Auth: header `Authorization: Cloud-IDE-JWT ` (RS256, ~14-day lifetime). +// Identity fields for common_params live in credentials.providerSpecificData. + +const STREAM_TIMEOUT_MS = parseInt(process.env.TRAE_STREAM_TIMEOUT_MS || "300000", 10); +const TRAE_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; + +function flattenQuery(messages) { + const parts = []; + for (const m of messages) { + let content = ""; + if (typeof m.content === "string") content = m.content; + else if (Array.isArray(m.content)) { + content = m.content + .map((p) => { + if (typeof p === "string") return p; + if (p && typeof p === "object") return String(p.text ?? ""); + return ""; + }) + .join(""); + } + if (m.role === "system") parts.push(`[System]\n${content}`); + else if (m.role === "assistant") parts.push(`[Assistant]\n${content}`); + else parts.push(content); + } + // Trae expects query as a JSON-encoded string of typed content blocks. + return JSON.stringify([{ type: "text", data: { content: parts.join("\n\n") } }]); +} + +export default class TraeExecutor extends BaseExecutor { + constructor() { + super("trae", PROVIDERS.trae); + } + + base() { + return (this.config.baseUrl || "https://core-normal.trae.ai/api/remote/v1").replace(/\/$/, ""); + } + + buildHeaders(credentials, stream = true) { + const token = credentials?.accessToken || ""; + const psd = credentials?.providerSpecificData || {}; + return { + Authorization: `Cloud-IDE-JWT ${token}`, + "Content-Type": "application/json", + "X-Trae-Client-Type": "web", + "X-Preferenced-Language": psd.appLanguage || "en", + "x-user-region": psd.userRegion || "US", + Referer: "https://solo.trae.ai/", + "User-Agent": TRAE_UA, + Accept: stream ? "text/event-stream" : "application/json", + }; + } + + // SOLO session modes: "code" (model picker) vs "work" (fast auto lane). + resolveMode(model) { + const m = (model || "").trim().toLowerCase(); + if (m === "work" || m === "auto-work" || m === "solo-work") { + return { mode: "work", strategy: "auto", modelName: "" }; + } + const auto = !m || m === "auto"; + return { mode: "code", strategy: auto ? "auto" : "manual", modelName: auto ? "" : model }; + } + + // common_params is a JSON-encoded string embedded inside initial_message. + commonParams(psd, mode, sessionId) { + const cp = { + language: "en-us", + app_language: psd.appLanguage || "en", + quality: "stable", + app_version: psd.appVersion || "1.0.0.1229", + web_id: psd.webId || "", + user_identity: psd.userIdentity || "Free", + is_freshman: "0", + biz_user_id: psd.bizUserId || "", + user_unique_id: psd.userUniqueId || "", + scope: psd.scope || "marscode-us", + tenant: psd.tenant || "marscode", + region: psd.region || "US-East", + aiRegion: psd.aiRegion || psd.region || "US-East", + is_privacy_mode: 0, + privacy_mode: "off", + solo_chat_mode: mode, + }; + if (sessionId) cp.biz_session_id = sessionId; + return JSON.stringify(cp); + } + + // POST /chat_sessions — creates a session and submits the first turn. + async createSession(headers, query, model, psd, signal) { + const { mode, strategy, modelName } = this.resolveMode(model); + const body = { + mode, + environment_id: "default", + initial_message: { + chat_session_id: "", + content: [], + query, + model_name: modelName, + agent_type: "solo_agent_remote", + model_selection_strategy: strategy, + common_params: this.commonParams(psd, mode), + }, + env: "remote", + auto_create_project: false, + origin: "web", + }; + const res = await proxyAwareFetch(`${this.base()}/chat_sessions`, { + method: "POST", + headers, + body: JSON.stringify(body), + signal, + }, null); + const text = await res.text(); + if (!res.ok) throw new Error(`[${res.status}] ${text}`); + const json = JSON.parse(text); + if (json?.code !== 0) throw new Error(`Trae create_session: ${JSON.stringify(json)}`); + return { sessionId: json.data.chat_session_id, messageId: json.data.message_id }; + } + + // GET /events SSE → invoke onEvent(eventType, dataObj) per frame. + // Resolves when `done`/`error` arrives, the stream ends, or timeout fires. + async streamEvents(headers, sessionId, replyTo, onEvent, signal) { + const url = `${this.base()}/chat_sessions/${sessionId}/events?reply_to_message_id=${encodeURIComponent(replyTo)}`; + const ctrl = new AbortController(); + if (signal?.aborted) ctrl.abort(); + const timer = setTimeout(() => ctrl.abort(new Error("trae stream timeout")), STREAM_TIMEOUT_MS); + const onAbort = () => ctrl.abort(); + if (signal) signal.addEventListener("abort", onAbort, { once: true }); + try { + const res = await proxyAwareFetch(url, { method: "GET", headers, signal: ctrl.signal }, null); + if (!res.ok || !res.body) throw new Error(`[${res.status}] events stream failed`); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + let ev = null; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let nl; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl).replace(/\r$/, ""); + buf = buf.slice(nl + 1); + if (line.startsWith("event:")) ev = line.slice(6).trim(); + else if (line.startsWith("data:")) { + const payload = line.slice(5).trim(); + let data; + try { data = JSON.parse(payload); } catch { data = { _raw: payload }; } + if (onEvent(ev, data)) { + await reader.cancel().catch(() => {}); + return; + } + } else if (line === "") ev = null; + } + } + } finally { + clearTimeout(timer); + if (signal) signal.removeEventListener("abort", onAbort); + } + } + + async execute({ model, body, stream, credentials, signal }) { + const headers = this.buildHeaders(credentials, stream !== false); + const psd = credentials?.providerSpecificData || {}; + const query = flattenQuery(body?.messages || []); + const responseId = `chatcmpl-trae-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + + const errResponse = (status, message) => new Response( + JSON.stringify({ error: { message, type: "api_error", code: "" } }), + { status, headers: { "Content-Type": "application/json" } } + ); + + let session; + try { + session = await this.createSession(headers, query, model, psd, signal); + } catch (err) { + return { response: errResponse(502, err?.message ? String(err.message) : String(err)), url: this.base(), headers, transformedBody: body }; + } + + // Shared per-turn state: plan_item thoughts (cumulative, longest wins). + const order = []; + const thoughts = {}; + let sent = 0; + let usage = null; + let errorEvent = null; + const renderNewText = (data) => { + const pid = data.id; + if (!pid) return ""; + if (!(pid in thoughts)) order.push(pid); + const t = data.thought || ""; + if (t.length >= (thoughts[pid] || "").length) thoughts[pid] = t; + const full = order.map((i) => thoughts[i]).join(""); + const piece = full.slice(sent); + sent = full.length; + return piece; + }; + + if (stream !== false) { + const enc = new TextEncoder(); + const sse = new ReadableStream({ + start: async (controller) => { + const emit = (obj) => controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`)); + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }); + try { + await this.streamEvents(headers, session.sessionId, session.messageId, (ev, data) => { + if (ev === "error") { errorEvent = data; return true; } + if (ev === "token_usage") usage = data; + if (ev === "plan_item") { + const piece = renderNewText(data); + if (piece) { + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content: piece }, finish_reason: null }], + }); + } + } + return ev === "done"; + }, signal); + if (errorEvent) { + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [], + error: { message: `trae ${errorEvent.code || ""}: ${errorEvent.message || ""}`, type: "api_error" }, + }); + } else { + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }); + if (usage) { + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [], + usage: { + prompt_tokens: usage.prompt_tokens || 0, + completion_tokens: usage.completion_tokens || 0, + total_tokens: usage.total_tokens || 0, + }, + }); + } + } + controller.enqueue(enc.encode("data: [DONE]\n\n")); + controller.close(); + } catch (err) { + controller.error(err); + } + }, + }); + return { + response: new Response(sse, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + }), + url: this.base(), + headers, + transformedBody: body, + }; + } + + // Non-streaming: drive to completion, return chat.completion JSON. + try { + await this.streamEvents(headers, session.sessionId, session.messageId, (ev, data) => { + if (ev === "error") { errorEvent = data; return true; } + if (ev === "token_usage") usage = data; + if (ev === "plan_item") renderNewText(data); + return ev === "done"; + }, signal); + } catch (err) { + return { response: errResponse(502, err?.message ? String(err.message) : String(err)), url: this.base(), headers, transformedBody: body }; + } + if (errorEvent) { + return { response: errResponse(502, `trae ${errorEvent.code || ""}: ${errorEvent.message || ""}`), url: this.base(), headers, transformedBody: body }; + } + const content = order.map((i) => thoughts[i]).join(""); + const out = { + id: responseId, + object: "chat.completion", + created, + model, + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + }; + if (usage) { + out.usage = { + prompt_tokens: usage.prompt_tokens || 0, + completion_tokens: usage.completion_tokens || 0, + total_tokens: usage.total_tokens || 0, + }; + } + return { + response: new Response(JSON.stringify(out), { status: 200, headers: { "Content-Type": "application/json" } }), + url: this.base(), + headers, + transformedBody: body, + }; + } + + // Refresh hook placeholder — Cloud-IDE-JWT is long-lived (~14d); refresh via + // ExchangeToken (refresh→access) is wired in services/tokenRefresh/providers.js. + async refreshCredentials() { + return null; + } +} diff --git a/open-sse/executors/windsurf.js b/open-sse/executors/windsurf.js new file mode 100644 index 00000000..8526df17 --- /dev/null +++ b/open-sse/executors/windsurf.js @@ -0,0 +1,588 @@ +import { BaseExecutor } from "./base.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { PROVIDERS } from "../config/providers.js"; +import { randomUUID } from "node:crypto"; + +// WindsurfExecutor — Codeium gRPC-web chat. +// +// Wire protocol: gRPC-web over HTTPS (Content-Type: application/grpc-web+proto). +// Service: exa.language_server_pb.LanguageServerService +// Method: GetChatMessage (unary request → streamed CompletionChunk frames) +// +// Auth: credentials.accessToken = Codeium apiKey (sk-ws-... or Firebase-derived) +// — placed in Metadata.api_key protobuf field of every request + Bearer header. + +const WS_BASE_URL = "https://server.codeium.com"; +const WS_SERVICE = "exa.language_server_pb.LanguageServerService"; +const WS_METHOD_CHAT = "GetChatMessage"; +const WS_CHAT_URL = `${WS_BASE_URL}/${WS_SERVICE}/${WS_METHOD_CHAT}`; + +const WS_IDE_NAME = "windsurf"; +const WS_IDE_VERSION = "3.14.0"; +const WS_EXT_VERSION = "3.14.0"; +const WS_LOCALE = "en-US"; + +// ─── Model alias map (catalog name → Windsurf wire name) ───────────────────── +const MODEL_ALIAS_MAP = { + // ── Cognition SWE ─────────────────────────────────────────────────────── + "swe-1.6-fast": "swe-1-6-fast", + "swe-1.6": "swe-1-6", + "swe-1.5-fast": "swe-1-5-fast", + "swe-1.5": "swe-1-5", + // ── Claude Opus 4.7 — effort-tiered ───────────────────────────────────── + "claude-opus-4.7-max": "claude-opus-4-7-max", + "claude-opus-4.7-xhigh": "claude-opus-4-7-xhigh", + "claude-opus-4.7-high": "claude-opus-4-7-high", + "claude-opus-4.7-medium": "claude-opus-4-7-medium", + "claude-opus-4.7-low": "claude-opus-4-7-low", + "claude-opus-4.7-review": "opus-4-7-review", + // ── Claude Opus/Sonnet 4.6 ────────────────────────────────────────────── + "claude-sonnet-4.6-thinking-1m": "claude-sonnet-4-6-thinking-1m", + "claude-sonnet-4.6-1m": "claude-sonnet-4-6-1m", + "claude-sonnet-4.6-thinking": "claude-sonnet-4-6-thinking", + "claude-sonnet-4.6": "claude-sonnet-4-6", + "claude-opus-4.6-thinking": "claude-opus-4-6-thinking", + "claude-opus-4.6": "claude-opus-4-6", + // ── Claude 4.5 ────────────────────────────────────────────────────────── + "claude-opus-4.5-thinking": "MODEL_CLAUDE_4_5_OPUS_THINKING", + "claude-opus-4.5": "MODEL_CLAUDE_4_5_OPUS", + "claude-sonnet-4.5-thinking": "MODEL_PRIVATE_3", + "claude-sonnet-4.5": "MODEL_PRIVATE_2", + "claude-haiku-4.5": "MODEL_PRIVATE_11", + // ── GPT-5.5 ───────────────────────────────────────────────────────────── + "gpt-5.5-xhigh-fast": "gpt-5-5-xhigh-priority", + "gpt-5.5-high-fast": "gpt-5-5-high-priority", + "gpt-5.5-medium-fast": "gpt-5-5-medium-priority", + "gpt-5.5-low-fast": "gpt-5-5-low-priority", + "gpt-5.5-none-fast": "gpt-5-5-none-priority", + "gpt-5.5-xhigh": "gpt-5-5-xhigh", + "gpt-5.5-high": "gpt-5-5-high", + "gpt-5.5-medium": "gpt-5-5-medium", + "gpt-5.5-low": "gpt-5-5-low", + "gpt-5.5-none": "gpt-5-5-none", + "gpt-5.5-review": "gpt-5-5-review", + "gpt-5.5": "gpt-5-5-medium", + // ── GPT-5.4 ───────────────────────────────────────────────────────────── + "gpt-5.4-xhigh-fast": "gpt-5-4-xhigh-priority", + "gpt-5.4-high-fast": "gpt-5-4-high-priority", + "gpt-5.4-medium-fast": "gpt-5-4-medium-priority", + "gpt-5.4-low-fast": "gpt-5-4-low-priority", + "gpt-5.4-none-fast": "gpt-5-4-none-priority", + "gpt-5.4-xhigh": "gpt-5-4-xhigh", + "gpt-5.4-high": "gpt-5-4-high", + "gpt-5.4-medium": "gpt-5-4-medium", + "gpt-5.4-low": "gpt-5-4-low", + "gpt-5.4-none": "gpt-5-4-none", + "gpt-5.4-mini-xhigh": "gpt-5-4-mini-xhigh", + "gpt-5.4-mini-high": "gpt-5-4-mini-high", + "gpt-5.4-mini-medium": "gpt-5-4-mini-medium", + "gpt-5.4-mini-low": "gpt-5-4-mini-low", + "gpt-5.4": "gpt-5-4-medium", + // ── GPT-5.3-Codex ─────────────────────────────────────────────────────── + "gpt-5.3-codex-xhigh-fast": "gpt-5-3-codex-xhigh-priority", + "gpt-5.3-codex-high-fast": "gpt-5-3-codex-high-priority", + "gpt-5.3-codex-medium-fast": "gpt-5-3-codex-medium-priority", + "gpt-5.3-codex-low-fast": "gpt-5-3-codex-low-priority", + "gpt-5.3-codex-xhigh": "gpt-5-3-codex-xhigh", + "gpt-5.3-codex-high": "gpt-5-3-codex-high", + "gpt-5.3-codex-medium": "gpt-5-3-codex-medium", + "gpt-5.3-codex-low": "gpt-5-3-codex-low", + "gpt-5.3-codex": "gpt-5-3-codex-medium", + // ── GPT-5.2 ───────────────────────────────────────────────────────────── + "gpt-5.2-xhigh": "MODEL_GPT_5_2_XHIGH", + "gpt-5.2-high": "MODEL_GPT_5_2_HIGH", + "gpt-5.2-medium": "MODEL_GPT_5_2_MEDIUM", + "gpt-5.2-low": "MODEL_GPT_5_2_LOW", + "gpt-5.2-none": "MODEL_GPT_5_2_NONE", + "gpt-5.2": "MODEL_GPT_5_2_MEDIUM", + // ── GPT-5 ─────────────────────────────────────────────────────────────── + "gpt-5": "gpt-5", + // ── GPT-4.1 / 4o ──────────────────────────────────────────────────────── + "gpt-4.1": "MODEL_CHAT_GPT_4_1_2025_04_14", + "gpt-4.1-mini": "gpt-4.1-mini", + "gpt-4o": "MODEL_CHAT_GPT_4O_2024_08_06", + // ── Gemini ────────────────────────────────────────────────────────────── + "gemini-3.1-pro-high": "gemini-3-1-pro-high", + "gemini-3.1-pro-low": "gemini-3-1-pro-low", + "gemini-3.1-pro": "gemini-3-1-pro-high", + "gemini-3.0-flash-high": "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH", + "gemini-3.0-flash-medium": "MODEL_GOOGLE_GEMINI_3_0_FLASH_MEDIUM", + "gemini-3.0-flash-low": "MODEL_GOOGLE_GEMINI_3_0_FLASH_LOW", + "gemini-3.0-flash-minimal": "MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL", + "gemini-3.0-flash": "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH", + "gemini-2.5-pro": "MODEL_GOOGLE_GEMINI_2_5_PRO", + // ── Others ────────────────────────────────────────────────────────────── + "deepseek-v4": "deepseek-v4", + "kimi-k2.6": "kimi-k2-6", + "kimi-k2.5": "kimi-k2-5", + "glm-5.1": "glm-5-1", +}; + +export function resolveWsModelId(model) { + return MODEL_ALIAS_MAP[model] ?? model; +} + +// ─── Minimal protobuf encoder ──────────────────────────────────────────────── +// Wire types: 0 = varint, 2 = length-delimited. + +function encodeVarint(value) { + const bytes = []; + let v = value >>> 0; + while (v > 0x7f) { + bytes.push((v & 0x7f) | 0x80); + v >>>= 7; + } + bytes.push(v & 0x7f); + return new Uint8Array(bytes); +} + +function concatBytes(arrays) { + const total = arrays.reduce((n, a) => n + a.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const a of arrays) { + out.set(a, off); + off += a.length; + } + return out; +} + +const TEXT_ENC = new TextEncoder(); +const TEXT_DEC = new TextDecoder(); + +function encodeField(fieldNum, payload) { + const tag = encodeVarint((fieldNum << 3) | 2); + const len = encodeVarint(payload.length); + return concatBytes([tag, len, payload]); +} + +function encodeString(fieldNum, value) { + return encodeField(fieldNum, TEXT_ENC.encode(value)); +} + +function encodeMessage(fieldNum, msg) { + return encodeField(fieldNum, msg); +} + +// ─── Protobuf message builders ─────────────────────────────────────────────── + +function buildMetadata(apiKey, sessionId) { + return concatBytes([ + encodeString(1, apiKey), + encodeString(2, WS_IDE_NAME), + encodeString(3, WS_IDE_VERSION), + encodeString(4, WS_EXT_VERSION), + encodeString(5, sessionId), + encodeString(6, WS_LOCALE), + ]); +} + +function buildModelOrAlias(model) { + return encodeString(1, model); +} + +function buildChatMessage(msg) { + const parts = [encodeString(1, msg.role), encodeString(2, msg.content)]; + if (msg.toolCallId) parts.push(encodeString(3, msg.toolCallId)); + return concatBytes(parts); +} + +export function buildGetChatMessageRequest(apiKey, model, messages) { + const sessionId = randomUUID(); + const cascadeId = randomUUID(); + + const parts = [ + encodeMessage(1, buildMetadata(apiKey, sessionId)), // metadata + encodeString(2, cascadeId), // cascade_id + encodeMessage(3, buildModelOrAlias(model)), // model_or_alias + ]; + + for (const msg of messages) { + parts.push(encodeMessage(4, buildChatMessage(msg))); // repeated messages + } + + return concatBytes(parts); +} + +// ─── gRPC-web framing ──────────────────────────────────────────────────────── + +export function grpcWebFrame(payload) { + const frame = new Uint8Array(5 + payload.length); + frame[0] = 0x00; // no compression + const view = new DataView(frame.buffer); + view.setUint32(1, payload.length, false); // big-endian length + frame.set(payload, 5); + return frame; +} + +// ─── Protobuf response decoder ─────────────────────────────────────────────── +// CompletionChunk (oneof): +// field 1 → ContentChunk { field 1: string text } +// field 2 → ToolCallChunk (skipped) +// field 3 → DoneChunk { field 1: UsageStats{ field1: prompt, field2: completion } } +// field 4 → ErrorChunk { field 1: string message } + +function readVarint(buf, offset) { + let result = 0; + let shift = 0; + while (offset < buf.length) { + const b = buf[offset++]; + result |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7; + } + return [result >>> 0, offset]; +} + +function decodeStringField(buf, targetField) { + let offset = 0; + while (offset < buf.length) { + let tag; + [tag, offset] = readVarint(buf, offset); + const fieldNum = tag >>> 3; + const wireType = tag & 0x07; + if (wireType === 2) { + let len; + [len, offset] = readVarint(buf, offset); + const payload = buf.slice(offset, offset + len); + offset += len; + if (fieldNum === targetField) return TEXT_DEC.decode(payload); + } else if (wireType === 0) { + let v; + [v, offset] = readVarint(buf, offset); + } else if (wireType === 1) { + offset += 8; + } else if (wireType === 5) { + offset += 4; + } else { + break; + } + } + return null; +} + +function decodeDoneChunk(buf) { + // DoneChunk: field 1 = UsageStats (nested) + // UsageStats: field 1 = prompt_tokens (varint), field 2 = completion_tokens (varint) + let offset = 0; + let usageBytes = null; + while (offset < buf.length) { + let tag; + [tag, offset] = readVarint(buf, offset); + const fieldNum = tag >>> 3; + const wireType = tag & 0x07; + if (wireType === 2) { + let len; + [len, offset] = readVarint(buf, offset); + if (fieldNum === 1) usageBytes = buf.slice(offset, offset + len); + offset += len; + } else if (wireType === 0) { + let v; + [v, offset] = readVarint(buf, offset); + } else { + break; + } + } + if (!usageBytes) return [0, 0]; + let promptTokens = 0; + let completionTokens = 0; + offset = 0; + while (offset < usageBytes.length) { + let tag; + [tag, offset] = readVarint(usageBytes, offset); + const fieldNum = tag >>> 3; + const wireType = tag & 0x07; + if (wireType === 0) { + let v; + [v, offset] = readVarint(usageBytes, offset); + if (fieldNum === 1) promptTokens = v; + else if (fieldNum === 2) completionTokens = v; + } else if (wireType === 2) { + let len; + [len, offset] = readVarint(usageBytes, offset); + offset += len; + } else { + break; + } + } + return [promptTokens, completionTokens]; +} + +export function decodeCompletionChunk(buf) { + let offset = 0; + while (offset < buf.length) { + let tag; + [tag, offset] = readVarint(buf, offset); + const fieldNum = tag >>> 3; + const wireType = tag & 0x07; + + if (wireType === 2) { + let len; + [len, offset] = readVarint(buf, offset); + const payload = buf.slice(offset, offset + len); + offset += len; + + if (fieldNum === 1) { + const text = decodeStringField(payload, 1); + if (text !== null) return { kind: "content", text }; + } else if (fieldNum === 3) { + const usage = decodeDoneChunk(payload); + return { kind: "done", promptTokens: usage[0], completionTokens: usage[1] }; + } else if (fieldNum === 4) { + const msg = decodeStringField(payload, 1); + return { kind: "error", message: msg ?? "unknown windsurf error" }; + } + // field 2 = ToolCallChunk — not yet handled; skip + } else if (wireType === 0) { + let v; + [v, offset] = readVarint(buf, offset); + } else if (wireType === 1) { + offset += 8; + } else if (wireType === 5) { + offset += 4; + } else { + break; + } + } + return { kind: "unknown" }; +} + +// ─── OpenAI messages → Windsurf wire ───────────────────────────────────────── + +function openAIMessagesToWs(messages) { + const out = []; + for (const m of messages) { + const role = String(m.role || "user"); + let content = ""; + if (typeof m.content === "string") { + content = m.content; + } else if (Array.isArray(m.content)) { + for (const part of m.content) { + if (part && typeof part === "object" && part.type === "text") { + content += String(part.text || ""); + } + } + } + out.push({ role, content, toolCallId: m.tool_call_id }); + } + return out; +} + +// ─── WindsurfExecutor ──────────────────────────────────────────────────────── + +export class WindsurfExecutor extends BaseExecutor { + constructor() { + super("windsurf", PROVIDERS.windsurf || { id: "windsurf", baseUrl: WS_CHAT_URL }); + } + + buildUrl() { + return WS_CHAT_URL; + } + + buildHeaders(credentials, stream = true) { + const token = credentials?.accessToken || credentials?.apiKey || ""; + return { + "Content-Type": "application/grpc-web+proto", + Accept: "application/grpc-web+proto", + // Codeium apiKey also goes in Metadata.api_key (protobuf field) — see request body. + ...(token ? { Authorization: `Bearer ${token}` } : {}), + "User-Agent": `windsurf/${WS_IDE_VERSION}`, + "X-Grpc-Web": "1", + }; + } + + // Request body is built manually in execute() — requires model + messages. + transformRequest() { + return null; + } + + async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders, proxyOptions = null }) { + const apiKey = credentials?.accessToken || credentials?.apiKey || ""; + const wsModel = resolveWsModelId(model); + + const b = body ?? {}; + const rawMessages = Array.isArray(b.messages) ? b.messages : []; + let wsMessages = openAIMessagesToWs(rawMessages); + if (wsMessages.length === 0) { + wsMessages.push({ role: "user", content: "" }); + } + + const protoPayload = buildGetChatMessageRequest(apiKey, wsModel, wsMessages); + const framedPayload = grpcWebFrame(protoPayload); + + const url = this.buildUrl(); + const headers = this.buildHeaders(credentials); + if (upstreamExtraHeaders) Object.assign(headers, upstreamExtraHeaders); + + log?.debug?.("WS", `Windsurf → ${wsModel} (${wsMessages.length} messages)`); + + const upstream = await proxyAwareFetch(url, { + method: "POST", + headers, + body: framedPayload, + signal, + }, proxyOptions); + + if (!upstream.ok && upstream.status !== 200) { + return { response: upstream, url, headers, transformedBody: protoPayload }; + } + + const sseResponse = this.transformToSSE(upstream, model); + return { response: sseResponse, url, headers, transformedBody: protoPayload }; + } + + // Convert a gRPC-web binary response into an OpenAI-compatible SSE stream. + transformToSSE(upstream, model) { + const responseId = `chatcmpl-ws-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + const executor = this; + + const sseStream = new ReadableStream({ + async start(controller) { + const enc = new TextEncoder(); + let roleEmitted = false; + let totalText = ""; + let promptTokens = 0; + let completionTokens = 0; + let hadError = null; + + const emit = (data) => controller.enqueue(enc.encode(data)); + + try { + let pending = new Uint8Array(0); + const reader = upstream.body?.getReader(); + + const handleFrame = (flag, payload) => { + if (flag === 0x80) { + // Trailer frame — contains grpc-status, grpc-message + const trailer = TEXT_DEC.decode(payload); + const statusMatch = /grpc-status:\s*(\d+)/i.exec(trailer); + if (statusMatch && statusMatch[1] !== "0") { + const msgMatch = /grpc-message:\s*(.+)/i.exec(trailer); + hadError = msgMatch + ? decodeURIComponent(msgMatch[1].trim()) + : `gRPC status ${statusMatch[1]}`; + } + return; + } + if (flag !== 0x00) return; // skip unknown flags + + const chunk = executor.constructor.decodeCompletionChunk + ? executor.constructor.decodeCompletionChunk(payload) + : decodeCompletionChunk(payload); + + if (chunk.kind === "content" && chunk.text) { + totalText += chunk.text; + if (!roleEmitted) { + emit(`data: ${JSON.stringify({ + id: responseId, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }], + })}\n\n`); + roleEmitted = true; + } + emit(`data: ${JSON.stringify({ + id: responseId, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { content: chunk.text }, finish_reason: null }], + })}\n\n`); + } else if (chunk.kind === "done") { + promptTokens = chunk.promptTokens; + completionTokens = chunk.completionTokens; + } else if (chunk.kind === "error") { + hadError = chunk.message; + } + }; + + const drainFrames = () => { + let offset = 0; + while (offset + 5 <= pending.length) { + const flag = pending[offset]; + const len = + (pending[offset + 1] << 24) | + (pending[offset + 2] << 16) | + (pending[offset + 3] << 8) | + pending[offset + 4]; + if (len < 0 || offset + 5 + len > pending.length) break; + handleFrame(flag, pending.slice(offset + 5, offset + 5 + len)); + offset += 5 + len; + } + if (offset > 0) pending = pending.slice(offset); + }; + + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + pending = pending.length === 0 ? value : concatBytes([pending, value]); + drainFrames(); + } + } finally { + reader.releaseLock(); + } + } + drainFrames(); + + if (hadError) { + emit(`data: ${JSON.stringify({ + error: { message: hadError, type: "windsurf_error", code: "upstream_error" }, + })}\n\n`); + emit("data: [DONE]\n\n"); + controller.close(); + return; + } + + // Unary fallback: nothing streamed but text decoded → emit as one chunk. + if (!roleEmitted && totalText) { + emit(`data: ${JSON.stringify({ + id: responseId, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }], + })}\n\n`); + emit(`data: ${JSON.stringify({ + id: responseId, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { content: totalText }, finish_reason: null }], + })}\n\n`); + } + + const finishPayload = { + id: responseId, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }; + if (promptTokens > 0 || completionTokens > 0) { + finishPayload.usage = { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }; + } + emit(`data: ${JSON.stringify(finishPayload)}\n\n`); + emit("data: [DONE]\n\n"); + } catch (err) { + const msg = err?.message ? String(err.message) : String(err); + emit(`data: ${JSON.stringify({ + error: { message: `Windsurf stream error: ${msg}`, type: "windsurf_error" }, + })}\n\n`); + emit("data: [DONE]\n\n"); + } + + controller.close(); + }, + }); + + return new Response(sseStream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } + + // apiKey is long-lived (Firebase-derived or Devin ide_token); refresh handled out-of-band. + async refreshCredentials() { + return null; + } +} + +export default WindsurfExecutor; diff --git a/open-sse/executors/zed.js b/open-sse/executors/zed.js new file mode 100644 index 00000000..e6233fcb --- /dev/null +++ b/open-sse/executors/zed.js @@ -0,0 +1,304 @@ +// ZedHostedExecutor — routes requests to Zed's hosted LLM aggregator +// (cloud.zed.dev/completions), a multi-format proxy fronting +// Anthropic/OpenAI/Google/xAI depending on the requested model. +// +// Wire protocol: POST /completions with an NDJSON/SSE-ish body-per-line +// response stream (`{"event": }` / `{"status": ...}` / +// `[DONE]`), authenticated with a short-lived LLM bearer token exchanged from +// the RSA-decrypted access_token (see open-sse/shared/zedAuth.js). The +// provider-shaped chunk is Claude/Gemini/OpenAI-Responses/xAI(OpenAI-shaped) +// depending on which upstream Zed fronts for the model — translated back to +// OpenAI Chat Completions by reusing the existing translators. +// +// Overrides execute() entirely (does NOT use DefaultExecutor's pipeline) because the Zed wire +// shape (thread envelope, LLM-token exchange, NDJSON status frames) doesn't +// fit the generic transformRequest/buildUrl contract. + +import { BaseExecutor } from "./base.js"; +import { FORMATS } from "../translator/formats.js"; +import { initState } from "../translator/index.js"; +import { openaiToClaudeRequest } from "../translator/request/openai-to-claude.js"; +import { openaiToGeminiRequest } from "../translator/request/openai-to-gemini.js"; +import { openaiToOpenAIResponsesRequest } from "../translator/request/openai-responses.js"; +import { claudeToOpenAIResponse } from "../translator/response/claude-to-openai.js"; +import { geminiToOpenAIResponse } from "../translator/response/gemini-to-openai.js"; +import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js"; +import { + ZED_HEADERS, + resolveZedModels, + zedLlmFetch, +} from "../shared/zedAuth.js"; + +const ZED_PROVIDER = { + anthropic: "Anthropic", + openai: "OpenAi", + google: "Google", + xai: "XAi", +}; + +function normalizeZedProvider(value, model) { + const raw = String(value || "").toLowerCase(); + if (raw === "anthropic") return ZED_PROVIDER.anthropic; + if (raw === "openai" || raw === "open_ai") return ZED_PROVIDER.openai; + if (raw === "google" || raw === "gemini") return ZED_PROVIDER.google; + if (raw === "xai" || raw === "x_ai" || raw === "x-ai") return ZED_PROVIDER.xai; + + const m = String(model || "").toLowerCase(); + if (m.includes("claude")) return ZED_PROVIDER.anthropic; + if (m.includes("gemini")) return ZED_PROVIDER.google; + if (m.includes("grok") || m.includes("xai")) return ZED_PROVIDER.xai; + return ZED_PROVIDER.openai; +} + +function buildProviderRequest(provider, model, body, stream, credentials) { + if (provider === ZED_PROVIDER.anthropic) { + return openaiToClaudeRequest(model, body, true); + } + if (provider === ZED_PROVIDER.google) { + return openaiToGeminiRequest(model, body, true); + } + if (provider === ZED_PROVIDER.openai) { + return openaiToOpenAIResponsesRequest(model, body, true, credentials); + } + // xAI is OpenAI-shaped — forward as-is. + return { ...(body || {}), model, stream: stream !== false }; +} + +function initProviderState(provider, model) { + if (provider === ZED_PROVIDER.anthropic) return initState(FORMATS.CLAUDE); + if (provider === ZED_PROVIDER.google) return initState(FORMATS.GEMINI); + if (provider === ZED_PROVIDER.openai) return initState(FORMATS.OPENAI_RESPONSES); + const state = initState(FORMATS.OPENAI); + state.model = model; + return state; +} + +function convertProviderEvent(provider, event, state) { + if (provider === ZED_PROVIDER.anthropic) return claudeToOpenAIResponse(event, state); + if (provider === ZED_PROVIDER.google) return geminiToOpenAIResponse(event, state); + if (provider === ZED_PROVIDER.openai) return openaiResponsesToOpenAIResponse(event, state); + return event; +} + +function createErrorChunk(model, message) { + return { + id: `chatcmpl-zed-error-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { index: 0, delta: { content: `[Zed error] ${message}` }, finish_reason: "stop" }, + ], + }; +} + +function enqueueSseObject(controller, encoder, chunk) { + if (!chunk) return; + const items = Array.isArray(chunk) ? chunk : [chunk]; + for (const item of items) { + if (!item) continue; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(item)}\n\n`)); + } +} + +function unwrapZedLine(line) { + let text = line.replace(/\r$/, "").trim(); + if (!text) return null; + if (text.startsWith("data:")) text = text.slice(5).trimStart(); + if (text === "[DONE]") return { done: true }; + try { + const parsed = JSON.parse(text); + if (parsed && Object.prototype.hasOwnProperty.call(parsed, "event")) { + return { event: parsed.event }; + } + if (parsed && Object.prototype.hasOwnProperty.call(parsed, "status")) { + return { status: parsed.status }; + } + return { event: parsed }; + } catch { + return null; + } +} + +function normalizeStatus(status) { + if (!status) return null; + if (typeof status === "string") return { type: status }; + if (typeof status === "object") { + const key = Object.keys(status)[0]; + if (key && typeof status[key] === "object") return { type: key, ...status[key] }; + return status; + } + return null; +} + +function wrapZedCompletionStream(response, provider, model) { + if (!response.ok || !response.body) return response; + + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + const state = initProviderState(provider, model); + let buffer = ""; + let done = false; + + const finish = (controller) => { + if (done) return; + const finalChunk = convertProviderEvent(provider, null, state); + enqueueSseObject(controller, encoder, finalChunk); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + done = true; + }; + + const processLine = (line, controller) => { + if (done) return; + const payload = unwrapZedLine(line); + if (!payload) return; + if (payload.done) { + finish(controller); + return; + } + if (payload.status) { + const status = normalizeStatus(payload.status); + if (status?.type === "failed" || status?.failed) { + const failed = status.failed || status; + const message = String(failed.message || failed.error || failed.code || "request failed"); + enqueueSseObject(controller, encoder, createErrorChunk(model, message)); + finish(controller); + } else if (status?.type === "stream_ended" || status === "stream_ended") { + finish(controller); + } + return; + } + const converted = convertProviderEvent(provider, payload.event, state); + enqueueSseObject(controller, encoder, converted); + }; + + const transformed = response.body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + let nl; + while ((nl = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + processLine(line, controller); + } + }, + flush(controller) { + buffer += decoder.decode(); + if (buffer) { + processLine(buffer, controller); + buffer = ""; + } + finish(controller); + }, + }), + ); + + return new Response(transformed, { + status: response.status, + statusText: response.statusText, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + }, + }); +} + +class ZedExecutor extends BaseExecutor { + constructor() { + super("zed"); + } + + async resolveModel(model, credentials, signal, log) { + try { + const catalog = await resolveZedModels(credentials, { config: this.config, signal }); + let raw = catalog?.rawById?.get(model) ?? null; + if (!raw) { + const refreshed = await resolveZedModels(credentials, { + config: this.config, + signal, + forceRefresh: true, + }); + raw = refreshed?.rawById?.get(model) ?? null; + } + return { raw, provider: normalizeZedProvider(raw?.provider, model) }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log?.warn?.("ZED", `model catalog unavailable, inferring provider for ${model}: ${message}`); + return { raw: null, provider: normalizeZedProvider(null, model) }; + } + } + + async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + const { provider } = await this.resolveModel(model, credentials, signal, log); + const providerRequest = buildProviderRequest(provider, model, body, stream, credentials); + const bodyRecord = body || {}; + const payload = { + thread_id: bodyRecord.thread_id || credentials?._clientSessionId, + prompt_id: bodyRecord.prompt_id, + provider, + model, + provider_request: providerRequest, + }; + + const response = await zedLlmFetch(credentials, "/completions", { + config: this.config, + signal, + fetchOptions: { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/x-ndjson, text/event-stream, */*", + "User-Agent": "9router/zed", + "x-zed-version": this.config?.appVersion?.toString() || "0.200.0", + [ZED_HEADERS.clientSupportsStatus]: "true", + [ZED_HEADERS.clientSupportsStreamEnded]: "true", + }, + body: JSON.stringify(payload), + }, + }); + + const wrapped = response.ok ? wrapZedCompletionStream(response, provider, model) : response; + return { + response: wrapped, + url: `${this.config?.llmBaseUrl || "https://cloud.zed.dev"}/completions`, + headers: { "Content-Type": "application/json", Authorization: "Bearer " }, + transformedBody: payload, + }; + } + + parseError(response, bodyText) { + let parsed = null; + try { + parsed = JSON.parse(bodyText || "{}"); + } catch { + parsed = null; + } + + const errorObj = parsed?.error || undefined; + const code = parsed?.code || errorObj?.code || ""; + const rawMessage = + parsed?.message || errorObj?.message || bodyText || response.statusText; + if (code === "trial_blocked") { + return { + status: response.status, + message: `Zed trial access is blocked upstream. The account can list hosted models, but Zed is refusing completions until trial/billing access is enabled or unblocked. Zed says: ${rawMessage}`, + }; + } + if (code) { + return { status: response.status, message: `Zed ${code}: ${rawMessage}` }; + } + return { status: response.status, message: rawMessage || `Zed upstream error: ${response.status}` }; + } + + async refreshCredentials() { + // Zed uses a long-lived RSA-decrypted access_token — no OAuth refresh. + return null; + } + + needsRefresh() { + return false; + } +} + +export default ZedExecutor; diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 47190acf..4f91e020 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); @@ -326,7 +330,18 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Handle 401/403 - try token refresh (skip for noAuth providers) if (!executor.noAuth && (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN)) { try { - const newCredentials = await refreshWithRetry(() => executor.refreshCredentials(credentials, log), 3, log); + // Mutate credentials after each successful refresh: rotating refresh_token + // providers (xAI/grok-cli) issue a new RT on every refresh; without this, + // refreshWithRetry's 2nd/3rd attempt reuses the already-consumed RT → + // invalid_grant → auth_failed retryable=false. + const newCredentials = await refreshWithRetry(async () => { + const result = await executor.refreshCredentials(credentials, log); + if (result?.refreshToken && result.refreshToken !== credentials.refreshToken) { + if (result.accessToken) credentials.accessToken = result.accessToken; + credentials.refreshToken = result.refreshToken; + } + return result; + }, 3, log); if (newCredentials?.accessToken || newCredentials?.copilotToken) { if (log?.line) log.line(reqTag, "🔑", `TOKEN REFRESHED · ${provider}/${model}`); Object.assign(credentials, newCredentials); @@ -335,7 +350,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 +401,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/handlers/embeddingsCore.js b/open-sse/handlers/embeddingsCore.js index 5a4c92ba..aa81117c 100644 --- a/open-sse/handlers/embeddingsCore.js +++ b/open-sse/handlers/embeddingsCore.js @@ -116,6 +116,7 @@ export async function handleEmbeddingsCore({ return { success: true, + usage: normalized.usage || null, response: new Response(JSON.stringify(normalized), { headers: { "Content-Type": "application/json", diff --git a/open-sse/handlers/fetch/index.js b/open-sse/handlers/fetch/index.js index da1c2303..187bd60e 100644 --- a/open-sse/handlers/fetch/index.js +++ b/open-sse/handlers/fetch/index.js @@ -49,7 +49,10 @@ function truncate(text, max) { } function parseJinaTitle(text) { - const m = String(text || "").match(/^\s*#\s+(.+)$/m); + const source = String(text || ""); + const metadataTitle = source.match(/^\s*Title:\s*(.+)$/mi); + if (metadataTitle) return metadataTitle[1].trim(); + const m = source.match(/^\s*#\s+(.+)$/m); return m ? m[1].trim() : null; } @@ -151,11 +154,14 @@ async function runFirecrawl({ url, fmt, timeoutMs, apiKey, maxCharacters, costPe } async function runJina({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }) { - const target = `https://r.jina.ai/${encodeURIComponent(url)}`; const upstreamStart = Date.now(); - const r = await tryFetch(target, { - method: "GET", - headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {} + const r = await tryFetch("https://r.jina.ai/", { + method: "POST", + headers: { + "content-type": "application/json", + ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}) + }, + body: JSON.stringify({ url }) }, timeoutMs); if (!r.ok) { diff --git a/open-sse/handlers/search/callers.js b/open-sse/handlers/search/callers.js index 64f045c3..e32d93ed 100644 --- a/open-sse/handlers/search/callers.js +++ b/open-sse/handlers/search/callers.js @@ -1,7 +1,6 @@ /** * Search Provider Request Builders * - * Ported from OmniRoute open-sse/handlers/search.ts (lines 223-610). * Builds HTTP request `{ url, init }` for 10 search providers. * * @typedef {Object} SearchProviderConfig diff --git a/open-sse/handlers/search/normalizers.js b/open-sse/handlers/search/normalizers.js index da008bf3..898b271f 100644 --- a/open-sse/handlers/search/normalizers.js +++ b/open-sse/handlers/search/normalizers.js @@ -1,7 +1,6 @@ /** * Search Response Normalizers * - * Ported from OmniRoute open-sse/handlers/search.ts. * Each normalizer maps a provider-specific response into the unified SearchResult shape. */ diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 6e54a683..526ceb9b 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -71,7 +71,11 @@ export function capabilitiesFromServiceKind(kind) { * otherwise mis-match. Only declare deltas vs DEFAULT. */ export const MODEL_CAPABILITIES = { - // Claude 4.6/4.7/4.8 and Kiro Sonnet 5 have 1M context + adaptive thinking (override generic claude pattern) + // Claude Opus 5, 4.6/4.7/4.8, and Kiro Sonnet 5 have 1M context + adaptive thinking (override generic claude pattern) + "claude-opus-5": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, + "claude-opus-5-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, + "claude-opus-5-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, + "claude-opus-5-thinking-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-opus-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-opus-4.7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-opus-4-7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, @@ -96,10 +100,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 +130,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, @@ -149,6 +174,11 @@ export const PROVIDER_CAPABILITIES = { "deepseek-v4-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 50000 }, "deepseek-v3-2-volc": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 96000, maxOutput: 32000 }, }, + // Poolside Laguna — OpenAI-compatible, all reasoning-capable (32K max output). + "poolside": { + "laguna-s-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 }, + "laguna-xs-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 }, + }, }; /** @@ -159,6 +189,7 @@ export const PROVIDER_CAPABILITIES = { */ export const PATTERN_CAPABILITIES = [ // ── Claude (4.6+ = adaptive thinking; older/haiku = budget) ────── + { pattern: "*claude*opus-5*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 } }, { pattern: "*claude*opus-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, { pattern: "*claude*opus-4.7*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, { pattern: "*claude*opus-4.8*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, @@ -222,7 +253,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 } }, @@ -268,6 +301,13 @@ export const PATTERN_CAPABILITIES = [ { pattern: "*pplx*", caps: { search: true, contextWindow: 128000 } }, { pattern: "*perplexity*", caps: { search: true, contextWindow: 128000 } }, + // ── Poolside Laguna (resellers: openrouter/nvidia/kilocode/vercel/...) ── + // Free tiers cap S 2.1 well below the paid 1M window → match the free suffix + // (":free" or "-free", depending on reseller) before the plain id. + { pattern: "*laguna-s-2.1*free*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } }, + { pattern: "*laguna-s-2.1*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 } }, + { pattern: "*laguna*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } }, + // ── Others ─────────────────────────────────────────────────────── { pattern: "*hunyuan*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } }, { pattern: "hy3*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } }, diff --git a/open-sse/providers/pricing.js b/open-sse/providers/pricing.js index 943365a1..9a0768ef 100644 --- a/open-sse/providers/pricing.js +++ b/open-sse/providers/pricing.js @@ -57,7 +57,13 @@ export const MODEL_PRICING = { "o1-mini": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 }, // === Gemini === - "gemini-3-flash-preview": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 }, + "gemini-3.6-flash": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.6-flash-high": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.6-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.6-flash-low": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.5-flash-lite": { input: 0.30, output: 2.50, cached: 0.03, reasoning: 3.75, cache_creation: 0.375 }, + "gemini-3.5-flash-high": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 }, + "gemini-3-flash-preview": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 }, "gemini-3-pro-preview": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 }, "gemini-3.1-pro-low": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 }, "gemini-3.1-pro-high": { input: 4.00, output: 18.00, cached: 0.50, reasoning: 27.00, cache_creation: 4.00 }, @@ -75,10 +81,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 +199,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/antigravity.js b/open-sse/providers/registry/antigravity.js index dd7fbc02..a4ca7346 100644 --- a/open-sse/providers/registry/antigravity.js +++ b/open-sse/providers/registry/antigravity.js @@ -36,6 +36,7 @@ export default { }, }, usage: { + // Discovery (quota/project) on PROD; daily host rejects these. quotaApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", loadProjectApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", tokenUrl: "https://oauth2.googleapis.com/token", @@ -44,6 +45,10 @@ export default { clientSecret: "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", }, models: [ + { id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", upstreamModelId: "gemini-3.6-flash-tiered(high)" }, + { id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", upstreamModelId: "gemini-3.6-flash-tiered(medium)" }, + { id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)", upstreamModelId: "gemini-3.6-flash-tiered(low)" }, + { id: "gemini-3.5-flash-high", name: "Gemini 3.5 Flash (High)" }, { id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)" }, { id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium)" }, { id: "gemini-3.5-flash-extra-low", name: "Gemini 3.5 Flash (Low)" }, @@ -67,7 +72,7 @@ export default { "https://www.googleapis.com/auth/cclog", "https://www.googleapis.com/auth/experimentsandconfigs", ], - apiEndpoint: "https://cloudcode-pa.googleapis.com", + apiEndpoint: "https://daily-cloudcode-pa.googleapis.com", apiVersion: "v1internal", loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", diff --git a/open-sse/providers/registry/api-airforce.js b/open-sse/providers/registry/api-airforce.js new file mode 100644 index 00000000..16ed8b3e --- /dev/null +++ b/open-sse/providers/registry/api-airforce.js @@ -0,0 +1,36 @@ +export default { + id: "api-airforce", + alias: "af", + aliases: [ + "airforce", + ], + uiAlias: "af", + display: { + name: "API.airforce", + icon: "flight", + color: "#0EA5E9", + textIcon: "AF", + website: "https://api.airforce", + notice: { + apiKeyUrl: "https://api.airforce", + }, + }, + category: "freeTier", + authType: "apikey", + authModes: [ + "apikey", + ], + transport: { + baseUrl: "https://api.airforce/v1/chat/completions", + validateUrl: "https://api.airforce/v1/models", + headers: { + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy", + }, + }, + models: [ + { id: "anthropic/claude-3.7-sonnet", name: "Claude 3.7 Sonnet (Free)", contextLength: 200000 }, + { id: "moonshot/kimi-k2.6", name: "Kimi K2.6 (Free)", contextLength: 262144 }, + { id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash (Free)", contextLength: 1048576 }, + ], +}; diff --git a/open-sse/providers/registry/baidu.js b/open-sse/providers/registry/baidu.js new file mode 100644 index 00000000..62541e7d --- /dev/null +++ b/open-sse/providers/registry/baidu.js @@ -0,0 +1,33 @@ +export default { + id: "baidu", + alias: "qianfan", + aliases: ["qianfan", "ernie", "baidu-qianfan"], + uiAlias: "qianfan", + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + display: { + name: "Baidu Qianfan", + icon: "search", + color: "#2932E1", + textIcon: "BD", + website: "https://cloud.baidu.com/product/qianfan.html", + notice: { + apiKeyUrl: + "https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application", + }, + }, + transport: { + baseUrl: "https://qianfan.baidubce.com/v2/chat/completions", + validateUrl: "https://qianfan.baidubce.com/v2/models", + }, + models: [ + { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", contextLength: 1048576 }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", contextLength: 1048576 }, + { id: "glm-5.2", name: "GLM 5.2", contextLength: 512000 }, + { id: "glm-5.1", name: "GLM 5.1", contextLength: 198000 }, + { id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 262144 }, + { id: "qwen3.5-397b-a17b", name: "Qwen 3.5 397B A17B", contextLength: 262144 }, + { id: "qwen3.5-27b", name: "Qwen 3.5 27B", contextLength: 262144 }, + ], +}; diff --git a/open-sse/providers/registry/bazaarlink.js b/open-sse/providers/registry/bazaarlink.js new file mode 100644 index 00000000..44d4dac8 --- /dev/null +++ b/open-sse/providers/registry/bazaarlink.js @@ -0,0 +1,47 @@ +export default { + id: "bazaarlink", + alias: "bzl", + aliases: ["bazaar-link"], + uiAlias: "bzl", + category: "freeTier", + authType: "apikey", + authModes: ["apikey"], + display: { + name: "Bazaarlink", + icon: "storefront", + color: "#DC2626", + textIcon: "BZ", + website: "https://bazaarlink.ai", + notice: { apiKeyUrl: "https://bazaarlink.ai" }, + }, + transport: { + baseUrl: "https://bazaarlink.ai/api/v1/chat/completions", + validateUrl: "https://bazaarlink.ai/api/v1/models", + }, + models: [ + { id: "auto:free", name: "Auto Free (Zero Cost)" }, + { id: "claude-opus-4.7", name: "Claude Opus 4.7", contextLength: 1000000 }, + { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", contextLength: 1000000 }, + { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", contextLength: 200000 }, + { id: "gpt-5.5", name: "GPT-5.5", contextLength: 1050000 }, + { id: "gpt-5.4", name: "GPT-5.4", contextLength: 1050000 }, + { id: "gpt-5.4-mini", name: "GPT-5.4 Mini", contextLength: 400000 }, + { id: "gpt-5.4-nano", name: "GPT-5.4 Nano", contextLength: 400000 }, + { id: "grok-4.3", name: "Grok 4.3", contextLength: 1000000 }, + { id: "grok-4.20", name: "Grok 4.20", contextLength: 2000000 }, + { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro", contextLength: 1048576 }, + { id: "gemini-3-flash-preview", name: "Gemini 3 Flash", contextLength: 1048576 }, + { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite", contextLength: 1048576 }, + { id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 262144 }, + { id: "kimi-k2.5", name: "Kimi K2.5", contextLength: 262144 }, + { id: "glm-5.1", name: "GLM 5.1", contextLength: 204800 }, + { id: "glm-5", name: "GLM 5", contextLength: 204800 }, + { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", contextLength: 1050000 }, + { id: "mimo-v2.5", name: "MiMo-V2.5", contextLength: 1050000 }, + { id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576 }, + { id: "minimax-m2.7", name: "MiniMax M2.7", contextLength: 204800 }, + { id: "minimax-m2.5", name: "MiniMax M2.5", contextLength: 204800 }, + { id: "qwen3.6-plus", name: "Qwen 3.6 Plus", contextLength: 1000000 }, + { id: "nemotron-3-super-120b-a12b", name: "Nemotron 3 Super", contextLength: 1000000 }, + ], +}; diff --git a/open-sse/providers/registry/bluesminds.js b/open-sse/providers/registry/bluesminds.js new file mode 100644 index 00000000..34577caf --- /dev/null +++ b/open-sse/providers/registry/bluesminds.js @@ -0,0 +1,38 @@ +export default { + id: "bluesminds", + alias: "bm", + aliases: ["blue-sminds"], + uiAlias: "bm", + hidden: true, + display: { + name: "BluesMinds", + icon: "psychology", + color: "#2563EB", + textIcon: "BM", + website: "https://bluesminds.com", + notice: { apiKeyUrl: "https://bluesminds.com" }, + }, + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://api.bluesminds.com/v1/chat/completions", + validateUrl: "https://api.bluesminds.com/v1/models", + }, + models: [ + { id: "gpt-4.1", name: "GPT-4.1", contextLength: 1048576 }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", contextLength: 1048576 }, + { id: "gpt-4.1-nano", name: "GPT-4.1 Nano", contextLength: 1048576 }, + { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", contextLength: 200000 }, + { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", contextLength: 200000 }, + { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", contextLength: 1048576 }, + { id: "gemini-2.0-flash-exp", name: "Gemini 2.0 Flash (Exp)", contextLength: 1048576 }, + { id: "qwen-turbo", name: "Qwen Turbo", contextLength: 1000000 }, + { id: "kimi-k2", name: "Kimi K2", contextLength: 262144 }, + { id: "kimi-k2-thinking", name: "Kimi K2 Thinking", contextLength: 262144 }, + { id: "glm-4.7", name: "GLM 4.7", contextLength: 204800 }, + { id: "minimax-m2.5", name: "MiniMax M2.5", contextLength: 204800 }, + { id: "claude-opus-4-5", name: "Claude Opus 4.5 (VIP)", contextLength: 200000 }, + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (VIP)", contextLength: 1048576 }, + ], +}; diff --git a/open-sse/providers/registry/claude.js b/open-sse/providers/registry/claude.js index a2912701..8b030e5a 100644 --- a/open-sse/providers/registry/claude.js +++ b/open-sse/providers/registry/claude.js @@ -60,10 +60,9 @@ export default { }, }, models: [ + { id: "claude-opus-5", name: "Claude Opus 5" }, { id: "claude-fable-5", name: "Claude Fable 5" }, { id: "claude-sonnet-5", name: "Claude Sonnet 5" }, - { id: "claude-opus-4-8", name: "Claude Opus 4.8" }, - { id: "claude-opus-4-7", name: "Claude Opus 4.7" }, { id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" }, ], oauth: { diff --git a/open-sse/providers/registry/codebuddy-intl.js b/open-sse/providers/registry/codebuddy-intl.js new file mode 100644 index 00000000..7e69836c --- /dev/null +++ b/open-sse/providers/registry/codebuddy-intl.js @@ -0,0 +1,73 @@ +// CodeBuddy international (codebuddy.ai) — mirrors codebuddy-cn registry shape, +// swapping the Tencent CN domain for the .ai endpoint set. All OAuth/plugin URLs +// use the /v2/plugin prefix with platform=ide (CN uses platform=CLI). +export default { + id: "codebuddy-intl", + alias: "cbai", + uiAlias: "cbai", + hidden: false, + priority: 90, + display: { + name: "CodeBuddy", + icon: "smart_toy", + color: "#006EFF", + website: "https://www.codebuddy.ai", + notice: { + signupUrl: "https://www.codebuddy.ai", + }, + }, + category: "oauth", + authModes: ["oauth", "apikey"], + hasOAuth: true, + transport: { + // Chat gateway is OpenAI-compatible SSE (same /v2/chat/completions path as CN). + baseUrl: "https://www.codebuddy.ai/v2/chat/completions", + forceStream: true, + // CodeBuddy intl speaks the same unified OpenAI reasoning_effort shape as CN. + thinkingFormat: "openai", + headers: { + "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1", + "X-Product": "SaaS", + "X-IDE-Type": "IDE", + "X-IDE-Name": "IDE", + "x-requested-with": "XMLHttpRequest", + "x-codebuddy-request": "1", + }, + auth: { + combined: true, + header: "Authorization", + scheme: "bearer", + }, + }, + // Same model lineup exposed by the CN gateway — intl backend is the same catalog. + models: [ + { id: "glm-5.2", name: "GLM-5.2" }, + { id: "glm-5.1", name: "GLM-5.1" }, + { id: "glm-5.0", name: "GLM-5.0" }, + { id: "glm-5.0-turbo", name: "GLM-5.0-Turbo" }, + { id: "glm-5v-turbo", name: "GLM-5v-Turbo" }, + { id: "glm-4.7", name: "GLM-4.7" }, + { id: "minimax-m3", name: "MiniMax-M3" }, + { id: "minimax-m2.7", name: "MiniMax-M2.7" }, + { id: "kimi-k2.7", name: "Kimi-K2.7-Code" }, + { id: "kimi-k2.6", name: "Kimi-K2.6" }, + { id: "kimi-k2.5", name: "Kimi-K2.5" }, + { id: "hy3-preview", name: "Hy3 Preview" }, + { id: "deepseek-v4-pro", name: "DeepSeek-V4-Pro" }, + { id: "deepseek-v4-flash", name: "DeepSeek-V4-Flash" }, + { id: "deepseek-v3-2-volc", name: "DeepSeek-V3.2" }, + ], + oauth: { + baseUrl: "https://www.codebuddy.ai", + stateUrl: "https://www.codebuddy.ai/v2/plugin/auth/state", + tokenUrl: "https://www.codebuddy.ai/v2/plugin/auth/token", + refreshUrl: "https://www.codebuddy.ai/v2/plugin/auth/token/refresh", + userAgent: "IDE/2.63.2 CodeBuddy/2.63.2", + platform: "ide", + pollInterval: 5000, + }, + features: { + usage: true, + usageApikey: true, + }, +}; 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/deepseek.js b/open-sse/providers/registry/deepseek.js index 5c167f7a..86123b28 100644 --- a/open-sse/providers/registry/deepseek.js +++ b/open-sse/providers/registry/deepseek.js @@ -48,4 +48,8 @@ export default { { id: "deepseek-chat", name: "DeepSeek V3.2 Chat" }, { id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" }, ], + features: { + usage: true, + usageApikey: true, + }, }; diff --git a/open-sse/providers/registry/devin-cli.js b/open-sse/providers/registry/devin-cli.js new file mode 100644 index 00000000..09391649 --- /dev/null +++ b/open-sse/providers/registry/devin-cli.js @@ -0,0 +1,63 @@ +export default { + id: "devin-cli", + alias: "dv", + aliases: ["devin"], + uiAlias: "dv", + hidden: true, + display: { + name: "Devin CLI", + icon: "smart_toy", + color: "#6366F1", + textIcon: "DV", + website: "https://devin.ai", + notice: { + signupUrl: "https://cli.devin.ai", + text: "Install: `curl -fsSL https://cli.devin.ai/install.sh | bash` (macOS: `brew install --cask devin-cli`, Windows PowerShell: `irm https://static.devin.ai/cli/setup.ps1 | iex`). Then run `devin auth login`. No API key needed.", + }, + }, + category: "free", + authType: "none", + noAuth: true, + authModes: ["none"], + transport: { + baseUrl: "devin://acp/stdio", + format: "openai", + }, + models: [ + { id: "swe-1.6-fast", name: "SWE-1.6 Fast" }, + { id: "swe-1.6", name: "SWE-1.6" }, + { id: "swe-1.5-fast", name: "SWE-1.5 Fast" }, + { id: "swe-1.5", name: "SWE-1.5" }, + { id: "claude-opus-4.7-max", name: "Claude Opus 4.7 Max", contextLength: 200000 }, + { id: "claude-opus-4.7-high", name: "Claude Opus 4.7 High", contextLength: 200000 }, + { id: "claude-opus-4.7-medium", name: "Claude Opus 4.7 Medium", contextLength: 200000 }, + { id: "claude-opus-4.7-low", name: "Claude Opus 4.7 Low", contextLength: 200000 }, + { id: "claude-sonnet-4.6-thinking-1m", name: "Claude Sonnet 4.6 Thinking 1M", contextLength: 1000000 }, + { id: "claude-sonnet-4.6-thinking", name: "Claude Sonnet 4.6 Thinking", contextLength: 200000 }, + { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", contextLength: 200000 }, + { id: "claude-opus-4.6-thinking", name: "Claude Opus 4.6 Thinking", contextLength: 200000 }, + { id: "claude-opus-4.6", name: "Claude Opus 4.6", contextLength: 200000 }, + { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", contextLength: 200000 }, + { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", contextLength: 200000 }, + { id: "gpt-5.5-xhigh", name: "GPT-5.5 XHigh", contextLength: 200000 }, + { id: "gpt-5.5-high", name: "GPT-5.5 High", contextLength: 200000 }, + { id: "gpt-5.5-medium", name: "GPT-5.5 Medium", contextLength: 200000 }, + { id: "gpt-5.5-low", name: "GPT-5.5 Low", contextLength: 200000 }, + { id: "gpt-5.4-high", name: "GPT-5.4 High", contextLength: 200000 }, + { id: "gpt-5.4-medium", name: "GPT-5.4 Medium", contextLength: 200000 }, + { id: "gpt-5.4-low", name: "GPT-5.4 Low", contextLength: 200000 }, + { id: "gpt-5.3-codex-high", name: "GPT-5.3 Codex High", contextLength: 200000 }, + { id: "gpt-5.3-codex-medium", name: "GPT-5.3 Codex Medium", contextLength: 200000 }, + { id: "gpt-5.3-codex-low", name: "GPT-5.3 Codex Low", contextLength: 200000 }, + { id: "gpt-5.2-high", name: "GPT-5.2 High", contextLength: 200000 }, + { id: "gpt-5.2-medium", name: "GPT-5.2 Medium", contextLength: 200000 }, + { id: "gpt-5.2-low", name: "GPT-5.2 Low", contextLength: 200000 }, + { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High", contextLength: 1000000 }, + { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low", contextLength: 1000000 }, + { id: "gemini-3.0-flash-high", name: "Gemini 3 Flash High", contextLength: 1000000 }, + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", contextLength: 1000000 }, + { id: "deepseek-v4", name: "DeepSeek V4", contextLength: 1048576 }, + { id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 262144 }, + { id: "glm-5.1", name: "GLM-5.1", contextLength: 204800 }, + ], +}; diff --git a/open-sse/providers/registry/gemini.js b/open-sse/providers/registry/gemini.js index 5c811042..c9b0de9b 100644 --- a/open-sse/providers/registry/gemini.js +++ b/open-sse/providers/registry/gemini.js @@ -16,6 +16,8 @@ export default { }, }, category: "freeTier", + authType: "apikey", + authModes: ["apikey"], mediaPriority: 1, transport: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/models", @@ -34,6 +36,8 @@ export default { }, }, models: [ + { id: "gemini-3.6-flash", name: "Gemini 3.6 Flash" }, + { id: "gemini-3.5-flash-lite", name: "Gemini 3.5 Flash Lite" }, { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" }, { id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" }, diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index e4cf5439..7bfdd0d7 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -52,53 +52,70 @@ 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 "./codebuddy-intl.js"; +// Temporarily hidden — no tool calling support (trae SOLO agent / windsurf gRPC skip ToolCallChunk). +// Re-enable by uncommenting both the import and the array entry below. +// import p102 from "./trae.js"; +import p103 from "./zed.js"; +import p105 from "./api-airforce.js"; +import p106 from "./baidu.js"; +import p107 from "./bazaarlink.js"; +import p108 from "./bluesminds.js"; +import p109 from "./kilo-gateway.js"; +import p110 from "./llm7.js"; +import p111 from "./sambanova.js"; +import p112 from "./tencent.js"; +import p113 from "./morph.js"; +// import p114 from "./devin-cli.js"; +// import p104 from "./windsurf.js"; +import p115 from "./poolside.js"; export default [ p0, @@ -200,5 +217,20 @@ export default [ p96, p97, p98, - p99 + p99, + p100, + // p102, // trae — hidden, no tool calling + p103, + p105, + p106, + p107, + p108, + p109, + p110, + p111, + p112, + p113, + // p114, // devin-cli — hidden, spawns local agent with shell/fs access + // p104, // windsurf — hidden, no tool calling + p115, ]; diff --git a/open-sse/providers/registry/kilo-gateway.js b/open-sse/providers/registry/kilo-gateway.js new file mode 100644 index 00000000..7e11cddc --- /dev/null +++ b/open-sse/providers/registry/kilo-gateway.js @@ -0,0 +1,34 @@ +export default { + id: "kilo-gateway", + alias: "kgw", + aliases: [ + "kilo-gateway", + "kilogateway", + ], + uiAlias: "kgw", + category: "freeTier", + display: { + name: "Kilo Gateway", + icon: "login", + color: "#8B5CF6", + textIcon: "KG", + website: "https://kilo.ai", + notice: { + apiKeyUrl: "https://kilo.ai/dashboard?tab=apiKeys", + }, + }, + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://api.kilo.ai/api/gateway/chat/completions", + validateUrl: "https://api.kilo.ai/api/gateway/models", + }, + models: [ + { id: "kilo-auto/free", name: "Kilo Auto Free", contextLength: 256000 }, + { id: "nvidia/nemotron-3-super-120b-a12b:free", name: "Nemotron 3 Super 120B (Free)", contextLength: 262144 }, + { id: "nvidia/nemotron-3-ultra-550b-a55b:free", name: "Nemotron 3 Ultra 550B (Free)", contextLength: 1000000 }, + { id: "kwaipilot/kat-coder-pro-v2.5:free", name: "Kat Coder Pro v2.5 (Free)", contextLength: 256000 }, + { id: "kilo-auto/frontier", name: "Kilo Auto Frontier", contextLength: 1000000 }, + { id: "kilo-auto/balanced", name: "Kilo Auto Balanced", contextLength: 1000000 }, + ], +}; diff --git a/open-sse/providers/registry/kimchi.js b/open-sse/providers/registry/kimchi.js index 99facd53..c2ec22b1 100644 --- a/open-sse/providers/registry/kimchi.js +++ b/open-sse/providers/registry/kimchi.js @@ -13,7 +13,7 @@ export default { signupUrl: "https://app.kimchi.dev", }, }, - category: "oauth", + category: "freeTier", authModes: ["oauth"], hasOAuth: true, transport: { 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..299b824f 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,50 @@ 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, + // API-key connections also hit /v1/usages (x-api-key) — need usageApikey + // so isUsageEligible + /api/usage allow non-oauth authType. + usageApikey: true, }, }; diff --git a/open-sse/providers/registry/kiro.js b/open-sse/providers/registry/kiro.js index 1a47adaa..f506b8d4 100644 --- a/open-sse/providers/registry/kiro.js +++ b/open-sse/providers/registry/kiro.js @@ -29,7 +29,6 @@ export default { headers: { "Content-Type": "application/json", Accept: "application/vnd.amazon.eventstream", - "X-Amz-Target": "AmazonCodeWhispererStreamingService.GenerateAssistantResponse", "User-Agent": "AWS-SDK-JS/3.0.0 kiro-ide/1.0.0", "X-Amz-User-Agent": "aws-sdk-js/3.0.0 kiro-ide/1.0.0", }, @@ -43,6 +42,10 @@ export default { }, models: [ // Opus (added per kiro.dev/changelog/models and kiro.dev/docs/models) + { id: "claude-opus-5", name: "Claude Opus 5" }, + { id: "claude-opus-5-thinking", name: "Claude Opus 5 (Thinking)" }, + { id: "claude-opus-5-agentic", name: "Claude Opus 5 (Agentic)" }, + { id: "claude-opus-5-thinking-agentic", name: "Claude Opus 5 (Thinking + Agentic)" }, { id: "claude-opus-4.8", name: "Claude Opus 4.8" }, { id: "claude-opus-4.8-thinking", name: "Claude Opus 4.8 (Thinking)" }, { id: "claude-opus-4.8-agentic", name: "Claude Opus 4.8 (Agentic)" }, diff --git a/open-sse/providers/registry/llm7.js b/open-sse/providers/registry/llm7.js new file mode 100644 index 00000000..6f4210a7 --- /dev/null +++ b/open-sse/providers/registry/llm7.js @@ -0,0 +1,35 @@ +export default { + id: "llm7", + alias: "llm7", + aliases: [ + "llm-7", + ], + uiAlias: "llm7", + display: { + name: "LLM7", + icon: "pool", + color: "#7C3AED", + textIcon: "L7", + website: "https://llm7.io", + notice: { + apiKeyUrl: "https://llm7.io", + }, + }, + category: "apikey", + authType: "apikey", + authModes: [ + "apikey", + ], + transport: { + baseUrl: "https://api.llm7.io/v1/chat/completions", + validateUrl: "https://api.llm7.io/v1/models", + }, + models: [ + { id: "gpt-5.5", name: "GPT-5.5 (LLM7)", contextLength: 1050000 }, + { id: "claude-opus-5", name: "Claude Opus 5 (LLM7)", contextLength: 1000000 }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash (LLM7)", contextLength: 1000000 }, + { id: "grok-4.5", name: "Grok 4.5 (LLM7)", contextLength: 500000 }, + { id: "kimi-k3", name: "Kimi K3 (LLM7)", contextLength: 1000000 }, + ], + passthroughModels: true, +}; diff --git a/open-sse/providers/registry/mimo-free.js b/open-sse/providers/registry/mimo-free.js index 4b9074d1..84911120 100644 --- a/open-sse/providers/registry/mimo-free.js +++ b/open-sse/providers/registry/mimo-free.js @@ -1,5 +1,8 @@ +// Xiaomi ended the free MiMo channel ("MiMo free API service has ended"). +// Hidden until/unless a replacement (OAuth MiMo Platform) is wired. export default { id: "mimo-free", + hidden: true, priority: 50, hasFree: true, alias: "mmf", diff --git a/open-sse/providers/registry/morph.js b/open-sse/providers/registry/morph.js new file mode 100644 index 00000000..e88a3639 --- /dev/null +++ b/open-sse/providers/registry/morph.js @@ -0,0 +1,29 @@ +export default { + id: "morph", + alias: "morph", + aliases: ["morphllm"], + uiAlias: "morph", + display: { + name: "Morph", + icon: "change_history", + color: "#14B8A6", + textIcon: "MP", + website: "https://morphllm.com", + notice: { apiKeyUrl: "https://morphllm.com" }, + }, + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://api.morphllm.com/v1/chat/completions", + validateUrl: "https://api.morphllm.com/v1/models", + }, + models: [ + { id: "morph-v3-large", name: "Morph v3 Large" }, + { id: "morph-v3-fast", name: "Morph v3 Fast" }, + { id: "morph-qwen35-397b", name: "Qwen 3.5 397B (Morph)", contextLength: 262144 }, + { id: "morph-minimax27-230b", name: "MiniMax M2.7 (Morph)", contextLength: 200704 }, + { id: "morph-qwen36-27b", name: "Qwen 3.6 27B (Morph)", contextLength: 262144 }, + { id: "morph-dsv4flash", name: "DeepSeek V4 Flash (Morph)", contextLength: 1048576 }, + ], +}; diff --git a/open-sse/providers/registry/nvidia.js b/open-sse/providers/registry/nvidia.js index 9522611a..4d375a17 100644 --- a/open-sse/providers/registry/nvidia.js +++ b/open-sse/providers/registry/nvidia.js @@ -15,6 +15,8 @@ export default { }, }, category: "freeTier", + authType: "apikey", + authModes: ["apikey"], transport: { baseUrl: "https://integrate.api.nvidia.com/v1/chat/completions", validateUrl: "https://integrate.api.nvidia.com/v1/models", diff --git a/open-sse/providers/registry/openrouter.js b/open-sse/providers/registry/openrouter.js index 4ac03641..a0df2a52 100644 --- a/open-sse/providers/registry/openrouter.js +++ b/open-sse/providers/registry/openrouter.js @@ -15,6 +15,8 @@ export default { }, }, category: "freeTier", + authType: "apikey", + authModes: ["apikey"], transport: { baseUrl: "https://openrouter.ai/api/v1/chat/completions", thinkingFormat: "openai", diff --git a/open-sse/providers/registry/poolside.js b/open-sse/providers/registry/poolside.js new file mode 100644 index 00000000..02882ad9 --- /dev/null +++ b/open-sse/providers/registry/poolside.js @@ -0,0 +1,30 @@ +export default { + id: "poolside", + priority: 60, + alias: "poolside", + aliases: [ + "ps", + ], + uiAlias: "ps", + display: { + name: "Poolside", + icon: "water_drop", + color: "#0EA5E9", + textIcon: "PS", + website: "https://poolside.ai", + notice: { + apiKeyUrl: "https://platform.poolside.ai/api-keys", + }, + }, + category: "freeTier", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://inference.poolside.ai/v1/chat/completions", + validateUrl: "https://inference.poolside.ai/v1/models", + }, + models: [ + { id: "poolside/laguna-s-2.1", name: "Laguna S 2.1" }, + { id: "poolside/laguna-xs-2.1", name: "Laguna XS 2.1" }, + ], +}; diff --git a/open-sse/providers/registry/qoder.js b/open-sse/providers/registry/qoder.js index 4ee2b52f..2b6c93ed 100644 --- a/open-sse/providers/registry/qoder.js +++ b/open-sse/providers/registry/qoder.js @@ -11,10 +11,11 @@ export default { notice: { signupUrl: "https://qoder.com", }, - deprecated: true, - deprecationNotice: "RISK_NOTICE", }, - category: "free", + category: "oauth", + authModes: ["oauth", "apikey"], + hasOAuth: true, + authHint: "Personal Access Token (pt-...) từ https://qoder.com/account/integrations", transport: { baseUrl: "https://api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation", headers: {}, @@ -25,18 +26,19 @@ export default { }, }, models: [ - // { id: "auto", name: "Qoder Auto" }, - // { id: "ultimate", name: "Qoder Ultimate" }, - // { id: "performance", name: "Qoder Performance" }, - // { id: "efficient", name: "Qoder Efficient" }, - // { id: "lite", name: "Qoder Lite" }, - // { id: "qmodel", name: "Qwen 3.6 Plus (Qoder)" }, - { id: "qmodel_latest", name: "Qoder Qwen 3.7 Max" }, - // { id: "dmodel", name: "DeepSeek V4 Pro (Qoder)" }, - // { id: "dfmodel", name: "DeepSeek V4 Flash (Qoder)" }, - // { id: "gm51model", name: "GLM 5.1 (Qoder)" }, - // { id: "kmodel", name: "Kimi K2.6 (Qoder)" }, - // { id: "mmodel", name: "MiniMax M2.7 (Qoder)" }, + { id: "ultimate", name: "Ultimate" }, + { id: "auto", name: "Auto" }, + { id: "performance", name: "Performance" }, + { id: "efficient", name: "Efficient" }, + { id: "qmodel_preview", name: "Qwen3.8-Max-Preview" }, + { id: "qmodel_latest", name: "Qwen3.7-Max" }, + { id: "qmodel", name: "Qwen3.7-Plus" }, + { id: "kmodel_latest", name: "Kimi-K3" }, + { id: "kmodel", name: "Kimi-K2.7-Code" }, + { id: "gm51model", name: "GLM-5.2" }, + { id: "dmodel", name: "DeepSeek-V4-Pro" }, + { id: "dfmodel", name: "DeepSeek-V4-Flash" }, + { id: "mmodel", name: "MiniMax-M3" }, ], oauth: { openApiBaseUrl: "https://openapi.qoder.sh", diff --git a/open-sse/providers/registry/sambanova.js b/open-sse/providers/registry/sambanova.js new file mode 100644 index 00000000..9c7e7174 --- /dev/null +++ b/open-sse/providers/registry/sambanova.js @@ -0,0 +1,27 @@ +export default { + id: "sambanova", + alias: "samba", + aliases: ["sambanova-ai"], + uiAlias: "samba", + hidden: true, + display: { + name: "SambaNova", + icon: "memory", + color: "#F97316", + textIcon: "SN", + website: "https://sambanova.ai", + notice: { + apiKeyUrl: "https://cloud.sambanova.ai/apis", + }, + }, + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://api.sambanova.ai/v1/chat/completions", + validateUrl: "https://api.sambanova.ai/v1/models", + }, + models: [ + { id: "MiniMax-M2.7", name: "MiniMax M2.7", contextLength: 196608 }, + ], +}; diff --git a/open-sse/providers/registry/tencent.js b/open-sse/providers/registry/tencent.js new file mode 100644 index 00000000..45876b67 --- /dev/null +++ b/open-sse/providers/registry/tencent.js @@ -0,0 +1,27 @@ +export default { + id: "tencent", + alias: "hunyuan", + aliases: ["hunyuan", "tencent-hunyuan"], + uiAlias: "hunyuan", + display: { + name: "Tencent Hunyuan", + icon: "cloud", + color: "#0052D9", + textIcon: "HY", + website: "https://cloud.tencent.com/product/hunyuan", + notice: { + apiKeyUrl: "https://console.cloud.tencent.com/hunyuan/api-key", + }, + }, + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://api.hunyuan.cloud.tencent.com/v1/chat/completions", + validateUrl: "https://api.hunyuan.cloud.tencent.com/v1/models", + }, + models: [ + { id: "hunyuan-turbos-latest", name: "Hunyuan TurboS Latest", contextLength: 200000 }, + { id: "hunyuan-t1-latest", name: "Hunyuan T1 Latest", contextLength: 256000 }, + ], +}; diff --git a/open-sse/providers/registry/trae.js b/open-sse/providers/registry/trae.js new file mode 100644 index 00000000..2b4ace60 --- /dev/null +++ b/open-sse/providers/registry/trae.js @@ -0,0 +1,76 @@ +// Trae (ByteDance marscode) provider registry entry. +// Chat = SOLO remote agent API: +// POST {base}/chat_sessions → {data:{chat_session_id, message_id}} +// GET {base}/chat_sessions/{id}/events?reply_to_message_id=... → SSE +// Auth: Authorization: Cloud-IDE-JWT +export default { + id: "trae", + alias: "tr", + uiAlias: "tr", + aliases: ["marscode"], + category: "oauth", + authType: "oauth", + hasOAuth: true, + authModes: ["oauth"], + display: { + name: "Trae", + icon: "bolt", + color: "#FF6A00", + textIcon: "TR", + website: "https://www.trae.ai", + notice: { signupUrl: "https://www.trae.ai" }, + }, + transport: { + // SOLO remote agent base — verified working chat endpoint. + baseUrl: "https://core-normal.trae.ai/api/remote/v1", + format: "openai", + headers: { + "X-Trae-Client-Type": "web", + "X-Preferenced-Language": "en", + "Referer": "https://solo.trae.ai/", + }, + // Auth: Cloud-IDE-JWT scheme on Authorization — injected by executor buildHeaders. + auth: { + combined: true, + header: "Authorization", + scheme: "Cloud-IDE-JWT", + }, + usage: { + url: "https://api.marscode.com/cloudide/api/v3/trae/GetUserInfo", + }, + regions: { + cn: "https://api.marscode.com", + sg: "https://api.trae.ai", + us: "https://www.trae.ai", + }, + defaultRegion: "cn", + }, + oauth: { + clientId: "ono9krqynydwx5", + clientSecret: "-", + platform: "trae", + pollInterval: 1500, + // Login guidance returns LoginHost for browser open. + loginGuidanceUrl: "https://api.marscode.com/cloudide/api/v3/trae/GetLoginGuidance", + // ExchangeToken: refresh -> access (POST JSON, body below). + tokenUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken", + exchangeTokenUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken", + refreshUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken", + userInfoUrl: "https://api.marscode.com/cloudide/api/v3/trae/GetUserInfo", + // Trae refresh uses custom JSON body, not OAuth form — handled by refresh.js, not config-driven. + refresh: { encoding: "json" }, + }, + // Model catalog (IDE flow, core-normal.trae.ai). + models: [ + { id: "auto", name: "Auto (Server Picks)" }, + { id: "work", name: "Work (Fast)" }, + { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, + { id: "gemini-3-flash-solo", name: "Gemini 3 Flash" }, + { id: "minimax-m3", name: "MiniMax M3" }, + { id: "minimax-m2.7", name: "MiniMax M2.7" }, + { id: "kimi-k2.5", name: "Kimi K2.5" }, + { id: "gpt-5.4", name: "GPT 5.4" }, + { id: "gpt-5.2", name: "GPT 5.2" }, + ], + features: { usage: true }, +}; diff --git a/open-sse/providers/registry/windsurf.js b/open-sse/providers/registry/windsurf.js new file mode 100644 index 00000000..f0b23fa8 --- /dev/null +++ b/open-sse/providers/registry/windsurf.js @@ -0,0 +1,143 @@ +// Windsurf provider registry — Firebase+Codeium+Devin auth chain. +// Chat = Codeium gRPC-web protobuf: +// POST {base} Content-Type: application/grpc-web+proto +// Service: exa.language_server_pb.LanguageServerService / GetChatMessage +export default { + id: "windsurf", + alias: "ws", + uiAlias: "ws", + display: { + name: "Windsurf", + icon: "surfing", + color: "#14B8A6", + website: "https://windsurf.com", + notice: { signupUrl: "https://windsurf.com" }, + }, + category: "oauth", + authType: "oauth", + hasOAuth: true, + authModes: ["oauth", "apikey"], + + transport: { + baseUrl: "https://server.codeium.com/exa.language_server_pb.LanguageServerService/GetChatMessage", + format: "openai", + headers: { + "Content-Type": "application/grpc-web+proto", + "Accept": "application/grpc-web+proto", + "X-Grpc-Web": "1", + }, + // apiKey (sk-ws-... or Firebase-derived) as Bearer + in protobuf Metadata.api_key. + auth: { combined: true, header: "Authorization", scheme: "Bearer" }, + }, + + // Auth chain (4 terminal paths, all yield apiKey): + // 1) OAuth web → Firebase JWT → POST register.windsurf.com/.../RegisterUser {firebase_id_token} → {apiKey, apiServerUrl, name} + // 2) sk-ws-... direct API key (apiKey used as metadata.apiKey on GetUserStatus) + // 3) Firebase JWT (eyJ...) → same RegisterUser exchange as #1 + // 4) Devin auth1_... → self-serve chain → ide_token used as apiKey on server.self-serve.windsurf.com + oauth: { + clientId: "3GUryQ7ldAeKEuD2obYnppsnmj58eP5u", + firebaseApiKey: "AIzaSyDsOl-1XpT5err0Tcn0TFFod1H8gVGIycY", + firebaseSignInUrl: "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword", + registerUrl: "https://register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser", + apiServerUrl: "https://server.codeium.com", + auth1ApiServerUrl: "https://server.self-serve.windsurf.com", + platform: "windsurf", + // Quota (Connect RPC, protobuf): POST windsurf.com/_backend/.../GetPlanStatus, + // headers Content-Type:application/proto + Connect-Protocol-Version:1 + X-Auth-Token:, + // body = field1:session_token, field2:varint 1. + quotaUrl: "https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/GetPlanStatus", + }, + + // Catalog verified against model_configs_v2.bin from Devin CLI (2026.5.x). + // Dot-notation ids; the executor MODEL_ALIAS_MAP maps these to Windsurf modelUid. + // contextLength dropped — 9router schema uses id+name only. + models: [ + // Cognition / SWE + { id: "swe-1.6-fast", name: "SWE-1.6 Fast" }, + { id: "swe-1.6", name: "SWE-1.6" }, + { id: "swe-1.5-fast", name: "SWE-1.5 Fast" }, + { id: "swe-1.5", name: "SWE-1.5" }, + // Claude Opus 4.7 — effort-tiered + { id: "claude-opus-4.7-max", name: "Claude Opus 4.7 Max" }, + { id: "claude-opus-4.7-xhigh", name: "Claude Opus 4.7 XHigh" }, + { id: "claude-opus-4.7-high", name: "Claude Opus 4.7 High" }, + { id: "claude-opus-4.7-medium", name: "Claude Opus 4.7 Medium" }, + { id: "claude-opus-4.7-low", name: "Claude Opus 4.7 Low" }, + { id: "claude-opus-4.7-review", name: "Claude Opus 4.7 Review" }, + // Claude Sonnet/Opus 4.6 + { id: "claude-sonnet-4.6-thinking-1m", name: "Claude Sonnet 4.6 Thinking 1M" }, + { id: "claude-sonnet-4.6-1m", name: "Claude Sonnet 4.6 1M" }, + { id: "claude-sonnet-4.6-thinking", name: "Claude Sonnet 4.6 Thinking" }, + { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, + { id: "claude-opus-4.6-thinking", name: "Claude Opus 4.6 Thinking" }, + { id: "claude-opus-4.6", name: "Claude Opus 4.6" }, + // Claude 4.5 + { id: "claude-opus-4.5-thinking", name: "Claude Opus 4.5 Thinking" }, + { id: "claude-opus-4.5", name: "Claude Opus 4.5" }, + { id: "claude-sonnet-4.5-thinking", name: "Claude Sonnet 4.5 Thinking" }, + { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, + { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, + // GPT-5.5 — effort-tiered + { id: "gpt-5.5-xhigh-fast", name: "GPT-5.5 XHigh Fast" }, + { id: "gpt-5.5-xhigh", name: "GPT-5.5 XHigh" }, + { id: "gpt-5.5-high-fast", name: "GPT-5.5 High Fast" }, + { id: "gpt-5.5-high", name: "GPT-5.5 High" }, + { id: "gpt-5.5-medium-fast", name: "GPT-5.5 Medium Fast" }, + { id: "gpt-5.5-medium", name: "GPT-5.5 Medium" }, + { id: "gpt-5.5-low-fast", name: "GPT-5.5 Low Fast" }, + { id: "gpt-5.5-low", name: "GPT-5.5 Low" }, + { id: "gpt-5.5-none-fast", name: "GPT-5.5 None Fast" }, + { id: "gpt-5.5-none", name: "GPT-5.5 None" }, + // GPT-5.4 — effort-tiered + { id: "gpt-5.4-xhigh-fast", name: "GPT-5.4 XHigh Fast" }, + { id: "gpt-5.4-xhigh", name: "GPT-5.4 XHigh" }, + { id: "gpt-5.4-high-fast", name: "GPT-5.4 High Fast" }, + { id: "gpt-5.4-high", name: "GPT-5.4 High" }, + { id: "gpt-5.4-medium-fast", name: "GPT-5.4 Medium Fast" }, + { id: "gpt-5.4-medium", name: "GPT-5.4 Medium" }, + { id: "gpt-5.4-low-fast", name: "GPT-5.4 Low Fast" }, + { id: "gpt-5.4-low", name: "GPT-5.4 Low" }, + { id: "gpt-5.4-none-fast", name: "GPT-5.4 None Fast" }, + { id: "gpt-5.4-none", name: "GPT-5.4 None" }, + { id: "gpt-5.4-mini-xhigh", name: "GPT-5.4 Mini XHigh" }, + { id: "gpt-5.4-mini-high", name: "GPT-5.4 Mini High" }, + { id: "gpt-5.4-mini-medium", name: "GPT-5.4 Mini Medium" }, + { id: "gpt-5.4-mini-low", name: "GPT-5.4 Mini Low" }, + // GPT-5.3 Codex + { id: "gpt-5.3-codex-xhigh-fast", name: "GPT-5.3 Codex XHigh Fast" }, + { id: "gpt-5.3-codex-xhigh", name: "GPT-5.3 Codex XHigh" }, + { id: "gpt-5.3-codex-high-fast", name: "GPT-5.3 Codex High Fast" }, + { id: "gpt-5.3-codex-high", name: "GPT-5.3 Codex High" }, + { id: "gpt-5.3-codex-medium-fast", name: "GPT-5.3 Codex Medium Fast" }, + { id: "gpt-5.3-codex-medium", name: "GPT-5.3 Codex Medium" }, + { id: "gpt-5.3-codex-low-fast", name: "GPT-5.3 Codex Low Fast" }, + { id: "gpt-5.3-codex-low", name: "GPT-5.3 Codex Low" }, + // GPT-5.2 / 5 + { id: "gpt-5.2-xhigh", name: "GPT-5.2 XHigh" }, + { id: "gpt-5.2-high", name: "GPT-5.2 High" }, + { id: "gpt-5.2-medium", name: "GPT-5.2 Medium" }, + { id: "gpt-5.2-low", name: "GPT-5.2 Low" }, + { id: "gpt-5.2-none", name: "GPT-5.2 None" }, + { id: "gpt-5", name: "GPT-5" }, + // GPT-4.1 / 4o + { id: "gpt-4.1", name: "GPT-4.1" }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini" }, + { id: "gpt-4.1-nano", name: "GPT-4.1 Nano" }, + { id: "gpt-4o", name: "GPT-4o" }, + { id: "gpt-4o-mini", name: "GPT-4o Mini" }, + // Gemini + { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High" }, + { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low" }, + { id: "gemini-3.0-flash-high", name: "Gemini 3 Flash High" }, + { id: "gemini-3.0-flash-medium", name: "Gemini 3 Flash Medium" }, + { id: "gemini-3.0-flash-low", name: "Gemini 3 Flash Low" }, + { id: "gemini-3.0-flash-minimal", name: "Gemini 3 Flash Minimal" }, + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" }, + // Others + { id: "deepseek-v4", name: "DeepSeek V4" }, + { id: "kimi-k2.6", name: "Kimi K2.6" }, + { id: "kimi-k2.5", name: "Kimi K2.5" }, + { id: "glm-5.1", name: "GLM-5.1" }, + ], +}; diff --git a/open-sse/providers/registry/zed.js b/open-sse/providers/registry/zed.js new file mode 100644 index 00000000..9224cf95 --- /dev/null +++ b/open-sse/providers/registry/zed.js @@ -0,0 +1,71 @@ +// Zed provider — RSA keypair callback auth (NOT standard OAuth). +export default { + id: "zed", + priority: 10, + alias: "zd", + uiAlias: "zd", + hidden: true, + display: { + name: "Zed", + icon: "code", + color: "#A855F7", + website: "https://zed.dev", + notice: { + signupUrl: "https://zed.dev/native_app_signin", + }, + }, + category: "oauth", + authType: "oauth", + hasOAuth: true, + + transport: { + // Zed hosted LLM aggregator: cloud.zed.dev/completions is a + // multi-format proxy fronting Anthropic/OpenAI/Google/xAI depending on the model. + // Wire protocol = NDJSON/SSE-ish stream authenticated with a short-lived LLM bearer + // token exchanged from the RSA-decrypted access_token (see open-sse/shared/zedAuth). + baseUrl: "https://cloud.zed.dev/completions", + format: "openai", + forceStream: true, + headers: { + "content-type": "application/json", + }, + // Auth scheme is non-standard: "Authorization: " plus a duplicate + // x-zed-cloud-token header (verified in zed_account.rs build_authorization_header + + // cloud fetch). Executor builds both; scheme here is a marker for config-driven tooling. + auth: { + combined: true, + header: "Authorization", + scheme: " ", // placeholder — real value built in executor + }, + usage: { + url: "https://cloud.zed.dev/client/users/me", // verified in zed_account.rs + }, + // Live catalog discovery — Zed's hosted model list changes frequently and is fetched + // per-connection rather than hardcoded. + modelsUrl: "https://cloud.zed.dev/models", + }, + + // Empty static catalog + passthrough: Zed fronts a rotating set of upstream models + // (Claude/GPT/Gemini/Grok). Resolved live via modelsUrl; any client-sent model id is + // forwarded as-is rather than validated against a frozen list. + models: [], + passthroughModels: true, + + oauth: { + // Zed auth flow is RSA-based, NOT OAuth2/PKCE: + // 1. App generates RSA-2048 keypair locally (PKCS#1 DER, URL-safe base64). + // 2. Bind random TCP port on 127.0.0.1. + // 3. Open https://zed.dev/native_app_signin?native_app_port={port}&native_app_public_key={pub}. + // 4. After login, browser redirects http://127.0.0.1:{port}/?user_id=...&access_token=... + // where access_token = base64(RSA-encrypted plaintext token). + // 5. Decrypt with private key (OAEP-SHA256, fallback PKCS1v15). Store user_id + plaintext token. + // No clientId/clientSecret/tokenUrl/refreshUrl — long-lived access_token, no refresh. + authorizeUrl: "https://zed.dev/native_app_signin", + platform: "zed", + rsaKeyExchange: true, // new flag: signals frontend/router this flow needs local RSA + TCP listener. + }, + + features: { + usage: true, + }, +}; diff --git a/open-sse/providers/shared.js b/open-sse/providers/shared.js index 6f6a2c20..85b256a1 100644 --- a/open-sse/providers/shared.js +++ b/open-sse/providers/shared.js @@ -58,7 +58,7 @@ export const ANTHROPIC_COMPAT_BASE = "https://api.anthropic.com/v1"; // Keep this static even when 9router runs on Linux: the provider profile is // intentionally matching the IDE client, not the server host. export const ANTIGRAVITY_IDE_VERSION = "2.1.1"; -export const ANTIGRAVITY_IDE_BASE_URL = "https://cloudcode-pa.googleapis.com"; +export const ANTIGRAVITY_IDE_BASE_URL = "https://daily-cloudcode-pa.googleapis.com"; export const ANTIGRAVITY_IDE_USER_AGENT = `antigravity/ide/${ANTIGRAVITY_IDE_VERSION} darwin/arm64`; // Antigravity OAuth client credentials (public CLI client — duplicated in usage.js + src/lib/oauth) diff --git a/open-sse/providers/thinkingLevels.js b/open-sse/providers/thinkingLevels.js index ba998ee7..7b638700 100644 --- a/open-sse/providers/thinkingLevels.js +++ b/open-sse/providers/thinkingLevels.js @@ -2,6 +2,7 @@ // Reuses capabilities.js (thinkingFormat/canDisable) so this file only maps format→levels (DRY). import { getCapabilitiesForModel } from "./capabilities.js"; import { matchPattern } from "./pricing.js"; +import { resolveKiroEffortPath } from "../config/kiroConstants.js"; // Shared level sets (deduped) — verified against provider docs + wire in thinkingUnified.applyFormat. const L = { @@ -39,6 +40,7 @@ const PATTERN_THINKING = [ // Returns valid thinking levels for a model, or null when the model has no reasoning. export function getThinkingLevels(provider, model) { + if (provider === "kiro" && resolveKiroEffortPath(model) === null) return null; const caps = getCapabilitiesForModel(provider, model); if (!caps.reasoning) return null; const hit = PATTERN_THINKING.find((p) => matchPattern(p.pattern, model)); 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/projectId.js b/open-sse/services/projectId.js index f9a24e1a..3801ac69 100644 --- a/open-sse/services/projectId.js +++ b/open-sse/services/projectId.js @@ -83,7 +83,7 @@ startCacheCleanup(); * @param {string} accessToken - Valid OAuth access token * @returns {Promise} Real project ID or null */ -export async function getProjectIdForConnection(connectionId, accessToken) { +export async function getProjectIdForConnection(connectionId, accessToken, provider = "gemini-cli") { if (!connectionId || !accessToken) return null; // Return cached value if still fresh @@ -102,7 +102,7 @@ export async function getProjectIdForConnection(connectionId, accessToken) { const promise = (async () => { try { - const projectId = await fetchProjectId(accessToken, controller.signal); + const projectId = await fetchProjectId(accessToken, controller.signal, provider); if (projectId) { projectIdCache.set(connectionId, {projectId, fetchedAt: Date.now()}); return projectId; @@ -155,8 +155,9 @@ export function removeConnection(connectionId) { * @param {AbortSignal} signal * @returns {Promise} */ -async function fetchProjectId(accessToken, signal) { - const response = await fetch(CLOUD_CODE_API.loadCodeAssist, { +async function fetchProjectId(accessToken, signal, provider) { + const endpoints = CLOUD_CODE_API[provider] || CLOUD_CODE_API["gemini-cli"]; + const response = await fetch(endpoints.loadCodeAssist, { method: "POST", headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` }, body: JSON.stringify({ metadata: LOAD_CODE_ASSIST_METADATA }), @@ -185,7 +186,7 @@ async function fetchProjectId(accessToken, signal) { } } - return onboardUser(accessToken, tierID, signal); + return onboardUser(accessToken, tierID, signal, endpoints); } /** @@ -196,7 +197,7 @@ async function fetchProjectId(accessToken, signal) { * @param {AbortSignal} externalSignal – propagated from the connection's AbortController * @returns {Promise} */ -async function onboardUser(accessToken, tierID, externalSignal) { +async function onboardUser(accessToken, tierID, externalSignal, endpoints) { console.log(`[ProjectId] Onboarding user with tier: ${tierID}`); const reqBody = { tierId: tierID, metadata: LOAD_CODE_ASSIST_METADATA }; @@ -213,7 +214,7 @@ async function onboardUser(accessToken, tierID, externalSignal) { externalSignal?.addEventListener("abort", forwardAbort); try { - const response = await fetch(CLOUD_CODE_API.onboardUser, { + const response = await fetch(endpoints.onboardUser, { method: "POST", headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` }, body: JSON.stringify(reqBody), diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index c8d264c6..634fd633 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, @@ -12,12 +13,17 @@ import { refreshGitHubToken, refreshCopilotToken, refreshCodebuddyToken, + refreshCodebuddyIntlToken, + refreshTraeToken, + refreshZedToken, + refreshWindsurfToken, classifyOAuthRefreshError, } from "./tokenRefresh/providers.js"; // Re-export all provider refresh functions (preserves public API for all consumers) export { refreshAccessToken, + refreshKimiToken, refreshClaudeOAuthToken, refreshGoogleToken, refreshQwenToken, @@ -27,6 +33,10 @@ export { refreshGitHubToken, refreshCopilotToken, refreshCodebuddyToken, + refreshCodebuddyIntlToken, + refreshTraeToken, + refreshZedToken, + refreshWindsurfToken, classifyOAuthRefreshError, }; @@ -44,7 +54,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 +146,13 @@ 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), + "codebuddy-intl": (c, log) => refreshCodebuddyIntlToken(c.refreshToken, log), + trae: (c, log) => refreshTraeToken(c.refreshToken, c, log), + zed: () => refreshZedToken(), + windsurf: (c, log) => refreshWindsurfToken(c, 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..7c13ae77 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"; @@ -31,10 +31,68 @@ export async function refreshXaiToken(refreshToken, log) { }, log); } +// Per-provider refresh variants for the generic path. Keys not listed fall back +// to the default form-encoded OAuth2 refresh with client_id + client_secret. +const REFRESH_PROFILES = { + claude: { + bodyFormat: "json", + includeClientSecret: false, + url: () => OAUTH_ENDPOINTS.anthropic.token, + dedupKey: "claude", + }, + qwen: { + url: () => OAUTH_ENDPOINTS.qwen.token, + dedupKey: "qwen", + parse: (tokens) => tokens.resource_url ? { providerSpecificData: { resourceUrl: tokens.resource_url } } : {}, + }, + iflow: { + url: () => OAUTH_ENDPOINTS.iflow.token, + dedupKey: "iflow", + extraHeaders: (creds, cfg) => ({ + Authorization: `Basic ${btoa(`${cfg.clientId}:${cfg.clientSecret}`)}`, + }), + }, + github: { + url: () => OAUTH_ENDPOINTS.github.token, + dedupKey: "github", + includeClientSecret: (cfg) => !!cfg?.clientSecret, + }, + kimi: { + dedupKey: "kimi", + extraHeaders: (creds) => buildKimiHeaders(creds?.providerSpecificData?.deviceId), + }, +}; + +function resolveRefreshUrl(provider, config, profile) { + if (profile?.url) { + try { return profile.url(); } catch { /* fall through */ } + } + return config?.refreshUrl || PROVIDER_OAUTH[provider]?.tokenUrl || null; +} + +function buildRefreshBody(profile, config, refreshToken) { + const fmt = profile?.bodyFormat === "json" ? "json" : "form"; + const includeSecret = profile?.includeClientSecret === undefined + ? true + : typeof profile.includeClientSecret === "function" + ? profile.includeClientSecret(config) + : profile.includeClientSecret; + const payload = { + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: config.clientId, + }; + if (includeSecret && config.clientSecret) payload.client_secret = config.clientSecret; + if (fmt === "json") return { format: "json", body: JSON.stringify(payload) }; + return { format: "form", body: new URLSearchParams(payload) }; +} + export async function refreshAccessToken(provider, refreshToken, credentials, log) { const config = PROVIDERS[provider]; + const profile = REFRESH_PROFILES[provider] || {}; + const url = resolveRefreshUrl(provider, config, profile); - if (!config || !config.refreshUrl) { + if (!config || !url) { log?.warn?.("TOKEN_REFRESH", `No refresh URL configured for provider: ${provider}`); return null; } @@ -44,21 +102,17 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo return null; } - return dedupRefresh(provider, refreshToken, async () => { + const dedupKey = profile.dedupKey || provider; + + return dedupRefresh(dedupKey, refreshToken, async () => { try { - const response = await fetch(config.refreshUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: config.clientId, - client_secret: config.clientSecret, - }), - }); + const { format: bodyFormat, body } = buildRefreshBody(profile, config, refreshToken); + const headers = { + "Content-Type": bodyFormat === "json" ? "application/json" : "application/x-www-form-urlencoded", + Accept: "application/json", + ...(profile.extraHeaders ? (profile.extraHeaders(credentials, config) || {}) : {}), + }; + const response = await fetch(url, { method: "POST", headers, body }); if (!response.ok) { const errorText = await response.text(); @@ -81,6 +135,7 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in, + ...(profile.parse ? (profile.parse(tokens) || {}) : {}), }; } catch (error) { log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, { @@ -91,37 +146,15 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo }, log); } +// CLIProxyAPI DeviceFlowClient.RefreshToken: form body (no client_secret) + X-Msh-* headers +// Delegate to refreshAccessToken("kimi", ...) — profile carries the X-Msh headers. +export async function refreshKimiToken(refreshToken, credentials, log) { + return refreshAccessToken("kimi", refreshToken, credentials, log); +} + +// Claude OAuth: JSON body, client_id only. Delegate to refreshAccessToken("claude", ...). export async function refreshClaudeOAuthToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("claude", refreshToken, async () => { - try { - const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.claude.clientId, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", { status: response.status, error: errorText }); - return null; - } - - const tokens = await response.json(); - log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in }); - return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`); - return null; - } - }, log); + return refreshAccessToken("claude", refreshToken, {}, log); } export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) { @@ -158,58 +191,9 @@ export async function refreshGoogleToken(refreshToken, clientId, clientSecret, l }, log); } +// Qwen: form body + clientId, surfaces resource_url. Delegate to refreshAccessToken("qwen", ...). export async function refreshQwenToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("qwen", refreshToken, async () => { - const endpoint = OAUTH_ENDPOINTS.qwen.token; - - try { - const response = await fetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.qwen.clientId, - }), - }); - - if (response.status === 200) { - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qwen token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - providerSpecificData: tokens.resource_url - ? { resourceUrl: tokens.resource_url } - : undefined, - }; - } else { - const errorText = await response.text().catch(() => ""); - log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, { - status: response.status, - error: errorText, - }); - } - } catch (error) { - log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, { - error: error.message, - }); - } - - log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token"); - return null; - }, log); + return refreshAccessToken("qwen", refreshToken, {}, log); } export function classifyOAuthRefreshError(errorText = "", status = 0) { @@ -434,95 +418,14 @@ export async function refreshKiroToken(refreshToken, providerSpecificData, log, }, log); } +// iFlow: Basic Auth + client_id+client_secret in body. Delegate to refreshAccessToken("iflow", ...). export async function refreshIflowToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("iflow", refreshToken, async () => { - const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`); - - const response = await fetch(OAUTH_ENDPOINTS.iflow.token, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - Authorization: `Basic ${basicAuth}`, - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.iflow.clientId, - client_secret: PROVIDERS.iflow.clientSecret, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh iFlow token", { - status: response.status, - error: errorText, - }); - return null; - } - - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed iFlow token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - }; - }, log); + return refreshAccessToken("iflow", refreshToken, {}, log); } +// GitHub: optional client_secret. Delegate to refreshAccessToken("github", ...). export async function refreshGitHubToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("github", refreshToken, async () => { - const params = { - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.github.clientId, - }; - if (PROVIDERS.github.clientSecret) { - params.client_secret = PROVIDERS.github.clientSecret; - } - - const response = await fetch(OAUTH_ENDPOINTS.github.token, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams(params), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh GitHub token", { - status: response.status, - error: errorText, - }); - return null; - } - - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitHub token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - }; - }, log); + return refreshAccessToken("github", refreshToken, {}, log); } export async function refreshCopilotToken(githubAccessToken, log) { @@ -622,3 +525,146 @@ export async function refreshCodebuddyToken(refreshToken, log) { }; }, log); } + +export async function refreshCodebuddyIntlToken(refreshToken, log) { + if (!refreshToken) return null; + return dedupRefresh("codebuddy-intl", refreshToken, async () => { + const oauth = PROVIDER_OAUTH["codebuddy-intl"] || {}; + const response = await fetch(oauth.refreshUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": oauth.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "www.codebuddy.ai", + "X-Refresh-Token": refreshToken, + "X-Auth-Refresh-Source": "plugin", + "X-Product": "SaaS", + }, + body: "{}", + }); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh CodeBuddy intl token", { + status: response.status, + error: errorText, + }); + return null; + } + + const data = await response.json(); + if (data.code !== 0 || !data.data?.accessToken) { + log?.error?.("TOKEN_REFRESH", "CodeBuddy intl token refresh returned no token", { + code: data.code, + msg: data.msg, + }); + return null; + } + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed CodeBuddy intl token", { + hasNewAccessToken: !!data.data.accessToken, + hasNewRefreshToken: !!data.data.refreshToken, + expiresIn: data.data.expiresIn, + }); + + return { + accessToken: data.data.accessToken, + refreshToken: data.data.refreshToken || refreshToken, + expiresIn: data.data.expiresIn, + }; + }, log); +} + +// Trae refresh — POST ExchangeToken with JSON body {ClientID, RefreshToken, ClientSecret, UserID}. +// Response: {Result: {AccessToken, RefreshToken, TokenType, ExpiresAt}}. +export async function refreshTraeToken(refreshToken, credentials, log) { + if (!refreshToken) return null; + const oauth = PROVIDER_OAUTH.trae || {}; + const url = oauth.exchangeTokenUrl || oauth.tokenUrl; + if (!url) { + log?.warn?.("TOKEN_REFRESH", "No Trae exchangeTokenUrl configured"); + return null; + } + + return dedupRefresh("trae", refreshToken, async () => { + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": "Trae/1.0.0 antigravity-cockpit-tools", + }, + body: JSON.stringify({ + ClientID: oauth.clientId || "ono9krqynydwx5", + RefreshToken: refreshToken, + ClientSecret: oauth.clientSecret || "-", + UserID: "", + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh Trae token", { + status: response.status, + error: errorText, + }); + return null; + } + + const payload = await response.json(); + const result = payload?.Result || payload?.result || payload; + const accessToken = result?.AccessToken || result?.accessToken; + if (!accessToken) { + log?.error?.("TOKEN_REFRESH", "Trae refresh returned no AccessToken", { payload }); + return null; + } + + const newRefresh = result?.RefreshToken || result?.refreshToken || refreshToken; + const expiresAt = result?.ExpiresAt || result?.expiresAt; + let expiresIn; + if (typeof expiresAt === "number") { + expiresIn = Math.max(1, expiresAt - Math.floor(Date.now() / 1000)); + } else if (typeof expiresAt === "string") { + const ms = new Date(expiresAt).getTime() - Date.now(); + expiresIn = ms > 0 ? Math.floor(ms / 1000) : undefined; + } + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Trae token", { + hasNewAccessToken: !!accessToken, + hasNewRefreshToken: newRefresh !== refreshToken, + expiresIn, + }); + + return { + accessToken, + refreshToken: newRefresh, + expiresIn, + }; + } catch (error) { + log?.error?.("TOKEN_REFRESH", `Error refreshing Trae token: ${error.message}`); + return null; + } + }, log); +} + +// Zed access_token is long-lived; auth flow returns no refresh_token. +// No refresh possible — re-login required when token expires/revoked. +// Mirrors cursor/kilocode null-refresh pattern. +export function refreshZedToken() { + return null; +} + +// Windsurf apiKey is the long-lived terminal credential (no OAuth2 refresh_token +// grant yields a fresh apiKey). Refresh handled out-of-band by the caller. +// TODO(firebase): if short-lived Firebase JWT credentials must be refreshed, +// re-run RegisterUser with the refreshed Firebase JWT (separate code path). +export async function refreshWindsurfToken(credentials, log) { + log?.info?.( + "TOKEN_REFRESH", + "windsurf: apiKey is long-lived (no refresh_token flow) — skipping" + ); + return null; +} diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 5331adb3..544d2584 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -13,6 +13,8 @@ import { getMiniMaxUsage } from "./usage/minimax.js"; import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.js"; import { getXaiUsage } from "./usage/xai.js"; import { getGrokCliUsage } from "./usage/grok-cli.js"; +import { getKimiUsage } from "./usage/kimi.js"; +import { getDeepseekUsage } from "./usage/deepseek.js"; import { getQwenUsage, getIflowUsage, @@ -47,6 +49,8 @@ const USAGE_HANDLERS = { "codebuddy-cn": (c) => getCodeBuddyCnUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions), xai: (c) => getXaiUsage(c.accessToken, c.proxyOptions), "grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), + kimi: (c) => getKimiUsage(c.accessToken, c.apiKey, c.proxyOptions, c.providerSpecificData), + deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions), }; export async function getUsageForProvider(connection, proxyOptions = null) { diff --git a/open-sse/services/usage/deepseek.js b/open-sse/services/usage/deepseek.js new file mode 100644 index 00000000..cb70a40d --- /dev/null +++ b/open-sse/services/usage/deepseek.js @@ -0,0 +1,112 @@ +/** + * DeepSeek usage — GET https://api.deepseek.com/user/balance + * Auth: Bearer + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { toFiniteNumber } from "./shared.js"; + +const BALANCE_URL = "https://api.deepseek.com/user/balance"; + +function parseBalanceInfos(data) { + const list = Array.isArray(data?.balance_infos) ? data.balance_infos : []; + const results = []; + for (const item of list) { + if (!item || typeof item !== "object") continue; + const currency = + typeof item.currency === "string" ? item.currency.toUpperCase() : ""; + if (!currency) continue; + const totalBalance = toFiniteNumber( + item.total_balance ?? item.totalBalance, + 0, + ); + results.push({ + currency, + totalBalance, + grantedBalance: toFiniteNumber( + item.granted_balance ?? item.grantedBalance, + 0, + ), + toppedUpBalance: toFiniteNumber( + item.topped_up_balance ?? item.toppedUpBalance, + 0, + ), + }); + } + return results; +} + +/** + * @param {string|null|undefined} apiKey + * @param {object|null} proxyOptions + */ +export async function getDeepseekUsage(apiKey = null, proxyOptions = null) { + if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) { + return { message: "DeepSeek API key not available. Add a key to view usage." }; + } + + try { + const response = await proxyAwareFetch( + BALANCE_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey.trim()}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + proxyOptions, + ); + + if (response.status === 401 || response.status === 403) { + return { + plan: "DeepSeek", + message: "DeepSeek authentication failed. Check the API key.", + }; + } + + if (!response.ok) { + const errText = await response.text().catch(() => ""); + return { + plan: "DeepSeek", + message: `DeepSeek balance API error (${response.status})${errText ? `: ${errText.slice(0, 120)}` : ""}`, + }; + } + + const data = await response.json().catch(() => null); + if (!data || typeof data !== "object") { + return { message: "DeepSeek balance response was not JSON." }; + } + + const balances = parseBalanceInfos(data); + if (balances.length === 0) { + return { + plan: "DeepSeek", + message: "DeepSeek connected. No balance data returned.", + }; + } + + const isAvailable = data.is_available === true || data.isAvailable === true; + const quotas = {}; + for (const b of balances) { + const total = Math.max(0, b.totalBalance); + // Credit pot: show full remaining against current balance; never set absolute + // `remaining` — QuotaTable treats it as a 0–100 percentage. + quotas[`Balance (${b.currency})`] = { + used: 0, + total, + remainingPercentage: total > 0 ? 100 : 0, + resetAt: null, + unlimited: total > 0, + }; + } + + return { + plan: isAvailable ? "DeepSeek" : "DeepSeek (Insufficient Balance)", + quotas, + }; + } catch (error) { + return { message: `DeepSeek error: ${error.message}` }; + } +} diff --git a/open-sse/services/usage/grok-cli.js b/open-sse/services/usage/grok-cli.js index 865baeb0..768192ad 100644 --- a/open-sse/services/usage/grok-cli.js +++ b/open-sse/services/usage/grok-cli.js @@ -29,11 +29,19 @@ import { GROK_CLI_USER_AGENT, GROK_CLI_VERSION, } from "../../config/grokCli.js"; +import { decodeGrokCreditsFrame } from "./grokCliQuotaFrame.js"; const USAGE = U("grok-cli"); const BILLING_URL = USAGE.url || "https://cli-chat-proxy.grok.com/v1/billing?format=credits"; const USER_URL = USAGE.userUrl || "https://cli-chat-proxy.grok.com/v1/user?include=subscription"; +// SuperGrok weekly pool. +const GRPC_CREDITS_URL = + "https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig"; +// Empty gRPC-web request frame (flag 0 + length 0). Without it upstream returns +// grpc-status 13 "Missing request message." with a 0-byte body. +const GRPC_WEB_EMPTY_REQUEST_FRAME = Buffer.from([0, 0, 0, 0, 0]); + /** Unwrap protobuf-json `{ val: n }` or plain numbers/strings. */ function unwrapVal(value, fallback = 0) { if (value == null) return fallback; @@ -198,6 +206,21 @@ export function parseGrokCliBilling(billing, user = null) { }; } + // SuperGrok weekly shared-pool usage (subscription tier). creditUsagePercent is + // the single total used %; productUsage is a breakdown legend, NOT independent + // quotas — never split it into separate bars. + const usedPct = unwrapVal( + config.creditUsagePercent ?? config.credit_usage_percent ?? root.creditUsagePercent, + NaN, + ); + if (Number.isFinite(usedPct) && usedPct >= 0) { + quotas["Weekly SuperGrok"] = makeQuota({ + used: Math.max(0, Math.min(100, usedPct)), + total: 100, + resetAt: periodEnd, + }); + } + // Opportunistic richer credit envelopes (future / other account types) const creditBags = [ root.credits, @@ -256,6 +279,50 @@ export function parseGrokCliBilling(billing, user = null) { }; } +/** + * Live SuperGrok weekly pool via gRPC-web GetGrokCreditsConfig. + * Fail-open: any network/auth/parse failure returns null. + * @returns {{ percentUsed: number, resetAt: string|null } | null} + */ +export async function fetchGrokCliCreditsConfig(accessToken, proxyOptions = null) { + if (!accessToken) return null; + try { + const res = await proxyAwareFetch( + GRPC_CREDITS_URL, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/grpc-web+proto", + "X-Grpc-Web": "1", + Accept: "application/grpc-web+proto", + }, + body: GRPC_WEB_EMPTY_REQUEST_FRAME, + }, + proxyOptions, + ); + if (!res?.ok) return null; + const arrayBuffer = await res.arrayBuffer().catch(() => null); + if (!arrayBuffer) return null; + return decodeGrokCreditsFrame(Buffer.from(arrayBuffer)); + } catch { + return null; + } +} + +function quotasFromGrpcCredits(decoded) { + if (!decoded || !Number.isFinite(decoded.percentUsed)) return null; + // Round for bar display (fixed32 ratio * 100 can be 34.999… for 0.35) + const used = Math.round(Math.max(0, Math.min(100, decoded.percentUsed))); + return { + "Weekly SuperGrok": makeQuota({ + used, + total: 100, + resetAt: decoded.resetAt || null, + }), + }; +} + /** * @param {string} accessToken * @param {object|null} providerSpecificData @@ -306,6 +373,16 @@ export async function getGrokCliUsage(accessToken, providerSpecificData = null, const parsed = parseGrokCliBilling(billing, user); if (!parsed.quotas || Object.keys(parsed.quotas).length === 0) { + // Paid SuperGrok often returns cap=0 over REST but exposes the shared + // weekly pool on GetGrokCreditsConfig — try that before giving up. + const grpc = await fetchGrokCliCreditsConfig(accessToken, proxyOptions); + const grpcQuotas = quotasFromGrpcCredits(grpc); + if (grpcQuotas) { + return { + plan: parsed.plan, + quotas: grpcQuotas, + }; + } return { plan: parsed.plan, message: parsed.subscriptionAccess diff --git a/open-sse/services/usage/grokCliQuotaFrame.js b/open-sse/services/usage/grokCliQuotaFrame.js new file mode 100644 index 00000000..1578bec6 --- /dev/null +++ b/open-sse/services/usage/grokCliQuotaFrame.js @@ -0,0 +1,191 @@ +/** + * gRPC-web frame decoder for xAI GetGrokCreditsConfig + * (grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig). + * + * Real response shape (live capture 2026-07-20): + * top-level field 1 (length-delimited) — nested credits info + * subfield 1 (fixed32 float) — usage ratio 0..1 + * subfield 5 (Timestamp{seconds,nanos}) — credit-pool reset time + * + * Fail-open: any malformed buffer returns null, never throws. + */ + +const FIELD_CREDITS_INFO = 1; +const CREDITS_FIELD_USAGE_RATIO = 1; +const CREDITS_FIELD_RESET_TIMESTAMP = 5; +const TIMESTAMP_FIELD_SECONDS = 1; +const TIMESTAMP_FIELD_NANOS = 2; + +const WIRE_TYPE_VARINT = 0; +const WIRE_TYPE_FIXED64 = 1; +const WIRE_TYPE_LENGTH_DELIMITED = 2; +const WIRE_TYPE_FIXED32 = 5; + +const GRPC_WEB_TRAILER_FLAG_BIT = 0x80; +const MAX_VARINT_SHIFT_BITS = 70n; + +/** + * Validate a gRPC-web frame header at `offset`. + * @returns {{ flag: number, payloadStart: number, payloadLength: number } | null} + */ +export function probeFrameHeader(buffer, offset = 0) { + if (!Buffer.isBuffer(buffer) || offset < 0 || buffer.length - offset < 5) return null; + const flag = buffer[offset]; + if (flag !== 0x00 && flag !== 0x01 && flag !== 0x80 && flag !== 0x81) return null; + const payloadStart = offset + 5; + const payloadLength = buffer.readUInt32BE(offset + 1); + if (payloadLength > buffer.length - payloadStart) return null; + return { flag, payloadStart, payloadLength }; +} + +function readVarint(buffer, offset) { + let result = 0n; + let shift = 0n; + let pos = offset; + for (;;) { + if (pos >= buffer.length) return null; + const byte = buffer[pos]; + result |= BigInt(byte & 0x7f) << shift; + pos += 1; + if ((byte & 0x80) === 0) break; + shift += 7n; + if (shift > MAX_VARINT_SHIFT_BITS) return null; + } + return { value: Number(result), next: pos }; +} + +function readLengthDelimitedField(buffer, offset) { + const lengthResult = readVarint(buffer, offset); + if (!lengthResult) return null; + const { value: length, next: bodyStart } = lengthResult; + if (length < 0 || bodyStart + length > buffer.length) return null; + return { + field: { wireType: WIRE_TYPE_LENGTH_DELIMITED, bytes: buffer.subarray(bodyStart, bodyStart + length) }, + next: bodyStart + length, + }; +} + +function readFixedWidthField(buffer, offset, width, wireType) { + if (offset + width > buffer.length) return null; + return { + field: { wireType, bytes: buffer.subarray(offset, offset + width) }, + next: offset + width, + }; +} + +function readField(buffer, offset) { + const tagResult = readVarint(buffer, offset); + if (!tagResult) return null; + const fieldNumber = tagResult.value >>> 3; + const wireType = tagResult.value & 0x7; + if (fieldNumber === 0) return null; + + if (wireType === WIRE_TYPE_VARINT) { + const valueResult = readVarint(buffer, tagResult.next); + if (!valueResult) return null; + return { + fieldNumber, + field: { wireType: WIRE_TYPE_VARINT, value: valueResult.value }, + next: valueResult.next, + }; + } + if (wireType === WIRE_TYPE_LENGTH_DELIMITED) { + const result = readLengthDelimitedField(buffer, tagResult.next); + return result ? { fieldNumber, field: result.field, next: result.next } : null; + } + if (wireType === WIRE_TYPE_FIXED64) { + const result = readFixedWidthField(buffer, tagResult.next, 8, WIRE_TYPE_FIXED64); + return result ? { fieldNumber, field: result.field, next: result.next } : null; + } + if (wireType === WIRE_TYPE_FIXED32) { + const result = readFixedWidthField(buffer, tagResult.next, 4, WIRE_TYPE_FIXED32); + return result ? { fieldNumber, field: result.field, next: result.next } : null; + } + return null; +} + +function decodeFields(buffer) { + const fields = new Map(); + let offset = 0; + while (offset < buffer.length) { + const result = readField(buffer, offset); + if (!result) return null; + fields.set(result.fieldNumber, result.field); + offset = result.next; + } + return fields; +} + +function findDataFramePayload(buffer) { + let offset = 0; + while (offset < buffer.length) { + const frame = probeFrameHeader(buffer, offset); + if (!frame) return null; + const frameEnd = frame.payloadStart + frame.payloadLength; + const isTrailer = (frame.flag & GRPC_WEB_TRAILER_FLAG_BIT) !== 0; + if (!isTrailer) { + return buffer.subarray(frame.payloadStart, frameEnd); + } + offset = frameEnd; + } + return null; +} + +function extractNestedMessage(field) { + if (!field || field.wireType !== WIRE_TYPE_LENGTH_DELIMITED) return null; + return decodeFields(field.bytes); +} + +function extractUsageRatio(field) { + if (!field) return 0; // proto3 omission = 0% used + if (field.wireType === WIRE_TYPE_FIXED32) return field.bytes.readFloatLE(0); + if (field.wireType === WIRE_TYPE_FIXED64) return field.bytes.readDoubleLE(0); + return null; +} + +function extractResetAt(field) { + if (!field || field.wireType !== WIRE_TYPE_LENGTH_DELIMITED) return null; + + const timestampFields = decodeFields(field.bytes); + if (!timestampFields) return null; + + const secondsField = timestampFields.get(TIMESTAMP_FIELD_SECONDS); + const nanosField = timestampFields.get(TIMESTAMP_FIELD_NANOS); + const seconds = secondsField?.wireType === WIRE_TYPE_VARINT ? secondsField.value : 0; + const nanos = nanosField?.wireType === WIRE_TYPE_VARINT ? nanosField.value : 0; + + const millis = seconds * 1000 + Math.round(nanos / 1_000_000); + const parsed = new Date(millis); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +} + +/** + * Decode GetGrokCreditsConfig response → `{ percentUsed: 0-100, resetAt }` or null. + * @param {Buffer} buffer + * @returns {{ percentUsed: number, resetAt: string|null } | null} + */ +export function decodeGrokCreditsFrame(buffer) { + if (!buffer || !Buffer.isBuffer(buffer) || buffer.length === 0) return null; + + try { + const framed = probeFrameHeader(buffer, 0) !== null; + const payload = framed ? findDataFramePayload(buffer) : buffer; + if (!payload) return null; + + const topLevelFields = decodeFields(payload); + if (!topLevelFields) return null; + + const creditsInfo = extractNestedMessage(topLevelFields.get(FIELD_CREDITS_INFO)); + if (!creditsInfo) return null; + + const usageRatio = extractUsageRatio(creditsInfo.get(CREDITS_FIELD_USAGE_RATIO)); + if (usageRatio === null || !Number.isFinite(usageRatio) || usageRatio < 0) return null; + + return { + percentUsed: Math.min(100, usageRatio * 100), + resetAt: extractResetAt(creditsInfo.get(CREDITS_FIELD_RESET_TIMESTAMP)), + }; + } catch { + return null; + } +} diff --git a/open-sse/services/usage/kimi.js b/open-sse/services/usage/kimi.js new file mode 100644 index 00000000..4400965b --- /dev/null +++ b/open-sse/services/usage/kimi.js @@ -0,0 +1,211 @@ +/** + * Kimi Coding usage — GET /v1/usages + * + * Dual auth (single provider id `kimi`): + * - apiKey present → x-api-key only (platform / coding API key) + * - else accessToken → Bearer + X-Msh-* (device-code OAuth) + * + * Note: chat messages use combined x-api-key; /usages OAuth is Bearer. + * 403 permission_denied is NOT auth-expired — account lacks usage feature / sub. + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { parseResetTime, toFiniteNumber } from "./shared.js"; +import { buildKimiHeaders } from "../../config/appConstants.js"; + +const USAGE_URL = "https://api.kimi.com/coding/v1/usages"; + +const PLAN_LEVELS = { + LEVEL_BASIC: "Moderato", + LEVEL_INTERMEDIATE: "Allegretto", + LEVEL_ADVANCED: "Allegro", + LEVEL_STANDARD: "Vivace", +}; + +function getKimiPlanName(level) { + if (!level) return ""; + const key = String(level); + if (PLAN_LEVELS[key]) return PLAN_LEVELS[key]; + return key.replace(/^LEVEL_/, "").toLowerCase(); +} + +/** Best-effort extract human message from Kimi error JSON (403 body is Connect-RPC-ish). */ +export function formatKimiUsageError(status, responseText) { + let parsed = null; + try { + parsed = JSON.parse(responseText || ""); + } catch { + /* plain text */ + } + + const detail0 = Array.isArray(parsed?.details) ? parsed.details[0] : null; + const debug = detail0?.debug || parsed?.debug || null; + const reason = debug?.reason || parsed?.reason || ""; + const localized = + debug?.localizedMessage?.message || + detail0?.localizedMessage?.message || + parsed?.message || + ""; + + if (status === 401) { + return "Kimi authentication expired. Please re-authorize."; + } + + // Live OAuth token without Kimi Code usage entitlement returns 403 + // REASON_FEATURE_NO_PERMISSION — not an expired session. + if ( + status === 403 && + (reason === "REASON_FEATURE_NO_PERMISSION" || + /permission_denied|do not have permission|subscribe/i.test( + `${parsed?.code || ""} ${localized} ${responseText || ""}`, + )) + ) { + return ( + localized || + "Kimi connected, but this account has no permission to view usage. Subscribe to Kimi Code to access quota." + ); + } + + const snippet = (localized || responseText || "").slice(0, 100); + return snippet + ? `Kimi Coding connected. API Error ${status}: ${snippet}` + : `Kimi Coding connected. API Error ${status}`; +} + +function makeQuota({ used, total, remaining, resetAt }) { + const safeTotal = Math.max(0, toFiniteNumber(total, 0)); + const safeUsed = Math.max(0, toFiniteNumber(used, 0)); + // Prefer provider remaining when present; never set absolute `remaining` + // on the quota object — QuotaTable treats it as a 0–100 percentage. + let remainingPct; + if (safeTotal > 0 && remaining != null && Number.isFinite(Number(remaining))) { + remainingPct = (Math.max(0, Number(remaining)) / safeTotal) * 100; + } else if (safeTotal > 0) { + remainingPct = (Math.max(0, safeTotal - safeUsed) / safeTotal) * 100; + } else { + remainingPct = 0; + } + return { + used: safeUsed, + total: safeTotal, + remainingPercentage: remainingPct, + resetAt: resetAt || null, + unlimited: false, + }; +} + +/** + * @param {string|null|undefined} accessToken + * @param {string|null|undefined} apiKey + * @param {object|null} proxyOptions + * @param {object|null} providerSpecificData + */ +export async function getKimiUsage( + accessToken = null, + apiKey = null, + proxyOptions = null, + providerSpecificData = null, +) { + const useApiKey = typeof apiKey === "string" && apiKey.length > 0; + const useOAuth = !useApiKey && typeof accessToken === "string" && accessToken.length > 0; + + if (!useApiKey && !useOAuth) { + return { message: "Kimi access token or API key not available." }; + } + + const authHeaders = useApiKey + ? { "x-api-key": apiKey } + : { + Authorization: `Bearer ${accessToken}`, + ...buildKimiHeaders(providerSpecificData?.deviceId), + }; + + try { + const response = await proxyAwareFetch( + USAGE_URL, + { + method: "GET", + headers: { + ...authHeaders, + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + proxyOptions, + ); + + const responseText = await response.text().catch(() => ""); + + if (!response.ok) { + return { + plan: "Kimi Coding", + message: formatKimiUsageError(response.status, responseText), + }; + } + + let data; + try { + data = JSON.parse(responseText || "{}"); + } catch { + return { + plan: "Kimi Coding", + message: "Kimi Coding connected. Invalid JSON response from API.", + }; + } + + const quotas = {}; + const usageObj = data?.usage && typeof data.usage === "object" ? data.usage : {}; + const usageLimit = toFiniteNumber(usageObj.limit ?? usageObj.Limit, 0); + const usageUsed = toFiniteNumber(usageObj.used ?? usageObj.Used, 0); + const usageRemainingRaw = usageObj.remaining ?? usageObj.Remaining; + const usageRemaining = + usageRemainingRaw != null && usageRemainingRaw !== "" + ? toFiniteNumber(usageRemainingRaw, NaN) + : NaN; + const usageResetTime = + usageObj.resetTime || usageObj.ResetTime || usageObj.reset_at || usageObj.resetAt; + + if (usageLimit > 0) { + quotas.Weekly = makeQuota({ + used: usageUsed, + total: usageLimit, + remaining: Number.isFinite(usageRemaining) ? usageRemaining : null, + resetAt: parseResetTime(usageResetTime), + }); + } + + const limitsArray = Array.isArray(data?.limits) ? data.limits : []; + for (const item of limitsArray) { + if (!item || typeof item !== "object") continue; + const detail = item.detail && typeof item.detail === "object" ? item.detail : {}; + const limit = toFiniteNumber(detail.limit ?? detail.Limit, 0); + const remaining = toFiniteNumber(detail.remaining ?? detail.Remaining, NaN); + const resetTime = detail.resetTime || detail.reset_at || detail.resetAt; + if (limit > 0) { + const rem = Number.isFinite(remaining) ? remaining : Math.max(0, limit); + quotas.Ratelimit = makeQuota({ + used: Math.max(0, limit - rem), + total: limit, + remaining: rem, + resetAt: parseResetTime(resetTime), + }); + } + } + + const membershipLevel = data?.user?.membership?.level; + const planName = getKimiPlanName(membershipLevel) || "Kimi Coding"; + + if (Object.keys(quotas).length > 0) { + return { plan: planName, quotas }; + } + + return { + plan: planName, + message: "Kimi Coding connected. Usage tracked per request.", + }; + } catch (error) { + return { + message: `Kimi Coding connected. Unable to fetch usage: ${error.message}`, + }; + } +} diff --git a/open-sse/shared/qoder/constants.js b/open-sse/shared/qoder/constants.js index 1d9ce303..184c35d6 100644 --- a/open-sse/shared/qoder/constants.js +++ b/open-sse/shared/qoder/constants.js @@ -20,6 +20,11 @@ export const QODER_USERINFO_URL = `${QODER_OPENAPI_BASE}/api/v1/userinfo`; export const QODER_QUOTA_USAGE_URL = `${QODER_OPENAPI_BASE}/api/v2/quota/usage`; export const QODER_REFRESH_TOKEN_URL = `${QODER_CENTER_BASE}/algo/api/v3/user/refresh_token`; +// PAT (Personal Access Token, pt-...) → short-lived job token (jt-...) exchange. +// PATs cannot sign COSY requests directly — they must be exchanged first. +// This endpoint is NOT COSY-signed (plain JSON POST). +export const QODER_JOB_TOKEN_EXCHANGE_URL = `${QODER_OPENAPI_BASE}/api/v1/jobToken/exchange`; + // Inference endpoints (under /algo on api3.qoder.sh, all COSY-signed) export const QODER_CHAT_SIG_PATH = "/api/v2/service/pro/sse/agent_chat_generation"; export const QODER_CHAT_URL = `${QODER_CHAT_BASE}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common`; diff --git a/open-sse/shared/zedAuth.js b/open-sse/shared/zedAuth.js new file mode 100644 index 00000000..aa3337d7 --- /dev/null +++ b/open-sse/shared/zedAuth.js @@ -0,0 +1,415 @@ +// Zed hosted LLM aggregator — auth + model-catalog helpers. +// +// Zed's cloud (cloud.zed.dev) authenticates native apps with a self-generated RSA +// keypair instead of a registered OAuth client_id/secret: +// 1. Client generates an ephemeral RSA keypair. +// 2. Sends the public key to zed.dev/native_app_signin. +// 3. User signs in via browser; Zed redirects to a local callback with the +// access token RSA-encrypted against the public key. +// 4. Client decrypts locally with the private key that never left the host. +// No embedded client_id/secret — the credential is a per-login keypair. + +import crypto from "node:crypto"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; + +export const ZED_WEB_BASE_URL = "https://zed.dev"; +export const ZED_CLOUD_BASE_URL = "https://cloud.zed.dev"; +export const ZED_LLM_BASE_URL = "https://cloud.zed.dev"; + +export const ZED_HEADERS = { + expiredToken: "x-zed-expired-token", + outdatedToken: "x-zed-outdated-token", + clientSupportsStatus: "x-zed-client-supports-status-messages", + clientSupportsStreamEnded: + "x-zed-client-supports-stream-ended-request-completion-status", + serverSupportsStatus: "x-zed-server-supports-status-messages", + clientSupportsXai: "x-zed-client-supports-x-ai", + systemId: "x-zed-system-id", +}; + +const PRIVATE_KEY_PREFIX = "zed-rsa-pkcs1:"; +const LLM_TOKEN_TTL_MS = 50 * 60 * 1000; +const MODEL_CACHE_TTL_MS = 60 * 60 * 1000; + +const llmTokenCache = new Map(); +const modelCache = new Map(); +const modelInflight = new Map(); + +function b64url(value) { + return Buffer.from(value).toString("base64url"); +} + +function b64urlPadded(buf) { + return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_"); +} + +function fromB64url(value) { + return Buffer.from(String(value || ""), "base64url").toString("utf8"); +} + +function normalizeBaseUrl(baseUrl, fallback) { + return String(baseUrl || fallback).replace(/\/+$/, ""); +} + +function zedUrl(config, key, path, fallbackBase) { + const base = normalizeBaseUrl(config?.[key], fallbackBase); + return `${base}${path}`; +} + +/** Encode a PEM private key as an opaque verifier (flows through the OAuth codeVerifier slot). */ +export function encodeZedPrivateKeyVerifier(privateKeyPem) { + return `${PRIVATE_KEY_PREFIX}${b64url(privateKeyPem)}`; +} + +export function decodeZedPrivateKeyVerifier(verifier) { + const value = String(verifier || ""); + if (!value.startsWith(PRIVATE_KEY_PREFIX)) { + throw new Error("Missing Zed private key verifier; restart the login flow"); + } + return fromB64url(value.slice(PRIVATE_KEY_PREFIX.length)); +} + +/** Generate a fresh RSA keypair + the zed.dev native_app_signin URL for it. */ +export function createZedNativeAuthData(config = {}, options = {}) { + const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "pkcs1", format: "der" }, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + }); + + const nativeAppPort = Number( + options.nativeAppPort || config.defaultNativeAppPort || 58443, + ); + const systemId = options.systemId || crypto.randomUUID(); + const publicKeyString = b64urlPadded(publicKey); + const signInUrl = new URL( + `${normalizeBaseUrl(config.webBaseUrl, ZED_WEB_BASE_URL)}/native_app_signin`, + ); + signInUrl.searchParams.set("native_app_port", String(nativeAppPort)); + signInUrl.searchParams.set("native_app_public_key", publicKeyString); + if (systemId) signInUrl.searchParams.set("system_id", systemId); + + return { + authUrl: signInUrl.toString(), + privateKeyVerifier: encodeZedPrivateKeyVerifier(privateKey), + nativeAppPort, + systemId, + publicKey: publicKeyString, + }; +} + +/** Parse the pasted native-app callback URL/JSON/query into userId + encrypted token. */ +export function parseZedCallbackPayload(input) { + const raw = String(input || "").trim(); + if (!raw) throw new Error("Missing Zed callback URL"); + + let data = {}; + try { + data = JSON.parse(raw); + } catch { + let url; + try { + url = new URL(raw); + } catch { + try { + url = new URL(`http://127.0.0.1/?${raw.replace(/^\?/, "")}`); + } catch { + throw new Error("Invalid Zed callback URL"); + } + } + url.searchParams.forEach((value, key) => { + data[key] = value; + }); + } + + const userId = data.user_id || data.userId; + const encryptedAccessToken = data.access_token || data.accessToken || data.token; + if (!userId || !encryptedAccessToken) { + throw new Error("Zed callback must include user_id and access_token"); + } + return { userId: String(userId), encryptedAccessToken: String(encryptedAccessToken) }; +} + +/** Decrypt the RSA-encrypted access token using the stored private key. */ +export function decryptZedAccessToken(encryptedAccessToken, privateKeyVerifier) { + const privateKey = decodeZedPrivateKeyVerifier(privateKeyVerifier); + const encrypted = Buffer.from(String(encryptedAccessToken), "base64url"); + try { + return crypto + .privateDecrypt( + { key: privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" }, + encrypted, + ) + .toString("utf8"); + } catch (oaepError) { + try { + return crypto + .privateDecrypt( + { key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, + encrypted, + ) + .toString("utf8"); + } catch { + const message = oaepError instanceof Error ? oaepError.message : String(oaepError); + throw new Error(`Failed to decrypt Zed access token: ${message}`); + } + } +} + +export function buildZedUserAuthHeader(credentials) { + const psd = credentials?.providerSpecificData || {}; + const userId = psd.userId || credentials?.userId; + const accessToken = credentials?.accessToken || credentials?.apiKey; + if (!userId || !accessToken) { + throw new Error("Zed credential is missing userId or accessToken"); + } + return `${userId} ${accessToken}`; +} + +function getSystemId(credentials) { + return String( + credentials?.providerSpecificData?.systemId || credentials?.systemId || "", + ); +} + +async function fetchJson(url, options) { + const res = await proxyAwareFetch(url, options); + const text = await res.text(); + let data = null; + if (text) { + try { + data = JSON.parse(text); + } catch { + data = { raw: text }; + } + } + if (!res.ok) { + const message = + data?.message || data?.error?.message || data?.error || text || `HTTP ${res.status}`; + const err = new Error(String(message)); + err.status = res.status; + err.body = data; + throw err; + } + return data; +} + +export async function fetchZedAuthenticatedUser(credentials, options = {}) { + const config = options.config || {}; + const headers = { + Accept: "application/json", + Authorization: buildZedUserAuthHeader(credentials), + }; + const systemId = getSystemId(credentials); + if (systemId) headers[ZED_HEADERS.systemId] = systemId; + + return fetchJson(zedUrl(config, "cloudBaseUrl", "/client/users/me", ZED_CLOUD_BASE_URL), { + method: "GET", + headers, + signal: options.signal ?? undefined, + }); +} + +function normalizeOrganizationId(value) { + if (!value) return ""; + if (typeof value === "string") return value; + if (typeof value === "object" && value !== null) { + if (typeof value[0] === "string") return value[0]; + if (typeof value.id === "string") return value.id; + } + return String(value); +} + +export function resolveZedOrganizationId(credentials, userInfo = null) { + const psd = credentials?.providerSpecificData || {}; + const explicit = normalizeOrganizationId(psd.organizationId || psd.defaultOrganizationId); + if (explicit) return explicit; + const fromUser = normalizeOrganizationId( + userInfo?.default_organization_id || userInfo?.defaultOrganizationId, + ); + if (fromUser) return fromUser; + const orgs = userInfo?.organizations || []; + const org = orgs.find((item) => item?.is_personal) || orgs[0]; + return normalizeOrganizationId(org?.id); +} + +function zedUserCacheKey(credentials, organizationId) { + const psd = credentials?.providerSpecificData || {}; + const userId = psd.userId || credentials?.userId || "unknown"; + const token = credentials?.accessToken || credentials?.apiKey || ""; + return `${userId}:${organizationId || "default"}:${token.slice(-16)}`; +} + +function zedModelCacheKey(credentials) { + const psd = credentials?.providerSpecificData || {}; + const org = psd.organizationId || psd.defaultOrganizationId || "default"; + const token = credentials?.accessToken || credentials?.apiKey || ""; + return `${psd.userId || "unknown"}:${org}:${token.slice(-16)}`; +} + +export async function fetchZedLlmToken(credentials, options = {}) { + const config = options.config || {}; + let organizationId = options.organizationId || resolveZedOrganizationId(credentials); + if (!organizationId) { + const userInfo = await fetchZedAuthenticatedUser(credentials, options); + organizationId = resolveZedOrganizationId(credentials, userInfo); + } + if (!organizationId) throw new Error("No Zed organization selected"); + + const cacheKey = zedUserCacheKey(credentials, organizationId); + const cached = llmTokenCache.get(cacheKey); + if (!options.forceRefresh && cached && cached.expiresAt > Date.now()) return cached.token; + + const headers = { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: buildZedUserAuthHeader(credentials), + }; + const systemId = getSystemId(credentials); + if (systemId) headers[ZED_HEADERS.systemId] = systemId; + + const data = await fetchJson( + zedUrl(config, "cloudBaseUrl", "/client/llm_tokens", ZED_CLOUD_BASE_URL), + { + method: "POST", + headers, + body: JSON.stringify({ organization_id: organizationId }), + signal: options.signal ?? undefined, + }, + ); + const token = + typeof data?.token === "string" ? data.token : data?.token?.[0] || data?.token?.value; + if (!token) throw new Error("Zed did not return an LLM token"); + llmTokenCache.set(cacheKey, { token, expiresAt: Date.now() + LLM_TOKEN_TTL_MS }); + return token; +} + +export function shouldRefreshZedLlmToken(response) { + return ( + response?.status === 401 || + !!response?.headers?.has?.(ZED_HEADERS.expiredToken) || + !!response?.headers?.has?.(ZED_HEADERS.outdatedToken) + ); +} + +export async function zedLlmFetch(credentials, path, options = {}) { + const config = options.config || {}; + const url = zedUrl(config, "llmBaseUrl", path, ZED_LLM_BASE_URL); + const buildRequest = async (forceRefresh) => { + const token = await fetchZedLlmToken(credentials, { ...options, forceRefresh }); + return proxyAwareFetch(url, { + ...options.fetchOptions, + headers: { + ...(options.fetchOptions?.headers || {}), + Authorization: `Bearer ${token}`, + }, + signal: options.signal ?? undefined, + }); + }; + + let response = await buildRequest(false); + if (shouldRefreshZedLlmToken(response)) { + response = await buildRequest(true); + } + return response; +} + +function normalizeZedModelId(id) { + if (!id) return ""; + if (typeof id === "string") return id; + if (typeof id === "object" && id !== null) { + if (typeof id[0] === "string") return id[0]; + if (typeof id.id === "string") return id.id; + } + return String(id); +} + +export function mapZedModel(model) { + const id = normalizeZedModelId(model?.id); + if (!id) return null; + return { + id, + name: model.display_name || model.displayName || id, + provider: model.provider, + isLatest: !!model.is_latest, + contextLength: model.max_token_count ?? model.maxTokenCount, + contextLengthInMaxMode: model.max_token_count_in_max_mode ?? model.maxTokenCountInMaxMode, + maxOutputTokens: model.max_output_tokens ?? model.maxOutputTokens, + supportsTools: !!model.supports_tools, + supportsImages: !!model.supports_images, + supportsThinking: !!model.supports_thinking, + supportsDisablingThinking: !!model.supports_disabling_thinking, + supportsFastMode: !!model.supports_fast_mode, + supportsServerSideCompaction: !!model.supports_server_side_compaction, + supportedEffortLevels: model.supported_effort_levels ?? model.supportedEffortLevels ?? [], + supportsStreamingTools: !!model.supports_streaming_tools, + supportsParallelToolCalls: !!model.supports_parallel_tool_calls, + isDisabled: !!model.is_disabled, + disabledReason: model.disabled_reason ?? null, + }; +} + +/** Resolve (and cache) the live Zed model catalog. Never hardcoded — always a live fetch. */ +export async function resolveZedModels(credentials, options = {}) { + if (!credentials?.accessToken) return null; + const key = zedModelCacheKey(credentials); + const cached = modelCache.get(key); + if (!options.forceRefresh && cached && cached.expiresAt > Date.now()) return cached; + + const existing = modelInflight.get(key); + if (existing && !options.forceRefresh) return existing; + + const promise = (async () => { + const response = await zedLlmFetch(credentials, "/models", { + ...options, + fetchOptions: { + method: "GET", + headers: { + Accept: "application/json", + [ZED_HEADERS.clientSupportsXai]: "true", + }, + }, + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Zed models failed: ${response.status} ${text}`); + } + const data = await response.json(); + const rawModels = Array.isArray(data?.models) ? data.models : []; + const models = rawModels + .map(mapZedModel) + .filter(Boolean) + .filter((model) => !model.isDisabled); + const rawById = new Map(); + for (const raw of rawModels) { + const id = normalizeZedModelId(raw?.id); + if (id) rawById.set(id, raw); + } + const entry = { + expiresAt: Date.now() + MODEL_CACHE_TTL_MS, + models, + rawModels, + rawById, + defaultModel: normalizeZedModelId(data?.default_model ?? data?.defaultModel), + defaultFastModel: normalizeZedModelId(data?.default_fast_model ?? data?.defaultFastModel), + recommendedModels: (data?.recommended_models || data?.recommendedModels || []) + .map(normalizeZedModelId) + .filter(Boolean), + }; + modelCache.set(key, entry); + return entry; + })(); + + modelInflight.set(key, promise); + try { + return await promise; + } finally { + if (modelInflight.get(key) === promise) modelInflight.delete(key); + } +} + +export function clearZedCaches() { + llmTokenCache.clear(); + modelCache.clear(); + modelInflight.clear(); +} diff --git a/open-sse/translator/concerns/kiroConversation.js b/open-sse/translator/concerns/kiroConversation.js new file mode 100644 index 00000000..11d49dc7 --- /dev/null +++ b/open-sse/translator/concerns/kiroConversation.js @@ -0,0 +1,435 @@ +import { + KIRO_TOOL_DESCRIPTION_MAX_LENGTH, + KIRO_TOOL_ID_MAX_LENGTH, + KIRO_TOOL_NAME_MAX_LENGTH, +} from "../../config/kiroConstants.js"; + +const TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; +const TOOL_NAME_PATTERN = /[^a-zA-Z0-9_-]/g; + +function clone(value) { + return value == null ? value : JSON.parse(JSON.stringify(value)); +} + +function text(value) { + if (typeof value === "string") return value; + if (value == null) return ""; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function appendText(target, extra) { + if (!extra) return; + target.content = target.content ? `${target.content}\n\n${extra}` : extra; +} + +function trimCodePoints(value, limit) { + return [...String(value || "")].slice(0, limit).join(""); +} + +function uniqueName(rawName, index, usedNames) { + const cleaned = String(rawName || "") + .trim() + .replace(TOOL_NAME_PATTERN, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, ""); + const base = trimCodePoints(cleaned || `tool_${index + 1}`, KIRO_TOOL_NAME_MAX_LENGTH); + let candidate = base; + let suffix = 2; + while (usedNames.has(candidate)) { + const tail = `_${suffix++}`; + candidate = `${base.slice(0, KIRO_TOOL_NAME_MAX_LENGTH - tail.length)}${tail}`; + } + usedNames.add(candidate); + return candidate; +} + +function cleanSchemaValue(value) { + if (Array.isArray(value)) return value.map(cleanSchemaValue); + if (!value || typeof value !== "object") return value; + + const cleaned = {}; + for (const [key, child] of Object.entries(value)) { + if (key === "additionalProperties") continue; + if (key === "required" && Array.isArray(child) && child.length === 0) continue; + cleaned[key] = cleanSchemaValue(child); + } + return cleaned; +} + +function normalizeRootSchema(schema) { + const cleaned = cleanSchemaValue(schema && typeof schema === "object" ? clone(schema) : {}); + cleaned.type = "object"; + if (!cleaned.properties || typeof cleaned.properties !== "object" || Array.isArray(cleaned.properties)) { + cleaned.properties = {}; + } + if (Array.isArray(cleaned.required)) { + cleaned.required = [...new Set(cleaned.required.filter( + (name) => typeof name === "string" && Object.hasOwn(cleaned.properties, name) + ))]; + if (cleaned.required.length === 0) delete cleaned.required; + } + return cleaned; +} + +/** Normalize OpenAI- or Claude-shaped tool definitions into Kiro tool specs. */ +export function normalizeKiroToolSpecs(tools) { + const specs = []; + const nameMap = new Map(); + const usedNames = new Set(); + + for (const [index, tool] of (Array.isArray(tools) ? tools : []).entries()) { + if (!tool || typeof tool !== "object") continue; + const rawName = tool.function?.name ?? tool.name; + if (typeof rawName !== "string" || !rawName.trim()) continue; + + // A repeated definition with the same source name describes the same tool. + if (nameMap.has(rawName)) continue; + const name = uniqueName(rawName, index, usedNames); + nameMap.set(rawName, name); + + const rawDescription = tool.function?.description ?? tool.description ?? `Tool: ${rawName}`; + const description = trimCodePoints( + String(rawDescription || `Tool: ${rawName}`), + KIRO_TOOL_DESCRIPTION_MAX_LENGTH + ); + const schema = tool.function?.parameters ?? tool.parameters ?? tool.input_schema ?? {}; + specs.push({ + toolSpecification: { + name, + description, + inputSchema: { json: normalizeRootSchema(schema) }, + }, + }); + } + + return { specs, nameMap }; +} + +function toolCallText(toolUse) { + return `[Tool call: ${toolUse?.name || "unknown"}(${text(toolUse?.input || {})})]`; +} + +function toolResultText(toolResult) { + const content = Array.isArray(toolResult?.content) + ? toolResult.content.map((part) => text(part?.text ?? part)).filter(Boolean).join("\n") + : text(toolResult?.content); + return `[Tool result${toolResult?.status === "error" ? " (error)" : ""}: ${content}]`; +} + +function mergeUser(target, source) { + appendText(target, source.content); + if (Array.isArray(source.images) && source.images.length > 0) { + target.images = [...(target.images || []), ...source.images]; + } + const results = source.userInputMessageContext?.toolResults; + if (Array.isArray(results) && results.length > 0) { + target.userInputMessageContext ||= {}; + target.userInputMessageContext.toolResults = [ + ...(target.userInputMessageContext.toolResults || []), + ...results, + ]; + } +} + +function mergeAssistant(target, source) { + appendText(target, source.content); + if (Array.isArray(source.toolUses) && source.toolUses.length > 0) { + target.toolUses = [...(target.toolUses || []), ...source.toolUses]; + } +} + +function normalizeTurns(history, currentMessage, modelId) { + const rawTurns = [...(Array.isArray(history) ? history : [])]; + if (currentMessage) rawTurns.push(currentMessage); + const turns = []; + + for (const raw of rawTurns) { + const isUser = !!raw?.userInputMessage; + const isAssistant = !!raw?.assistantResponseMessage; + if (isUser === isAssistant) continue; + + const turn = isUser + ? { userInputMessage: clone(raw.userInputMessage) } + : { assistantResponseMessage: clone(raw.assistantResponseMessage) }; + const previous = turns[turns.length - 1]; + if (turn.userInputMessage && previous?.userInputMessage) { + mergeUser(previous.userInputMessage, turn.userInputMessage); + } else if (turn.assistantResponseMessage && previous?.assistantResponseMessage) { + mergeAssistant(previous.assistantResponseMessage, turn.assistantResponseMessage); + } else { + turns.push(turn); + } + } + + if (turns[0]?.assistantResponseMessage) { + turns.unshift({ userInputMessage: { content: "continue", modelId } }); + } + if (turns.length === 0 || turns[turns.length - 1]?.assistantResponseMessage) { + turns.push({ userInputMessage: { content: "continue", modelId } }); + } + + for (const turn of turns) { + if (turn.userInputMessage) { + turn.userInputMessage.content = text(turn.userInputMessage.content).trim() || "continue"; + turn.userInputMessage.modelId ||= modelId; + if (turn.userInputMessage.userInputMessageContext?.tools) { + delete turn.userInputMessage.userInputMessageContext.tools; + } + } else { + turn.assistantResponseMessage.content = + text(turn.assistantResponseMessage.content).trim() || "..."; + } + } + return turns; +} + +function rawId(value) { + return typeof value === "string" ? value : ""; +} + +function reserveToolId(value, turnIndex, callIndex, name, usedIds) { + const sanitized = rawId(value).replace(/[^a-zA-Z0-9_-]/g, ""); + const generated = `call_msg${turnIndex}_tc${callIndex}_${name || "tool"}`; + const base = trimCodePoints( + TOOL_ID_PATTERN.test(sanitized) && sanitized ? sanitized : generated, + KIRO_TOOL_ID_MAX_LENGTH + ); + let candidate = base; + let suffix = 2; + while (usedIds.has(candidate)) { + const tail = `_${suffix++}`; + candidate = `${base.slice(0, KIRO_TOOL_ID_MAX_LENGTH - tail.length)}${tail}`; + } + usedIds.add(candidate); + return candidate; +} + +function normalizeToolInput(input) { + if (input && typeof input === "object" && !Array.isArray(input)) return clone(input); + if (typeof input === "string") { + try { + const parsed = JSON.parse(input); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed; + } catch { + return null; + } + } + return input == null ? {} : null; +} + +function normalizeToolResult(result) { + const content = Array.isArray(result?.content) + ? result.content.map((part) => ({ text: text(part?.text ?? part) })) + : [{ text: text(result?.content) }]; + return { + toolUseId: rawId(result?.toolUseId), + status: result?.status === "error" ? "error" : "success", + content: content.length > 0 ? content : [{ text: "" }], + }; +} + +function flattenResults(userMessage, results) { + for (const result of results) appendText(userMessage, toolResultText(result)); +} + +function cleanUserContext(userMessage) { + const context = userMessage.userInputMessageContext; + if (!context) return; + if (!context.toolResults?.length) delete context.toolResults; + if (!context.tools?.length) delete context.tools; + if (Object.keys(context).length === 0) delete userMessage.userInputMessageContext; +} + +function reconcileToolPair(assistant, user, turnIndex, nameMap, specNames, usedIds, repairs) { + const calls = Array.isArray(assistant.toolUses) ? assistant.toolUses : []; + const results = Array.isArray(user.userInputMessageContext?.toolResults) + ? user.userInputMessageContext.toolResults.map(normalizeToolResult) + : []; + if (calls.length === 0) { + if (results.length > 0) { + flattenResults(user, results); + repairs.orphanResults += results.length; + } + if (user.userInputMessageContext) delete user.userInputMessageContext.toolResults; + cleanUserContext(user); + return; + } + + const callQueues = new Map(); + const callRecords = calls.map((call, callIndex) => { + const key = rawId(call?.toolUseId); + const mappedName = nameMap.get(call?.name) || call?.name; + const input = normalizeToolInput(call?.input); + const record = { call, callIndex, key, mappedName, input, result: null }; + const queue = callQueues.get(key) || []; + queue.push(record); + callQueues.set(key, queue); + return record; + }); + + const orphanResults = []; + for (const result of results) { + const queue = callQueues.get(rawId(result.toolUseId)); + const record = queue?.find((candidate) => !candidate.result); + if (record) record.result = result; + else orphanResults.push(result); + } + + const keptCalls = []; + const keptResults = []; + for (const record of callRecords) { + const hasSpec = typeof record.mappedName === "string" && specNames.has(record.mappedName); + const valid = !!record.result && hasSpec && record.input !== null; + if (!valid) { + appendText(assistant, toolCallText({ name: record.mappedName, input: record.call?.input })); + repairs.missingResults += record.result ? 0 : 1; + repairs.invalidToolUses += hasSpec && record.input !== null ? 0 : 1; + if (record.result) { + flattenResults(user, [record.result]); + repairs.orphanResults++; + } + continue; + } + + const toolUseId = reserveToolId( + record.key, + turnIndex, + record.callIndex, + record.mappedName, + usedIds + ); + keptCalls.push({ + toolUseId, + name: record.mappedName, + input: record.input, + }); + keptResults.push({ ...record.result, toolUseId }); + } + + if (orphanResults.length > 0) { + flattenResults(user, orphanResults); + repairs.orphanResults += orphanResults.length; + } + + if (keptCalls.length > 0) assistant.toolUses = keptCalls; + else delete assistant.toolUses; + user.userInputMessageContext ||= {}; + if (keptResults.length > 0) user.userInputMessageContext.toolResults = keptResults; + else delete user.userInputMessageContext.toolResults; + cleanUserContext(user); +} + +/** Validate the final Kiro wire conversation without mutating it. */ +export function validateKiroConversation(history, currentMessage, toolSpecs = []) { + const errors = []; + const turns = [...(history || []), currentMessage].filter(Boolean); + const specNames = new Set(toolSpecs.map((spec) => spec?.toolSpecification?.name).filter(Boolean)); + const usedIds = new Set(); + + for (let index = 0; index < turns.length; index++) { + const expectedUser = index % 2 === 0; + const isUser = !!turns[index]?.userInputMessage; + if (isUser !== expectedUser) errors.push(`role:${index}`); + if (!isUser) { + const calls = turns[index].assistantResponseMessage?.toolUses || []; + const results = turns[index + 1]?.userInputMessage?.userInputMessageContext?.toolResults || []; + const callIds = calls.map((call) => call.toolUseId); + const resultIds = results.map((result) => result.toolUseId); + if (calls.length !== results.length || callIds.some((id) => !resultIds.includes(id))) { + errors.push(`pair:${index}`); + } + for (const call of calls) { + if (!call.toolUseId || usedIds.has(call.toolUseId)) errors.push(`id:${index}`); + usedIds.add(call.toolUseId); + if (!specNames.has(call.name)) errors.push(`spec:${index}`); + } + } else if (index === 0) { + const results = turns[index].userInputMessage?.userInputMessageContext?.toolResults; + if (results?.length) errors.push("orphan:0"); + } + } + if (!currentMessage?.userInputMessage?.content) errors.push("current"); + return { valid: errors.length === 0, errors }; +} + +function flattenAllStructuredTools(turns, repairs) { + for (const turn of turns) { + if (turn.assistantResponseMessage?.toolUses?.length) { + for (const call of turn.assistantResponseMessage.toolUses) { + appendText(turn.assistantResponseMessage, toolCallText(call)); + } + repairs.invalidToolUses += turn.assistantResponseMessage.toolUses.length; + delete turn.assistantResponseMessage.toolUses; + } + const user = turn.userInputMessage; + const results = user?.userInputMessageContext?.toolResults; + if (results?.length) { + flattenResults(user, results); + repairs.orphanResults += results.length; + delete user.userInputMessageContext.toolResults; + cleanUserContext(user); + } + } +} + +/** + * Produce a strict Kiro conversation: alternating turns, current user message, + * adjacent one-to-one tool use/result pairs, and tool specs only on currentMessage. + */ +export function canonicalizeKiroConversation({ + history, + currentMessage, + modelId, + toolSpecs = [], + nameMap = new Map(), +} = {}) { + const turns = normalizeTurns(history, currentMessage, modelId); + const repairs = { missingResults: 0, orphanResults: 0, invalidToolUses: 0 }; + const specNames = new Set(toolSpecs.map((spec) => spec?.toolSpecification?.name).filter(Boolean)); + const usedIds = new Set(); + + for (let index = 0; index < turns.length; index += 2) { + const user = turns[index].userInputMessage; + if (index === 0) { + const leadingResults = user.userInputMessageContext?.toolResults || []; + if (leadingResults.length > 0) { + flattenResults(user, leadingResults); + repairs.orphanResults += leadingResults.length; + delete user.userInputMessageContext.toolResults; + cleanUserContext(user); + } + } + const assistant = turns[index + 1]?.assistantResponseMessage; + const nextUser = turns[index + 2]?.userInputMessage; + if (assistant && nextUser) { + reconcileToolPair(assistant, nextUser, index + 1, nameMap, specNames, usedIds, repairs); + } + } + + const finalCurrent = turns[turns.length - 1]; + finalCurrent.userInputMessage.userInputMessageContext ||= {}; + if (toolSpecs.length > 0) { + finalCurrent.userInputMessage.userInputMessageContext.tools = clone(toolSpecs); + } + cleanUserContext(finalCurrent.userInputMessage); + + let finalHistory = turns.slice(0, -1); + let validation = validateKiroConversation(finalHistory, finalCurrent, toolSpecs); + if (!validation.valid) { + flattenAllStructuredTools(turns, repairs); + finalHistory = turns.slice(0, -1); + validation = validateKiroConversation(finalHistory, finalCurrent, toolSpecs); + } + + return { + history: finalHistory, + currentMessage: finalCurrent, + repairs, + valid: validation.valid, + errors: validation.errors, + }; +} diff --git a/open-sse/translator/formats/gemini.js b/open-sse/translator/formats/gemini.js index bf6c4586..b1d4db34 100644 --- a/open-sse/translator/formats/gemini.js +++ b/open-sse/translator/formats/gemini.js @@ -353,6 +353,19 @@ export function cleanJSONSchemaForAntigravity(schema) { function addPlaceholders(obj) { if (!obj || typeof obj !== "object") return; + // Empty schema {} (no type, no properties) after $ref removal — treat as object with placeholder + if (Object.keys(obj).length === 0) { + obj.type = "object"; + obj.properties = { + reason: { + type: "string", + description: "Brief explanation of why you are calling this tool" + } + }; + obj.required = ["reason"]; + return; + } + if (obj.type === "object") { if (!obj.properties || Object.keys(obj.properties).length === 0) { obj.properties = { diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index 37e5bde6..48bd1530 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -62,8 +62,13 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream // Always ensure tool_calls have id (some providers require it) ensureToolCallIds(result); - // Fix missing tool responses (insert empty tool_result if needed) - fixMissingToolResponses(result); + // Kiro performs stricter source-aware reconciliation after session replay. + // The generic helper inserts OpenAI `role: tool` messages, which a direct + // Claude→Kiro translator cannot consume and which cannot repair partial + // parallel tool results. + if (targetFormat !== FORMATS.KIRO) { + fixMissingToolResponses(result); + } // Capture thinking intent from the original (pre-translation) body, before any // format conversion strips/renames the fields. Applied after translation. diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index bcb8b97f..8651e819 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -6,17 +6,10 @@ * direct `claude:kiro` route in ../index.js uses; it is NOT reached through the * claude→openai→kiro pivot. * - * It reproduces the two 400-guards that live in openai-to-kiro.js so that a - * Claude client which omits the `tools` array on a follow-up turn (typical - * after client-side compaction) does not trip Kiro's schema validator and get - * "Improperly formed request" (HTTP 400): - * - * 1. flattenClaudeToolInteractions — when the client sent NO tools, collapse - * every tool_use / tool_result block to plain text so no structured tool - * reference survives to trigger the "tools required" rule. - * 2. reconcileOrphanedToolResults — when tools ARE present, fold any - * tool_result whose tool_use_id has no matching tool_use back into the - * user text instead of leaving a dangling structured reference. + * After session replay it delegates to the shared Kiro conversation + * canonicalizer. That layer enforces adjacent one-to-one tool use/results, + * repairs partial parallel calls, and flattens compacted structured references + * that can no longer be represented safely. * * It also handles the 9router-synthetic `-agentic` / `-thinking` suffixes and * the `enabled` reasoning trigger, matching @@ -27,91 +20,28 @@ import { FORMATS } from "../formats.js"; import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js"; import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js"; import { - resolveKiroModel, + resolveKiroModelIntent, + applyKiroThinkingOverride, resolveKiroThinkingBudget, buildThinkingSystemPrefix, 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"; - -/** Stringify a tool_use input as a readable line. */ -function toolUseToText(name, input) { - let argStr; - try { - argStr = typeof input === "string" ? input : JSON.stringify(input ?? {}); - } catch { - argStr = "{}"; - } - return `[Tool call: ${name || "unknown"}(${argStr})]`; -} - -/** Render a Claude tool_result block's content as a readable line. */ -function toolResultBlockToText(content) { - let text = ""; - if (typeof content === "string") { - text = content; - } else if (Array.isArray(content)) { - text = content - .map((c) => (typeof c === "string" ? c : c?.text || "")) - .filter(Boolean) - .join("\n"); - } else if (content) { - try { - text = JSON.stringify(content); - } catch { - text = ""; - } - } - return `[Tool result: ${text}]`; -} - -/** - * When the client sent no tools, rewrite every tool_use (assistant) and - * tool_result (user) content block into plain text. Keeps text + images. - * Returns a new messages array; never mutates the input. - */ -function flattenClaudeToolInteractions(messages) { - const out = []; - for (const msg of messages) { - if (!msg) continue; - - if (msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) { - const parts = []; - for (const block of msg.content) { - if (block.type === CLAUDE_BLOCK.TEXT && block.text) { - parts.push(block.text); - } else if (block.type === CLAUDE_BLOCK.TOOL_USE) { - parts.push(toolUseToText(block.name, block.input)); - } - } - out.push({ ...msg, content: parts.join("\n") }); - continue; - } - - if (msg.role === ROLE.USER && Array.isArray(msg.content)) { - const newContent = msg.content.map((block) => - block.type === CLAUDE_BLOCK.TOOL_RESULT - ? { type: CLAUDE_BLOCK.TEXT, text: toolResultBlockToText(block.content) } - : block - ); - out.push({ ...msg, content: newContent }); - continue; - } - - out.push(msg); - } - return out; -} +import { + canonicalizeKiroConversation, + normalizeKiroToolSpecs, +} from "../concerns/kiroConversation.js"; /** * Convert Claude messages to Kiro history + currentMessage. * Kiro requires alternating user/assistant turns; consecutive same-role * messages are merged. */ -function convertClaudeMessagesToKiro(messages, tools, model) { +function convertClaudeMessagesToKiro(messages, model) { const history = []; let currentMessage = null; @@ -120,27 +50,6 @@ function convertClaudeMessagesToKiro(messages, tools, model) { let pendingToolResults = []; let pendingImages = []; let currentRole = null; - let toolsInjected = false; - - const clientProvidedTools = Array.isArray(tools) && tools.length > 0; - - const buildToolSpecs = () => - tools.map((t) => { - const name = t.name; - const description = t.description || `Tool: ${name}`; - const schema = t.input_schema || {}; - const normalizedSchema = - Object.keys(schema).length === 0 - ? { type: "object", properties: {}, required: [] } - : { ...schema, required: schema.required ?? [] }; - return { - toolSpecification: { - name, - description, - inputSchema: { json: normalizedSchema }, - }, - }; - }); const flushPending = () => { if (currentRole === ROLE.USER) { @@ -155,15 +64,6 @@ function convertClaudeMessagesToKiro(messages, tools, model) { toolResults: pendingToolResults, }; } - // Attach tools to the first user turn only. - if (clientProvidedTools && !toolsInjected) { - if (!userMsg.userInputMessage.userInputMessageContext) { - userMsg.userInputMessage.userInputMessageContext = {}; - } - userMsg.userInputMessage.userInputMessageContext.tools = buildToolSpecs(); - toolsInjected = true; - } - history.push(userMsg); currentMessage = userMsg; pendingUserContent = []; @@ -207,7 +107,7 @@ function convertClaudeMessagesToKiro(messages, tools, model) { } pendingToolResults.push({ toolUseId: block.tool_use_id, - status: "success", + status: block.is_error ? "error" : "success", content: [{ text: resultContent }], }); } @@ -254,14 +154,7 @@ function convertClaudeMessagesToKiro(messages, tools, model) { } } - // Grab tools from the first history user turn before cleanup strips them. - const firstHistoryTools = - history[0]?.userInputMessage?.userInputMessageContext?.tools; - history.forEach((item) => { - if (item.userInputMessage?.userInputMessageContext?.tools) { - delete item.userInputMessage.userInputMessageContext.tools; - } if ( item.userInputMessage?.userInputMessageContext && Object.keys(item.userInputMessage.userInputMessageContext).length === 0 @@ -305,66 +198,9 @@ function convertClaudeMessagesToKiro(messages, tools, model) { currentMessage = { userInputMessage: { content: "", modelId: model } }; } - // Inject tools into currentMessage after cleanup if not already present. - if ( - firstHistoryTools?.length > 0 && - !currentMessage.userInputMessage.userInputMessageContext?.tools - ) { - if (!currentMessage.userInputMessage.userInputMessageContext) { - currentMessage.userInputMessage.userInputMessageContext = {}; - } - currentMessage.userInputMessage.userInputMessageContext.tools = - firstHistoryTools; - } - return { history: mergedHistory, currentMessage }; } -/** - * Fold orphaned toolResults (those whose toolUseId has no matching toolUse in - * any assistant turn) back into the user text, removing the dangling - * structured reference that makes Kiro 400. - */ -function reconcileOrphanedToolResults(history, currentMessage) { - const validIds = new Set(); - for (const h of history) { - const arm = h.assistantResponseMessage; - if (!arm) continue; - for (const tu of arm.toolUses || []) { - if (tu.toolUseId) validIds.add(tu.toolUseId); - } - } - - const carriers = currentMessage ? [...history, currentMessage] : history; - for (const item of carriers) { - const uim = item.userInputMessage; - const ctx = uim?.userInputMessageContext; - if (!ctx?.toolResults?.length) continue; - - const kept = []; - const salvaged = []; - for (const tr of ctx.toolResults) { - if (validIds.has(tr.toolUseId)) { - kept.push(tr); - } else { - const text = Array.isArray(tr.content) - ? tr.content.map((c) => c?.text || "").join("\n") - : ""; - salvaged.push(`[Tool result: ${text}]`); - } - } - - if (salvaged.length === 0) continue; - - const extra = salvaged.join("\n"); - uim.content = uim.content ? `${uim.content}\n\n${extra}` : extra; - ctx.toolResults = kept; - if (kept.length === 0 && !ctx.tools?.length) { - delete uim.userInputMessageContext; - } - } -} - function extractClaudeSystemText(system) { if (!system) return ""; if (typeof system === "string") return system; @@ -381,31 +217,21 @@ function extractClaudeSystemText(system) { * Build a Kiro payload directly from a Claude Messages API request body. */ export function claudeToKiroRequest(model, body, stream, credentials) { - let messages = Array.isArray(body.messages) ? body.messages : []; + const messages = Array.isArray(body.messages) ? body.messages : []; const tools = Array.isArray(body.tools) ? body.tools : []; - const clientProvidedTools = tools.length > 0; const maxTokens = body.max_tokens || 32000; const temperature = body.temperature; const topP = body.top_p; - const { upstream: upstreamModel, agentic } = resolveKiroModel(model); - const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model); + const modelIntent = resolveKiroModelIntent(model); + const { upstream: upstreamModel, agentic } = modelIntent; + const thinkingBody = applyKiroThinkingOverride(body, modelIntent.thinkingOverride); + const thinkingBudget = resolveKiroThinkingBudget(thinkingBody, credentials?.rawHeaders, modelIntent.model); + const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(thinkingBody, upstreamModel); + const usesNativeGptEffort = usesKiroNativeGptEffort(thinkingBody, upstreamModel); - // Guard 1: no client tools → flatten all tool interactions to text. - if (!clientProvidedTools) { - messages = flattenClaudeToolInteractions(messages); - } - - const { history, currentMessage } = convertClaudeMessagesToKiro( - messages, - tools, - upstreamModel - ); - - // Guard 2: tools present → reconcile dangling tool_results. - if (clientProvidedTools) { - reconcileOrphanedToolResults(history, currentMessage); - } + const { specs: toolSpecs, nameMap } = normalizeKiroToolSpecs(tools); + const { history, currentMessage } = convertClaudeMessagesToKiro(messages, upstreamModel); // api_key / idc / external_idp must never use the shared default ARN (belongs // to another account → 403 "bearer token invalid"); OAuth/social fall back to it. @@ -421,7 +247,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); @@ -452,7 +280,14 @@ export function claudeToKiroRequest(model, body, stream, credentials) { history, currentMessage, }); - const replayCurrent = replay.currentMessage?.userInputMessage || {}; + const canonical = canonicalizeKiroConversation({ + history: replay.history, + currentMessage: replay.currentMessage, + modelId: upstreamModel, + toolSpecs, + nameMap, + }); + const replayCurrent = canonical.currentMessage.userInputMessage; const userInputMessage = { content: replayCurrent.content || "", modelId: upstreamModel, @@ -474,14 +309,13 @@ export function claudeToKiroRequest(model, body, stream, credentials) { currentMessage: { userInputMessage, }, - history: replay.history, + history: canonical.history, }, agentMode: "vibe", }; 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..aa776949 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -8,158 +8,22 @@ import { v4 as uuidv4 } from "uuid"; import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js"; import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js"; import { - resolveKiroModel, + resolveKiroModelIntent, + applyKiroThinkingOverride, resolveKiroThinkingBudget, 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"; import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js"; - -/** Render a single tool call as a readable text line. */ -function toolCallToText(name, input) { - let argStr; - try { - argStr = typeof input === "string" ? input : JSON.stringify(input ?? {}); - } catch { - argStr = "{}"; - } - return `[Tool call: ${name || "unknown"}(${argStr})]`; -} - -/** Render a tool result (string or content-block array) as a text line. */ -function toolResultToText(content) { - const text = Array.isArray(content) - ? content.map(c => (typeof c === "string" ? c : c.text || "")).join("\n") - : (typeof content === "string" ? content : ""); - return `[Tool result: ${text}]`; -} - -/** - * Flatten all tool calls/results in a conversation into plain text. - * - * Kiro's schema validator requires a non-empty - * currentMessage.userInputMessageContext.tools array whenever the history - * references any tool use; otherwise it returns "Improperly formed request" - * (HTTP 400). A client can hit this by omitting the `tools` array on a - * follow-up request — typically after client-side compaction (e.g. OpenCode). - * - * Rather than fabricate stub tool specs — which would advertise tool-calling - * capability the client never requested and may not handle, risking a phantom - * tool call on an otherwise plain turn — we collapse the tool interaction into - * text. The request stays honest, and since no structured tool content - * remains, the validator's "tools required" rule never fires. - * - * Only invoked when the client did NOT send tools; when tools are present the - * structured form is preserved. - */ -function flattenToolInteractions(messages) { - const out = []; - - for (const msg of messages) { - // OpenAI tool-result message → user text line - if (msg.role === ROLE.TOOL) { - out.push({ role: ROLE.USER, content: toolResultToText(msg.content) }); - continue; - } - - if (msg.role === ROLE.ASSISTANT) { - const parts = []; - if (Array.isArray(msg.content)) { - for (const c of msg.content) { - if (c.type === CLAUDE_BLOCK.TOOL_USE) { - parts.push(toolCallToText(c.name, c.input)); - } else if (c.type === OPENAI_BLOCK.TEXT || c.text) { - parts.push(c.text || ""); - } - } - } else if (typeof msg.content === "string") { - parts.push(msg.content); - } - for (const tc of msg.tool_calls || []) { - parts.push(toolCallToText(tc.function?.name, tc.function?.arguments)); - } - out.push({ role: ROLE.ASSISTANT, content: parts.filter(Boolean).join("\n") }); - continue; - } - - // User messages: replace tool_result blocks with text, keep text + images. - if (msg.role === ROLE.USER && Array.isArray(msg.content)) { - const newContent = msg.content.map(c => - c.type === CLAUDE_BLOCK.TOOL_RESULT - ? { type: OPENAI_BLOCK.TEXT, text: toolResultToText(c.content) } - : c - ); - out.push({ ...msg, content: newContent }); - continue; - } - - out.push(msg); - } - - return out; -} - -/** - * Reconcile orphaned toolResults — those whose toolUseId has no matching - * toolUse in any assistant message. This happens when client-side compaction - * truncates the conversation and removes the assistant message containing the - * tool_use, but keeps the user message with the corresponding tool_result. - * - * A dangling structured reference makes Kiro return 400, so it must be removed. - * But the client deliberately kept the result content through compaction, so - * rather than discard it we fold it back into the user message as text — the - * same shape flattenToolInteractions() produces. The 400 trigger (the - * structured reference) is gone; the content survives. - * - * `messages` is every carrier that can hold toolResults — both history items - * and the popped-out currentMessage (orphans can land on either). - */ -function reconcileOrphanedToolResults(history, currentMessage) { - // Phase 1: collect all valid toolUseIds from assistant messages in history. - // (currentMessage is always a user turn, so it carries no toolUses.) - const validIds = new Set(); - for (const h of history) { - const arm = h.assistantResponseMessage; - if (!arm) continue; - for (const tu of arm.toolUses || []) { - if (tu.toolUseId) validIds.add(tu.toolUseId); - } - } - - // Phase 2: across history + currentMessage, keep results with a matching - // toolUse and salvage the rest as text. - const carriers = currentMessage ? [...history, currentMessage] : history; - for (const item of carriers) { - const uim = item.userInputMessage; - const ctx = uim?.userInputMessageContext; - if (!ctx?.toolResults?.length) continue; - - const kept = []; - const salvaged = []; - for (const tr of ctx.toolResults) { - if (validIds.has(tr.toolUseId)) { - kept.push(tr); - } else { - salvaged.push(toolResultToText(tr.content)); - } - } - - if (salvaged.length === 0) continue; // no orphans — leave untouched - - // Fold orphaned result content into the user text so it is not lost - const extra = salvaged.join("\n"); - uim.content = uim.content ? `${uim.content}\n\n${extra}` : extra; - - ctx.toolResults = kept; - if (kept.length === 0 && !ctx.tools?.length) { - delete uim.userInputMessageContext; - } - } -} +import { + canonicalizeKiroConversation, + normalizeKiroToolSpecs, +} from "../concerns/kiroConversation.js"; /** * Safely parse JSON string, returning fallback on failure. @@ -175,26 +39,15 @@ function safeJSONParse(str, fallback) { * * Returns { history, currentMessage }. */ -function convertMessages(messages, tools, model) { +function convertMessages(messages, model) { let history = []; let currentMessage = null; - const clientProvidedTools = tools && tools.length > 0; - - // When the client did not send tools, flatten any tool calls/results in the - // history into plain text (see flattenToolInteractions). This keeps the - // request honest and sidesteps Kiro's "tools required" 400, since no - // structured tool content survives to trigger it. - if (!clientProvidedTools) { - messages = flattenToolInteractions(messages); - } - let pendingUserContent = []; let pendingAssistantContent = []; let pendingToolResults = []; let pendingImages = []; let currentRole = null; - let toolsInjectedToFirstUserMsg = false; const flushPending = () => { if (currentRole === "user") { @@ -217,39 +70,6 @@ function convertMessages(messages, tools, model) { }; } - // Add tools to the user message that has no preceding assistant messages, - // OR the first user message (whichever comes first after any opening - // assistant messages). We track whether any user message has already - // received tools via a flag on the history array. - if (clientProvidedTools && !toolsInjectedToFirstUserMsg) { - if (!userMsg.userInputMessage.userInputMessageContext) { - userMsg.userInputMessage.userInputMessageContext = {}; - } - userMsg.userInputMessage.userInputMessageContext.tools = tools.map(t => { - const name = t.function?.name || t.name; - let description = t.function?.description || t.description || ""; - - if (!description.trim()) { - description = `Tool: ${name}`; - } - - const schema = t.function?.parameters || t.parameters || t.input_schema || {}; - // Normalize schema: Kiro requires required[] and proper type/properties - const normalizedSchema = Object.keys(schema).length === 0 - ? { type: "object", properties: {}, required: [] } - : { ...schema, required: schema.required ?? [] }; - - return { - toolSpecification: { - name, - description, - inputSchema: { json: normalizedSchema } - } - }; - }); - toolsInjectedToFirstUserMsg = true; - } - history.push(userMsg); currentMessage = userMsg; pendingUserContent = []; @@ -325,7 +145,7 @@ function convertMessages(messages, tools, model) { pendingToolResults.push({ toolUseId: block.tool_use_id, - status: "success", + status: block.is_error ? "error" : "success", content: [{ text: text }] }); }); @@ -337,7 +157,7 @@ function convertMessages(messages, tools, model) { const toolContent = typeof msg.content === "string" ? msg.content : ""; pendingToolResults.push({ toolUseId: msg.tool_call_id, - status: "success", + status: msg.is_error || msg.status === "error" ? "error" : "success", content: [{ text: toolContent }] }); } else if (content) { @@ -411,14 +231,8 @@ function convertMessages(messages, tools, model) { } } - // Grab tools from first history item BEFORE cleanup removes them - const firstHistoryTools = history[0]?.userInputMessage?.userInputMessageContext?.tools; - // Clean up history for Kiro API compatibility history.forEach(item => { - if (item.userInputMessage?.userInputMessageContext?.tools) { - delete item.userInputMessage.userInputMessageContext.tools; - } if (item.userInputMessage?.userInputMessageContext && Object.keys(item.userInputMessage.userInputMessageContext).length === 0) { delete item.userInputMessage.userInputMessageContext; @@ -471,33 +285,6 @@ function convertMessages(messages, tools, model) { }; } - // Reconcile orphaned toolResults across history AND currentMessage — when - // client-side compaction removes assistant messages containing tool_use but - // keeps the tool_result, the dangling reference triggers a Kiro 400. Fold the - // content back into the user text instead of discarding it. Run after - // currentMessage is finalized (an orphan can be merged into it) and before - // tool injection (which may re-add userInputMessageContext). - // - // Only needed on the tools-present path: when the client sent no tools, - // flattenToolInteractions already collapsed every toolResult to text, so - // there is nothing structured left to orphan. - if (clientProvidedTools) { - reconcileOrphanedToolResults(mergedHistory, currentMessage); - } - - // Inject tools into currentMessage AFTER cleanup. Tools only exist here when - // the client explicitly sent them (otherwise flattenToolInteractions already - // collapsed all tool content to text upstream, so there is nothing to carry). - const resolvedTools = firstHistoryTools; - - if (resolvedTools?.length > 0 && - !currentMessage.userInputMessage.userInputMessageContext?.tools) { - if (!currentMessage.userInputMessage.userInputMessageContext) { - currentMessage.userInputMessage.userInputMessageContext = {}; - } - currentMessage.userInputMessage.userInputMessageContext.tools = resolvedTools; - } - return { history: mergedHistory, currentMessage }; } @@ -511,12 +298,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 || []; @@ -525,10 +310,15 @@ export function openaiToKiroRequest(model, body, stream, credentials) { const temperature = body.temperature; const topP = body.top_p; - const { upstream: upstreamModel, agentic } = resolveKiroModel(model); - const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model); + const modelIntent = resolveKiroModelIntent(model); + const { upstream: upstreamModel, agentic } = modelIntent; + const thinkingBody = applyKiroThinkingOverride(body, modelIntent.thinkingOverride); + const thinkingBudget = resolveKiroThinkingBudget(thinkingBody, credentials?.rawHeaders, modelIntent.model); + const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(thinkingBody, upstreamModel); + const usesNativeGptEffort = usesKiroNativeGptEffort(thinkingBody, upstreamModel); - const { history, currentMessage } = convertMessages(messages, tools, upstreamModel); + const { specs: toolSpecs, nameMap } = normalizeKiroToolSpecs(tools); + const { history, currentMessage } = convertMessages(messages, upstreamModel); // API-key (headless) auth uses a raw CodeWhisperer credential whose profile is // account-specific. Injecting the shared builder-id/social *default* placeholder @@ -554,7 +344,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) { @@ -582,7 +372,14 @@ export function openaiToKiroRequest(model, body, stream, credentials) { history, currentMessage, }); - const replayCurrent = replay.currentMessage?.userInputMessage || {}; + const canonical = canonicalizeKiroConversation({ + history: replay.history, + currentMessage: replay.currentMessage, + modelId: upstreamModel, + toolSpecs, + nameMap, + }); + const replayCurrent = canonical.currentMessage.userInputMessage; const payload = { conversationState: { @@ -603,7 +400,7 @@ export function openaiToKiroRequest(model, body, stream, credentials) { }) } }, - history: replay.history + history: canonical.history }, agentMode: "vibe", }; @@ -612,7 +409,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/open-sse/utils/kiroSessionReplay.js b/open-sse/utils/kiroSessionReplay.js index 11cae9cd..d758eed6 100644 --- a/open-sse/utils/kiroSessionReplay.js +++ b/open-sse/utils/kiroSessionReplay.js @@ -42,6 +42,14 @@ function findFirstUserIndex(history) { return history.findIndex((item) => item?.userInputMessage); } +function hasToolResults(message) { + return !!message?.userInputMessage?.userInputMessageContext?.toolResults?.length; +} + +function canReplaceSessionStart(history, firstUserIndex) { + return firstUserIndex === 0 && !hasToolResults(history[firstUserIndex]); +} + function rememberSessionStart(key, entry) { if (sessionStartStore.size >= MAX_SESSION_STARTS) { sessionStartStore.delete(sessionStartStore.keys().next().value); @@ -73,10 +81,13 @@ export function applyKiroSessionReplay({ existing.lastUsed = Date.now(); const firstUserIndex = findFirstUserIndex(baseHistory); const sessionStart = ensureUserMessageModelId(clone(existing.sessionStart), modelId); - if (firstUserIndex >= 0) { + if (canReplaceSessionStart(baseHistory, firstUserIndex)) { baseHistory[firstUserIndex] = sessionStart; } else { baseHistory.unshift(sessionStart); + if (baseHistory.length === 1) { + baseHistory.push({ assistantResponseMessage: { content: "..." } }); + } } return { history: ensureHistoryModelIds(baseHistory, modelId), @@ -88,10 +99,18 @@ export function applyKiroSessionReplay({ const firstUserIndex = findFirstUserIndex(baseHistory); let sessionStart; let nextCurrent = ensureUserMessageModelId(baseCurrent, modelId); - if (firstUserIndex >= 0) { + if (canReplaceSessionStart(baseHistory, firstUserIndex)) { sessionStart = prefixUserMessage(baseHistory[firstUserIndex], contentPrefix, modelId); baseHistory[firstUserIndex] = clone(sessionStart); nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId); + } else if (firstUserIndex >= 0) { + sessionStart = prefixUserMessage( + { userInputMessage: { content: "", modelId } }, + contentPrefix, + modelId + ); + baseHistory.unshift(clone(sessionStart)); + nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId); } else { sessionStart = prefixUserMessage(baseCurrent, contentPrefix, modelId); nextCurrent = clone(sessionStart); diff --git a/package.json b/package.json index ba2170c5..fbedad57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.5.35", + "version": "0.5.45", "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/i18n/literals/pt-BR.json b/public/i18n/literals/pt-BR.json index 6edba2e7..f2b5f6d2 100644 --- a/public/i18n/literals/pt-BR.json +++ b/public/i18n/literals/pt-BR.json @@ -1,195 +1,988 @@ { - "Cancel": "Cancelar", - "Delete": "Excluir", - "Edit": "Editar", - "Save": "Salvar", - "Close": "Fechar", - "Add": "Adicionar", - "Remove": "Remover", - "Settings": "Configurações", - "Profile": "Perfil", - "Dashboard": "Painel de controle", - "Logout": "Sair", - "Login": "Conectar", - "Providers": "Provedores", - "Usage": "Estatísticas", + "(Caveman)": "(Caveman)", + "(Headroom)": "(Headroom)", + "(PXPIPE)": "(PXPIPE)", + "(Ponytail)": "(Ponytail)", + "(RTK)": "(RTK)", + "9Router (Entry)": "9Router (Inicial)", + "API": "API", + "API Endpoint": "Endpoint da API", "API Key": "Chave API", - "Connected": "Conectado", - "Disconnected": "Desconectado", - "Active": "Ativo", - "Inactive": "Inativo", - "Success": "Sucesso", - "Failed": "Falha", - "Error": "Erro", - "Warning": "Aviso", - "Info": "Informações", - "Loading": "Carregando", - "Search": "Pesquisar", - "Filter": "Filtrar", - "Sort": "Classificar", - "Export": "Exportar", - "Import": "Importar", - "Refresh": "Atualizar", - "Back": "Voltar", - "Next": "Próximo", - "Previous": "Anterior", - "Submit": "Enviar", - "Confirm": "Confirmar", - "Yes": "Sim", - "No": "Não", - "OK": "OK", - "Apply": "Aplicar", - "Reset": "Redefinir", - "Clear": "Limpar", - "Select": "Selecionar", - "Upload": "Enviar", - "Download": "Baixar", - "Copy": "Copiar", - "Paste": "Colar", - "Cut": "Cortar", - "Undo": "Desfazer", - "Redo": "Refazer", - "Name": "Nome", - "Description": "Descrição", - "Status": "Status", - "Type": "Tipo", - "Date": "Data", - "Time": "Hora", - "Created": "Criado", - "Updated": "Atualizado", - "Actions": "Ações", - "Details": "Detalhes", - "View": "Visualizar", - "New": "Novo", - "Total": "Total", - "Count": "Contagem", - "Price": "Preço", - "Cost": "Custo", - "Free": "Gratuito", - "Paid": "Pago", - "Enable": "Ativar", - "Disable": "Desativar", - "Enabled": "Ativado", - "Disabled": "Desativado", - "Online": "Online", - "Offline": "Offline", - "Available": "Disponível", - "Unavailable": "Indisponível", - "Required": "Obrigatório", - "Optional": "Opcional", - "Default": "Padrão", - "Custom": "Personalizado", - "Advanced": "Avançado", - "Basic": "Básico", - "Help": "Ajuda", - "Support": "Suporte", - "Documentation": "Documentação", - "Version": "Versão", - "Language": "Idioma", - "Theme": "Tema", - "Light": "Claro", - "Dark": "Escuro", - "Auto": "Automático", - "Endpoint": "Ponto de extremidade", - "Combos": "Combinações", - "Quota Tracker": "Rastreador de cota", - "MITM": "MITM", - "CLI Tools": "Ferramentas CLI", - "Console Log": "Log do console", - "System": "Sistema", - "Debug": "Depuração", - "Shutdown": "Desligar", - "Close Proxy": "Fechar proxy", - "Are you sure you want to close the proxy server?": "Tem certeza de que deseja fechar o servidor proxy?", - "Server Disconnected": "Servidor desconectado", - "The proxy server has been stopped.": "O servidor proxy foi parado.", - "Reload Page": "Recarregar página", - "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "O serviço está em execução no terminal. Você pode fechar esta página da web. O desligamento interromperá o serviço.", - "Manage your AI provider connections": "Gerencie suas conexões de provedor de IA", - "Model combos with fallback": "Combinações de modelos com fallback", - "Monitor your API usage, token consumption, and request logs": "Monitore seu uso de API, consumo de tokens e logs de solicitação", - "Intercept CLI tool traffic and route through 9Router": "Intercepte o tráfego da ferramenta CLI e roteie através do 9Router", - "Configure CLI tools": "Configurar ferramentas CLI", + "API Key (for Check)": "Chave API (para Verificação)", + "API Key Created": "Chave API Criada", + "API Keys": "Chaves de API", + "API Token": "Token de API", + "API Tokens": "Tokens de API", + "API Type": "Tipo de API", + "API Version": "Versão da API", "API endpoint configuration": "Configuração do ponto de extremidade da API", - "Manage your preferences": "Gerenciar suas preferências", - "Debug translation flow between formats": "Depurar fluxo de tradução entre formatos", - "Live server console output": "Saída do console do servidor ao vivo", + "AWS Builder ID": "AWS Builder ID", + "AWS IAM Identity Center": "AWS IAM Identity Center", + "AWS Region": "Região AWS", + "AWS region for the key (default: us-east-1)": "Região AWS para a chave (padrão: us-east-1)", + "AWS region for your Identity Center (default: us-east-1)": "Região AWS para seu Identity Center (padrão: us-east-1)", + "About": "Sobre", + "Access token will be auto-filled...": "O token de acesso será preenchido automaticamente...", + "Account": "Conta", + "Account ID": "ID da Conta", + "Account Resources": "Recursos da Conta", + "Accounts per page": "Contas por página", + "Action": "Ação", + "Actions": "Ações", + "Activate": "Ativar", + "Active": "Ativo", + "Active All": "Ativar Todos", + "Active:": "Ativo:", + "Add": "Adicionar", + "Add API Key": "Adicionar Chave de API", + "Add Anthropic Compatible": "Adicionar Compatível com Anthropic", + "Add Connection": "Adicionar Conexão", + "Add Custom Embedding": "Adicionar Embedding Personalizado", + "Add Custom MCP": "Adicionar MCP Personalizado", + "Add Custom Model": "Adicionar Modelo Personalizado", + "Add Model": "Adicionar Modelo", + "Add Model for GitHub Copilot": "Adicionar Modelo para GitHub Copilot", + "Add Model for OpenCode": "Adicionar Modelo para OpenCode", + "Add Model to Combo": "Adicionar Modelo ao Combo", + "Add New Provider": "Adicionar Novo Provedor", + "Add OpenAI Compatible": "Adicionar Compatível com OpenAI", + "Add Provider": "Adicionar Provedor", + "Add Proxy Pool": "Adicionar Pool de Proxy", + "Add a connection to enable importing models.": "Adicione uma conexão para ativar a importação de modelos.", + "Add connection using browser cookie": "Adicionar conexão usando cookie do navegador", + "Add model": "Adicionar modelo", + "Advanced": "Avançado", + "After PXPIPE": "Após PXPIPE", + "After authorization, copy the full URL from your browser address bar.": "Após a autorização, copie a URL completa da barra de endereço do navegador.", + "After installation, run": "Após a instalação, execute", + "All": "Todos", + "All AI Providers": "Todos os Provedores de IA", + "All Providers": "Todos os Provedores", + "All data stored on your machine": "Todos os dados armazenados em sua máquina", + "All models are responding normally.": "Todos os modelos estão respondendo normalmente.", + "All providers": "Todos os provedores", + "Allow dashboard access via tunnel": "Permitir acesso ao painel via túnel", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Solicitação do Antigravity/Copilot IDE → Redirecionamento DNS para localhost:443 → Proxy MITM intercepta → 9Router → resposta para Antigravity/Copilot", + "App Name": "Nome do App", + "Appearance": "Aparência", + "Appends (level) suffix to copied model names": "Adiciona sufixo (nível) aos nomes de modelo copiados", + "Apply": "Aplicar", + "Apply Proxy": "Aplicar Proxy", + "Applying...": "Aplicando...", + "Are you sure you want to close the proxy server?": "Tem certeza de que deseja fechar o servidor proxy?", + "Audio File": "Arquivo de Áudio", + "Auth Mode": "Modo de Autenticação", + "Authenticate": "Autenticar", + "Authentication Successful": "Autenticação Bem-sucedida", + "Authentication Successful!": "Autenticação Bem-sucedida!", + "Authless": "Sem Autenticação", + "Authorization Successful!": "Autorização Bem-sucedida!", + "Authorize": "Autorizar", + "Auto": "Automático", + "Auto (by priority)": "Auto (por prioridade)", + "Auto Refresh (3s)": "Atualização Automática (3s)", + "Auto-detect": "Detecção Automática", + "Auto-detecting token...": "Detectando token automaticamente...", + "Auto-detecting tokens...": "Detectando tokens automaticamente...", + "Auto-ping": "Ping Automático", + "Auto-refresh": "Atualização automática", + "Available": "Disponível", + "Azure Endpoint": "Endpoint Azure", + "Azure OpenAI Configuration": "Configuração Azure OpenAI", + "BXAuth=xxx; ...": "BXAuth=xxx; ...", + "Back": "Voltar", + "Back to CLI Tools": "Voltar para Ferramentas CLI", + "Back to Providers": "Voltar para Provedores", + "Base URL": "URL Base", + "Basic": "Básico", + "Batch Import": "Importar em Lote", + "Batch Import Proxies": "Importar Proxies em Lote", + "Batch Size": "Tamanho do lote", + "Bias the model toward minimal code: YAGNI, reuse stdlib, deletion over addition": "Tendenciar o modelo para código mínimo: YAGNI, reutilizar stdlib, deletar ao invés de adicionar", + "Binary File": "Arquivo Binário", + "Browse MCP Marketplace": "Explorar Marketplace MCP", + "Browse source, README, and examples.": "Navegue pelo código fonte, README e exemplos.", + "Browser Control (Browser MCP)": "Controle do Navegador (Browser MCP)", + "Bulk Add": "Adição em Massa", + "Bypassed": "Ignorado", + "CLI Tools": "Ferramentas CLI", + "Cache Creation": "Criação de Cache", + "Cache Creation:": "Criação de Cache:", + "Cached": "Em Cache", + "Cached Tokens": "Tokens em Cache", + "Cached Tokens:": "Tokens em Cache:", + "Cached:": "Em Cache:", + "Calls per account before switching": "Chamadas por conta antes de alternar", + "Calls per combo model before switching": "Chamadas por modelo de combo antes de alternar", + "Cancel": "Cancelar", + "Capacity auto-switch": "Troca automática de capacidade", + "Cert": "Certificado", + "Change Log": "Registro de Alterações", + "Changelog": "Registro de Alterações", + "Chat": "Chat", + "Chat / code-gen via OpenAI or Anthropic format with streaming.": "Chat / geração de código via formato OpenAI ou Anthropic com streaming.", + "Check again": "Verificar novamente", + "Checking Claude CLI...": "Verificando Claude CLI...", + "Checking Claude Cowork...": "Verificando Claude Cowork...", + "Checking Cline...": "Verificando Cline...", + "Checking Codex CLI...": "Verificando Codex CLI...", + "Checking Copilot config...": "Verificando configuração do Copilot...", + "Checking DeepSeek TUI...": "Verificando DeepSeek TUI...", + "Checking Factory Droid CLI...": "Verificando Factory Droid CLI...", + "Checking Grok Build...": "Verificando Grok Build...", + "Checking Hermes Agent...": "Verificando Hermes Agent...", + "Checking Kilo Code...": "Verificando Kilo Code...", + "Checking Open Claw CLI...": "Verificando Open Claw CLI...", + "Checking OpenCode CLI...": "Verificando OpenCode CLI...", + "Checking jcode CLI...": "Verificando jcode CLI...", + "Checking...": "Verificando...", + "Checking…": "Verificando…", + "Choose how to authenticate with GitLab Duo:": "Escolha como autenticar com GitLab Duo:", + "Choose your authentication method:": "Escolha seu método de autenticação:", + "Claude CLI - Manual Configuration": "Claude CLI - Configuração Manual", + "Claude CLI not detected locally": "Claude CLI não detectado localmente", + "Claude Cowork - Manual Configuration": "Claude Cowork - Configuração Manual", + "Claude Desktop (Cowork mode) not detected": "Claude Desktop (modo Cowork) não detectado", + "Clear": "Limpar", + "Clear (inherit main model for subagents)": "Limpar (herdar modelo principal para subagentes)", + "Clear (will use main model)": "Limpar (usará o modelo principal)", + "Clear Filters": "Limpar Filtros", + "Clear search": "Limpar pesquisa", + "Click": "Clique", + "Click a model to set/clear active": "Clique em um modelo para ativar/desativar", + "Click to add, click again to remove.": "Clique para adicionar, clique novamente para remover.", + "Click to add, click again to remove. Changes are saved automatically.": "Clique para adicionar, clique novamente para remover. As alterações são salvas automaticamente.", + "Click to edit": "Clique para editar", + "Client ID": "ID do Cliente", + "Client Secret": "Segredo do Cliente", + "Client Secret (optional for PKCE)": "Segredo do Cliente (opcional para PKCE)", + "Cline - Manual Configuration": "Cline - Configuração Manual", + "Cline not detected locally": "Cline não detectado localmente", + "Clone and run locally": "Clone e execute localmente", + "Close": "Fechar", + "Close Proxy": "Fechar proxy", + "Close menu": "Fechar menu", + "Close provider filter": "Fechar filtro de provedor", + "Close reset credit expiry modal": "Fechar redefinição de crédito", + "Close test results": "Fechar resultados de teste", + "Closing in": "Fechando em", + "Cloudflare Relay": "Cloudflare Relay", + "Cloudflare Tunnel": "Túnel Cloudflare", + "Cloudflare Workers AI": "Cloudflare Workers AI", + "Codex CLI - Manual Configuration": "Codex CLI - Configuração Manual", + "Codex CLI not detected locally": "Codex CLI não detectado localmente", + "Codex Reset Credit Expiry": "Redefinir Expiração de Crédito do Codex", + "Combo Name": "Nome do Combo", + "Combo Round Robin": "Combo Round Robin", + "Combo Sticky Limit": "Limite Fixo do Combo", + "Combos": "Combinações", + "Coming soon...": "Em breve...", + "Comma-separated hostnames/domains to bypass the proxy.": "Nomes de host/domínios separados por vírgula para contornar o proxy.", + "Company": "Empresa", + "Compress LLM output": "Comprimir saída do LLM", + "Compress context": "Comprimir contexto", + "Compress prompts as images": "Comprimir prompts como imagens", + "Compress prompts via /v1/compress before routing to the model": "Comprimir prompts via /v1/compress antes de rotear para o modelo", + "Compress tool output": "Comprimir saída da ferramenta", + "Compressed": "Comprimido", + "Compressed (est.)": "Comprimido (est.)", + "Compression extras": "Extras de compressão", + "Configure CLI tools": "Configurar ferramentas CLI", + "Configure a new AI provider to use with your applications.": "Configure um novo provedor de IA para usar com suas aplicações.", + "Configure pricing rates for cost tracking and calculations": "Configure taxas de preço para rastreamento e cálculo de custos", + "Configure providers and API keys via web interface": "Configure provedores e chaves de API via interface web", + "Confirm": "Confirmar", + "Confirm New Password": "Confirmar nova senha", + "Confirm Password": "Confirmar Senha", + "Confirm new password": "Confirme a nova senha", + "Connect": "Conectar", + "Connect Cursor IDE": "Conectar Cursor IDE", + "Connect GitLab Duo": "Conectar GitLab Duo", + "Connect Kiro": "Conectar Kiro", + "Connect with OAuth2": "Conectar com OAuth2", + "Connect your account using OAuth2 authentication.": "Conecte sua conta usando autenticação OAuth2.", + "Connected": "Conectado", + "Connected Successfully!": "Conectado com Sucesso!", + "Connection": "Conexão", + "Connection Failed": "Falha na Conexão", + "Connections": "Conexões", + "Console Log": "Log do console", + "Content": "Conteúdo", + "Continue": "Continuar", + "Continue to summary": "Continuar para resumo", + "Continue with GitHub": "Continuar com GitHub", + "Continue with Google": "Continuar com Google", + "Cookie": "Cookie", + "Cookie String": "String de Cookie", + "Copied": "Copiado", + "Copied!": "Copiado!", + "Copy": "Copiar", + "Copy This URL": "Copiar Esta URL", + "Copy combo name": "Copiar nome do combo", + "Copy install command": "Copiar comando de instalação", + "Copy link": "Copiar link", + "Copy the entire cookie string (must include BXAuth)": "Copie a string completa do cookie (deve incluir BXAuth)", + "Cost": "Custo", + "Cost Calculation:": "Cálculo de Custo:", + "Costs": "Custos", + "Could not read Cursor database automatically.": "Não foi possível ler o banco de dados do Cursor automaticamente.", + "Count": "Contagem", + "Create": "Criar", + "Create API Key": "Criar Chave API", + "Create Combo": "Criar Combo", + "Create Cowork Combo": "Criar Combo Cowork", + "Create Key": "Criar Chave", + "Create Provider": "Criar Provedor", + "Create Token": "Criar Token", + "Create a": "Criar um(a)", + "Create a proxy pool entry, then assign it to connections.": "Crie uma entrada de pool de proxy e atribua-a às conexões.", "Create model combos with fallback support": "Crie combinações de modelos com suporte a fallback", - "Local Mode": "Modo local", - "Running on your machine": "Executando em sua máquina", + "Create your first API key to get started": "Crie sua primeira chave de API para começar", + "Created": "Criado", + "Current": "Atual", + "Current Password": "Senha atual", + "Current Pricing Overview": "Visão Geral de Preços Atual", + "Current password": "Senha atual", + "Cursor IDE not detected. Please paste your tokens manually.": "Cursor IDE não detectado. Cole seus tokens manualmente.", + "Custom": "Personalizado", + "Custom Pricing:": "Preço Personalizado:", + "Custom Token": "Token Personalizado", + "Custom accounts per page": "Contas personalizadas por página", + "Custom...": "Personalizado...", + "Cut": "Cortar", + "Cycle through accounts to distribute load": "Percorrer contas para distribuir carga", + "Cycle through providers in combos instead of always starting with first": "Percorrer provedores em combos ao invés de sempre começar pelo primeiro", + "DNS off": "DNS desligado", + "Dark": "Escuro", + "Dashboard": "Painel", + "Data Location:": "Localização dos Dados:", + "Data flows seamlessly from your application through our intelligent routing layer to the best provider for the job.": "Os dados fluem perfeitamente da sua aplicação através de nossa camada de roteamento inteligente para o melhor provedor.", "Database Location": "Localização do banco de dados", - "Download Backup": "Baixar backup", - "Import Backup": "Importar backup", "Database backup downloaded": "Backup do banco de dados baixado", "Database imported successfully": "Banco de dados importado com sucesso", - "Security": "Segurança", - "Require login": "Exigir login", - "When ON, dashboard requires password. When OFF, access without login.": "Quando ATIVO, o painel requer senha. Quando DESATIVO, acesso sem login.", - "Current Password": "Senha atual", - "Enter current password": "Digite a senha atual", - "New Password": "Nova senha", - "Enter new password": "Digite a nova senha", - "Confirm New Password": "Confirmar nova senha", - "Confirm new password": "Confirme a nova senha", - "Update Password": "Atualizar senha", - "Set Password": "Definir senha", - "Password updated successfully": "Senha atualizada com sucesso", - "Passwords do not match": "As senhas não correspondem", - "Routing Strategy": "Estratégia de roteamento", - "Round Robin": "Round Robin", - "Cycle through accounts to distribute load": "Percorrer contas para distribuir carga", - "Sticky Limit": "Limite pegajoso", - "Calls per account before switching": "Chamadas por conta antes de alternar", - "Network": "Rede", - "Outbound Proxy": "Proxy de saída", - "Enable proxy for OAuth + provider outbound requests.": "Ativar proxy para OAuth + solicitações de saída do provedor.", - "Proxy URL": "URL do proxy", - "Leave empty to inherit existing env proxy (if any).": "Deixe em branco para herdar o proxy env existente (se houver).", - "No Proxy": "Sem proxy", - "Comma-separated hostnames/domains to bypass the proxy.": "Nomes de host/domínios separados por vírgula para contornar o proxy.", - "Test proxy URL": "Testar URL do proxy", - "Proxy settings applied": "Configurações de proxy aplicadas", - "Proxy enabled": "Proxy ativado", - "Proxy disabled": "Proxy desativado", - "Proxy test OK": "Teste de proxy OK", - "Proxy test failed": "Falha no teste de proxy", - "Please enter a Proxy URL to test": "Por favor, digite uma URL de proxy para testar", - "Observability": "Observabilidade", + "Date": "Data", + "DateTime": "Data e Hora", + "Deactivate": "Desativar", + "Debug": "Depuração", + "Debug translation flow between formats": "Depurar fluxo de tradução entre formatos", + "DeepSeek TUI - Manual Configuration": "DeepSeek TUI - Configuração Manual", + "DeepSeek TUI not detected locally": "DeepSeek TUI não detectado localmente", + "Default": "Padrão", + "Default Model": "Modelo Padrão", + "Delete": "Excluir", + "Delete connection": "Excluir conexão", + "Delete saved endpoint": "Excluir endpoint salvo", + "Delete selected preset": "Excluir predefinição selecionada", + "Deno Deploy API Token": "Token de API Deno Deploy", + "Deno Deploy v2 runs on a high-performance global edge network": "Deno Deploy v2 roda em uma rede edge global de alto desempenho", + "Deno Relay": "Deno Relay", + "Deploy Cloudflare Relay": "Implantar Relay Cloudflare", + "Deploy Deno Relay": "Implantar Relay Deno", + "Deploy Relay": "Implantar Relay", + "Deploy Vercel Relay": "Implantar Relay Vercel", + "Deploy multiple relays for maximum IP diversity": "Implantar múltiplos relays para máxima diversidade de IP", + "Deploy multiple relays on different accounts for more IP diversity": "Implantar múltiplos relays em contas diferentes para mais diversidade de IP", + "Deployment Name": "Nome da Implantação", + "Description": "Descrição", + "Detail": "Detalhe", + "Details": "Detalhes", + "Dimensions": "Dimensões", + "Disable": "Desativar", + "Disable All": "Desativar Todos", + "Disable Tailscale": "Desativar Tailscale", + "Disable Tunnel": "Desativar Túnel", + "Disable connections with depleted quota on the current page": "Desativar conexões com cota esgotada na página atual", + "Disable this model": "Desativar este modelo", + "Disabled": "Desativado", + "Disconnected": "Desconectado", + "Dismiss notification": "Dispensar notificação", + "Display Name": "Nome de Exibição", + "Display language": "Idioma de exibição", + "Docs": "Documentação", + "Documentation": "Documentação", + "Donate": "Doar", + "Done": "Concluído", + "Download": "Baixar", + "Download Backup": "Baixar backup", + "Drag to reorder": "Arraste para reordenar", + "Duration": "Duração", + "Edit": "Editar", + "Edit Connection": "Editar Conexão", + "Edit Pricing": "Editar Preços", + "Edit connection": "Editar conexão", + "Edit hosts file manually to add the following entries:": "Edite o arquivo hosts manualmente para adicionar as seguintes entradas:", + "Email": "E-mail", + "Embeddings": "Embeddings", + "Enable": "Ativar", + "Enable DNS per tool below to activate interception": "Ativar DNS para cada ferramenta abaixo para ativar a interceptação", "Enable Observability": "Ativar observabilidade", - "Turn request detail recording on/off globally": "Ativar/desativar globalmente o registro de detalhes da solicitação", + "Enable Tunnel": "Ativar Túnel", + "Enable connections that still have quota on the current page": "Ativar conexões que ainda têm cota na página atual", + "Enable proxy for OAuth + provider outbound requests.": "Ativar proxy para OAuth + solicitações de saída do provedor.", + "Enabled": "Ativado", + "End Date": "Data Final", + "Endpoint": "Ponto de extremidade", + "Endpoint is exposed without an API key.": "O endpoint está exposto sem uma chave de API.", + "Enter current password": "Digite a senha atual", + "Enter model id": "Digite o ID do modelo", + "Enter model id (provider-specific)": "Digite o ID do modelo (específico do provedor)", + "Enter new API key": "Digite a nova chave de API", + "Enter new password": "Digite a nova senha", + "Enter password": "Digite a senha", + "Enter sudo password": "Digite a senha sudo", + "Enter the model ID exactly as your compatible endpoint expects it.": "Digite o ID do modelo exatamente como seu endpoint compatível espera.", + "Enter your API key": "Digite sua chave de API", + "Enter your password to access the dashboard": "Digite sua senha para acessar o painel", + "Enter your sudo password to start/stop MITM server": "Digite sua senha sudo para iniciar/parar o servidor MITM", + "EnvironmentVariables": "Variáveis de Ambiente", + "Error": "Erro", + "Error updating setting:": "Erro ao atualizar configuração:", + "Est. Cost": "Custo Est.", + "Estimated, not actual billing": "Estimado, não é a cobrança real", + "Everything you need to manage your AI infrastructure in one place, built for scale.": "Tudo que você precisa para gerenciar sua infraestrutura de IA em um só lugar, projetado para escala.", + "Exa MCP": "Exa MCP", + "Example": "Exemplo", + "Expires At": "Expira Em", + "Expiring first": "Expirando primeiro", + "Export": "Exportar", + "External": "Externo", + "FREE": "GRATUITO", + "Factory Droid - Manual Configuration": "Factory Droid - Configuração Manual", + "Factory Droid CLI not detected locally": "Factory Droid CLI não detectado localmente", + "Fail request if proxy is unreachable instead of falling back to direct.": "Falhar requisição se o proxy estiver inacessível ao invés de cair para direto.", + "Failed": "Falha", + "Failed to load usage statistics.": "Falha ao carregar estatísticas de uso.", + "Failed to start proxy": "Falha ao iniciar proxy", + "Failed to start server": "Falha ao iniciar o servidor", + "Failed to stop server": "Falha ao parar o servidor", + "Fallback": "Fallback", + "Features": "Recursos", + "Filter": "Filtrar", + "Filter accounts by status": "Filtrar contas por status", + "Filter naming": "Filtrar nomenclatura", + "Filter naming requests": "Solicitações de filtro de nomenclatura", + "Filter quota providers": "Filtrar provedores de cota", + "Filters": "Filtros", + "Find MCPs →": "Encontrar MCPs →", + "First Page": "Primeira Página", + "Flush Interval (ms)": "Intervalo de liberação (ms)", + "For enterprise users with custom AWS IAM Identity Center.": "Para usuários empresariais com AWS IAM Identity Center personalizado.", + "Format": "Formato", + "Found on the right side of the Cloudflare dashboard overview page.": "Encontrado no lado direito da página de visão geral do painel Cloudflare.", + "Free": "Gratuito", + "Free Tier Providers": "Provedores Gratuitos", + "Free tier: 100,000 requests per day": "Camada gratuita: 100.000 requisições por dia", + "Free tier: 100GB bandwidth/month, 500K edge invocations": "Camada gratuita: 100GB largura de banda/mês, 500K invocações edge", + "Fresh API key obtained": "Nova chave de API obtida", + "Fusion": "Fusão", + "General": "Geral", + "Get 9Remote": "Obter 9Remote", + "Get API Key": "Obter Chave de API", + "Get API Key →": "Obter Chave de API →", + "Get Started": "Começar", + "Get Started in 30 Seconds": "Comece em 30 Segundos", + "Get started": "Começar", + "Get token →": "Obter token →", + "GitHub": "GitHub", + "GitHub Account": "Conta GitHub", + "GitHub Copilot - Manual Configuration": "GitHub Copilot - Configuração Manual", + "GitLab Access Tokens": "Tokens de Acesso GitLab", + "GitLab Applications": "Aplicativos GitLab", + "GitLab Base URL": "URL Base do GitLab", + "Go to": "Ir para", + "Google Account": "Conta Google", + "Granted At": "Concedido Em", + "Grok Build - Manual Configuration": "Grok Build - Configuração Manual", + "Grok Build not detected locally": "Grok Build não detectado localmente", + "Group models under one name, then pick a strategy per combo:": "Agrupe modelos sob um nome e escolha uma estratégia por combo:", + "Headroom": "Headroom", + "Headroom proxy is reachable. You can enable the token saver.": "Proxy Headroom está acessível. Você pode ativar o economizador de tokens.", + "Health check": "Verificação de integridade", + "Healthy": "Saudável", + "Help": "Ajuda", + "Hermes Agent - Manual Configuration": "Hermes Agent - Configuração Manual", + "Hermes Agent not detected locally": "Hermes Agent não detectado localmente", + "Hidden:": "Oculto:", + "Hide this quota row": "Ocultar esta linha de cota", + "High performance global routing and IP masking via Cloudflare Workers": "Roteamento global de alto desempenho e mascaramento de IP via Cloudflare Workers", + "History": "Histórico", + "How 9Router Works": "Como o 9Router Funciona", + "How Pricing Works": "Como Funcionam os Preços", + "How it Works": "Como Funciona", + "How it works:": "Como funciona:", + "How to generate API token:": "Como gerar o token de API:", + "How to generate your API Token:": "Como gerar seu Token de API:", + "How to get cookie:": "Como obter o cookie:", + "ID:": "ID:", + "Image Generation": "Geração de Imagens", + "Images": "Imagens", + "Import": "Importar", + "Import Backup": "Importar backup", + "Import CLIProxyAPI JSON": "Importar JSON CLIProxyAPI", + "Import Token": "Importar Token", + "In / Out": "Entrada / Saída", + "Inactive": "Inativo", + "Inactive pools are ignored by runtime resolution.": "Pools inativos são ignorados pela resolução em tempo de execução.", + "Info": "Informações", + "Initializing...": "Inicializando...", + "Input": "Entrada", + "Input Tokens": "Tokens de Entrada", + "Input Tokens:": "Tokens de Entrada:", + "Input:": "Entrada:", + "Install": "Instalar", + "Install 9Router": "Instalar 9Router", + "Install 9Router, configure your providers via web dashboard, and start routing AI requests.": "Instale o 9Router, configure seus provedores via painel web e comece a rotear requisições de IA.", + "Install Chrome extension": "Instalar extensão Chrome", + "Install Cline VS Code extension or CLI from": "Instalar extensão Cline VS Code ou CLI de", + "Install Kilo Code from": "Instalar Kilo Code de", + "Install Tailscale": "Instalar Tailscale", + "Install [ml]": "Instalar [ml]", + "Install command:": "Comando de instalação:", + "Install failed": "Falha na instalação", + "Install jcode to enable automatic configuration:": "Instale jcode para ativar a configuração automática:", + "Install then click Start:": "Instale e clique em Iniciar:", + "Install via npm:": "Instalar via npm:", + "Installation Guide": "Guia de Instalação", + "Installing Tailscale...": "Instalando Tailscale...", + "Installing…": "Instalando…", + "Interactive diagram visible on desktop": "Diagrama interativo visível no desktop", + "Intercept CLI tool traffic and route through 9Router": "Intercepte o tráfego da ferramenta CLI e roteie através do 9Router", + "Invalid": "Inválido", + "Issuer URL": "URL do Emissor", + "JSON Response": "Resposta JSON", + "Join developers who are streamlining their AI integrations with 9Router.": "Junte-se aos desenvolvedores que estão otimizando suas integrações de IA com o 9Router.", + "Judge": "Julgador", + "KeepAlive": "KeepAlive", + "Key Name": "Nome da Chave", + "Kill this process to start MITM Server?": "Encerrar este processo para iniciar o Servidor MITM?", + "Kilo Code - Manual Configuration": "Kilo Code - Configuração Manual", + "Kilo Code not detected locally": "Kilo Code não detectado localmente", + "Kiro IDE not detected. Please paste your refresh token manually.": "Kiro IDE não detectado. Cole seu token de atualização manualmente.", + "Kompress-v2 HF model for prose/agentic traces (~+1GB)": "Modelo Kompress-v2 HF para texto/rastros agênticos (~+1GB)", + "Label": "Rótulo", + "Language": "Idioma", + "Last Page": "Última Página", + "Latency": "Latência", + "Latency:": "Latência:", + "Lazy senior dev": "Dev sênior preguiçoso", + "Leave blank to inherit Main Model. Each override keeps its own context window.": "Deixe em branco para herdar o Modelo Principal. Cada substituição mantém sua própria janela de contexto.", + "Leave blank to keep existing secret": "Deixe em branco para manter o segredo existente", + "Leave empty for public PKCE app": "Deixe vazio para app PKCE público", + "Leave empty to inherit existing env proxy (if any).": "Deixe em branco para herdar o proxy env existente (se houver).", + "Legal": "Legal", + "Light": "Claro", + "Live server console output": "Saída do console do servidor ao vivo", + "Load": "Carregar", + "Loading": "Carregando", + "Loading logs...": "Carregando logs...", + "Loading pricing data...": "Carregando dados de preço...", + "Loading registry...": "Carregando registro...", + "Loading reset credits...": "Carregando créditos de redefinição...", + "Loading...": "Carregando...", + "Local": "Local", + "Local Mode": "Modo local", + "Local Mode - All data stored on your machine": "Modo Local - Todos os dados armazenados em sua máquina", + "Local Plugins": "Plugins Locais", + "Login": "Conectar", + "Login Button Label": "Texto do Botão de Login", + "Login URL": "URL de Login", + "Login to your account": "Conecte-se à sua conta", + "Login with your GitHub account (manual callback).": "Faça login com sua conta GitHub (callback manual).", + "Login with your Google account (manual callback).": "Faça login com sua conta Google (callback manual).", + "Logout": "Sair", + "Logs": "Logs", + "Logs are loaded from the request history database.": "Logs são carregados do banco de dados de histórico de requisições.", + "MCP": "MCP", + "MIT License": "Licença MIT", + "MITM": "MITM", + "MITM Server": "Servidor MITM", + "MITM Tools": "Ferramentas MITM", + "Machine ID will be auto-filled...": "O ID da máquina será preenchido automaticamente...", + "Main Model": "Modelo Principal", + "Manage": "Gerenciar", + "Manage your AI provider connections": "Gerencie suas conexões de provedor de IA", + "Manage your preferences": "Gerenciar suas preferências", + "Manual / current endpoint": "Endpoint manual / atual", + "Manual Callback Required": "Callback Manual Necessário", + "Manual Config": "Configuração Manual", + "Manual configuration is still available if 9router is deployed on a remote server.": "A configuração manual ainda está disponível se o 9Router estiver implantado em um servidor remoto.", + "Mask (URL)": "Máscara (URL)", + "Max JSON Size (KB)": "Tamanho máximo de JSON (KB)", "Max Records": "Número máximo de registros", "Maximum request detail records to keep (older records are auto-deleted)": "Número máximo de registros de detalhes de solicitação a manter (registros antigos são excluídos automaticamente)", - "Batch Size": "Tamanho do lote", - "Number of items to accumulate before writing to database (higher = better performance)": "Número de itens a acumular antes de gravar no banco de dados (maior = melhor desempenho)", - "Flush Interval (ms)": "Intervalo de liberação (ms)", - "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Tempo máximo de espera antes de liberar o buffer (evita perda de dados durante baixo tráfego)", - "Max JSON Size (KB)": "Tamanho máximo de JSON (KB)", "Maximum size for each JSON field (request/response) before truncation": "Tamanho máximo para cada campo JSON (solicitação/resposta) antes do truncamento", - "All data stored on your machine": "Todos os dados armazenados em sua máquina", - "MITM Server": "Servidor MITM", - "Running": "Executando", - "Stopped": "Parado", - "Cert": "Certificado", - "Server": "Servidor", - "Purpose:": "Propósito:", - "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Use Antigravity IDE e GitHub Copilot → com QUALQUER provedor/modelo do 9Router", - "How it works:": "Como funciona:", - "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Solicitação do Antigravity/Copilot IDE → Redirecionamento DNS para localhost:443 → Proxy MITM intercepta → 9Router → resposta para Antigravity/Copilot", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Tempo máximo de espera antes de liberar o buffer (evita perda de dados durante baixo tráfego)", + "Media Providers": "Provedores de Mídia", + "Menu": "Menu", + "Message AI": "IA de Mensagens", + "Messages": "Mensagens", + "Minimum prompt size (chars)": "Tamanho mínimo do prompt (caracteres)", + "Model": "Modelo", + "Model ID": "ID do Modelo", + "Model ID (for Check)": "ID do Modelo (para Verificação)", + "Model ID (from OpenRouter)": "ID do Modelo (do OpenRouter)", + "Model ID (optional)": "ID do Modelo (opcional)", + "Model Status": "Status do Modelo", + "Model combos with fallback": "Combinações de modelos com fallback", + "Model is reachable": "Modelo está acessível", + "Model list is filtered from connected providers.": "Lista de modelos é filtrada dos provedores conectados.", + "Model mappings will be available soon.": "Mapeamentos de modelo estarão disponíveis em breve.", + "Model:": "Modelo:", + "Models": "Modelos", + "Monitor your API usage, token consumption, and request logs": "Monitore seu uso de API, consumo de tokens e logs de solicitação", + "More on GitHub": "Mais no GitHub", + "Move down": "Mover para baixo", + "Move up": "Mover para cima", + "My Profile": "Meu Perfil", + "NPM": "NPM", + "Name": "Nome", + "Navigate to home": "Navegar para início", + "Network": "Rede", + "New": "Novo", + "New Password": "Nova senha", + "New password": "Nova senha", + "Next": "Próximo", + "Next accounts page": "Próxima página de contas", + "No": "Não", + "No API keys yet": "Nenhuma chave de API ainda", "No API keys — create one in Keys page": "Sem chaves de API — crie uma na página Chaves", - "sk_9router (default)": "sk_9router (padrão)", + "No MCPs added": "Nenhum MCP adicionado", + "No PXPIPE activity yet": "Nenhuma atividade PXPIPE ainda", + "No Providers Connected": "Nenhum Provedor Conectado", + "No Proxy": "Sem proxy", + "No active connections found for this group.": "Nenhuma conexão ativa encontrada para este grupo.", + "No active proxy pools available.": "Nenhum pool de proxy ativo disponível.", + "No authentication required": "Nenhuma autenticação necessária", + "No combos yet": "Nenhum combo ainda", + "No combos yet.": "Nenhum combo ainda.", + "No connections": "Nenhuma conexão", + "No connections yet": "Nenhuma conexão ainda", + "No console logs yet.": "Nenhum log de console ainda.", + "No conversations yet.": "Nenhuma conversa ainda.", + "No data for this period": "Nenhum dado para este período", + "No install log yet.": "Nenhum log de instalação ainda.", + "No key configured": "Nenhuma chave configurada", + "No language selected": "Nenhum idioma selecionado", + "No languages found.": "Nenhum idioma encontrado.", + "No logs recorded yet.": "Nenhum log registrado ainda.", + "No models": "Nenhum modelo", + "No models added yet": "Nenhum modelo adicionado ainda", + "No models found": "Nenhum modelo encontrado", + "No models selected": "Nenhum modelo selecionado", + "No per-request CPU time limits (unlike Vercel/Cloudflare)": "Sem limites de tempo de CPU por requisição (diferente de Vercel/Cloudflare)", + "No pricing data available": "Nenhum dado de preço disponível", + "No providers connected": "Nenhum provedor conectado", + "No providers match your search": "Nenhum provedor corresponde à sua pesquisa", + "No providers yet.": "Nenhum provedor ainda.", + "No providers.": "Nenhum provedor.", + "No proxy pool entries yet": "Nenhuma entrada no pool de proxy ainda", + "No quota data available": "Nenhum dado de cota disponível", + "No request details found": "Nenhum detalhe de requisição encontrado", + "No requests yet.": "Nenhuma requisição ainda.", + "No reset credit details returned for this account.": "Nenhum detalhe de crédito de redefinição retornado para esta conta.", + "No servers match filter": "Nenhum servidor corresponde ao filtro", + "No tools advertised by server.": "Nenhuma ferramenta anunciada pelo servidor.", + "No usage yet.": "Nenhum uso ainda.", + "None": "Nenhum", + "None (unbind all)": "Nenhum (desvincular todos)", + "Not configured": "Não configurado", + "Not installed": "Não instalado", + "Notice": "Aviso", + "Notifications": "Notificações", + "Number of items to accumulate before writing to database (higher = better performance)": "Número de itens a acumular antes de gravar no banco de dados (maior = melhor desempenho)", + "OAuth": "OAuth", + "OAuth App": "App OAuth", + "OAuth Providers": "Provedores OAuth", + "OIDC": "OIDC", + "OIDC Dashboard Login": "Login no Painel via OIDC", + "OK": "OK", + "Observability": "Observabilidade", + "Office Proxy": "Proxy de Escritório", + "Offline": "Offline", + "Ollama Host URL": "URL do Host Ollama", + "One key per line. Format:": "Uma chave por linha. Formato:", + "One-to-one (rotate)": "Um-para-um (rotacionar)", + "Online": "Online", + "Only from connected providers": "Apenas de provedores conectados", + "Only letters, numbers, -, _ and .": "Apenas letras, números, -, _ e .", + "Open": "Abrir", + "Open Claw - Manual Configuration": "Open Claw - Configuração Manual", + "Open Claw CLI not detected locally": "Open Claw CLI não detectado localmente", + "Open Dashboard": "Abrir Painel", + "Open DevTools (F12) → Application/Storage → Cookies": "Abra DevTools (F12) → Application/Storage → Cookies", + "Open Headroom Dashboard": "Abrir Painel Headroom", + "Open Logs": "Abrir Logs", + "Open menu": "Abrir menu", + "Open source": "Código aberto", + "Open source and free to start.": "Código aberto e gratuito para começar.", + "OpenAI / ElevenLabs / Edge / Google / Deepgram voices.": "Vozes OpenAI / ElevenLabs / Edge / Google / Deepgram.", + "OpenCode - Manual Configuration": "OpenCode - Configuração Manual", + "OpenCode CLI not detected locally": "OpenCode CLI não detectado localmente", + "OpenRouter supports any model. Add models and create aliases for quick access.": "OpenRouter suporta qualquer modelo. Adicione modelos e crie aliases para acesso rápido.", + "Optional": "Opcional", + "Or paste callback URL manually": "Ou cole a URL de callback manualmente", + "Organization": "Organização", + "Organization Domain": "Domínio da Organização", + "Organization ID": "ID da Organização", + "Organization Token": "Token da Organização", + "Organization Tokens": "Tokens da Organização", + "Original": "Original", + "Original (est.)": "Original (est.)", + "Original tokens": "Tokens originais", + "Other": "Outro", + "Our engine analyzes the prompt, checks provider health, and routes for lowest latency or cost.": "Nosso mecanismo analisa o prompt, verifica a integridade do provedor e roteia para menor latência ou custo.", + "Out": "Saída", + "Outbound Proxy": "Proxy de saída", + "Output": "Saída", + "Output Format": "Formato de Saída", + "Output Tokens": "Tokens de Saída", + "Output Tokens:": "Tokens de Saída:", + "Output:": "Saída:", + "PATH": "PATH", + "PXPIPE": "PXPIPE", + "PXPIPE Dashboard": "Painel PXPIPE", + "PXPIPE Logs": "Logs PXPIPE", + "PXPIPE install failed": "Falha na instalação do PXPIPE", + "PXPIPE is not installed.": "PXPIPE não está instalado.", + "PXPIPE restart failed": "Falha ao reiniciar PXPIPE", + "PXPIPE start failed": "Falha ao iniciar PXPIPE", + "PXPIPE stop failed": "Falha ao parar PXPIPE", + "Paid": "Pago", + "Partial preview": "Visualização parcial", + "Password": "Senha", + "Password updated successfully": "Senha atualizada com sucesso", + "Passwords do not match": "As senhas não correspondem", + "Paste": "Colar", + "Paste Proxy List (One per line)": "Cole a Lista de Proxy (um por linha)", + "Paste it below": "Cole abaixo", + "Paste refresh token from Kiro IDE.": "Cole o token de atualização do Kiro IDE.", + "Paste the command into your terminal and press Enter.": "Cole o comando no terminal e pressione Enter.", + "Paste this to your AI:": "Cole isto em sua IA:", + "Paste your Kiro API key...": "Cole sua chave de API Kiro...", + "Paused": "Pausado", + "Permissions": "Permissões", + "Personal Access Token": "Token de Acesso Pessoal", + "Pick the model that fuses panel answers": "Escolha o modelo que funde as respostas do painel", + "Please copy the URL from the address bar and paste it in the application.": "Copie a URL da barra de endereço e cole no aplicativo.", + "Please enter a Proxy URL to test": "Por favor, digite uma URL de proxy para testar", + "Please wait while we complete the authorization.": "Aguarde enquanto concluímos a autorização.", + "Point your CLI tools to http://localhost:20128": "Aponte suas ferramentas CLI para http://localhost:20128", + "Port 443 Already In Use": "Porta 443 já está em uso", + "Port 443 is currently used by another process:": "A porta 443 está sendo usada por outro processo:", + "Powerful Features": "Recursos Poderosos", + "Prefix": "Prefixo", + "Preset": "Predefinição", + "Previous": "Anterior", + "Previous accounts page": "Página anterior de contas", + "Price": "Preço", + "Pricing Configuration": "Configuração de Preços", + "Pricing Format:": "Formato de Preço:", + "Pricing Rates Format": "Formato das Taxas de Preço", + "Pricing Settings": "Configurações de Preço", + "Priority": "Prioridade", + "Privacy Policy": "Política de Privacidade", + "Probing server for tools...": "Verificando servidor por ferramentas...", + "Processing...": "Processando...", + "Product": "Produto", + "Production Key": "Chave de Produção", + "Profile": "Perfil", + "ProgramArguments": "Argumentos do Programa", + "Project Name": "Nome do Projeto", + "Prompt": "Prompt", + "Provider": "Provedor", + "Provider not found": "Provedor não encontrado", + "Provider:": "Provedor:", + "Providers": "Provedores", + "Proxy": "Proxy", + "Proxy Pool": "Pool de Proxy", + "Proxy Pools": "Pools de Proxy", + "Proxy URL": "URL do Proxy", + "Proxy disabled": "Proxy desativado", + "Proxy enabled": "Proxy ativado", + "Proxy settings applied": "Configurações de proxy aplicadas", + "Proxy test OK": "Teste de proxy OK", + "Proxy test failed": "Falha no teste de proxy", + "Purpose:": "Propósito:", + "Python >= 3.10 required for local managed mode.": "Python >= 3.10 necessário para modo gerenciado local.", + "Quota Tracker": "Rastreador de cota", + "Read Documentation": "Ler Documentação", + "Read this skill and use it:": "Leia esta skill e use-a:", + "Ready": "Pronto", + "Ready to Simplify Your AI Infrastructure?": "Pronto para Simplificar Sua Infraestrutura de IA?", + "Reasoning": "Raciocínio", + "Reasoning:": "Raciocínio:", + "Recent Requests": "Requisições Recentes", + "Recent chats": "Chats recentes", + "Recheck": "Verificar novamente", + "Record request details for inspection in the logs view": "Gravar detalhes da requisição para inspeção na visualização de logs", + "Redirect URI": "URI de Redirecionamento", + "Redo": "Refazer", + "Reduction": "Redução", + "Ref Image (URL)": "URL da Imagem de Referência", + "Refresh": "Atualizar", + "Refresh all": "Atualizar tudo", + "Refresh quota": "Atualizar cota", + "Region": "Região", + "Reload Page": "Recarregar página", + "Reload VS Code after applying for changes to take effect.": "Recarregue o VS Code após aplicar para que as alterações entrem em vigor.", + "Remaining": "Restante", + "Remote": "Remoto", + "Remove": "Remover", + "Remove [code] and its packages?": "Remover [code] e seus pacotes?", + "Remove [ml] and its packages?": "Remover [ml] e seus pacotes?", + "Remove attachment": "Remover anexo", + "Remove custom model": "Remover modelo personalizado", + "Remove failed": "Falha ao remover", + "Remove model": "Remover modelo", + "Repair": "Reparar", + "Replaces built-in WebSearch/WebFetch. Auto-strips duplicates from tool list.": "Substitui WebSearch/WebFetch nativos. Remove automaticamente duplicatas da lista de ferramentas.", + "Request": "Requisição", + "Request Details": "Detalhes da Requisição", + "Request Logs": "Logs de Requisições", + "Requests": "Requisições", + "Requests smaller than this bypass PXPIPE and are sent as-is.": "Requisições menores que isso ignoram PXPIPE e são enviadas como estão.", + "Requests without a valid key will be rejected": "Requisições sem uma chave válida serão rejeitadas", + "Require API key": "Exigir chave de API", + "Require login": "Exigir login", + "Required": "Obrigatório", + "Required for SSL certificate and DNS configuration": "Necessário para certificado SSL e configuração de DNS", + "Required for SSL certificate and server startup": "Necessário para certificado SSL e inicialização do servidor", + "Required to modify /etc/hosts and flush DNS cache": "Necessário para modificar /etc/hosts e limpar cache DNS", + "Requires Cloudflare Account ID and a Workers API Token": "Requer ID da Conta Cloudflare e um Token de API Workers", + "Requires outbound port 7844 (TCP/UDP). Connection may take 10-30s.": "Requer porta de saída 7844 (TCP/UDP). Conexão pode levar 10-30s.", + "Reset": "Redefinir", + "Reset Codex limit?": "Redefinir limite do Codex?", + "Reset Password to Default": "Redefinir Senha para Padrão", + "Reset judge to Auto": "Redefinir julgador para Automático", + "Reset to Defaults": "Redefinir para Padrões", + "Resources": "Recursos", + "Response": "Resposta", + "Response Format": "Formato da Resposta", + "Restart": "Reiniciar", + "Restart failed": "Falha ao reiniciar", + "Restarting proxy…": "Reiniciando proxy…", + "Restore model": "Restaurar modelo", + "Retry": "Tentar novamente", + "Risk Notice": "Aviso de Risco", + "Rotation Strategy": "Estratégia de Rotação", + "Round Robin": "Round Robin", + "Route Requests": "Rotear Requisições", + "Routing Strategy": "Estratégia de roteamento", + "Rows:": "Linhas:", + "Run": "Executar", + "Run npx command to start the server instantly": "Execute o comando npx para iniciar o servidor instantaneamente", + "RunAtLoad": "Executar ao Carregar", + "Running": "Executando", + "Running on your machine": "Executando em sua máquina", + "SSE URL": "URL SSE", + "START HERE": "COMECE AQUI", + "Save": "Salvar", + "Save Mappings": "Salvar Mapeamentos", + "Save auth mode": "Salvar modo de autenticação", + "Save current Base URL and API key as a browser-local preset": "Salvar URL Base e chave de API atuais como predefinição local do navegador", + "Save this key now!": "Salve esta chave agora!", + "Saved": "Salvo", + "Scopes": "Escopos", + "Scroll down to": "Role para baixo até", + "Search": "Pesquisar", + "Search by name or description...": "Pesquisar por nome ou descrição...", + "Search language...": "Pesquisar idioma...", + "Search models...": "Pesquisar modelos...", + "Search providers...": "Pesquisar provedores...", + "Search...": "Pesquisar...", + "Security": "Segurança", + "Security risk: no password set.": "Risco de segurança: nenhuma senha definida.", + "Security risk: no password set. You will be asked to set one when logging in remotely.": "Risco de segurança: nenhuma senha definida. Será solicitado que você defina uma ao fazer login remotamente.", + "Select": "Selecionar", + "Select All": "Selecionar Todos", + "Select Cowork Model": "Selecionar Modelo Cowork", + "Select Endpoint": "Selecionar Endpoint", + "Select Judge Model": "Selecionar Modelo Julgador", + "Select Language": "Selecionar Idioma", + "Select Model": "Selecionar Modelo", + "Select Model for Cline": "Selecionar Modelo para Cline", + "Select Model for Codex": "Selecionar Modelo para Codex", + "Select Model for DeepSeek TUI": "Selecionar Modelo para DeepSeek TUI", + "Select Model for Factory Droid": "Selecionar Modelo para Factory Droid", + "Select Model for Hermes Agent": "Selecionar Modelo para Hermes Agent", + "Select Model for Kilo Code": "Selecionar Modelo para Kilo Code", + "Select Model for Open Claw": "Selecionar Modelo para Open Claw", + "Select Model for jcode": "Selecionar Modelo para jcode", + "Select Subagent Model for Codex": "Selecionar Modelo de Subagente para Codex", + "Select Subagent Model for OpenCode": "Selecionar Modelo de Subagente para OpenCode", + "Select a provider": "Selecionar um provedor", + "Select language": "Selecionar idioma", + "Select your": "Selecione seu(sua)", + "Selected provider": "Provedor selecionado", + "Send": "Enviar", + "Server": "Servidor", + "Server Disconnected": "Servidor desconectado", + "Server off": "Servidor desligado", "Server started": "Servidor iniciado", - "Failed to start server": "Falha ao iniciar o servidor", "Server stopped — all DNS cleared": "Servidor parado — todo DNS foi limpo", - "Failed to stop server": "Falha ao parar o servidor", - "Sudo password is required": "Senha sudo é necessária", - "Stop Server": "Parar servidor", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "O serviço está em execução no terminal. Você pode fechar esta página da web. O desligamento interromperá o serviço.", + "Set Password": "Definir senha", + "Set a new password before accessing the dashboard remotely.": "Defina uma nova senha antes de acessar o painel remotamente.", + "Set password": "Definir senha", + "Settings": "Configurações", + "Setup": "Configurar", + "Setup + index of all capabilities. Start here — covers base URL, auth, model discovery, and links to every capability skill.": "Configuração + índice de todas as capacidades. Comece aqui — cobre URL base, autenticação, descoberta de modelos e links para todas as skills.", + "Setup Headroom": "Configurar Headroom", + "Setup PXPIPE": "Configurar PXPIPE", + "Show this quota row": "Mostrar esta linha de cota", + "Shutdown": "Desligar", + "Sign in with OIDC": "Entrar com OIDC", + "Single": "Único", + "Sort": "Classificar", + "Sort Codex quotas by remaining": "Ordenar cotas do Codex por saldo restante", + "Sort accounts by earliest quota reset time": "Ordenar contas pelo horário de redefinição de cota", + "Speech-to-Text": "Fala-para-Texto", + "StandardErrorPath": "Caminho do Erro Padrão", + "StandardOutPath": "Caminho da Saída Padrão", + "Start": "Iniciar", + "Start DNS": "Iniciar DNS", + "Start Date": "Data de Início", + "Start Free": "Começar Gratuito", + "Start Headroom": "Iniciar Headroom", + "Start Headroom separately at the configured URL, then recheck.": "Inicie o Headroom separadamente na URL configurada e verifique novamente.", + "Start MITM": "Iniciar MITM", "Start Server": "Iniciar servidor", - "Enable DNS per tool below to activate interception": "Ativar DNS para cada ferramenta abaixo para ativar a interceptação", - "Sudo Password Required": "Senha Sudo necessária", - "Enter your sudo password to start/stop MITM server": "Digite sua senha sudo para iniciar/parar o servidor MITM", + "Start Tunnel": "Iniciar Túnel", + "Start a conversation": "Iniciar uma conversa", + "Starting…": "Iniciando…", + "Status": "Status", + "Status:": "Status:", + "Step 1: Open this URL in your browser": "Passo 1: Abra esta URL no seu navegador", + "Step 2: Paste the callback URL here": "Passo 2: Cole a URL de callback aqui", + "Sticky Limit": "Limite pegajoso", + "Sticky:": "Fixo:", + "Stop": "Parar", + "Stop DNS": "Parar DNS", + "Stop Headroom": "Parar Headroom", + "Stop MITM": "Parar MITM", + "Stop Server": "Parar servidor", + "Stopped": "Parado", + "Stopping…": "Parando…", + "Strict Proxy": "Proxy Estrito", + "Subagent Model": "Modelo de Subagente", + "Subagent model overrides": "Substituições de modelo de subagente", + "Submit": "Enviar", + "Success": "Sucesso", "Sudo Password": "Senha sudo", - "Click to add, click again to remove. Changes are saved automatically.": "Clique para adicionar, clique novamente para remover. As alterações são salvas automaticamente.", - "⚠️ 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.": "⚠️ Aviso de Risco: Este provedor usa uma sessão de assinatura/OAuth não licenciada oficialmente para uso de proxy/roteador. A conta pode ser restrita ou banida. Use por sua conta e risco.", + "Sudo Password Required": "Senha Sudo necessária", + "Sudo password is required": "Senha sudo é necessária", + "Support": "Suporte", + "Switch language": "Trocar idioma", + "System": "Sistema", + "TTFT:": "TTFT:", + "Tailscale": "Tailscale", + "Tailscale Funnel": "Tailscale Funnel", + "Tailscale Funnel will be stopped.": "O Tailscale Funnel será parado.", + "Tailscale installed": "Tailscale instalado", + "Tailscale is not installed. Install it to enable Funnel.": "Tailscale não está instalado. Instale para ativar o Funnel.", + "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.": "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.", + "Temperature": "Temperatura", + "Terms of Service": "Termos de Serviço", + "Terse-style system prompt → ~65% fewer output tokens (up to 87%)": "Prompt de sistema conciso → ~65% menos tokens de saída (até 87%)", + "Test Example": "Exemplo de Teste", + "Test Results": "Resultados do Teste", + "Test all API Key connections": "Testar todas as conexões de Chave API", + "Test all Free connections": "Testar todas as conexões Gratuitas", + "Test all Free provider connections": "Testar todas as conexões de provedores Gratuitos", + "Test all OAuth connections": "Testar todas as conexões OAuth", + "Test connection": "Testar conexão", + "Test proxy": "Testar proxy", + "Test proxy URL": "Testar URL do proxy", + "Text-to-Speech": "Texto-para-Fala", + "Text-to-image via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…": "Texto-para-imagem via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…", + "The Cloudflare tunnel will be disconnected.": "O túnel Cloudflare será desconectado.", + "The proxy server has been stopped.": "O servidor proxy foi parado.", + "The unified endpoint for AI generation. Connect, route, and manage your AI providers with ease.": "O endpoint unificado para geração de IA. Conecte, roteie e gerencie seus provedores de IA com facilidade.", + "The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "A interface unificada para infraestrutura de IA moderna. Segura, observável e escalável.", + "Theme": "Tema", + "Thinking Process": "Processo de Raciocínio", + "This is the only time you will see this key. Store it securely.": "Esta é a única vez que você verá esta chave. Armazene-a com segurança.", + "Time": "Hora", + "Timestamp": "Timestamp", + "Timestamp:": "Timestamp:", + "Toggle auto-ping": "Alternar ping automático", + "Token Saver": "Economizador de Tokens", + "Token Saver settings": "Configurações do Economizador de Tokens", + "Token Types:": "Tipos de Token:", + "Tokens": "Tokens", + "Tools": "Ferramentas", + "Total": "Total", + "Total Input Tokens": "Total de Tokens de Entrada", + "Total Models": "Total de Modelos", + "Total Requests": "Total de Requisições", + "Total:": "Total:", + "Transcribe audio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…": "Transcreva áudio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…", + "Translator Debug": "Depuração do Tradutor", + "Tried in order (top-down) or rotated when round-robin is on.": "Tentado em ordem (de cima para baixo) ou rotacionado quando round-robin está ativo.", + "Trust Cert": "Confiar Certificado", + "Try Again": "Tentar Novamente", + "Tunnel": "Túnel", + "Turn off Empty": "Desligar Vazio", + "Turn on Available": "Ligar Disponível", + "Turn request detail recording on/off globally": "Ativar/desativar globalmente o registro de detalhes da solicitação", + "Twitter": "Twitter", + "Type": "Tipo", + "URL → markdown / text / HTML via Firecrawl, Jina, Tavily, Exa.": "URL → markdown / texto / HTML via Firecrawl, Jina, Tavily, Exa.", + "Unavailable": "Indisponível", + "Under": "Abaixo", + "Undo": "Desfazer", + "Uninstall": "Desinstalar", + "Uninstalling…": "Desinstalando…", + "Update": "Atualizar", + "Update 9Router": "Atualizar 9Router", + "Update Password": "Atualizar senha", + "Update now": "Atualizar agora", + "Updated": "Atualizado", + "Upload": "Enviar", + "Uptime": "Tempo de atividade", + "Usage": "Uso", + "Usage Logs": "Logs de Uso", + "Usage:": "Uso:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Use Antigravity IDE e GitHub Copilot → com QUALQUER provedor/modelo do 9Router", + "Use a GitLab OAuth application": "Usar um aplicativo OAuth GitLab", + "Use a GitLab PAT with api scope": "Usar um PAT GitLab com escopo de API", + "Valid": "Válido", + "Vectors for RAG / semantic search via OpenAI, Gemini, Mistral…": "Vetores para RAG / busca semântica via OpenAI, Gemini, Mistral…", + "Vercel API Token": "Token de API Vercel", + "Vercel Relay": "Vercel Relay", + "Version": "Versão", + "View": "Visualizar", + "View Codex reset credit expiry": "Ver expiração de crédito do Codex", + "View Full Details": "Ver Detalhes Completos", + "View on GitHub": "Ver no GitHub", + "Visit the login URL below and authorize:": "Visite a URL de login abaixo e autorize:", + "Voice": "Voz", + "Voice ID": "ID de Voz", + "Voyage AI": "Voyage AI", + "Waiting for authorization...": "Aguardando autorização...", + "Warning": "Aviso", + "Web Fetch": "Fetch Web", + "Web Search": "Busca Web", + "What is Cloudflare Relay?": "O que é Cloudflare Relay?", + "What is Deno Relay?": "O que é Deno Relay?", + "What is Vercel Relay?": "O que é Vercel Relay?", + "When": "Quando", + "When ON, dashboard requires password. When OFF, access without login.": "Quando ATIVO, o painel requer senha. Quando DESATIVO, acesso sem login.", + "Windows: Run terminal (9Router) as Administrator to enable MITM": "Windows: Execute o terminal (9Router) como Administrador para ativar MITM", + "Worker Name": "Nome do Worker", + "Writes to": "Grava em", + "Yes": "Sim", + "You will be asked to set one when logging in remotely.": "Será solicitado que você defina uma ao fazer login remotamente.", + "Your Account Name": "Nome da Sua Conta", + "Your Code": "Seu Código", + "Your OAuth application client ID": "ID do cliente do seu aplicativo OAuth", + "Your requests start from your favorite tools or our unified SDK.": "Suas requisições começam de suas ferramentas favoritas ou do nosso SDK unificado.", + "[ml] downloads ~1 GB (torch + huggingface-hub). Continue?": "[ml] baixa ~1 GB (torch + huggingface-hub). Continuar?", + "extras status failed": "falha no status dos extras", + "git/grep/ls/tree/logs → 60-90% fewer input tokens": "git/grep/ls/tree/logs → 60-90% menos tokens de entrada", + "not installed": "não instalado", + "sk_9router (default)": "sk_9router (padrão)", + "tree-sitter AST compression for code responses": "Compressão AST tree-sitter para respostas de código", "⚠️ 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 intercepta tráfego HTTPS de ferramentas IDE (Antigravity, GitHub Copilot, Kiro) via CA local para redirecionar solicitações aos seus provedores. Pode violar ToS → risco de banimento de conta. Use por sua conta e risco.", - "Endpoint is exposed without an API key.": "O endpoint está exposto sem uma chave de API." -} + "⚠️ 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.": "⚠️ Aviso de Risco: Este provedor usa uma sessão de assinatura/OAuth não licenciada oficialmente para uso de proxy/roteador. A conta pode ser restrita ou banida. Use por sua conta e risco." +} \ No newline at end of file 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/api-airforce.png b/public/providers/api-airforce.png new file mode 100644 index 00000000..9fa71e6a Binary files /dev/null and b/public/providers/api-airforce.png differ diff --git a/public/providers/baidu.png b/public/providers/baidu.png new file mode 100644 index 00000000..8ad826f2 Binary files /dev/null and b/public/providers/baidu.png differ diff --git a/public/providers/bazaarlink.png b/public/providers/bazaarlink.png new file mode 100644 index 00000000..2a16fd77 Binary files /dev/null and b/public/providers/bazaarlink.png differ diff --git a/public/providers/bluesminds.png b/public/providers/bluesminds.png new file mode 100644 index 00000000..1e5ef31e Binary files /dev/null and b/public/providers/bluesminds.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/codebuddy-intl.png b/public/providers/codebuddy-intl.png new file mode 100644 index 00000000..c836282f Binary files /dev/null and b/public/providers/codebuddy-intl.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/devin-cli.png b/public/providers/devin-cli.png new file mode 100644 index 00000000..1fe62d23 Binary files /dev/null and b/public/providers/devin-cli.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/kilo-gateway.png b/public/providers/kilo-gateway.png new file mode 100644 index 00000000..99ea60b0 Binary files /dev/null and b/public/providers/kilo-gateway.png differ diff --git a/public/providers/llm7.png b/public/providers/llm7.png new file mode 100644 index 00000000..4f2a70a0 Binary files /dev/null and b/public/providers/llm7.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..82f938f3 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/poolside.png b/public/providers/poolside.png new file mode 100644 index 00000000..592d604e Binary files /dev/null and b/public/providers/poolside.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/sambanova.png b/public/providers/sambanova.png new file mode 100644 index 00000000..38b016c7 Binary files /dev/null and b/public/providers/sambanova.png differ diff --git a/public/providers/tencent.png b/public/providers/tencent.png new file mode 100644 index 00000000..6293ff7d Binary files /dev/null and b/public/providers/tencent.png differ diff --git a/public/providers/trae.png b/public/providers/trae.png new file mode 100644 index 00000000..c056daf0 Binary files /dev/null and b/public/providers/trae.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/public/providers/windsurf.png b/public/providers/windsurf.png new file mode 100644 index 00000000..c97179a2 Binary files /dev/null and b/public/providers/windsurf.png differ diff --git a/public/providers/workbuddy.png b/public/providers/workbuddy.png new file mode 100644 index 00000000..c836282f Binary files /dev/null and b/public/providers/workbuddy.png differ diff --git a/public/providers/zed.png b/public/providers/zed.png new file mode 100644 index 00000000..009c3e9d Binary files /dev/null and b/public/providers/zed.png differ diff --git a/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js b/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js index a97d0a5e..f9331074 100644 --- a/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js +++ b/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js @@ -891,7 +891,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/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js index 589d847a..7ee5ad2e 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -9,6 +9,16 @@ import { matchKnownEndpoint } from "./cliEndpointMatch"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; +// Context window presets. UI shows the round number; the value written is nudged +// down 2K to stay safely under the upstream hard cap. +const CONTEXT_OPTIONS = [ + { label: "Default", value: "" }, + { label: "200K", value: "198000" }, + { label: "300K", value: "298000" }, + { label: "500K", value: "498000" }, + { label: "1M", value: "998000" }, +]; + export default function ClaudeToolCard({ tool, isExpanded, @@ -39,6 +49,8 @@ export default function ClaudeToolCard({ const [showManualConfigModal, setShowManualConfigModal] = useState(false); const [customBaseUrl, setCustomBaseUrl] = useState(""); const [ccFilterNaming, setCcFilterNaming] = useState(false); + const [exaMcpEnabled, setExaMcpEnabled] = useState(false); + const [maxContextTokens, setMaxContextTokens] = useState(""); const hasInitializedModels = useRef(false); const getConfigStatus = () => { @@ -58,15 +70,22 @@ export default function ClaudeToolCard({ }, [apiKeys, selectedApiKey]); useEffect(() => { - if (initialStatus) setClaudeStatus(initialStatus); + if (initialStatus) { + setClaudeStatus(initialStatus); + setExaMcpEnabled(!!initialStatus.exaMcpEnabled); + } }, [initialStatus]); useEffect(() => { - if (isExpanded && !claudeStatus) { - checkClaudeStatus(); + const v = claudeStatus?.settings?.env?.CLAUDE_CODE_MAX_CONTEXT_TOKENS; + setMaxContextTokens(v || ""); + }, [claudeStatus?.settings?.env?.CLAUDE_CODE_MAX_CONTEXT_TOKENS]); + + useEffect(() => { + if (isExpanded) { + if (!claudeStatus) checkClaudeStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); useEffect(() => { @@ -123,6 +142,7 @@ export default function ClaudeToolCard({ const res = await fetch("/api/cli-tools/claude-settings"); const data = await res.json(); setClaudeStatus(data); + setExaMcpEnabled(!!data.exaMcpEnabled); } catch (error) { setClaudeStatus({ installed: false, error: error.message }); } finally { @@ -159,15 +179,18 @@ export default function ClaudeToolCard({ const targetModel = modelMappings[model.alias]; if (targetModel && model.envKey) env[model.envKey] = targetModel; }); + if (maxContextTokens) { + env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = maxContextTokens; + } const res = await fetch("/api/cli-tools/claude-settings", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ env }), + body: JSON.stringify({ env, exaMcpEnabled, maxContextTokens }), }); const data = await res.json(); if (res.ok) { setMessage({ type: "success", text: "Settings applied successfully!" }); - setClaudeStatus(prev => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env } })); + setClaudeStatus(prev => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env }, exaMcpEnabled })); } else { setMessage({ type: "error", text: data.error || "Failed to apply settings" }); } @@ -188,6 +211,8 @@ export default function ClaudeToolCard({ setMessage({ type: "success", text: "Settings reset successfully!" }); tool.defaultModels.forEach((model) => onModelMappingChange(model.alias, model.defaultValue || "")); setSelectedApiKey(""); + setExaMcpEnabled(false); + setMaxContextTokens(""); } else { setMessage({ type: "error", text: data.error || "Failed to reset settings" }); } @@ -217,6 +242,9 @@ export default function ClaudeToolCard({ const targetModel = modelMappings[model.alias]; if (targetModel && model.envKey) env[model.envKey] = targetModel; }); + if (maxContextTokens) { + env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = maxContextTokens; + } return [ { @@ -231,7 +259,7 @@ export default function ClaudeToolCard({
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -340,6 +368,17 @@ export default function ClaudeToolCard({
))} + {/* Context Window */} +
+ Context window + arrow_forward + +
+ {/* CC Filter Naming */}
Filter naming @@ -352,6 +391,19 @@ export default function ClaudeToolCard({
+ + {/* Exa MCP — ~/.claude.json mcpServers (not settings.json) */} +
+ Web Search + arrow_forward + +
{message && ( @@ -377,7 +429,9 @@ export default function ClaudeToolCard({
)} - 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}`} /> + )} { - if (isExpanded && !status) { - checkStatus(); + if (isExpanded) { + if (!status) checkStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); useEffect(() => { @@ -157,7 +156,7 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -280,15 +279,17 @@ export default function ClineToolCard({ tool, isExpanded, onToggle, baseUrl, api
)} - setModalOpen(false)} - onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Cline" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Cline" + /> + )} { - if (isExpanded && !codexStatus) { - checkCodexStatus(); + if (isExpanded) { + if (!codexStatus) checkCodexStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); const fetchModelAliases = async () => { @@ -199,7 +198,7 @@ model = "${effectiveSubagentModel}"
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -371,25 +370,29 @@ model = "${effectiveSubagentModel}"
)} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Codex" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Codex" + /> + )} - setSubagentModalOpen(false)} - onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }} - selectedModel={subagentModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Subagent Model for Codex" - /> + {subagentModalOpen && ( + setSubagentModalOpen(false)} + onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }} + selectedModel={subagentModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Subagent Model for Codex" + /> + )} { - if (isExpanded && !status) { - checkStatus(); + if (isExpanded) { + if (!status) checkStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); // Pre-fill from existing config @@ -184,7 +183,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -290,27 +289,29 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
)} - { - setModalOpen(false); - saveModels(selectedModelsRef.current); - }} - onSelect={(model) => { - if (!selectedModels.includes(model.value)) { - setSelectedModels([...selectedModels, model.value]); - } - }} - onDeselect={(model) => { - setSelectedModels(selectedModels.filter(m => m !== model.value)); - }} - selectedModel={null} - activeProviders={activeProviders} - modelAliases={modelAliases} - addedModelValues={selectedModels} - closeOnSelect={false} - title="Add Model for GitHub Copilot" - /> + {modalOpen && ( + { + setModalOpen(false); + saveModels(selectedModelsRef.current); + }} + onSelect={(model) => { + if (!selectedModels.includes(model.value)) { + setSelectedModels([...selectedModels, model.value]); + } + }} + onDeselect={(model) => { + setSelectedModels(selectedModels.filter(m => m !== model.value)); + }} + selectedModel={null} + activeProviders={activeProviders} + modelAliases={modelAliases} + addedModelValues={selectedModels} + closeOnSelect={false} + title="Add Model for GitHub Copilot" + /> + )}
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -514,27 +514,31 @@ export default function CoworkToolCard({ configs={getManualConfigs()} /> - setComboModalOpen(false)} - onSave={handleCreateCombo} - activeProviders={activeProviders} - forcePrefix="claude-" - title="Create Cowork Combo" - /> + {comboModalOpen && ( + setComboModalOpen(false)} + onSave={handleCreateCombo} + activeProviders={activeProviders} + forcePrefix="claude-" + title="Create Cowork Combo" + /> + )} - setModelSelectOpen(false)} - onSelect={handleAddModel} - onDeselect={handleRemoveModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Cowork Model" - addedModelValues={selectedModels} - closeOnSelect={false} - /> + {modelSelectOpen && ( + setModelSelectOpen(false)} + onSelect={handleAddModel} + onDeselect={handleRemoveModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Cowork Model" + addedModelValues={selectedModels} + closeOnSelect={false} + /> + )} { - if (isExpanded && !deepseekStatus) { - checkStatus(); + if (isExpanded) { + if (!deepseekStatus) checkStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); const fetchModelAliases = async () => { @@ -187,7 +186,7 @@ model = "${selectedModel || "provider/model-id"}"
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -317,15 +316,17 @@ model = "${selectedModel || "provider/model-id"}"
)} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for DeepSeek TUI" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for DeepSeek TUI" + /> + )} { 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" /> ); }; @@ -257,14 +269,16 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba
)} - setShowModelModal(false)} - onSelect={handleSelectModel} - selectedModel={modelValue} - activeProviders={activeProviders} - title="Select Model" - /> + {showModelModal && ( + setShowModelModal(false)} + onSelect={handleSelectModel} + selectedModel={modelValue} + activeProviders={activeProviders} + title="Select Model" + /> + )} ); } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js index 7e8e2eac..adc2a7ae 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js @@ -60,11 +60,10 @@ export default function DroidToolCard({ }, [initialStatus]); useEffect(() => { - if (isExpanded && !droidStatus) { - checkDroidStatus(); + if (isExpanded) { + if (!droidStatus) checkDroidStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); const fetchModelAliases = async () => { @@ -225,7 +224,7 @@ export default function DroidToolCard({
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -389,15 +388,17 @@ export default function DroidToolCard({
)} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={null} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Factory Droid" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={null} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Factory Droid" + /> + )} +
+ {label} + {help &&

{help}

} +
+ arrow_forward +
+ onChange(event.target.value)} + placeholder={placeholder} + className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" + /> + {value && ( + + )} +
+ +
+ ); +} export default function GrokBuildToolCard({ tool, isExpanded, onToggle, - baseUrl, hasActiveProviders, apiKeys, activeProviders, @@ -25,48 +73,49 @@ export default function GrokBuildToolCard({ tailscaleEnabled, tailscaleUrl, }) { + const { getCaps } = useModelCaps(); + const getContextWindow = (model) => getCaps(model)?.contextWindow || null; + const initialModel = initialStatus?.settings?.model?.model || ""; + const initialSubagents = Object.fromEntries( + SUBAGENT_TYPES + .map((type) => [type.id, initialStatus?.settings?.subagentModels?.[type.id]?.model]) + .filter(([, model]) => Boolean(model)), + ); const [grokStatus, setGrokStatus] = useState(initialStatus || null); const [checking, setChecking] = useState(false); const [applying, setApplying] = useState(false); const [restoring, setRestoring] = useState(false); const [message, setMessage] = useState(null); - const [selectedApiKey, setSelectedApiKey] = useState(""); - const [selectedModel, setSelectedModel] = useState(""); - const [modalOpen, setModalOpen] = useState(false); + const [selectedApiKey, setSelectedApiKey] = useState(apiKeys?.[0]?.key || ""); + const [selectedModel, setSelectedModel] = useState(initialModel); + const [subagentModels, setSubagentModels] = useState(initialSubagents); + const [modelTarget, setModelTarget] = useState(null); // "main" or subagent type const [modelAliases, setModelAliases] = useState({}); const [showManualConfigModal, setShowManualConfigModal] = useState(false); const [customBaseUrl, setCustomBaseUrl] = useState(""); - const hasInitializedModel = useRef(false); + const hasFetchedStatus = useRef(Boolean(initialStatus)); - const getConfigStatus = () => { - if (!grokStatus?.installed) return null; - const cfg = grokStatus.settings?.model; - if (!cfg?.base_url) return "not_configured"; - if (matchKnownEndpoint(cfg.base_url, { tunnelPublicUrl, tailscaleUrl })) return "configured"; - return "other"; - }; + const configuredModel = grokStatus?.settings?.model; + const configStatus = !grokStatus?.installed + ? null + : !configuredModel?.base_url + ? "not_configured" + : matchKnownEndpoint(configuredModel.base_url, { tunnelPublicUrl, tailscaleUrl }) + ? "configured" + : "other"; - const configStatus = getConfigStatus(); + const hydrateForm = useCallback((status) => { + const mainModel = status?.settings?.model?.model || ""; + const configuredSubagents = Object.fromEntries( + SUBAGENT_TYPES + .map((type) => [type.id, status?.settings?.subagentModels?.[type.id]?.model]) + .filter(([, model]) => Boolean(model)), + ); + setSelectedModel(mainModel); + setSubagentModels(configuredSubagents); + }, []); - useEffect(() => { - if (apiKeys?.length > 0 && !selectedApiKey) { - setSelectedApiKey(apiKeys[0].key); - } - }, [apiKeys, selectedApiKey]); - - useEffect(() => { - if (initialStatus) setGrokStatus(initialStatus); - }, [initialStatus]); - - useEffect(() => { - if (isExpanded && !grokStatus) { - checkStatus(); - fetchModelAliases(); - } - if (isExpanded) fetchModelAliases(); - }, [isExpanded]); - - const fetchModelAliases = async () => { + const fetchModelAliases = useCallback(async () => { try { const res = await fetch("/api/models/alias"); const data = await res.json(); @@ -74,40 +123,38 @@ export default function GrokBuildToolCard({ } catch (error) { console.log("Error fetching model aliases:", error); } - }; + }, []); - useEffect(() => { - if (grokStatus?.installed && !hasInitializedModel.current) { - hasInitializedModel.current = true; - const cfg = grokStatus.settings?.model; - if (cfg?.model) setSelectedModel(cfg.model); - } - }, [grokStatus]); - - const checkStatus = async () => { + const checkStatus = useCallback(async ({ hydrate = false } = {}) => { setChecking(true); try { const res = await fetch(ENDPOINT); - const data = await res.json(); - setGrokStatus(data); + const status = await res.json(); + setGrokStatus(status); + hasFetchedStatus.current = true; + if (hydrate) hydrateForm(status); } catch (error) { setGrokStatus({ installed: false, error: error.message }); } finally { setChecking(false); } - }; + }, [hydrateForm]); - const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1"); - - const getLocalBaseUrl = () => { - if (typeof window !== "undefined") { - return normalizeLocalhost(window.location.origin); - } - return "http://127.0.0.1:20128"; - }; + useEffect(() => { + if (!isExpanded) return; + let cancelled = false; + const synchronize = async () => { + if (!hasFetchedStatus.current) await checkStatus({ hydrate: true }); + if (!cancelled) await fetchModelAliases(); + }; + synchronize(); + return () => { cancelled = true; }; + }, [isExpanded, checkStatus, fetchModelAliases]); const getEffectiveBaseUrl = () => { - const url = customBaseUrl || getLocalBaseUrl(); + const url = customBaseUrl || (typeof window !== "undefined" + ? window.location.origin.replace("://localhost", "://127.0.0.1") + : "http://127.0.0.1:20128"); return url.endsWith("/v1") ? url : `${url}/v1`; }; @@ -118,6 +165,11 @@ export default function GrokBuildToolCard({ const keyToUse = selectedApiKey?.trim() || (apiKeys?.length > 0 ? apiKeys[0].key : null) || (!cloudEnabled ? "sk_9router" : null); + const mappedSubagents = {}; + for (const type of SUBAGENT_TYPES) { + const model = subagentModels[type.id]?.trim(); + if (model) mappedSubagents[type.id] = { model, contextWindow: getContextWindow(model) }; + } const res = await fetch(ENDPOINT, { method: "POST", @@ -126,11 +178,13 @@ export default function GrokBuildToolCard({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, model: selectedModel, + contextWindow: getContextWindow(selectedModel), + subagentModels: mappedSubagents, }), }); const data = await res.json(); if (res.ok) { - setMessage({ type: "success", text: "Settings applied successfully!" }); + setMessage({ type: "success", text: "Main and subagent models applied successfully!" }); checkStatus(); } else { setMessage({ type: "error", text: data.error || "Failed to apply settings" }); @@ -151,6 +205,7 @@ export default function GrokBuildToolCard({ if (res.ok) { setMessage({ type: "success", text: "Settings reset successfully!" }); setSelectedModel(""); + setSubagentModels({}); checkStatus(); } else { setMessage({ type: "error", text: data.error || "Failed to reset settings" }); @@ -163,31 +218,33 @@ export default function GrokBuildToolCard({ }; const handleModelSelect = (model) => { - setSelectedModel(model.value); - setModalOpen(false); + if (modelTarget === "main") { + setSelectedModel(model.value); + } else if (modelTarget) { + setSubagentModels((current) => ({ ...current, [modelTarget]: model.value })); + } + setModelTarget(null); }; const getManualConfigs = () => { - const keyToUse = (selectedApiKey && selectedApiKey.trim()) - ? selectedApiKey - : (!cloudEnabled ? "sk_9router" : ""); - - const modelId = selectedModel || "provider/model-id"; - const tomlContent = `[models] -default = "${MODEL_SLOT}" - -[model.${MODEL_SLOT}] -model = "${modelId}" -base_url = "${getEffectiveBaseUrl()}" -name = "9Router" -description = "Routed via 9Router gateway" -api_backend = "chat_completions" -api_key = "${keyToUse}" -`; - - return [ - { filename: "~/.grok/config.toml", content: tomlContent }, + const keyToUse = selectedApiKey?.trim() + || (!cloudEnabled ? "sk_9router" : ""); + const baseUrl = getEffectiveBaseUrl(); + const mainModel = selectedModel || "provider/model-id"; + const blocks = [ + `[models]\ndefault = "${MODEL_SLOT}"`, + `[model.${MODEL_SLOT}]\nmodel = "${mainModel}"\nbase_url = "${baseUrl}"\nname = "9Router"\ndescription = "Routed via 9Router gateway"\napi_backend = "chat_completions"\napi_key = "${keyToUse}"\ncontext_window = ${getContextWindow(mainModel) || 200000}`, ]; + const mappings = []; + for (const type of SUBAGENT_TYPES) { + const model = subagentModels[type.id]?.trim(); + if (!model) continue; + const slot = `${MODEL_SLOT}-${type.id}`; + mappings.push(`${type.id} = "${slot}"`); + blocks.push(`[model.${slot}]\nmodel = "${model}"\nbase_url = "${baseUrl}"\nname = "9Router ${type.id}"\ndescription = "Routed via 9Router gateway"\napi_backend = "chat_completions"\napi_key = "${keyToUse}"\ncontext_window = ${getContextWindow(model) || 200000}`); + } + if (mappings.length) blocks.splice(1, 0, `[subagents.models]\n${mappings.join("\n")}`); + return [{ filename: "~/.grok/config.toml", content: `${blocks.join("\n\n")}\n` }]; }; return ( @@ -203,6 +260,8 @@ api_key = "${keyToUse}" className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} + loading="lazy" + decoding="async" />
@@ -220,168 +279,105 @@ api_key = "${keyToUse}" {isExpanded && (
- {checking && ( -
- progress_activity - Checking Grok Build... -
- )} + {checking &&
progress_activityChecking Grok Build...
} {!checking && grokStatus && !grokStatus.installed && ( -
-
-
- warning -
-

Grok Build not detected locally

-

Install:

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

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

-
-
-
- +
+
+ warning +
+

Grok Build not detected locally

+ curl -fsSL https://x.ai/cli/install.sh | bash
+
)} {!checking && grokStatus?.installed && ( <>
- {tool.notes && tool.notes.length > 0 && ( -
- {tool.notes.map((note, idx) => ( -
- - {note.type === "warning" ? "warning" : note.type === "error" ? "error" : "info"} - + {tool.notes?.length > 0 && ( +
+ {tool.notes.map((note, index) => ( +
+ {note.type === "warning" ? "warning" : "info"} {note.text}
))}
)} -
Select Endpoint arrow_forward - +
- {grokStatus?.settings?.model?.base_url && ( -
+ {configuredModel?.base_url && ( +
Current arrow_forward - - {grokStatus.settings.model.base_url} - {grokStatus.settings.model.model ? ` · ${grokStatus.settings.model.model}` : ""} - + {configuredModel.base_url} · {configuredModel.model}{configuredModel.context_window ? ` · ${(configuredModel.context_window / 1000).toLocaleString()}K ctx` : ""}
)} -
+
API Key arrow_forward
-
- Default Model - arrow_forward -
- setSelectedModel(e.target.value)} - placeholder="provider/model-id" - className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" - /> - {selectedModel && ( - - )} + setModelTarget("main")} disabled={!hasActiveProviders} /> + +
+
+ account_tree +
+

Subagent model overrides

+

Leave blank to inherit Main Model. Each override keeps its own context window.

+
-
+ + {SUBAGENT_TYPES.map((type) => ( + setSubagentModels((current) => ({ ...current, [type.id]: value }))} + placeholder={`${selectedModel || "Main Model"} (inherit)`} + onSelect={() => setModelTarget(type.id)} + disabled={!hasActiveProviders} + /> + ))}
- {message && ( -
- {message.type === "success" ? "check_circle" : "error"} - {message.text} -
- )} + {message &&
{message.type === "success" ? "check_circle" : "error"}{message.text}
}
- - - + + +
)}
)} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Grok Build" - /> + {modelTarget && ( + setModelTarget(null)} + onSelect={handleModelSelect} + selectedModel={modelTarget === "main" ? selectedModel : subagentModels[modelTarget] || ""} + activeProviders={activeProviders} + modelAliases={modelAliases} + title={modelTarget === "main" ? "Select Main Model for Grok Build" : `Select ${SUBAGENT_TYPES.find((type) => type.id === modelTarget)?.label || "Subagent"} Model`} + /> + )} - setShowManualConfigModal(false)} - title="Grok Build - Manual Configuration" - configs={getManualConfigs()} - /> + setShowManualConfigModal(false)} title="Grok Build - Manual Configuration" configs={getManualConfigs()} /> ); } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js index 806be6bb..974372d7 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js @@ -58,11 +58,10 @@ export default function HermesToolCard({ }, [initialStatus]); useEffect(() => { - if (isExpanded && !hermesStatus) { - checkStatus(); + if (isExpanded) { + if (!hermesStatus) checkStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); const fetchModelAliases = async () => { @@ -185,7 +184,7 @@ export default function HermesToolCard({
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -296,15 +295,17 @@ export default function HermesToolCard({
)} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Hermes Agent" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Hermes Agent" + /> + )} { - if (isExpanded && !jcodeStatus) { - checkJcodeStatus(); + if (isExpanded) { + if (!jcodeStatus) checkJcodeStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); const fetchModelAliases = async () => { @@ -215,7 +214,7 @@ id = "${selectedModel || "cc/claude-opus-4-7"}"`;
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -359,15 +358,17 @@ id = "${selectedModel || "cc/claude-opus-4-7"}"`;
)} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for jcode" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for jcode" + /> + )} { - if (isExpanded && !status) { - checkStatus(); + if (isExpanded) { + if (!status) checkStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); const fetchModelAliases = async () => { @@ -144,7 +143,7 @@ export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiK
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -254,15 +253,17 @@ export default function KiloToolCard({ tool, isExpanded, onToggle, baseUrl, apiK
)} - setModalOpen(false)} - onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Kilo Code" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={(model) => { setSelectedModel(model.value); setModalOpen(false); }} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Kilo Code" + /> + )} { 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 82384292..3ca51d2e 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js @@ -143,6 +143,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" />
@@ -304,15 +306,17 @@ export default function MitmToolCard({ )} {/* 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/OpenClawToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js index 88a73c63..b646a6eb 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js @@ -57,11 +57,10 @@ export default function OpenClawToolCard({ }, [initialStatus]); useEffect(() => { - if (isExpanded && !openclawStatus) { - checkOpenclawStatus(); + if (isExpanded) { + if (!openclawStatus) checkOpenclawStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); const fetchModelAliases = async () => { @@ -233,7 +232,7 @@ export default function OpenClawToolCard({
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -367,15 +366,17 @@ export default function OpenClawToolCard({
)} - setModalOpen(false)} - onSelect={handleModelSelect} - selectedModel={selectedModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Model for Open Claw" - /> + {modalOpen && ( + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Open Claw" + /> + )} { - if (isExpanded && !status) { - checkStatus(); + if (isExpanded) { + if (!status) checkStatus(); fetchModelAliases(); } - if (isExpanded) fetchModelAliases(); }, [isExpanded]); // Sync models from existing config @@ -222,7 +221,7 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
- {tool.name} { e.target.style.display = "none"; }} /> + {tool.name} { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
@@ -452,42 +451,46 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
)} - { - setModalOpen(false); - saveModels(selectedModelsRef.current); - }} - onSelect={(model) => { - if (!selectedModels.includes(model.value)) { - setSelectedModels([...selectedModels, model.value]); - if (!activeModel) setActiveModel(model.value); - } - }} - onDeselect={(model) => { - const remaining = selectedModels.filter(m => m !== model.value); - setSelectedModels(remaining); - if (activeModel === model.value) { - setActiveModel(remaining[0] || ""); - } - }} - selectedModel={null} - activeProviders={activeProviders} - modelAliases={modelAliases} - addedModelValues={selectedModels} - closeOnSelect={false} - title="Add Model for OpenCode" - /> + {modalOpen && ( + { + setModalOpen(false); + saveModels(selectedModelsRef.current); + }} + onSelect={(model) => { + if (!selectedModels.includes(model.value)) { + setSelectedModels([...selectedModels, model.value]); + if (!activeModel) setActiveModel(model.value); + } + }} + onDeselect={(model) => { + const remaining = selectedModels.filter(m => m !== model.value); + setSelectedModels(remaining); + if (activeModel === model.value) { + setActiveModel(remaining[0] || ""); + } + }} + selectedModel={null} + activeProviders={activeProviders} + modelAliases={modelAliases} + addedModelValues={selectedModels} + closeOnSelect={false} + title="Add Model for OpenCode" + /> + )} - setSubagentModalOpen(false)} - onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }} - selectedModel={subagentModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Select Subagent Model for OpenCode" - /> + {subagentModalOpen && ( + setSubagentModalOpen(false)} + onSelect={(model) => { setSubagentModel(model.value); setSubagentModalOpen(false); }} + selectedModel={subagentModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Subagent Model for OpenCode" + /> + )}
{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 abfc215c..fb4c17d6 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -7,6 +7,7 @@ import { CSS } from "@dnd-kit/utilities"; import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers"; import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ConfirmModal, CapacityBadges, Select } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { useModelCaps } from "@/shared/hooks/useModelCaps"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; // Validate combo name: only a-z, A-Z, 0-9, -, _ @@ -19,7 +20,7 @@ export default function CombosPage() { const [editingCombo, setEditingCombo] = useState(null); const [activeProviders, setActiveProviders] = useState([]); const [comboStrategies, setComboStrategies] = useState({}); - const [modelCaps, setModelCaps] = useState({}); + const { getCaps } = useModelCaps(); const [confirmState, setConfirmState] = useState(null); const { copied, copy } = useCopyToClipboard(); @@ -29,11 +30,10 @@ export default function CombosPage() { const fetchData = async () => { try { - const [combosRes, providersRes, settingsRes, modelsRes] = await Promise.all([ + const [combosRes, providersRes, settingsRes] = await Promise.all([ fetch("/api/combos"), fetch("/api/providers"), fetch("/api/settings"), - fetch("/api/models"), ]); const combosData = await combosRes.json(); const providersData = await providersRes.json(); @@ -44,13 +44,6 @@ export default function CombosPage() { if (providersRes.ok) { setActiveProviders(providersData.connections || []); } - if (modelsRes.ok) { - const md = await modelsRes.json(); - // Build fullModel -> caps map for badge lookup - const map = {}; - for (const m of md.models || []) if (m.caps) map[m.fullModel] = m.caps; - setModelCaps(map); - } setComboStrategies(settingsData.comboStrategies || {}); } catch (error) { console.log("Error fetching data:", error); @@ -189,7 +182,7 @@ export default function CombosPage() { setShowCreateModal(false)} - onSave={handleCreate} - activeProviders={activeProviders} - /> + {showCreateModal && ( + setShowCreateModal(false)} + onSave={handleCreate} + activeProviders={activeProviders} + /> + )} - {/* Edit Modal - Use key to force remount and reset state */} - setEditingCombo(null)} - onSave={(data) => handleUpdate(editingCombo.id, data)} - activeProviders={activeProviders} - /> + {editingCombo && ( + setEditingCombo(null)} + onSave={(data) => handleUpdate(editingCombo.id, data)} + activeProviders={activeProviders} + /> + )} {/* Confirm Delete Modal */} ( {model} - + )) )} @@ -340,15 +336,17 @@ function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy
{/* Judge model picker (single-select; combo members make natural judges too) */} - setShowJudgeSelect(false)} - onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }} - activeProviders={activeProviders} - title="Select Judge Model" - addedModelValues={judge ? [judge] : []} - closeOnSelect={true} - /> + {showJudgeSelect && ( + setShowJudgeSelect(false)} + onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }} + activeProviders={activeProviders} + title="Select Judge Model" + addedModelValues={judge ? [judge] : []} + closeOnSelect={true} + /> + )} ); } @@ -637,18 +635,20 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF {/* Model Select Modal */} - setShowModelSelect(false)} - onSelect={handleAddModel} - onDeselect={handleDeselectModel} - activeProviders={activeProviders} - modelAliases={modelAliases} - title="Add Model to Combo" - kindFilter={kindFilter} - addedModelValues={models} - closeOnSelect={false} - /> + {showModelSelect && ( + setShowModelSelect(false)} + onSelect={handleAddModel} + onDeselect={handleDeselectModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + 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 815528d7..76c571b8 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 @@ -350,6 +350,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" /> )}
@@ -383,6 +385,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" /> )}
@@ -487,6 +491,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" />
)} @@ -529,6 +535,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 45c15460..7ad8b673 100644 --- a/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/combo/[id]/page.js @@ -357,7 +357,7 @@ export default function ComboDetailPage() { Download
- Generated + Generated
)} {testResult.audioUrl && ( @@ -393,18 +393,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 d8881cf3..6720eef6 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"; @@ -67,6 +68,7 @@ export default function ProviderDetailPage() { const [providerTimeout, setProviderTimeout] = useState(""); const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} }); const [suggestedModels, setSuggestedModels] = useState([]); + const [liveModels, setLiveModels] = useState([]); const [kiloFreeModels, setKiloFreeModels] = useState([]); const [disabledModelIds, setDisabledModelIds] = useState([]); const [confirmState, setConfirmState] = useState(null); @@ -147,7 +149,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); @@ -157,8 +162,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; @@ -518,6 +527,34 @@ export default function ProviderDetailPage() { fetchDisabledModels(); }, [fetchConnections, fetchAliases, fetchCustomModels, fetchDisabledModels]); + // 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; @@ -1473,7 +1510,7 @@ export default function ProviderDetailPage() { if (isAnthropicCompatible) { return "/providers/anthropic-m.png"; } - return `/providers/${providerInfo.id}.png`; + return getProviderIconSrc(providerInfo.id); }; return ( @@ -1492,7 +1529,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()} @@ -1504,7 +1541,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 dd134f33..eab63c7a 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, @@ -276,6 +277,16 @@ export default function ProvidersPage() { })) .filter((p) => matchSearch(p.name)); + // Dual-auth providers (oauth + apikey) store API keys as authType "apikey" + // (and sometimes "api_key"). Card stats must count both so totals match detail. + // kiro has no authModes in registry but accepts both (headless uses "api_key"). + const dualAuthTypes = (info, key) => { + if (key === "kiro") return ["oauth", "apikey", "api_key"]; + const modes = info?.authModes; + if (!Array.isArray(modes) || !modes.includes("apikey")) return "oauth"; + return ["oauth", "apikey", "api_key"]; + }; + const oauthEntries = sortByPriority( Object.entries(OAUTH_PROVIDERS).filter(([, info]) => !info.hidden && matchSearch(info.name)), "oauth", @@ -283,15 +294,27 @@ export default function ProvidersPage() { const freeEntries = Object.entries(FREE_PROVIDERS) .filter(([, info]) => !info.hidden && matchSearch(info.name)) .sort(([, a], [, b]) => (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0)); - const freeTierEntries = sortByPriority( - Object.entries(FREE_TIER_PROVIDERS).filter( + // Free Tier cards may be oauth-only (e.g. kimchi) or dual-auth, so count via + // dualAuthTypes per provider instead of a fixed "apikey" — otherwise oauth + // connections are invisible here (mismatch with the detail page). + const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS) + .filter( ([, info]) => !info.hidden && matchSearch(info.name) && (info.serviceKinds ?? ["llm"]).includes("llm"), - ), - "freeTier", - ).sort(([, a], [, b]) => (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0)); + ) + .sort(([ka, a], [kb, b]) => { + const pa = a.priority ?? 999; + const pb = b.priority ?? 999; + if (pa !== pb) return pa - pb; + const noAuthDiff = (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0); + if (noAuthDiff !== 0) return noAuthDiff; + const ca = getProviderStats(ka, dualAuthTypes(a, ka)).connected > 0 ? 0 : 1; + const cb = getProviderStats(kb, dualAuthTypes(b, kb)).connected > 0 ? 0 : 1; + if (ca !== cb) return ca - cb; + return (a.name || "").localeCompare(b.name || ""); + }); // API Key: connected providers first, then alphabetical by name const apikeyEntries = Object.entries(APIKEY_PROVIDERS) .filter( @@ -423,16 +446,19 @@ export default function ProvidersPage() {
- {oauthEntries.map(([key, info]) => ( - handleToggleProvider(key, "oauth", active)} - /> - ))} + {oauthEntries.map(([key, info]) => { + const authTypes = dualAuthTypes(info, key); + return ( + handleToggleProvider(key, authTypes, active)} + /> + ); + })}
)} @@ -465,12 +491,9 @@ export default function ProvidersPage() {
{freeEntries.map(([key, info]) => { - // Kiro accepts both OAuth and api-key connections; count/toggle both - // so the card total matches the provider detail page (#kiro-apikey). - // Kiro's headless api-key flow persists authType "api_key" (underscore), - // while generic apikey providers use "apikey" — include both spellings. - const freeAuthTypes = - key === "kiro" ? ["oauth", "apikey", "api_key"] : "oauth"; + // Dual-auth (e.g. kiro): count/toggle oauth + apikey/api_key so the + // card total matches the provider detail page. + const freeAuthTypes = dualAuthTypes(info, key); return ( ); })} - {freeTierEntries.map(([key, info]) => ( - handleToggleProvider(key, "apikey", active)} - /> - ))} + {freeTierEntries.map(([key, info]) => { + const freeAuthTypes = dualAuthTypes(info, key); + return ( + handleToggleProvider(key, freeAuthTypes, active)} + /> + ); + })}
)} @@ -756,12 +782,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/QuotaTable.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js index 299445ef..9f18e5d1 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js @@ -148,103 +148,100 @@ export default function QuotaTable({ )}
-
- - - {currentPageRows.map((quota) => { - const colors = getColorClasses(quota.remaining); - const countdown = formatResetTime(quota.resetAt); - const resetDisplay = formatResetTimeDisplay(quota.resetAt); - // recurring defaults true: a missing flag means the quota - // refreshes at resetAt. Bonus/one-shot packs set recurring:false - // and their resetAt is a hard expiry, so word it as "expires". - const recurring = quota.recurring !== false; - const countdownLabel = recurring ? `in ${countdown}` : `expires in ${countdown}`; +
+ {currentPageRows.map((quota) => { + const colors = getColorClasses(quota.remaining); + const countdown = formatResetTime(quota.resetAt); + const resetDisplay = formatResetTimeDisplay(quota.resetAt); + // recurring defaults true: a missing flag means the quota + // refreshes at resetAt. Bonus/one-shot packs set recurring:false + // and their resetAt is a hard expiry, so word it as "expires". + const recurring = quota.recurring !== false; + const countdownLabel = recurring ? `in ${countdown}` : `expires in ${countdown}`; - return ( -
+ {/* Name */} +
+ {colors.emoji} + + {quota.name} + +
+ + {/* Progress + used/total */} +
+
+
+
+ +
+ 0 ? quota.total.toLocaleString() : "∞"}`} + > + {quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"} + + + {quota.remaining}% + +
+
+ + {/* Reset time */} +
+ {countdown !== "-" || resetDisplay ? ( + compact ? ( +
+ {countdown !== "-" ? countdownLabel : resetDisplay} +
+ ) : ( +
+ {countdown !== "-" && ( +
+ {countdownLabel} +
+ )} + {resetDisplay && ( +
+ {resetDisplay} +
+ )} +
+ ) + ) : ( +
N/A
+ )} +
+ + {/* Hide action */} + {hasHideAction && ( +
- - - - - - {hasHideAction && ( - - )} - - ); - })} - -
-
- {colors.emoji} - - {quota.name} - -
-
-
-
-
-
- -
- - {quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"} - - - {quota.remaining}% - -
-
-
- {countdown !== "-" || resetDisplay ? ( - compact ? ( -
- {countdown !== "-" ? countdownLabel : resetDisplay} -
- ) : ( -
- {countdown !== "-" && ( -
- {countdownLabel} -
- )} - {resetDisplay && ( -
- {resetDisplay} -
- )} -
- ) - ) : ( -
N/A
- )} -
- -
+ + visibility_off + + + )} +
+ ); + })}
{totalPages > 1 && ( diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index babfa6e2..eb486cc0 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -1265,22 +1265,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/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index 0f0a88d8..3334d9a4 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -534,6 +534,36 @@ export function parseQuotaData(provider, data) { } break; + case "kimi": + // Weekly / Ratelimit from /v1/usages. Prefer remainingPercentage only. + if (data.quotas) { + Object.entries(data.quotas).forEach(([name, quota]) => { + normalizedQuotas.push({ + name, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + remainingPercentage: quota.remainingPercentage, + }); + }); + } + break; + + case "deepseek": + // Credit balance — remainingPercentage only (no absolute remaining). + if (data.quotas) { + Object.entries(data.quotas).forEach(([name, quota]) => { + normalizedQuotas.push({ + name, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + remainingPercentage: quota.remainingPercentage, + }); + }); + } + break; + default: // Generic fallback for unknown providers if (data.quotas) { diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js b/src/app/(dashboard)/dashboard/usage/components/ProviderTopology.js index 3827a0f8..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 - 9Router + 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/cli-tools/all-statuses/route.js b/src/app/api/cli-tools/all-statuses/route.js index c3ac832b..5925e526 100644 --- a/src/app/api/cli-tools/all-statuses/route.js +++ b/src/app/api/cli-tools/all-statuses/route.js @@ -14,6 +14,7 @@ import { GET as kiloGet } from "../kilo-settings/route"; import { GET as deepseekTuiGet } from "../deepseek-tui-settings/route"; import { GET as jcodeGet } from "../jcode-settings/route"; import { GET as grokBuildGet } from "../grok-build-settings/route"; +import { GET as devinGet } from "../devin-settings/route"; const STATUS_GETTERS = { claude: claudeGet, @@ -29,6 +30,7 @@ const STATUS_GETTERS = { "deepseek-tui": deepseekTuiGet, jcode: jcodeGet, "grok-build": grokBuildGet, + devin: devinGet, }; // Batch endpoint: gather all CLI tool statuses in one round-trip diff --git a/src/app/api/cli-tools/claude-settings/route.js b/src/app/api/cli-tools/claude-settings/route.js index bb5fa06f..76ba9232 100644 --- a/src/app/api/cli-tools/claude-settings/route.js +++ b/src/app/api/cli-tools/claude-settings/route.js @@ -6,15 +6,52 @@ import { promisify } from "util"; import fs from "fs/promises"; import path from "path"; import os from "os"; +import { DEFAULT_PLUGINS } from "@/shared/constants/coworkPlugins"; const execAsync = promisify(exec); +// Exa MCP def — reuse from coworkPlugins (DRY). +const EXA_PLUGIN = DEFAULT_PLUGINS.find((p) => p.name === "exa"); +const buildExaMcpEntry = () => ({ + type: EXA_PLUGIN.transport, + url: EXA_PLUGIN.url, +}); + // Get claude settings path based on OS const getClaudeSettingsPath = () => { const homeDir = os.homedir(); return path.join(homeDir, ".claude", "settings.json"); }; +// Claude Code CLI reads mcpServers from ~/.claude.json (NOT settings.json). +const getClaudeJsonPath = () => path.join(os.homedir(), ".claude.json"); + +const readClaudeJson = async () => { + try { + const content = await fs.readFile(getClaudeJsonPath(), "utf-8"); + return JSON.parse(content.replace(/,(\s*[}\]])/g, "$1")); + } catch { + return null; + } +}; + +const writeClaudeJsonMcp = async (mcpServers) => { + const filePath = getClaudeJsonPath(); + let data = {}; + try { + data = JSON.parse(await fs.readFile(filePath, "utf-8")); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + if (mcpServers && Object.keys(mcpServers).length > 0) { + data.mcpServers = { ...(data.mcpServers || {}), ...mcpServers }; + } else if (data.mcpServers) { + delete data.mcpServers.exa; + if (Object.keys(data.mcpServers).length === 0) delete data.mcpServers; + } + await fs.writeFile(filePath, JSON.stringify(data, null, 2)); +}; + // Check if claude CLI is installed (via which/where or config file exists) const checkClaudeInstalled = async () => { @@ -65,11 +102,13 @@ export async function GET() { const settings = await readSettings(); const has9Router = !!(settings?.env?.ANTHROPIC_BASE_URL); + const claudeJson = await readClaudeJson(); return NextResponse.json({ installed: true, settings: settings, has9Router: has9Router, + exaMcpEnabled: !!claudeJson?.mcpServers?.exa, settingsPath: getClaudeSettingsPath(), }); } catch (error) { @@ -84,7 +123,7 @@ export async function GET() { // POST - Backup old fields and write new settings export async function POST(request) { try { - const { env } = await request.json(); + const { env, exaMcpEnabled, maxContextTokens } = await request.json(); if (!env || typeof env !== "object") { return NextResponse.json( @@ -127,9 +166,22 @@ export async function POST(request) { }, }; + // CLAUDE_CODE_MAX_CONTEXT_TOKENS — only set when a concrete value is chosen; + // "Default" removes the key so Claude Code falls back to the model's window. + if (maxContextTokens) { + newSettings.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(maxContextTokens); + } else { + delete newSettings.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS; + } + // Write new settings await fs.writeFile(settingsPath, JSON.stringify(newSettings, null, 2)); + // Exa MCP toggle — write to ~/.claude.json (CLI reads mcpServers from here). + if (EXA_PLUGIN) { + await writeClaudeJsonMcp(exaMcpEnabled ? { exa: buildExaMcpEntry() } : null); + } + return NextResponse.json({ success: true, message: "Settings updated successfully", @@ -151,6 +203,7 @@ const RESET_ENV_KEYS = [ "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "API_TIMEOUT_MS", + "CLAUDE_CODE_MAX_CONTEXT_TOKENS", ]; // DELETE - Reset settings (remove env fields) @@ -185,6 +238,9 @@ export async function DELETE() { } } + // Remove injected MCP servers (Exa) from ~/.claude.json + await writeClaudeJsonMcp(null); + // Write updated settings await fs.writeFile(settingsPath, JSON.stringify(currentSettings, null, 2)); @@ -200,4 +256,3 @@ export async function DELETE() { ); } } - diff --git a/src/app/api/cli-tools/devin-settings/route.js b/src/app/api/cli-tools/devin-settings/route.js new file mode 100644 index 00000000..240df0a8 --- /dev/null +++ b/src/app/api/cli-tools/devin-settings/route.js @@ -0,0 +1,88 @@ +"use server"; + +import { NextResponse } from "next/server"; +import { exec } from "child_process"; +import { promisify } from "util"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +const execAsync = promisify(exec); + +// Mirror the executor's resolveDevinBin discovery so the dashboard's status +// matches what the runtime actually spawns. +const candidateDevinPaths = () => { + const home = os.homedir(); + const isWin = os.platform() === "win32"; + const localAppData = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"); + // Mirror resolveDevinBin in the executor — cover installer + common + // package-manager locations so detection matches runtime resolution. + return isWin + ? [ + path.join(localAppData, "devin", "cli", "bin", "devin.exe"), + path.join(home, ".local", "bin", "devin.exe"), + path.join(home, "scoop", "shims", "devin.exe"), + path.join(localAppData, "Programs", "devin", "devin.exe"), + ] + : [ + path.join(home, ".local", "share", "devin", "bin", "devin"), + path.join(home, ".devin", "bin", "devin"), + path.join(home, ".local", "bin", "devin"), + "/opt/homebrew/bin/devin", + "/usr/local/bin/devin", + "/usr/bin/devin", + ]; +}; + +const checkDevinInstalled = async () => { + // 1. PATH lookup + try { + const isWindows = os.platform() === "win32"; + const command = isWindows ? "where devin" : "which devin"; + await execAsync(command, { windowsHide: true }); + return { installed: true, source: "path" }; + } catch { + // fall through to filesystem probes + } + // 2. Known installer paths + for (const candidate of candidateDevinPaths()) { + try { + await fs.access(candidate); + return { installed: true, source: candidate }; + } catch { /* keep probing */ } + } + return { installed: false, source: null }; +}; + +const readDevinVersion = async () => { + try { + const { stdout } = await execAsync("devin --version", { windowsHide: true }); + return stdout.trim().split("\n")[0] || null; + } catch { + return null; + } +}; + +// GET — install detection only. No config to write: the binary handles its own auth. +export async function GET() { + try { + const { installed, source } = await checkDevinInstalled(); + if (!installed) { + return NextResponse.json({ + installed: false, + message: "Devin CLI is not installed. Install it from https://cli.devin.ai and run `devin auth login`.", + installUrl: "https://cli.devin.ai", + }); + } + const version = await readDevinVersion(); + return NextResponse.json({ + installed: true, + source, + version, + message: "Devin CLI detected. Make sure `devin auth login` has been run.", + }); + } catch (error) { + console.log("Error checking devin settings:", error); + return NextResponse.json({ error: "Failed to check devin settings" }, { status: 500 }); + } +} diff --git a/src/app/api/cli-tools/grok-build-settings/route.js b/src/app/api/cli-tools/grok-build-settings/route.js index afc747ef..02299a3b 100644 --- a/src/app/api/cli-tools/grok-build-settings/route.js +++ b/src/app/api/cli-tools/grok-build-settings/route.js @@ -6,24 +6,16 @@ import { promisify } from "util"; import fs from "fs/promises"; import path from "path"; import os from "os"; +import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js"; +import { + applyGrokBuildConfig, + GROK_SUBAGENT_TYPES, + parseGrokBuildConfig, + resetGrokBuildConfig, +} from "@/lib/grokBuildConfig"; const execAsync = promisify(exec); -const PROVIDER_NAME = "9router"; -const MODEL_SLOT = "9router"; -const BUILTIN_DEFAULT = "grok-build"; - -// [model.9router] ... until next [section] header or EOF -const MODEL_SECTION_RE = new RegExp( - `^\\[model\\.${MODEL_SLOT}\\][ \\t]*\\r?\\n(?:(?!\\[)[^\\r\\n]*\\r?\\n?)*`, - "m" -); - -const MODELS_SECTION_RE = /^\[models\][ \t]*\r?\n((?:(?!\[)[^\r\n]*\r?\n?)*)/m; - -// Marker written on Apply so Reset can restore the previous [models].default -const PREV_DEFAULT_RE = /^# 9router-prev-default = "([^"]*)"[ \t]*\r?\n?/m; - const getGrokDir = () => path.join(os.homedir(), ".grok"); const getGrokConfigPath = () => path.join(getGrokDir(), "config.toml"); const getGrokBinPath = () => path.join(getGrokDir(), "bin", "grok"); @@ -31,21 +23,16 @@ const getGrokBinPath = () => path.join(getGrokDir(), "bin", "grok"); const checkGrokInstalled = async () => { try { const isWindows = os.platform() === "win32"; - const command = isWindows ? "where grok" : "which grok"; - await execAsync(command, { windowsHide: true }); + await execAsync(isWindows ? "where grok" : "which grok", { windowsHide: true }); return true; } catch { - try { - await fs.access(getGrokBinPath()); - return true; - } catch { + for (const candidate of [getGrokBinPath(), getGrokConfigPath()]) { try { - await fs.access(getGrokConfigPath()); + await fs.access(candidate); return true; - } catch { - return false; - } + } catch { /* try next */ } } + return false; } }; @@ -58,99 +45,32 @@ const readConfigToml = async () => { } }; -const getTomlField = (body, key) => { - const m = body.match(new RegExp(`^[ \\t]*${key}[ \\t]*=[ \\t]*"([^"]*)"`, "m")); - return m ? m[1] : null; +const normalizeContextWindow = (value, model) => { + const explicit = Number(value); + if (Number.isFinite(explicit) && explicit > 0) return Math.floor(explicit); + const slash = model.indexOf("/"); + const provider = slash > 0 ? model.slice(0, slash) : null; + const modelId = slash > 0 ? model.slice(slash + 1) : model; + return getCapabilitiesForModel(provider, modelId).contextWindow; }; -const parseModelSection = (toml) => { - const match = toml.match(MODEL_SECTION_RE); - if (!match) return null; - const body = match[0].replace(/^\[model\.[^\]]+\][ \t]*\r?\n/, ""); - return { - model: getTomlField(body, "model"), - base_url: getTomlField(body, "base_url"), - name: getTomlField(body, "name"), - api_key: getTomlField(body, "api_key"), - api_backend: getTomlField(body, "api_backend"), - }; -}; - -const parseModelsDefault = (toml) => { - const match = toml.match(MODELS_SECTION_RE); - if (!match) return null; - return getTomlField(match[1] || "", "default"); -}; - -const buildModelSection = (model, baseUrl, apiKey) => { - const lines = [ - `[model.${MODEL_SLOT}]`, - `model = "${model}"`, - `base_url = "${baseUrl}"`, - `name = "9Router"`, - `description = "Routed via 9Router gateway"`, - `api_backend = "chat_completions"`, - ]; - if (apiKey) lines.push(`api_key = "${apiKey}"`); - return `${lines.join("\n")}\n`; -}; - -const upsertModelSection = (toml, section) => { - if (MODEL_SECTION_RE.test(toml)) return toml.replace(MODEL_SECTION_RE, section); - const needsNl = toml.length > 0 && !toml.endsWith("\n"); - return `${toml}${needsNl ? "\n" : ""}\n${section}`; -}; - -const removeModelSection = (toml) => - toml.replace(MODEL_SECTION_RE, "").replace(/\n{3,}/g, "\n\n"); - -// Set or insert default = "..." inside existing [models], or create the section -const setModelsDefault = (toml, value) => { - const match = toml.match(MODELS_SECTION_RE); - if (match) { - const body = match[1] || ""; - let newBody; - if (/^[ \t]*default[ \t]*=/m.test(body)) { - newBody = body.replace(/^[ \t]*default[ \t]*=[ \t]*"[^"]*"/m, `default = "${value}"`); - } else { - newBody = `default = "${value}"\n${body}`; - } - return toml.replace(match[0], `[models]\n${newBody}`); +const normalizeSubagentModels = (value) => { + if (value === undefined) return undefined; // backwards-compatible callers leave current overrides untouched + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const result = {}; + for (const type of GROK_SUBAGENT_TYPES) { + const entry = value[type]; + const model = typeof entry === "string" ? entry.trim() : entry?.model?.trim(); + if (!model) continue; // blank means inherit the main model + result[type] = { + model, + contextWindow: normalizeContextWindow(entry?.contextWindow, model), + }; } - const block = `[models]\ndefault = "${value}"\n\n`; - return toml.length > 0 ? block + toml : block; + return result; }; -// Remember the previous default once (so re-Apply does not overwrite it with "9router") -const rememberPrevDefault = (toml) => { - if (PREV_DEFAULT_RE.test(toml)) return toml; - const current = parseModelsDefault(toml); - if (!current || current === MODEL_SLOT) return toml; - const marker = `# 9router-prev-default = "${current}"\n`; - // Prefer placing the marker just above [model.9router] if present, else at EOF - if (MODEL_SECTION_RE.test(toml)) { - return toml.replace(MODEL_SECTION_RE, (section) => marker + section); - } - const needsNl = toml.length > 0 && !toml.endsWith("\n"); - return `${toml}${needsNl ? "\n" : ""}${marker}`; -}; - -// If default points at our slot, restore previous (or built-in) default and drop marker -const clearModelsDefaultIfOurs = (toml) => { - const prevMatch = toml.match(PREV_DEFAULT_RE); - const restoreTo = prevMatch?.[1] || BUILTIN_DEFAULT; - let next = toml.replace(PREV_DEFAULT_RE, ""); - const current = parseModelsDefault(next); - if (current === MODEL_SLOT) { - next = setModelsDefault(next, restoreTo); - } - return next; -}; - -const has9RouterConfig = (modelCfg) => { - if (!modelCfg?.base_url) return false; - return true; -}; +const has9RouterConfig = (settings) => Boolean(settings?.model?.base_url); export async function GET() { try { @@ -163,17 +83,11 @@ export async function GET() { }); } - const toml = await readConfigToml(); - const model = parseModelSection(toml); - const defaultModel = parseModelsDefault(toml); - + const settings = parseGrokBuildConfig(await readConfigToml()); return NextResponse.json({ installed: true, - settings: { - model, - default: defaultModel, - }, - has9Router: has9RouterConfig(model), + settings, + has9Router: has9RouterConfig(settings), configPath: getGrokConfigPath(), }); } catch (error) { @@ -184,29 +98,28 @@ export async function GET() { export async function POST(request) { try { - const { baseUrl, apiKey, model } = await request.json(); - if (!baseUrl || !model) { + const { baseUrl, apiKey, model, contextWindow, subagentModels } = await request.json(); + const selectedModel = typeof model === "string" ? model.trim() : ""; + if (!baseUrl || !selectedModel) { return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 }); } - const dir = getGrokDir(); - await fs.mkdir(dir, { recursive: true }); - + await fs.mkdir(getGrokDir(), { recursive: true }); const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; - const keyToWrite = apiKey || "sk_9router"; - - let toml = await readConfigToml(); - toml = rememberPrevDefault(toml); - toml = upsertModelSection(toml, buildModelSection(model, normalizedBaseUrl, keyToWrite)); - toml = setModelsDefault(toml, MODEL_SLOT); - + const toml = applyGrokBuildConfig(await readConfigToml(), { + baseUrl: normalizedBaseUrl, + apiKey: apiKey || "sk_9router", + model: selectedModel, + contextWindow: normalizeContextWindow(contextWindow, selectedModel), + subagentModels: normalizeSubagentModels(subagentModels), + }); await fs.writeFile(getGrokConfigPath(), toml); return NextResponse.json({ success: true, message: "Grok Build settings applied successfully!", configPath: getGrokConfigPath(), - modelSlot: MODEL_SLOT, + modelSlot: "9router", }); } catch (error) { console.log("Error updating grok-build settings:", error); @@ -217,7 +130,7 @@ export async function POST(request) { export async function DELETE() { try { const configPath = getGrokConfigPath(); - let toml = ""; + let toml; try { toml = await fs.readFile(configPath, "utf-8"); } catch (error) { @@ -227,13 +140,10 @@ export async function DELETE() { throw error; } - toml = removeModelSection(toml); - toml = clearModelsDefaultIfOurs(toml); - await fs.writeFile(configPath, toml); - + await fs.writeFile(configPath, resetGrokBuildConfig(toml)); return NextResponse.json({ success: true, - message: `${PROVIDER_NAME} model slot removed from Grok Build`, + message: "9router model slots removed from Grok Build", }); } catch (error) { console.log("Error resetting grok-build settings:", error); diff --git a/src/app/api/models/route.js b/src/app/api/models/route.js index a4e833a9..b2e4fe0a 100644 --- a/src/app/api/models/route.js +++ b/src/app/api/models/route.js @@ -19,12 +19,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 57bff272..f85bbfa1 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -1,10 +1,10 @@ import { NextResponse } from "next/server"; -import { - getProvider, - generateAuthData, - exchangeTokens, - requestDeviceCode, - pollForToken +import { + getProvider, + generateAuthData, + exchangeTokens, + requestDeviceCode, + pollForToken } from "@/lib/oauth/providers"; import { createProviderConnection } from "@/models"; import { @@ -18,7 +18,24 @@ import { registerXaiSession, getXaiSessionStatus, clearXaiSession, + startTraeProxy, + stopTraeProxy, + registerTraeSession, + getTraeSessionStatus, + clearTraeSession, + startWindsurfProxy, + stopWindsurfProxy, + registerWindsurfSession, + getWindsurfSessionStatus, + clearWindsurfSession, + startZedProxy, + stopZedProxy, + registerZedSession, + getZedSessionStatus, + clearZedSession, } from "@/lib/oauth/utils/server"; +import { detectIdeInstalled } from "@/lib/oauth/utils/ideDetect"; +import { ZED_HOSTED_CONFIG } from "@/lib/oauth/constants/oauth"; async function completeXaiManualCode(code, state) { const session = state ? getXaiSessionStatus(state) : null; @@ -77,13 +94,34 @@ export async function GET(request, { params }) { const reservedParams = new Set(["redirect_uri"]); const meta = {}; searchParams.forEach((value, key) => { if (!reservedParams.has(key)) meta[key] = value; }); + // Zed: derive native_app_port from the local callback URL so the RSA keypair + // is bound to the port the proxy is actually listening on. + if (provider === "zed") { + try { const p = new URL(redirectUri).port; if (p) meta.nativeAppPort = p; } catch { /* ignore */ } + } const authData = await generateAuthData(provider, redirectUri, Object.keys(meta).length ? meta : undefined); return NextResponse.json(authData); } if (action === "start-proxy") { + // Trae/Windsurf/Zed use a dynamic-port local callback server (singleton session, + // state is registered separately via /register-session after /authorize). + if (provider === "trae") { + const result = await startTraeProxy(); + return NextResponse.json(result); + } + if (provider === "windsurf") { + const result = await startWindsurfProxy(); + return NextResponse.json(result); + } + if (provider === "zed") { + // Prefer ZED_HOSTED_CONFIG.defaultNativeAppPort (58443) so the browser redirect + // matches what Zed expects; falls back to a random port if it's busy. + const result = await startZedProxy(searchParams.get("native_app_port") || ZED_HOSTED_CONFIG.defaultNativeAppPort); + return NextResponse.json(result); + } if (!["codex", "xai"].includes(provider)) { - return NextResponse.json({ error: "Proxy only supported for codex/xai" }, { status: 400 }); + return NextResponse.json({ error: "Proxy only supported for codex/xai/trae/windsurf/zed" }, { status: 400 }); } const appPort = searchParams.get("app_port"); if (!appPort) { @@ -105,18 +143,24 @@ export async function GET(request, { params }) { } if (action === "poll-status") { - if (!["codex", "xai"].includes(provider)) { - return NextResponse.json({ error: "Poll only supported for codex/xai" }, { status: 400 }); - } const state = searchParams.get("state"); if (!state) { return NextResponse.json({ error: "Missing state" }, { status: 400 }); } - const session = provider === "xai" ? getXaiSessionStatus(state) : getCodexSessionStatus(state); + let session; + if (provider === "trae") session = getTraeSessionStatus(state); + else if (provider === "windsurf") session = getWindsurfSessionStatus(state); + else if (provider === "zed") session = getZedSessionStatus(state); + else if (provider === "xai") session = getXaiSessionStatus(state); + else if (provider === "codex") session = getCodexSessionStatus(state); + else return NextResponse.json({ error: "Poll only supported for codex/xai/trae/windsurf/zed" }, { status: 400 }); if (!session) return NextResponse.json({ status: "unknown" }); if (session.status === "done" || session.status === "error") { const payload = { ...session }; - if (provider === "xai") clearXaiSession(state); + if (provider === "trae") clearTraeSession(state); + else if (provider === "windsurf") clearWindsurfSession(state); + else if (provider === "zed") clearZedSession(state); + else if (provider === "xai") clearXaiSession(state); else clearCodexSession(state); return NextResponse.json(payload); } @@ -124,14 +168,24 @@ export async function GET(request, { params }) { } if (action === "stop-proxy") { - if (!["codex", "xai"].includes(provider)) { - return NextResponse.json({ error: "Proxy only supported for codex/xai" }, { status: 400 }); - } - if (provider === "xai") stopXaiProxy(); - else stopCodexProxy(); + if (provider === "trae") stopTraeProxy(); + else if (provider === "windsurf") stopWindsurfProxy(); + else if (provider === "zed") stopZedProxy(); + else if (provider === "xai") stopXaiProxy(); + else if (provider === "codex") stopCodexProxy(); + else return NextResponse.json({ error: "Proxy only supported for codex/xai/trae/windsurf/zed" }, { status: 400 }); return NextResponse.json({ success: true }); } + if (action === "ide-status") { + // Detect whether the IDE is installed locally (used by import-token UX). + if (provider !== "trae" && provider !== "windsurf") { + return NextResponse.json({ error: "ide-status only supported for trae/windsurf" }, { status: 400 }); + } + const status = await detectIdeInstalled(provider); + return NextResponse.json(status); + } + if (action === "device-code") { const providerData = getProvider(provider); if (providerData.flowType !== "device_code") { @@ -154,9 +208,11 @@ export async function GET(request, { params }) { const noPkceDeviceProviders = [ "github", "kiro", + "kimi", "kimi-coding", "kilocode", "codebuddy-cn", + "codebuddy-intl", "qoder", "grok-cli", ]; @@ -195,9 +251,54 @@ export async function POST(request, { params }) { return NextResponse.json({ error: "Invalid or empty request body" }, { status: 400 }); } + if (action === "register-session") { + // Register proxy session out of URL query (state) + body (codeVerifier). + // Zed's codeVerifier encodes the RSA private key — must stay out of URL/logs. + const state = searchParams.get("state") || body?.state; + if (!state) return NextResponse.json({ error: "Missing state" }, { status: 400 }); + let ok = false; + if (provider === "trae") ok = registerTraeSession({ state }); + else if (provider === "windsurf") ok = registerWindsurfSession({ state }); + else if (provider === "zed") ok = registerZedSession({ state, codeVerifier: body?.codeVerifier }); + else return NextResponse.json({ error: "register-session only supported for trae/windsurf/zed" }, { status: 400 }); + return NextResponse.json({ success: ok }); + } + if (action === "exchange") { const { code, redirectUri, codeVerifier, state, meta } = body; + // Trae/Windsurf: code is either a raw callback URL or a pasted token. + // exchangeTokens() handles both paths; no PKCE, skip codex JWT extraction. + if (provider === "trae" || provider === "windsurf") { + const token = typeof code === "string" ? code.trim() : ""; + if (!token) { + return NextResponse.json({ error: "Missing token or callback URL" }, { status: 400 }); + } + try { + const tokenData = await exchangeTokens(provider, token, null, null, state); + const connection = await createProviderConnection({ + provider, + authType: provider === "windsurf" ? "api_key" : "oauth", + ...tokenData, + expiresAt: tokenData.expiresIn + ? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString() + : null, + testStatus: "active", + }); + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + displayName: connection.displayName, + } + }); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: 500 }); + } + } + // Detect if "code" is actually a raw JWT access token (starts with eyJ) if (code && code.startsWith("eyJ") && code.includes(".")) { const { extractCodexAccountInfo } = await import("@/lib/oauth/providers"); @@ -279,10 +380,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", "codebuddy-intl"]; 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); @@ -303,9 +405,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", ...result.tokens, expiresAt: result.tokens.expiresIn diff --git a/src/app/api/oauth/kiro/api-key/route.js b/src/app/api/oauth/kiro/api-key/route.js index 139df9b5..bd2140b3 100644 --- a/src/app/api/oauth/kiro/api-key/route.js +++ b/src/app/api/oauth/kiro/api-key/route.js @@ -5,8 +5,8 @@ import { createProviderConnection } from "@/models"; /** * POST /api/oauth/kiro/api-key * Import a Kiro API key (headless auth). The key is a long-lived bearer - * credential — there is no refresh token. It is validated by listing - * CodeWhisperer profiles, then stored with authMethod="api_key". + * credential — there is no refresh token. It is validated against the Amazon + * Q model catalog, then stored with authMethod="api_key". */ export async function POST(request) { try { @@ -21,7 +21,7 @@ export async function POST(request) { const kiroService = new KiroService(); - // Validate the key and resolve its profileArn via ListAvailableProfiles + // Validate the key against the same Amazon Q surface used for inference. const credential = await kiroService.validateApiKey( apiKey, region || "us-east-1" @@ -40,7 +40,7 @@ export async function POST(request) { expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), email: email || null, providerSpecificData: { - profileArn: credential.profileArn, + ...(credential.profileArn ? { profileArn: credential.profileArn } : {}), region: credential.region, authMethod: "api_key", provider: "API Key", diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index 89b1ca4b..592cd704 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { getProviderConnectionById } from "@/models"; 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"; @@ -10,9 +10,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 || []; @@ -157,12 +165,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", @@ -229,6 +245,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"), @@ -269,6 +293,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 a05e6f4e..8ee8c496 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -75,7 +75,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`, @@ -619,10 +620,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", @@ -781,6 +785,26 @@ async function testApiKeyConnection(connection, effectiveProxy = null) { }, effectiveProxy); return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; } + case "qoder": { + // PAT (pt-...) exchange → job token. A successful exchange proves the PAT. + const raw = connection.apiKey || ""; + const pat = raw.startsWith("pt-") ? raw : `pt-${raw}`; + const exRes = await fetchWithConnectionProxy( + "https://openapi.qoder.sh/api/v1/jobToken/exchange", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "Cosy-Version": "1.0.1", + "Cosy-ClientType": "5", + }, + body: JSON.stringify({ personal_token: pat }), + }, + effectiveProxy, + ); + return { valid: exRes.ok, error: exRes.ok ? null : "Invalid Personal Access Token" }; + } default: return { valid: false, error: "Provider test not supported" }; } diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js index d5684091..0be11b1e 100644 --- a/src/app/api/providers/validate/route.js +++ b/src/app/api/providers/validate/route.js @@ -301,11 +301,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/translator/console-logs/stream/route.js b/src/app/api/translator/console-logs/stream/route.js index 5eb3ae37..51ed3702 100644 --- a/src/app/api/translator/console-logs/stream/route.js +++ b/src/app/api/translator/console-logs/stream/route.js @@ -83,8 +83,9 @@ export async function GET(request) { return new Response(stream, { headers: { "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", + "Cache-Control": "no-cache, no-transform", "Connection": "keep-alive", + "X-Accel-Buffering": "no", }, }); } diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index 6a05da37..26c9d010 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -13,6 +13,8 @@ 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 { resolveZedModels } from "open-sse/shared/zedAuth.js"; import { updateProviderCredentials } from "@/sse/services/tokenRefresh"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js"; @@ -97,6 +99,29 @@ 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; + }, + zed: async (conn) => { + const result = await resolveZedModels({ + accessToken: conn.accessToken, + providerSpecificData: conn.providerSpecificData || {}, + }); + if (!result?.models?.length) return null; + return { + models: result.models + .filter((m) => !m.isDisabled) + .map((m) => ({ + id: m.id, + name: m.name, + capabilities: m.supportsTools ? { tools: true } : undefined, + })), + }; + }, }; const parseOpenAIStyleModels = (data) => { diff --git a/src/app/globals.css b/src/app/globals.css index fdc219ba..2a1434fe 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -256,6 +256,37 @@ input, textarea { background: rgba(229, 106, 74, 0.55); } +/* Global thin mac-like scrollbars (overrides clunky Windows/Linux default) */ +* { + scrollbar-width: thin; + scrollbar-color: rgba(120, 120, 120, 0.35) transparent; +} +.dark * { + scrollbar-color: rgba(180, 180, 180, 0.3) transparent; +} +*::-webkit-scrollbar { + width: 8px; + height: 8px; +} +*::-webkit-scrollbar-track { + background: transparent; +} +*::-webkit-scrollbar-thumb { + background-color: rgba(120, 120, 120, 0.35); + border-radius: 9999px; + border: 2px solid transparent; + background-clip: padding-box; +} +*::-webkit-scrollbar-thumb:hover { + background-color: rgba(120, 120, 120, 0.55); +} +.dark *::-webkit-scrollbar-thumb { + background-color: rgba(180, 180, 180, 0.3); +} +.dark *::-webkit-scrollbar-thumb:hover { + background-color: rgba(180, 180, 180, 0.5); +} + /* Reusable elevated card */ .card-soft { background-color: var(--color-surface); @@ -442,6 +473,68 @@ button:disabled, opacity: 0.04; } +/* 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/instrumentation.js b/src/instrumentation.js new file mode 100644 index 00000000..f511eae2 --- /dev/null +++ b/src/instrumentation.js @@ -0,0 +1,6 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + const { initConsoleLogCapture } = await import("@/lib/consoleLogBuffer"); + initConsoleLogCapture(); + } +} 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..d66e32c5 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"] }; @@ -111,6 +117,9 @@ export const GITLAB_CONFIG = { ...PROVIDER_OAUTH["gitlab"] }; // CodeBuddy (Tencent) OAuth Configuration (Browser OAuth Polling Flow) export const CODEBUDDY_CONFIG = { ...PROVIDER_OAUTH["codebuddy-cn"] }; +// CodeBuddy International — same shape as CN, .ai domain (mirror of codebuddy-cn). +export const CODEBUDDY_INTL_CONFIG = { ...PROVIDER_OAUTH["codebuddy-intl"] }; + // Kimchi OAuth Configuration (Browser token callback flow) export const KIMCHI_CONFIG = { ...PROVIDER_OAUTH["kimchi"] }; @@ -118,6 +127,77 @@ export const KIMCHI_CONFIG = { ...PROVIDER_OAUTH["kimchi"] }; // Endpoint: cli-chat-proxy.grok.com — same client_id as xai, different flow + scopes export const GROK_CLI_CONFIG = { ...PROVIDER_OAUTH["grok-cli"] }; +// Trae (ByteDance marscode) OAuth — authorization_code flow with local callback. +// 1) POST GetLoginGuidance {loginTraceID} → {Result.LoginHost} +// 2) Browser opens ${loginHost}/authorization?client_id=...&login_trace_id=...&auth_callback_url=${cb} +// 3) Redirect → ${cb}?refreshToken=...&loginHost=...&isRedirect=true +// 4) POST ExchangeToken {ClientID, RefreshToken, ClientSecret:"-"} → {Result.AccessToken, ExpiresAt} +// 5) POST GetUserInfo (x-cloudide-token) → email/name +export const TRAE_CONFIG = { + clientId: "ono9krqynydwx5", + clientSecret: "-", + loginGuidanceUrls: [ + "https://api.marscode.com/cloudide/api/v3/trae/GetLoginGuidance", + "https://api.trae.ai/cloudide/api/v3/trae/GetLoginGuidance", + "https://www.trae.ai/cloudide/api/v3/trae/GetLoginGuidance", + ], + apiOrigins: [ + "https://api.marscode.com", + "https://api.trae.ai", + "https://www.trae.ai", + "https://www.marscode.com", + ], + exchangeTokenPath: "/cloudide/api/v3/trae/oauth/ExchangeToken", + getUserInfoPath: "/cloudide/api/v3/trae/GetUserInfo", + authorizationPath: "/authorization", + callbackPath: "/callback", + minAppVersion: "3.5.54", + defaultAppVersion: "3.5.54", + defaultAppType: "stable", + defaultPluginVersion: "local", + // service machine id is derived at runtime; device_id "0" is the stable default + defaultDeviceId: "0", + userAgent: "Trae/1.0.0 antigravity-cockpit-tools", + webUrl: "https://www.trae.ai", + authScheme: "Cloud-IDE-JWT", + tokenLifetimeDays: 14, + oauthTimeoutMs: 600_000, +}; + +// Windsurf / Devin CLI OAuth — authorization_code (implicit) flow with local callback. +// 1) Browser opens windsurf.com/windsurf/signin?response_type=token&client_id=...&redirect_uri=${cb} +// 2) Redirect → ${cb}?access_token=${firebaseJWT}&state=... +// 3) POST RegisterUser {firebase_id_token} → {apiKey, apiServerUrl, name} +// 4) POST GetOneTimeAuthToken → GetCurrentUser (best-effort email/plan) +export const WINDSURF_CONFIG = { + clientId: "3GUryQ7ldAeKEuD2obYnppsnmj58eP5u", + authBaseUrl: "https://www.windsurf.com", + signInPath: "/windsurf/signin", + registerApiBaseUrl: "https://register.windsurf.com", + registerPath: "/exa.seat_management_pb.SeatManagementService/RegisterUser", + oneTimeAuthPath: "/exa.seat_management_pb.SeatManagementService/GetOneTimeAuthToken", + currentUserPath: "/exa.seat_management_pb.SeatManagementService/GetCurrentUser", + planStatusPath: "/exa.seat_management_pb.SeatManagementService/GetPlanStatus", + userStatusPath: "/exa.seat_management_pb.SeatManagementService/GetUserStatus", + defaultApiServerUrl: "https://server.codeium.com", + firebaseApiKey: "AIzaSyDsOl-1XpT5err0Tcn0TFFod1H8gVGIycY", + callbackPath: "/windsurf-auth-callback", + userAgent: "antigravity-cockpit-tools", + oauthTimeoutMs: 600_000, +}; + +// Zed hosted LLM aggregator — RSA keypair native-app auth (NOT OAuth). +// Client generates ephemeral RSA-2048 keypair; user signs in at zed.dev/native_app_signin; +// Zed redirects to local callback with access_token RSA-encrypted against our public key. +// See open-sse/shared/zedAuth.js for the keypair/decrypt helpers. +export const ZED_HOSTED_CONFIG = { + webBaseUrl: "https://zed.dev", + cloudBaseUrl: "https://cloud.zed.dev", + llmBaseUrl: "https://cloud.zed.dev", + defaultNativeAppPort: 58443, + oauthTimeoutMs: 600_000, +}; + // OAuth timeout (5 minutes) export const OAUTH_TIMEOUT = 300000; @@ -134,12 +214,17 @@ export const PROVIDERS = { GITHUB: "github", KIRO: "kiro", CURSOR: "cursor", - KIMI_CODING: "kimi-coding", + KIMI: "kimi", + KIMI_CODING: "kimi", KILOCODE: "kilocode", CLINE: "cline", CLINEPASS: "clinepass", GITLAB: "gitlab", CODEBUDDY: "codebuddy-cn", + CODEBUDDY_INTL: "codebuddy-intl", KIMCHI: "kimchi", GROK_CLI: "grok-cli", + TRAE: "trae", + WINDSURF: "windsurf", + ZED: "zed", }; diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index d2d14daf..8adfe8c4 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -1,1691 +1 @@ -/** - * OAuth Provider Configurations and Handlers - * Centralized DRY approach for all OAuth providers - */ - -// Ensure outbound fetch respects HTTP(S)_PROXY/ALL_PROXY in Node runtime -import "open-sse/index.js"; -import crypto from "crypto"; - -import { generatePKCE, generateState } from "./utils/pkce"; -import { - CLAUDE_CONFIG, - CODEX_CONFIG, - GEMINI_CONFIG, - QWEN_CONFIG, - QODER_CONFIG, - IFLOW_CONFIG, - ANTIGRAVITY_CONFIG, - GITHUB_CONFIG, - KIRO_CONFIG, - assertValidAwsRegion, - CURSOR_CONFIG, - KIMI_CODING_CONFIG, - KILOCODE_CONFIG, - CLINE_CONFIG, - CLINEPASS_CONFIG, - GITLAB_CONFIG, - CODEBUDDY_CONFIG, - KIMCHI_CONFIG, - GROK_CLI_CONFIG, - getOAuthClientMetadata, -} from "./constants/oauth"; -import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "./constants/xai"; -import { - validateXaiOAuthEndpoint, - decodeXaiIdTokenEmail, - extractEmailFromAccessToken, - extractCodexAccountInfo, - fetchKiroProfileArn, -} from "./providerHelpers"; - -export { extractCodexAccountInfo, fetchKiroProfileArn }; - -// Inlined from services/xai.js to keep web route bundle free of `open` (CLI-only) package -let cachedXaiDiscovery = null; - -async function discoverXaiEndpoints() { - if (cachedXaiDiscovery) return cachedXaiDiscovery; - try { - const res = await fetch(XAI_CONFIG.discoveryUrl, { headers: { Accept: "application/json" } }); - if (res.ok) { - const data = await res.json(); - cachedXaiDiscovery = { - authorizeUrl: validateXaiOAuthEndpoint(data.authorization_endpoint, "authorization_endpoint"), - tokenUrl: validateXaiOAuthEndpoint(data.token_endpoint, "token_endpoint"), - }; - return cachedXaiDiscovery; - } - } catch { /* fall through to static fallback */ } - cachedXaiDiscovery = { authorizeUrl: XAI_CONFIG.authorizeUrl, tokenUrl: XAI_CONFIG.tokenUrl }; - return cachedXaiDiscovery; -} - -// Provider configurations -const PROVIDERS = { - claude: { - config: CLAUDE_CONFIG, - flowType: "authorization_code_pkce", - buildAuthUrl: (config, redirectUri, state, codeChallenge) => { - const params = new URLSearchParams({ - code: "true", - client_id: config.clientId, - response_type: "code", - redirect_uri: redirectUri, - scope: config.scopes.join(" "), - code_challenge: codeChallenge, - code_challenge_method: config.codeChallengeMethod, - state: state, - }); - return `${config.authorizeUrl}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri, codeVerifier, state) => { - // Parse code - may contain state after # - let authCode = code; - let codeState = ""; - if (authCode.includes("#")) { - const parts = authCode.split("#"); - authCode = parts[0]; - codeState = parts[1] || ""; - } - - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - code: authCode, - state: codeState || state, - grant_type: "authorization_code", - client_id: config.clientId, - redirect_uri: redirectUri, - code_verifier: codeVerifier, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - scope: tokens.scope, - }), - }, - - codex: { - config: CODEX_CONFIG, - flowType: "authorization_code_pkce", - fixedPort: CODEX_CONFIG.fixedPort, - callbackPath: CODEX_CONFIG.callbackPath, - buildAuthUrl: (config, redirectUri, state, codeChallenge) => { - const params = { - response_type: "code", - client_id: config.clientId, - redirect_uri: redirectUri, - scope: config.scope, - code_challenge: codeChallenge, - code_challenge_method: config.codeChallengeMethod, - ...config.extraParams, - state: state, - }; - const queryString = Object.entries(params) - .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) - .join("&"); - return `${config.authorizeUrl}?${queryString}`; - }, - exchangeToken: async (config, code, redirectUri, codeVerifier) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: config.clientId, - code: code, - redirect_uri: redirectUri, - code_verifier: codeVerifier, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - }, - mapTokens: (tokens) => { - const info = extractCodexAccountInfo(tokens.id_token); - const mapped = { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - idToken: tokens.id_token, - expiresIn: tokens.expires_in, - lastRefreshAt: new Date().toISOString(), - }; - const email = info.email || extractEmailFromAccessToken(tokens.access_token); - if (email) mapped.email = email; - if (info.chatgptAccountId || info.chatgptPlanType) { - mapped.providerSpecificData = { - chatgptAccountId: info.chatgptAccountId, - chatgptPlanType: info.chatgptPlanType, - }; - } - return mapped; - }, - }, - - xai: { - config: XAI_CONFIG, - flowType: "authorization_code_pkce", - fixedPort: XAI_CONFIG.loopbackPort, - callbackPath: XAI_CONFIG.callbackPath, - pkceVerifierBytes: XAI_PKCE_VERIFIER_BYTES, - prepareConfig: async (config) => { - const endpoints = await discoverXaiEndpoints(); - return { - ...config, - authorizeUrl: endpoints.authorizeUrl, - tokenUrl: endpoints.tokenUrl, - }; - }, - buildAuthUrl: (config, redirectUri, state, codeChallenge) => { - // Mirror CLIProxyAPI BuildAuthorizeURL: includes nonce, plan, referrer - const nonce = crypto.randomBytes(16).toString("hex"); - const params = { - response_type: "code", - client_id: config.clientId, - redirect_uri: redirectUri, - scope: config.scope, - code_challenge: codeChallenge, - code_challenge_method: config.codeChallengeMethod, - state, - nonce, - plan: "generic", - referrer: "cli-proxy-api", - }; - const qs = Object.entries(params) - .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) - .join("&"); - return `${config.authorizeUrl}?${qs}`; - }, - exchangeToken: async (config, code, redirectUri, codeVerifier) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: config.clientId, - code, - redirect_uri: redirectUri, - code_verifier: codeVerifier, - }), - }); - if (!response.ok) { - const error = await response.text(); - throw new Error(`xAI token exchange failed: ${error}`); - } - return await response.json(); - }, - mapTokens: (tokens) => { - const mapped = { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - scope: tokens.scope, - }; - const email = decodeXaiIdTokenEmail(tokens.id_token); - if (email) mapped.email = email; - if (tokens.id_token) { - mapped.providerSpecificData = { idToken: tokens.id_token }; - } - return mapped; - }, - }, - - // Grok CLI / Grok Build — device code flow to auth.x.ai, inference on cli-chat-proxy.grok.com - "grok-cli": { - config: GROK_CLI_CONFIG, - flowType: "device_code", - requestDeviceCode: async (config) => { - const body = new URLSearchParams({ - client_id: config.clientId, - scope: config.scope, - }); - // Official CLI sends referrer=grok-build - if (config.referrer) body.set("referrer", config.referrer); - - const response = await fetch(config.deviceCodeUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", - }, - body, - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Grok CLI device code request failed: ${error}`); - } - - return await response.json(); - }, - pollToken: async (config, deviceCode) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", - }, - body: new URLSearchParams({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - device_code: deviceCode, - client_id: config.clientId, - }), - }); - - let data; - try { - data = await response.json(); - } catch { - const text = await response.text(); - data = { error: "invalid_response", error_description: text }; - } - - // Device flow: 400 + authorization_pending is expected while user authorizes - const pending = - data?.error === "authorization_pending" || - data?.error === "slow_down"; - return { - ok: response.ok || pending, - data, - }; - }, - postExchange: async (tokens) => { - // Best-effort user profile from cli-chat-proxy (non-fatal) - try { - const res = await fetch("https://cli-chat-proxy.grok.com/v1/user", { - headers: { - Authorization: `Bearer ${tokens.access_token}`, - Accept: "application/json", - "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-version": "0.2.93", - }, - }); - if (res.ok) return { user: await res.json() }; - } catch { - /* ignore */ - } - return { user: null }; - }, - mapTokens: (tokens, extra) => { - const email = - decodeXaiIdTokenEmail(tokens.id_token) || - extractEmailFromAccessToken(tokens.access_token) || - extra?.user?.email || - null; - const userId = - extra?.user?.userId || - extra?.user?.principalId || - null; - const displayName = [extra?.user?.firstName, extra?.user?.lastName] - .filter(Boolean) - .join(" ") - .trim() || null; - - const expiresAt = tokens.expires_in - ? new Date(Date.now() + tokens.expires_in * 1000).toISOString() - : null; - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || null, - expiresIn: tokens.expires_in, - // Surface an absolute expiry so the proactive refresh path - // (shouldRefreshCredentials / checkAndRefreshToken) can refresh the - // xAI token before it silently expires ~40-45 min after login. - // Without this, only the reactive 401 path in chatCore would refresh, - // causing intermittent "token expired" failures for Grok CLI. - expiresAt, - scope: tokens.scope, - // Top-level for dashboard connection cards - email: email || undefined, - displayName: displayName || undefined, - // Mirror identity into providerSpecificData so GrokCliExecutor can set - // x-email / x-userid without depending on top-level credential shape. - providerSpecificData: { - authMethod: "device_code", - idToken: tokens.id_token || null, - email: email || null, - userId, - hasGrokCodeAccess: extra?.user?.hasGrokCodeAccess ?? null, - subscriptionTier: extra?.user?.subscriptionTier ?? null, - }, - }; - }, - }, - - "gemini-cli": { - config: GEMINI_CONFIG, - flowType: "authorization_code", - buildAuthUrl: (config, redirectUri, state) => { - const params = new URLSearchParams({ - client_id: config.clientId, - response_type: "code", - redirect_uri: redirectUri, - scope: config.scopes.join(" "), - state: state, - access_type: "offline", - prompt: "consent", - }); - return `${config.authorizeUrl}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: config.clientId, - client_secret: config.clientSecret, - code: code, - redirect_uri: redirectUri, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - }, - postExchange: async (tokens) => { - // Fetch user info - const userInfoRes = await fetch(`${GEMINI_CONFIG.userInfoUrl}?alt=json`, { - headers: { Authorization: `Bearer ${tokens.access_token}` }, - }); - const userInfo = userInfoRes.ok ? await userInfoRes.json() : {}; - - // Fetch project ID - let projectId = ""; - try { - const projectRes = await fetch( - "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", - { - method: "POST", - headers: { - Authorization: `Bearer ${tokens.access_token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - metadata: getOAuthClientMetadata(), - mode: 1, - }), - } - ); - if (projectRes.ok) { - const data = await projectRes.json(); - projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; - } - } catch (e) { - console.log("Failed to fetch project ID:", e); - } - - return { userInfo, projectId }; - }, - mapTokens: (tokens, extra) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - scope: tokens.scope, - email: extra?.userInfo?.email, - projectId: extra?.projectId, - }), - }, - - antigravity: { - config: ANTIGRAVITY_CONFIG, - flowType: "authorization_code", - buildAuthUrl: (config, redirectUri, state) => { - const params = new URLSearchParams({ - client_id: config.clientId, - response_type: "code", - redirect_uri: redirectUri, - scope: config.scopes.join(" "), - state: state, - access_type: "offline", - prompt: "consent", - }); - return `${config.authorizeUrl}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - client_id: config.clientId, - client_secret: config.clientSecret, - code: code, - redirect_uri: redirectUri, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - }, - postExchange: async (tokens) => { - // Numeric enums matching Antigravity binary ClientMetadata - const loadHeaders = { - "Authorization": `Bearer ${tokens.access_token}`, - "Content-Type": "application/json", - "User-Agent": ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent, - "X-Goog-Api-Client": ANTIGRAVITY_CONFIG.loadCodeAssistApiClient, - "Client-Metadata": ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata, - "x-request-source": "local", - }; - const metadata = getOAuthClientMetadata(); - - // Fetch user info - const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, { - headers: { - Authorization: `Bearer ${tokens.access_token}`, - "x-request-source": "local", - }, - }); - const userInfo = userInfoRes.ok ? await userInfoRes.json() : {}; - - // Load Code Assist to get project ID and tier - let projectId = ""; - let tierId = "legacy-tier"; - try { - const loadRes = await fetch(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, { - method: "POST", - headers: loadHeaders, - body: JSON.stringify({ metadata }), - }); - if (loadRes.ok) { - const data = await loadRes.json(); - projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; - if (Array.isArray(data.allowedTiers)) { - for (const tier of data.allowedTiers) { - if (tier.isDefault && tier.id) { - tierId = tier.id.trim(); - break; - } - } - } - } - } catch (e) { - console.log("Failed to load code assist:", e); - } - - // Fire-and-forget onboarding — does not block DB save - if (projectId) { - const doOnboard = async () => { - for (let i = 0; i < 10; i++) { - try { - const onboardRes = await fetch(ANTIGRAVITY_CONFIG.onboardUserEndpoint, { - method: "POST", - headers: loadHeaders, - body: JSON.stringify({ tierId, metadata }), - }); - if (onboardRes.ok) { - const result = await onboardRes.json(); - if (result.done === true) break; - } - } catch (e) { - break; - } - await new Promise(resolve => setTimeout(resolve, 5000)); - } - }; - doOnboard().catch(() => {}); - } - - return { userInfo, projectId }; - }, - mapTokens: (tokens, extra) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - scope: tokens.scope, - email: extra?.userInfo?.email, - projectId: extra?.projectId, - }), - }, - - iflow: { - config: IFLOW_CONFIG, - flowType: "authorization_code", - buildAuthUrl: (config, redirectUri, state) => { - const params = new URLSearchParams({ - loginMethod: config.extraParams.loginMethod, - type: config.extraParams.type, - redirect: redirectUri, - state: state, - client_id: config.clientId, - }); - return `${config.authorizeUrl}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri) => { - // Create Basic Auth header - const basicAuth = Buffer.from( - `${config.clientId}:${config.clientSecret}` - ).toString("base64"); - - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - Authorization: `Basic ${basicAuth}`, - }, - body: new URLSearchParams({ - grant_type: "authorization_code", - code: code, - redirect_uri: redirectUri, - client_id: config.clientId, - client_secret: config.clientSecret, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Token exchange failed: ${error}`); - } - - return await response.json(); - }, - postExchange: async (tokens) => { - // Fetch user info (MUST succeed to get API key) - const userInfoRes = await fetch( - `${IFLOW_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`, - { - headers: { - Accept: "application/json", - }, - } - ); - - if (!userInfoRes.ok) { - const errorText = await userInfoRes.text(); - throw new Error(`Failed to fetch user info: ${errorText}`); - } - - const result = await userInfoRes.json(); - if (!result.success) { - throw new Error(`User info request failed: ${result.message || 'Unknown error'}`); - } - - const userInfo = result.data || {}; - - // Validate API key (critical for iFlow) - if (!userInfo.apiKey || userInfo.apiKey.trim() === "") { - throw new Error("Empty API key returned from iFlow"); - } - - // Validate email/phone - const email = userInfo.email?.trim() || userInfo.phone?.trim(); - if (!email) { - throw new Error("Missing account email/phone in user info"); - } - - return { userInfo }; - }, - mapTokens: (tokens, extra) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - apiKey: extra?.userInfo?.apiKey, - email: extra?.userInfo?.email || extra?.userInfo?.phone, - displayName: extra?.userInfo?.nickname || extra?.userInfo?.name, - }), - }, - - qoder: { - config: QODER_CONFIG, - flowType: "device_code", - // Qoder uses a custom device flow: PKCE + nonce + machine_id are generated - // locally, the user lands on qoder.com/device/selectAccounts in the - // browser, and we poll openapi.qoder.sh until a `dt-...` token appears. - requestDeviceCode: async (config) => { - const { QoderService } = await import("@/lib/oauth/services/qoder"); - const flow = new QoderService().initiateDeviceFlow(); - // Match the device_code shape the rest of the OAuthModal expects - // (device_code, user_code, verification_uri[_complete], interval). - // The poll endpoint identifies us by nonce+verifier, not by a - // server-issued device_code, so we plumb our own values through: - // device_code = nonce (modal forwards as deviceCode on poll) - // codeVerifier = our PKCE verifier (route forwards as codeVerifier) - return { - device_code: flow.nonce, - user_code: flow.nonce.slice(0, 8).toUpperCase(), - verification_uri: config.loginUrl, - verification_uri_complete: flow.verificationUriComplete, - expires_in: 300, - interval: 2, - codeVerifier: flow.codeVerifier, - _qoderNonce: flow.nonce, - _qoderMachineId: flow.machineId, - }; - }, - pollToken: async (config, deviceCode, codeVerifier, extraData) => { - const { QoderService } = await import("@/lib/oauth/services/qoder"); - const svc = new QoderService(); - const nonce = deviceCode || extraData?._qoderNonce; - const verifier = codeVerifier || extraData?._qoderVerifier; - if (!nonce || !verifier) { - return { - ok: false, - data: { error: "invalid_request", error_description: "Missing nonce/verifier" }, - }; - } - let result; - try { - result = await svc.pollDeviceToken({ nonce, codeVerifier: verifier }); - } catch (err) { - return { - ok: false, - data: { error: "poll_failed", error_description: err.message }, - }; - } - if (result.status === "pending") { - return { ok: false, data: { error: "authorization_pending" } }; - } - // Best-effort profile lookup so we have a name/email to display. - const userInfo = await svc.fetchUserInfo(result.accessToken); - // expireTime is a Unix-ms timestamp from QoderService.parseExpiry, - // which already falls back to "now + 30 days" when the upstream - // omits expiry. Floor to a sane minimum (1 day) so a stale or - // skewed upstream timestamp doesn't truncate the stored token below - // something useful. - const minSeconds = 24 * 60 * 60; - const remainingSeconds = Math.floor((result.expireTime - Date.now()) / 1000); - const expiresIn = Math.max(minSeconds, remainingSeconds); - return { - ok: true, - data: { - access_token: result.accessToken, - refresh_token: result.refreshToken, - expires_in: expiresIn, - _qoderUserId: result.userId, - _qoderMachineId: extraData?._qoderMachineId || "", - _qoderName: userInfo.name, - _qoderEmail: userInfo.email, - _qoderOrganizationId: userInfo.organizationId, - }, - }; - }, - mapTokens: (tokens) => { - const rawEmail = (tokens._qoderEmail || "").trim(); - const displayName = (tokens._qoderName || "").trim() || null; - const userId = tokens._qoderUserId || ""; - // Dedup in createProviderConnection requires a non-empty email. When - // fetchUserInfo silently fails (returns ""), fall back to a stable - // synthetic identifier derived from userId so re-logins update the - // existing row instead of accumulating "Account N" duplicates. - const email = rawEmail || (userId ? `qoder-user-${userId}` : null); - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || null, - expiresIn: tokens.expires_in, - email, - displayName, - providerSpecificData: { - authMethod: "device", - userId, - machineId: tokens._qoderMachineId || "", - organizationId: tokens._qoderOrganizationId || "", - }, - }; - }, - }, - - qwen: { - config: QWEN_CONFIG, - flowType: "device_code", - requestDeviceCode: async (config, codeChallenge) => { - const response = await fetch(config.deviceCodeUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - client_id: config.clientId, - scope: config.scope, - code_challenge: codeChallenge, - code_challenge_method: config.codeChallengeMethod, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Device code request failed: ${error}`); - } - - return await response.json(); - }, - pollToken: async (config, deviceCode, codeVerifier) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - client_id: config.clientId, - device_code: deviceCode, - code_verifier: codeVerifier, - }), - }); - - return { - ok: response.ok, - data: await response.json(), - }; - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - providerSpecificData: { resourceUrl: tokens.resource_url }, - }), - }, - - github: { - config: GITHUB_CONFIG, - flowType: "device_code", - requestDeviceCode: async (config) => { - const response = await fetch(config.deviceCodeUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - client_id: config.clientId, - scope: config.scopes, - }), - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`Device code request failed: ${error}`); - } - - return await response.json(); - }, - pollToken: async (config, deviceCode) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - client_id: config.clientId, - device_code: deviceCode, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }), - }); - - // Handle response properly - if not ok, try to get error as text first - let data; - try { - data = await response.json(); - } catch (e) { - // If response is not JSON, get as text - const text = await response.text(); - data = { error: "invalid_response", error_description: text }; - } - - return { - ok: response.ok, - data: data, - }; - }, - postExchange: async (tokens) => { - // Get Copilot token using GitHub access token - const copilotRes = await fetch(GITHUB_CONFIG.copilotTokenUrl, { - headers: { - Authorization: `Bearer ${tokens.access_token}`, - Accept: "application/json", - "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, - "User-Agent": GITHUB_CONFIG.userAgent, - }, - }); - const copilotToken = copilotRes.ok ? await copilotRes.json() : {}; - - // Get user info from GitHub - const userRes = await fetch(GITHUB_CONFIG.userInfoUrl, { - headers: { - Authorization: `Bearer ${tokens.access_token}`, - Accept: "application/json", - "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, - "User-Agent": GITHUB_CONFIG.userAgent, - }, - }); - const userInfo = userRes.ok ? await userRes.json() : {}; - - return { copilotToken, userInfo }; - }, - mapTokens: (tokens, extra) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - name: extra?.userInfo?.login || extra?.userInfo?.name, - displayName: extra?.userInfo?.name || extra?.userInfo?.login, - email: extra?.userInfo?.email || null, - providerSpecificData: { - copilotToken: extra?.copilotToken?.token, - copilotTokenExpiresAt: extra?.copilotToken?.expires_at, - githubUserId: extra?.userInfo?.id, - githubLogin: extra?.userInfo?.login, - githubName: extra?.userInfo?.name, - githubEmail: extra?.userInfo?.email, - }, - }), - }, - - kiro: { - config: KIRO_CONFIG, - flowType: "device_code", - // Kiro uses AWS SSO OIDC - requires client registration first - requestDeviceCode: async (config, codeChallenge, options = {}) => { - const trimmedRegion = typeof options.region === "string" ? options.region.trim() : ""; - const region = trimmedRegion || "us-east-1"; - assertValidAwsRegion(region); - const trimmedStartUrl = typeof options.startUrl === "string" ? options.startUrl.trim() : ""; - const startUrl = trimmedStartUrl || config.startUrl; - const authMethod = options.authMethod === "idc" ? "idc" : "builder-id"; - const registerClientUrl = `https://oidc.${region}.amazonaws.com/client/register`; - const deviceAuthUrl = `https://oidc.${region}.amazonaws.com/device_authorization`; - - // Step 1: Register client with AWS SSO OIDC - const registerRes = await fetch(registerClientUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - clientName: config.clientName, - clientType: config.clientType, - scopes: config.scopes, - grantTypes: config.grantTypes, - issuerUrl: config.issuerUrl, - }), - }); - - if (!registerRes.ok) { - const error = await registerRes.text(); - throw new Error(`Client registration failed: ${error}`); - } - - const clientInfo = await registerRes.json(); - - // Step 2: Request device authorization - const deviceRes = await fetch(deviceAuthUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - clientId: clientInfo.clientId, - clientSecret: clientInfo.clientSecret, - startUrl, - }), - }); - - if (!deviceRes.ok) { - const error = await deviceRes.text(); - throw new Error(`Device authorization failed: ${error}`); - } - - const deviceData = await deviceRes.json(); - - // Return combined data for polling - return { - device_code: deviceData.deviceCode, - user_code: deviceData.userCode, - verification_uri: deviceData.verificationUri, - verification_uri_complete: deviceData.verificationUriComplete, - expires_in: deviceData.expiresIn, - interval: deviceData.interval || 5, - // Store client credentials for token exchange - _clientId: clientInfo.clientId, - _clientSecret: clientInfo.clientSecret, - _region: region, - _authMethod: authMethod, - _startUrl: startUrl, - }; - }, - pollToken: async (config, deviceCode, codeVerifier, extraData) => { - const region = extraData?._region || "us-east-1"; - assertValidAwsRegion(region); - const tokenUrl = `https://oidc.${region}.amazonaws.com/token`; - const response = await fetch(tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - clientId: extraData?._clientId, - clientSecret: extraData?._clientSecret, - deviceCode: deviceCode, - grantType: "urn:ietf:params:oauth:grant-type:device_code", - }), - }); - - let data; - try { - data = await response.json(); - } catch (e) { - const text = await response.text(); - data = { error: "invalid_response", error_description: text }; - } - - // AWS SSO OIDC returns camelCase - if (data.accessToken) { - return { - ok: true, - data: { - access_token: data.accessToken, - refresh_token: data.refreshToken, - expires_in: data.expiresIn, - profile_arn: data?.profileArn || null, - // Store client credentials for refresh - _clientId: extraData?._clientId, - _clientSecret: extraData?._clientSecret, - _region: extraData?._region, - _authMethod: extraData?._authMethod, - _startUrl: extraData?._startUrl, - }, - }; - } - - return { - ok: false, - data: { - error: data.error || "authorization_pending", - error_description: data.error_description || data.message, - }, - }; - }, - mapTokens: (tokens) => { - const email = extractEmailFromAccessToken(tokens.access_token); - const mapped = { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - email, - providerSpecificData: { - profileArn: tokens?.profile_arn || null, - clientId: tokens._clientId, - clientSecret: tokens._clientSecret, - region: tokens._region || "us-east-1", - authMethod: tokens._authMethod || "builder-id", - startUrl: tokens._startUrl || KIRO_CONFIG.startUrl, - }, - }; - return mapped; - }, - }, - - cursor: { - config: CURSOR_CONFIG, - flowType: "import_token", - // Cursor uses import token flow - tokens are extracted from local SQLite database - // No OAuth flow needed, handled by /api/oauth/cursor/import route - mapTokens: (tokens) => ({ - accessToken: tokens.accessToken, - refreshToken: null, // Cursor doesn't have public refresh endpoint - expiresIn: tokens.expiresIn || 86400, - providerSpecificData: { - machineId: tokens.machineId, - authMethod: "imported", - }, - }), - }, - - "kimi-coding": { - config: KIMI_CODING_CONFIG, - flowType: "device_code", - requestDeviceCode: async (config) => { - const response = await fetch(config.deviceCodeUrl, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, - body: new URLSearchParams({ client_id: config.clientId }), - }); - if (!response.ok) { - const error = await response.text(); - throw new Error(`Device code request failed: ${error}`); - } - const data = await response.json(); - 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_complete: - data.verification_uri_complete || - `https://www.kimi.com/code/authorize_device?user_code=${data.user_code}`, - expires_in: data.expires_in, - interval: data.interval || 5, - }; - }, - pollToken: async (config, deviceCode) => { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, - body: new URLSearchParams({ - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - client_id: config.clientId, - device_code: deviceCode, - }), - }); - let data; - try { - data = await response.json(); - } catch (e) { - const text = await response.text(); - data = { error: "invalid_response", error_description: text }; - } - return { ok: response.ok, data }; - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - }), - }, - - kilocode: { - config: KILOCODE_CONFIG, - flowType: "device_code", - requestDeviceCode: async (config) => { - const response = await fetch(config.initiateUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - }); - if (!response.ok) { - if (response.status === 429) { - throw new Error("Too many pending authorization requests. Please try again later."); - } - const error = await response.text(); - throw new Error(`Device auth initiation failed: ${error}`); - } - const data = await response.json(); - return { - device_code: data.code, - user_code: data.code, - verification_uri: data.verificationUrl, - verification_uri_complete: data.verificationUrl, - expires_in: data.expiresIn || 300, - interval: 3, - }; - }, - pollToken: async (config, deviceCode) => { - const response = await fetch(`${config.pollUrlBase}/${deviceCode}`); - if (response.status === 202) return { ok: false, data: { error: "authorization_pending" } }; - if (response.status === 403) return { ok: false, data: { error: "access_denied", error_description: "Authorization denied by user" } }; - if (response.status === 410) return { ok: false, data: { error: "expired_token", error_description: "Authorization code expired" } }; - if (!response.ok) return { ok: false, data: { error: "poll_failed", error_description: `Poll failed: ${response.status}` } }; - const data = await response.json(); - if (data.status === "approved" && data.token) { - // Fetch profile to get orgId for X-Kilocode-OrganizationID header - let orgId = null; - try { - const profileRes = await fetch(`${config.apiBaseUrl}/api/profile`, { - headers: { "Authorization": `Bearer ${data.token}` } - }); - if (profileRes.ok) { - const profile = await profileRes.json(); - orgId = profile.organizations?.[0]?.id || null; - } - } catch {} - return { ok: true, data: { access_token: data.token, _userEmail: data.userEmail, _orgId: orgId } }; - } - return { ok: false, data: { error: "authorization_pending" } }; - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: null, - expiresIn: null, - email: tokens._userEmail, - ...(tokens._orgId ? { providerSpecificData: { orgId: tokens._orgId } } : {}), - }), - }, - - cline: { - config: CLINE_CONFIG, - flowType: "authorization_code", - buildAuthUrl: (config, redirectUri) => { - const params = new URLSearchParams({ - client_type: "extension", - callback_url: redirectUri, - redirect_uri: redirectUri, - }); - return `${config.authorizeUrl}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri) => { - try { - // Cline encodes token data as base64 in the code param - let base64 = code; - const padding = 4 - (base64.length % 4); - if (padding !== 4) base64 += "=".repeat(padding); - const decoded = Buffer.from(base64, "base64").toString("utf-8"); - const lastBrace = decoded.lastIndexOf("}"); - if (lastBrace === -1) throw new Error("No JSON found in decoded code"); - const tokenData = JSON.parse(decoded.substring(0, lastBrace + 1)); - return { - access_token: tokenData.accessToken, - refresh_token: tokenData.refreshToken, - email: tokenData.email, - firstName: tokenData.firstName, - lastName: tokenData.lastName, - expires_at: tokenData.expiresAt, - }; - } catch (e) { - const response = await fetch(config.tokenExchangeUrl, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify({ grant_type: "authorization_code", code, client_type: "extension", redirect_uri: redirectUri }), - }); - if (!response.ok) { - const error = await response.text(); - throw new Error(`Cline token exchange failed: ${error}`); - } - const data = await response.json(); - return { - access_token: data.data?.accessToken || data.accessToken, - refresh_token: data.data?.refreshToken || data.refreshToken, - email: data.data?.userInfo?.email || "", - expires_at: data.data?.expiresAt || data.expiresAt, - }; - } - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_at - ? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000) - : 3600, - email: tokens.email, - providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName }, - }), - }, - clinepass: { - config: CLINEPASS_CONFIG, - flowType: "authorization_code", - buildAuthUrl: (config, redirectUri) => { - const params = new URLSearchParams({ - client_type: "extension", - callback_url: redirectUri, - redirect_uri: redirectUri, - }); - return `${config.authorizeUrl}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri) => { - try { - // Cline encodes token data as base64 in the code param - let base64 = code; - const padding = 4 - (base64.length % 4); - if (padding !== 4) base64 += "=".repeat(padding); - const decoded = Buffer.from(base64, "base64").toString("utf-8"); - const lastBrace = decoded.lastIndexOf("}"); - if (lastBrace === -1) throw new Error("No JSON found in decoded code"); - const tokenData = JSON.parse(decoded.substring(0, lastBrace + 1)); - return { - access_token: tokenData.accessToken, - refresh_token: tokenData.refreshToken, - email: tokenData.email, - firstName: tokenData.firstName, - lastName: tokenData.lastName, - expires_at: tokenData.expiresAt, - }; - } catch (e) { - const response = await fetch(config.tokenUrl, { - method: "POST", - headers: { "Content-Type": "application/json", Accept: "application/json" }, - body: JSON.stringify({ grant_type: "authorization_code", code, client_type: "extension", redirect_uri: redirectUri }), - }); - if (!response.ok) { - const error = await response.text(); - throw new Error(`ClinePass token exchange failed: ${error}`); - } - const data = await response.json(); - return { - access_token: data.data?.accessToken || data.accessToken, - refresh_token: data.data?.refreshToken || data.refreshToken, - email: data.data?.userInfo?.email || "", - expires_at: data.data?.expiresAt || data.expiresAt, - }; - } - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_at - ? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000) - : 3600, - email: tokens.email, - providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName }, - }), - }, - // GitLab Duo - Authorization Code Flow with PKCE - // Supports two login modes via loginMode metadata: "oauth" (default) or "pat" - gitlab: { - config: GITLAB_CONFIG, - flowType: "authorization_code_pkce", - buildAuthUrl: (config, redirectUri, state, codeChallenge, meta = {}) => { - const baseUrl = meta.baseUrl || config.defaultBaseUrl; - const clientId = meta.clientId || ""; - const params = new URLSearchParams({ - client_id: clientId, - redirect_uri: redirectUri, - response_type: "code", - state, - scope: config.scope, - code_challenge: codeChallenge, - code_challenge_method: config.codeChallengeMethod, - }); - return `${baseUrl}${config.authorizeUrlPath}?${params.toString()}`; - }, - exchangeToken: async (config, code, redirectUri, codeVerifier, state, meta = {}) => { - const baseUrl = meta.baseUrl || config.defaultBaseUrl; - const clientId = meta.clientId || ""; - const clientSecret = meta.clientSecret || ""; - const body = new URLSearchParams({ - client_id: clientId, - grant_type: "authorization_code", - code, - redirect_uri: redirectUri, - code_verifier: codeVerifier, - }); - if (clientSecret) body.set("client_secret", clientSecret); - const response = await fetch(`${baseUrl}${config.tokenUrlPath}`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, - body: body.toString(), - }); - if (!response.ok) throw new Error(`GitLab token exchange failed: ${await response.text()}`); - const tokens = await response.json(); - // Fetch user info - const userRes = await fetch(`${baseUrl}${config.userInfoUrlPath}`, { - headers: { Authorization: `Bearer ${tokens.access_token}` }, - }); - const user = userRes.ok ? await userRes.json() : {}; - return { ...tokens, _user: user, _baseUrl: baseUrl, _clientId: clientId }; - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in, - scope: tokens.scope, - providerSpecificData: { - username: tokens._user?.username || "", - email: tokens._user?.email || tokens._user?.public_email || "", - name: tokens._user?.name || "", - baseUrl: tokens._baseUrl, - clientId: tokens._clientId, - authKind: "oauth", - }, - }), - }, - - // CodeBuddy (Tencent) - Browser OAuth Polling Flow - // 1. POST stateUrl → get { state, authUrl } - // 2. Open authUrl in browser - // 3. Poll tokenUrl with state until success (code 0) or timeout - "codebuddy-cn": { - config: CODEBUDDY_CONFIG, - flowType: "device_code", - requestDeviceCode: async (config) => { - const response = await fetch(`${config.stateUrl}?platform=${config.platform}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - "User-Agent": config.userAgent, - "X-Requested-With": "XMLHttpRequest", - "X-Domain": "copilot.tencent.com", - "X-No-Authorization": "true", - "X-No-User-Id": "true", - "X-Product": "SaaS", - }, - body: "{}", - }); - if (!response.ok) throw new Error(`CodeBuddy state request failed: ${await response.text()}`); - const data = await response.json(); - if (data.code !== 0 || !data.data?.state || !data.data?.authUrl) { - throw new Error(`CodeBuddy state error: ${data.msg || "missing state/authUrl"}`); - } - return { - device_code: data.data.state, - verification_uri: data.data.authUrl, - user_code: "", - interval: config.pollInterval / 1000, - _isCodeBuddy: true, - }; - }, - pollToken: async (config, deviceCode) => { - // CodeBuddy polls the token endpoint via GET with the state as a query - // param (not POST/body) — matches the official CLI's /v2/plugin/auth/token?state=... - const response = await fetch(`${config.tokenUrl}?state=${encodeURIComponent(deviceCode)}`, { - method: "GET", - headers: { - Accept: "application/json", - "User-Agent": config.userAgent, - "X-Requested-With": "XMLHttpRequest", - "X-Domain": "copilot.tencent.com", - "X-No-Authorization": "true", - "X-No-User-Id": "true", - "X-No-Enterprise-Id": "true", - "X-No-Department-Info": "true", - "X-Product": "SaaS", - }, - }); - if (!response.ok) return { ok: false, data: { error: "request_failed" } }; - const data = await response.json(); - // code 11217 = pending (RetryFetchToken), code 0 = success - if (data.code === 0 && data.data?.accessToken) { - return { - ok: true, - data: { - access_token: data.data.accessToken, - refresh_token: data.data.refreshToken || "", - token_type: data.data.tokenType || "Bearer", - expires_in: data.data.expiresIn, - }, - }; - } - if (data.code === 11217) return { ok: true, data: { error: "authorization_pending" } }; - return { ok: false, data: { error: data.msg || "unknown_error" } }; - }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresIn: tokens.expires_in || 86400, - providerSpecificData: {}, - }), - }, - - kimchi: { - config: KIMCHI_CONFIG, - flowType: "browser_token", - buildAuthUrl: (config, redirectUri, state) => { - const baseUrl = (config.webAppUrl || "https://app.kimchi.dev").replace(/\/+$/, ""); - const params = new URLSearchParams({ - callback: redirectUri, - state, - }); - return `${baseUrl}/cli-auth?${params.toString()}`; - }, - exchangeToken: async (config, token) => { - const accessToken = String(token || "").trim(); - if (!accessToken) { - throw new Error("Missing Kimchi token"); - } - - const validationUrl = config.validationUrl || "https://api.cast.ai/v1/llm/openai/supported-providers"; - const validationRes = await fetch(validationUrl, { - method: "GET", - headers: { - Accept: "application/json", - Authorization: `Bearer ${accessToken}`, - }, - }); - if (!validationRes.ok) { - throw new Error(`Kimchi token validation failed: ${validationRes.status}`); - } - - let userInfo = {}; - if (config.userInfoUrl) { - try { - const userRes = await fetch(config.userInfoUrl, { - method: "GET", - headers: { - Accept: "application/json", - Authorization: `Bearer ${accessToken}`, - }, - }); - if (userRes.ok) { - userInfo = await userRes.json(); - } - } catch { - userInfo = {}; - } - } - - return { - access_token: accessToken, - token_type: "Bearer", - _kimchiUser: userInfo, - }; - }, - mapTokens: (tokens) => { - const user = tokens._kimchiUser || {}; - const userId = user.id ? String(user.id) : ""; - const username = user.username || ""; - const email = user.email || (userId ? `kimchi-user-${userId}` : null); - return { - accessToken: tokens.access_token, - refreshToken: null, - email, - displayName: user.name || username || null, - providerSpecificData: { - authMethod: "browser_token", - userId, - username, - }, - }; - }, - }, -}; - -/** - * Get provider handler - */ -export function getProvider(name) { - const provider = PROVIDERS[name]; - if (!provider) { - throw new Error(`Unknown provider: ${name}`); - } - return provider; -} - -/** - * Get all provider names - */ -export function getProviderNames() { - return Object.keys(PROVIDERS); -} - -/** - * Generate auth data for a provider - * @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl) - */ -export async function generateAuthData(providerName, redirectUri, meta) { - const provider = getProvider(providerName); - const config = provider.prepareConfig - ? await provider.prepareConfig(provider.config, meta || {}) - : provider.config; - const { codeVerifier, codeChallenge, state } = generatePKCE(provider.pkceVerifierBytes); - - let authUrl; - if (provider.flowType === "device_code") { - // Device code flow doesn't have auth URL upfront - authUrl = null; - } else if (provider.flowType === "authorization_code_pkce") { - authUrl = provider.buildAuthUrl(config, redirectUri, state, codeChallenge, meta || {}); - } else { - authUrl = provider.buildAuthUrl(config, redirectUri, state, undefined, meta || {}); - } - - return { - authUrl, - state, - codeVerifier, - codeChallenge, - redirectUri, - flowType: provider.flowType, - fixedPort: provider.fixedPort, - callbackPath: provider.callbackPath || "/callback", - }; -} - -/** - * Exchange code for tokens - * @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl) - */ -export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state, meta) { - const provider = getProvider(providerName); - const config = provider.prepareConfig - ? await provider.prepareConfig(provider.config, meta || {}) - : provider.config; - - const tokens = await provider.exchangeToken(config, code, redirectUri, codeVerifier, state, meta || {}); - - let extra = null; - if (provider.postExchange) { - extra = await provider.postExchange(tokens); - } - - return provider.mapTokens(tokens, extra); -} - -/** - * Request device code (for device_code flow) - */ -export async function requestDeviceCode(providerName, codeChallenge, options) { - const provider = getProvider(providerName); - if (provider.flowType !== "device_code") { - throw new Error(`Provider ${providerName} does not support device code flow`); - } - return await provider.requestDeviceCode(provider.config, codeChallenge, options || {}); -} - -/** - * Poll for token (for device_code flow) - * @param {string} providerName - Provider name - * @param {string} deviceCode - Device code from requestDeviceCode - * @param {string} codeVerifier - PKCE code verifier (optional for some providers) - * @param {object} extraData - Extra data from device code response (e.g. clientId/clientSecret for Kiro) - */ -export async function pollForToken(providerName, deviceCode, codeVerifier, extraData) { - const provider = getProvider(providerName); - if (provider.flowType !== "device_code") { - throw new Error(`Provider ${providerName} does not support device code flow`); - } - - const result = await provider.pollToken(provider.config, deviceCode, codeVerifier, extraData); - - if (result.ok) { - // For device code flows, success is only when we have an access token - if (result.data.access_token) { - // Call postExchange to get additional data (copilotToken, userInfo, etc.) - let extra = null; - if (provider.postExchange) { - extra = await provider.postExchange(result.data); - } - const tokens = provider.mapTokens(result.data, extra); - // Kiro IDC/Builder-ID tokens lack profileArn; resolve it to avoid 403 - if (providerName === "kiro" && !tokens.providerSpecificData?.profileArn) { - const profileArn = await fetchKiroProfileArn(tokens.accessToken); - if (profileArn) tokens.providerSpecificData.profileArn = profileArn; - } - return { success: true, tokens }; - } else { - // Check if it's still pending authorization - if (result.data.error === 'authorization_pending' || result.data.error === 'slow_down') { - // This is not a failure, just still waiting - return { - success: false, - error: result.data.error, - errorDescription: result.data.error_description || result.data.message, - pending: result.data.error === 'authorization_pending' - }; - } else { - // Actual error - return { - success: false, - error: result.data.error || 'no_access_token', - errorDescription: result.data.error_description || result.data.message || 'No access token received' - }; - } - } - } - - return { success: false, error: result.data.error, errorDescription: result.data.error_description }; -} - -// Run-once guard across the process lifetime -let codexBackfillDone = false; - -// Backfill email + chatgpt account info for existing codex OAuth connections missing them -export async function backfillCodexEmails() { - if (codexBackfillDone) return; - codexBackfillDone = true; - try { - const { getProviderConnections, updateProviderConnection } = await import("@/lib/localDb"); - const connections = await getProviderConnections(); - const targets = connections.filter((c) => { - if (c.provider !== "codex" || c.authType !== "oauth" || !c.idToken) return false; - const hasEmail = !!c.email; - const hasAccountInfo = !!c.providerSpecificData?.chatgptAccountId; - return !hasEmail || !hasAccountInfo; - }); - for (const conn of targets) { - const info = extractCodexAccountInfo(conn.idToken); - if (!info.email && !info.chatgptAccountId) continue; - const patch = {}; - if (!conn.email && info.email) patch.email = info.email; - if (info.chatgptAccountId || info.chatgptPlanType) { - patch.providerSpecificData = { - ...(conn.providerSpecificData || {}), - chatgptAccountId: info.chatgptAccountId, - chatgptPlanType: info.chatgptPlanType, - }; - } - if (Object.keys(patch).length) { - await updateProviderConnection(conn.id, patch); - } - } - } catch (err) { - codexBackfillDone = false; - console.log("backfillCodexEmails failed:", err?.message || err); - } -} +export * from "./providers/index.js"; diff --git a/src/lib/oauth/providers/_shared.js b/src/lib/oauth/providers/_shared.js new file mode 100644 index 00000000..e08f5965 --- /dev/null +++ b/src/lib/oauth/providers/_shared.js @@ -0,0 +1,14 @@ +// Shared helpers used across provider entry files (currently trae + windsurf). + +export function extractJsonPath(root, paths) { + for (const path of paths) { + let cur = root; + for (const key of path) { + if (cur == null || typeof cur !== "object") { cur = undefined; break; } + cur = cur[key]; + } + if (typeof cur === "string" && cur.trim()) return cur.trim(); + if (typeof cur === "number") return String(cur); + } + return null; +} diff --git a/src/lib/oauth/providers/antigravity.js b/src/lib/oauth/providers/antigravity.js new file mode 100644 index 00000000..6cf28e81 --- /dev/null +++ b/src/lib/oauth/providers/antigravity.js @@ -0,0 +1,122 @@ +import { ANTIGRAVITY_CONFIG, getOAuthClientMetadata } from "../constants/oauth.js"; + +const antigravity = { + config: ANTIGRAVITY_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri, state) => { + const params = new URLSearchParams({ + client_id: config.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: config.scopes.join(" "), + state: state, + access_type: "offline", + prompt: "consent", + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + client_secret: config.clientSecret, + code: code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + postExchange: async (tokens) => { + // Numeric enums matching Antigravity binary ClientMetadata + const loadHeaders = { + "Authorization": `Bearer ${tokens.access_token}`, + "Content-Type": "application/json", + "User-Agent": ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent, + "X-Goog-Api-Client": ANTIGRAVITY_CONFIG.loadCodeAssistApiClient, + "Client-Metadata": ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata, + "x-request-source": "local", + }; + const metadata = getOAuthClientMetadata(); + + // Fetch user info + const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + "x-request-source": "local", + }, + }); + const userInfo = userInfoRes.ok ? await userInfoRes.json() : {}; + + // Load Code Assist to get project ID and tier + let projectId = ""; + let tierId = "legacy-tier"; + try { + const loadRes = await fetch(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, { + method: "POST", + headers: loadHeaders, + body: JSON.stringify({ metadata }), + }); + if (loadRes.ok) { + const data = await loadRes.json(); + projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; + if (Array.isArray(data.allowedTiers)) { + for (const tier of data.allowedTiers) { + if (tier.isDefault && tier.id) { + tierId = tier.id.trim(); + break; + } + } + } + } + } catch (e) { + console.log("Failed to load code assist:", e); + } + + // Fire-and-forget onboarding — does not block DB save + if (projectId) { + const doOnboard = async () => { + for (let i = 0; i < 10; i++) { + try { + const onboardRes = await fetch(ANTIGRAVITY_CONFIG.onboardUserEndpoint, { + method: "POST", + headers: loadHeaders, + body: JSON.stringify({ tierId, metadata }), + }); + if (onboardRes.ok) { + const result = await onboardRes.json(); + if (result.done === true) break; + } + } catch (e) { + break; + } + await new Promise(resolve => setTimeout(resolve, 5000)); + } + }; + doOnboard().catch(() => {}); + } + + return { userInfo, projectId }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + email: extra?.userInfo?.email, + projectId: extra?.projectId, + }), +}; + +export default antigravity; diff --git a/src/lib/oauth/providers/claude.js b/src/lib/oauth/providers/claude.js new file mode 100644 index 00000000..498b6b81 --- /dev/null +++ b/src/lib/oauth/providers/claude.js @@ -0,0 +1,60 @@ +import { CLAUDE_CONFIG } from "../constants/oauth.js"; + +const claude = { + config: CLAUDE_CONFIG, + flowType: "authorization_code_pkce", + buildAuthUrl: (config, redirectUri, state, codeChallenge) => { + const params = new URLSearchParams({ + code: "true", + client_id: config.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: config.scopes.join(" "), + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + state: state, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri, codeVerifier, state) => { + // Parse code - may contain state after # + let authCode = code; + let codeState = ""; + if (authCode.includes("#")) { + const parts = authCode.split("#"); + authCode = parts[0]; + codeState = parts[1] || ""; + } + + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + code: authCode, + state: codeState || state, + grant_type: "authorization_code", + client_id: config.clientId, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + }), +}; + +export default claude; diff --git a/src/lib/oauth/providers/cline.js b/src/lib/oauth/providers/cline.js new file mode 100644 index 00000000..5c94357b --- /dev/null +++ b/src/lib/oauth/providers/cline.js @@ -0,0 +1,62 @@ +import { CLINE_CONFIG } from "../constants/oauth.js"; + +const cline = { + config: CLINE_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri) => { + const params = new URLSearchParams({ + client_type: "extension", + callback_url: redirectUri, + redirect_uri: redirectUri, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + try { + // Cline encodes token data as base64 in the code param + let base64 = code; + const padding = 4 - (base64.length % 4); + if (padding !== 4) base64 += "=".repeat(padding); + const decoded = Buffer.from(base64, "base64").toString("utf-8"); + const lastBrace = decoded.lastIndexOf("}"); + if (lastBrace === -1) throw new Error("No JSON found in decoded code"); + const tokenData = JSON.parse(decoded.substring(0, lastBrace + 1)); + return { + access_token: tokenData.accessToken, + refresh_token: tokenData.refreshToken, + email: tokenData.email, + firstName: tokenData.firstName, + lastName: tokenData.lastName, + expires_at: tokenData.expiresAt, + }; + } catch (e) { + const response = await fetch(config.tokenExchangeUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ grant_type: "authorization_code", code, client_type: "extension", redirect_uri: redirectUri }), + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`Cline token exchange failed: ${error}`); + } + const data = await response.json(); + return { + access_token: data.data?.accessToken || data.accessToken, + refresh_token: data.data?.refreshToken || data.refreshToken, + email: data.data?.userInfo?.email || "", + expires_at: data.data?.expiresAt || data.expiresAt, + }; + } + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_at + ? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000) + : 3600, + email: tokens.email, + providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName }, + }), +}; + +export default cline; diff --git a/src/lib/oauth/providers/clinepass.js b/src/lib/oauth/providers/clinepass.js new file mode 100644 index 00000000..a49a2119 --- /dev/null +++ b/src/lib/oauth/providers/clinepass.js @@ -0,0 +1,62 @@ +import { CLINEPASS_CONFIG } from "../constants/oauth.js"; + +const clinepass = { + config: CLINEPASS_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri) => { + const params = new URLSearchParams({ + client_type: "extension", + callback_url: redirectUri, + redirect_uri: redirectUri, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + try { + // Cline encodes token data as base64 in the code param + let base64 = code; + const padding = 4 - (base64.length % 4); + if (padding !== 4) base64 += "=".repeat(padding); + const decoded = Buffer.from(base64, "base64").toString("utf-8"); + const lastBrace = decoded.lastIndexOf("}"); + if (lastBrace === -1) throw new Error("No JSON found in decoded code"); + const tokenData = JSON.parse(decoded.substring(0, lastBrace + 1)); + return { + access_token: tokenData.accessToken, + refresh_token: tokenData.refreshToken, + email: tokenData.email, + firstName: tokenData.firstName, + lastName: tokenData.lastName, + expires_at: tokenData.expiresAt, + }; + } catch (e) { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ grant_type: "authorization_code", code, client_type: "extension", redirect_uri: redirectUri }), + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`ClinePass token exchange failed: ${error}`); + } + const data = await response.json(); + return { + access_token: data.data?.accessToken || data.accessToken, + refresh_token: data.data?.refreshToken || data.refreshToken, + email: data.data?.userInfo?.email || "", + expires_at: data.data?.expiresAt || data.expiresAt, + }; + } + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_at + ? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000) + : 3600, + email: tokens.email, + providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName }, + }), +}; + +export default clinepass; diff --git a/src/lib/oauth/providers/codebuddy-cn.js b/src/lib/oauth/providers/codebuddy-cn.js new file mode 100644 index 00000000..82be6b51 --- /dev/null +++ b/src/lib/oauth/providers/codebuddy-cn.js @@ -0,0 +1,80 @@ +import { CODEBUDDY_CONFIG } from "../constants/oauth.js"; + +// CodeBuddy (Tencent) - Browser OAuth Polling Flow +// 1. POST stateUrl → get { state, authUrl } +// 2. Open authUrl in browser +// 3. Poll tokenUrl with state until success (code 0) or timeout +const codebuddyCn = { + config: CODEBUDDY_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const response = await fetch(`${config.stateUrl}?platform=${config.platform}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": config.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "copilot.tencent.com", + "X-No-Authorization": "true", + "X-No-User-Id": "true", + "X-Product": "SaaS", + }, + body: "{}", + }); + if (!response.ok) throw new Error(`CodeBuddy state request failed: ${await response.text()}`); + const data = await response.json(); + if (data.code !== 0 || !data.data?.state || !data.data?.authUrl) { + throw new Error(`CodeBuddy state error: ${data.msg || "missing state/authUrl"}`); + } + return { + device_code: data.data.state, + verification_uri: data.data.authUrl, + user_code: "", + interval: config.pollInterval / 1000, + _isCodeBuddy: true, + }; + }, + pollToken: async (config, deviceCode) => { + // CodeBuddy polls the token endpoint via GET with the state as a query + // param (not POST/body) — matches the official CLI's /v2/plugin/auth/token?state=... + const response = await fetch(`${config.tokenUrl}?state=${encodeURIComponent(deviceCode)}`, { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": config.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "copilot.tencent.com", + "X-No-Authorization": "true", + "X-No-User-Id": "true", + "X-No-Enterprise-Id": "true", + "X-No-Department-Info": "true", + "X-Product": "SaaS", + }, + }); + if (!response.ok) return { ok: false, data: { error: "request_failed" } }; + const data = await response.json(); + // code 11217 = pending (RetryFetchToken), code 0 = success + if (data.code === 0 && data.data?.accessToken) { + return { + ok: true, + data: { + access_token: data.data.accessToken, + refresh_token: data.data.refreshToken || "", + token_type: data.data.tokenType || "Bearer", + expires_in: data.data.expiresIn, + }, + }; + } + if (data.code === 11217) return { ok: true, data: { error: "authorization_pending" } }; + return { ok: false, data: { error: data.msg || "unknown_error" } }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in || 86400, + providerSpecificData: {}, + }), +}; + +export default codebuddyCn; diff --git a/src/lib/oauth/providers/codebuddy-intl.js b/src/lib/oauth/providers/codebuddy-intl.js new file mode 100644 index 00000000..e82e430f --- /dev/null +++ b/src/lib/oauth/providers/codebuddy-intl.js @@ -0,0 +1,74 @@ +import { CODEBUDDY_INTL_CONFIG } from "../constants/oauth.js"; + +// CodeBuddy International — mirrors codebuddy-cn flow against the .ai domain. +const codebuddyIntl = { + config: CODEBUDDY_INTL_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const response = await fetch(`${config.stateUrl}?platform=${config.platform}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": config.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "www.codebuddy.ai", + "X-No-Authorization": "true", + "X-No-User-Id": "true", + "X-Product": "SaaS", + }, + body: "{}", + }); + if (!response.ok) throw new Error(`CodeBuddy Intl state request failed: ${await response.text()}`); + const data = await response.json(); + if (data.code !== 0 || !data.data?.state || !data.data?.authUrl) { + throw new Error(`CodeBuddy Intl state error: ${data.msg || "missing state/authUrl"}`); + } + return { + device_code: data.data.state, + verification_uri: data.data.authUrl, + user_code: "", + interval: config.pollInterval / 1000, + _isCodeBuddy: true, + }; + }, + pollToken: async (config, deviceCode) => { + const response = await fetch(`${config.tokenUrl}?state=${encodeURIComponent(deviceCode)}`, { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": config.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "www.codebuddy.ai", + "X-No-Authorization": "true", + "X-No-User-Id": "true", + "X-No-Enterprise-Id": "true", + "X-No-Department-Info": "true", + "X-Product": "SaaS", + }, + }); + if (!response.ok) return { ok: false, data: { error: "request_failed" } }; + const data = await response.json(); + if (data.code === 0 && data.data?.accessToken) { + return { + ok: true, + data: { + access_token: data.data.accessToken, + refresh_token: data.data.refreshToken || "", + token_type: data.data.tokenType || "Bearer", + expires_in: data.data.expiresIn, + }, + }; + } + if (data.code === 11217) return { ok: true, data: { error: "authorization_pending" } }; + return { ok: false, data: { error: data.msg || "unknown_error" } }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in || 86400, + providerSpecificData: {}, + }), +}; + +export default codebuddyIntl; diff --git a/src/lib/oauth/providers/codex.js b/src/lib/oauth/providers/codex.js new file mode 100644 index 00000000..be4cbe65 --- /dev/null +++ b/src/lib/oauth/providers/codex.js @@ -0,0 +1,69 @@ +import { CODEX_CONFIG } from "../constants/oauth.js"; +import { extractCodexAccountInfo, extractEmailFromAccessToken } from "../providerHelpers.js"; + +const codex = { + config: CODEX_CONFIG, + flowType: "authorization_code_pkce", + fixedPort: CODEX_CONFIG.fixedPort, + callbackPath: CODEX_CONFIG.callbackPath, + buildAuthUrl: (config, redirectUri, state, codeChallenge) => { + const params = { + response_type: "code", + client_id: config.clientId, + redirect_uri: redirectUri, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + ...config.extraParams, + state: state, + }; + const queryString = Object.entries(params) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join("&"); + return `${config.authorizeUrl}?${queryString}`; + }, + exchangeToken: async (config, code, redirectUri, codeVerifier) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + code: code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + mapTokens: (tokens) => { + const info = extractCodexAccountInfo(tokens.id_token); + const mapped = { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + idToken: tokens.id_token, + expiresIn: tokens.expires_in, + lastRefreshAt: new Date().toISOString(), + }; + const email = info.email || extractEmailFromAccessToken(tokens.access_token); + if (email) mapped.email = email; + if (info.chatgptAccountId || info.chatgptPlanType) { + mapped.providerSpecificData = { + chatgptAccountId: info.chatgptAccountId, + chatgptPlanType: info.chatgptPlanType, + }; + } + return mapped; + }, +}; + +export default codex; diff --git a/src/lib/oauth/providers/cursor.js b/src/lib/oauth/providers/cursor.js new file mode 100644 index 00000000..a6aab0dc --- /dev/null +++ b/src/lib/oauth/providers/cursor.js @@ -0,0 +1,19 @@ +import { CURSOR_CONFIG } from "../constants/oauth.js"; + +const cursor = { + config: CURSOR_CONFIG, + flowType: "import_token", + // Cursor uses import token flow - tokens are extracted from local SQLite database + // No OAuth flow needed, handled by /api/oauth/cursor/import route + mapTokens: (tokens) => ({ + accessToken: tokens.accessToken, + refreshToken: null, // Cursor doesn't have public refresh endpoint + expiresIn: tokens.expiresIn || 86400, + providerSpecificData: { + machineId: tokens.machineId, + authMethod: "imported", + }, + }), +}; + +export default cursor; diff --git a/src/lib/oauth/providers/gemini-cli.js b/src/lib/oauth/providers/gemini-cli.js new file mode 100644 index 00000000..1e4d3d0b --- /dev/null +++ b/src/lib/oauth/providers/gemini-cli.js @@ -0,0 +1,85 @@ +import { GEMINI_CONFIG, getOAuthClientMetadata } from "../constants/oauth.js"; + +const geminiCli = { + config: GEMINI_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri, state) => { + const params = new URLSearchParams({ + client_id: config.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: config.scopes.join(" "), + state: state, + access_type: "offline", + prompt: "consent", + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + client_secret: config.clientSecret, + code: code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + postExchange: async (tokens) => { + // Fetch user info + const userInfoRes = await fetch(`${GEMINI_CONFIG.userInfoUrl}?alt=json`, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + const userInfo = userInfoRes.ok ? await userInfoRes.json() : {}; + + // Fetch project ID + let projectId = ""; + try { + const projectRes = await fetch( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + metadata: getOAuthClientMetadata(), + mode: 1, + }), + } + ); + if (projectRes.ok) { + const data = await projectRes.json(); + projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; + } + } catch (e) { + console.log("Failed to fetch project ID:", e); + } + + return { userInfo, projectId }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + email: extra?.userInfo?.email, + projectId: extra?.projectId, + }), +}; + +export default geminiCli; diff --git a/src/lib/oauth/providers/github.js b/src/lib/oauth/providers/github.js new file mode 100644 index 00000000..518652f4 --- /dev/null +++ b/src/lib/oauth/providers/github.js @@ -0,0 +1,98 @@ +import { GITHUB_CONFIG } from "../constants/oauth.js"; + +const github = { + config: GITHUB_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const response = await fetch(config.deviceCodeUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: config.clientId, + scope: config.scopes, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Device code request failed: ${error}`); + } + + return await response.json(); + }, + pollToken: async (config, deviceCode) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: config.clientId, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + // Handle response properly - if not ok, try to get error as text first + let data; + try { + data = await response.json(); + } catch (e) { + // If response is not JSON, get as text + const text = await response.text(); + data = { error: "invalid_response", error_description: text }; + } + + return { + ok: response.ok, + data: data, + }; + }, + postExchange: async (tokens) => { + // Get Copilot token using GitHub access token + const copilotRes = await fetch(GITHUB_CONFIG.copilotTokenUrl, { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + Accept: "application/json", + "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, + "User-Agent": GITHUB_CONFIG.userAgent, + }, + }); + const copilotToken = copilotRes.ok ? await copilotRes.json() : {}; + + // Get user info from GitHub + const userRes = await fetch(GITHUB_CONFIG.userInfoUrl, { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + Accept: "application/json", + "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, + "User-Agent": GITHUB_CONFIG.userAgent, + }, + }); + const userInfo = userRes.ok ? await userRes.json() : {}; + + return { copilotToken, userInfo }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + name: extra?.userInfo?.login || extra?.userInfo?.name, + displayName: extra?.userInfo?.name || extra?.userInfo?.login, + email: extra?.userInfo?.email || null, + providerSpecificData: { + copilotToken: extra?.copilotToken?.token, + copilotTokenExpiresAt: extra?.copilotToken?.expires_at, + githubUserId: extra?.userInfo?.id, + githubLogin: extra?.userInfo?.login, + githubName: extra?.userInfo?.name, + githubEmail: extra?.userInfo?.email, + }, + }), +}; + +export default github; diff --git a/src/lib/oauth/providers/gitlab.js b/src/lib/oauth/providers/gitlab.js new file mode 100644 index 00000000..eacc0fa9 --- /dev/null +++ b/src/lib/oauth/providers/gitlab.js @@ -0,0 +1,64 @@ +import { GITLAB_CONFIG } from "../constants/oauth.js"; + +// GitLab Duo - Authorization Code Flow with PKCE +// Supports two login modes via loginMode metadata: "oauth" (default) or "pat" +const gitlab = { + config: GITLAB_CONFIG, + flowType: "authorization_code_pkce", + buildAuthUrl: (config, redirectUri, state, codeChallenge, meta = {}) => { + const baseUrl = meta.baseUrl || config.defaultBaseUrl; + const clientId = meta.clientId || ""; + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: "code", + state, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + }); + return `${baseUrl}${config.authorizeUrlPath}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri, codeVerifier, state, meta = {}) => { + const baseUrl = meta.baseUrl || config.defaultBaseUrl; + const clientId = meta.clientId || ""; + const clientSecret = meta.clientSecret || ""; + const body = new URLSearchParams({ + client_id: clientId, + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }); + if (clientSecret) body.set("client_secret", clientSecret); + const response = await fetch(`${baseUrl}${config.tokenUrlPath}`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + body: body.toString(), + }); + if (!response.ok) throw new Error(`GitLab token exchange failed: ${await response.text()}`); + const tokens = await response.json(); + // Fetch user info + const userRes = await fetch(`${baseUrl}${config.userInfoUrlPath}`, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + const user = userRes.ok ? await userRes.json() : {}; + return { ...tokens, _user: user, _baseUrl: baseUrl, _clientId: clientId }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + providerSpecificData: { + username: tokens._user?.username || "", + email: tokens._user?.email || tokens._user?.public_email || "", + name: tokens._user?.name || "", + baseUrl: tokens._baseUrl, + clientId: tokens._clientId, + authKind: "oauth", + }, + }), +}; + +export default gitlab; diff --git a/src/lib/oauth/providers/grok-cli.js b/src/lib/oauth/providers/grok-cli.js new file mode 100644 index 00000000..2bd853b2 --- /dev/null +++ b/src/lib/oauth/providers/grok-cli.js @@ -0,0 +1,130 @@ +import { GROK_CLI_CONFIG } from "../constants/oauth.js"; +import { decodeXaiIdTokenEmail, extractEmailFromAccessToken } from "../providerHelpers.js"; + +// Grok CLI / Grok Build — device code flow to auth.x.ai, inference on cli-chat-proxy.grok.com +const grokCli = { + config: GROK_CLI_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const body = new URLSearchParams({ + client_id: config.clientId, + scope: config.scope, + }); + // Official CLI sends referrer=grok-build + if (config.referrer) body.set("referrer", config.referrer); + + const response = await fetch(config.deviceCodeUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", + }, + body, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Grok CLI device code request failed: ${error}`); + } + + return await response.json(); + }, + pollToken: async (config, deviceCode) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: deviceCode, + client_id: config.clientId, + }), + }); + + let data; + try { + data = await response.json(); + } catch { + const text = await response.text(); + data = { error: "invalid_response", error_description: text }; + } + + // Device flow: 400 + authorization_pending is expected while user authorizes + const pending = + data?.error === "authorization_pending" || + data?.error === "slow_down"; + return { + ok: response.ok || pending, + data, + }; + }, + postExchange: async (tokens) => { + // Best-effort user profile from cli-chat-proxy (non-fatal) + try { + const res = await fetch("https://cli-chat-proxy.grok.com/v1/user", { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + Accept: "application/json", + "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-version": "0.2.93", + }, + }); + if (res.ok) return { user: await res.json() }; + } catch { + /* ignore */ + } + return { user: null }; + }, + mapTokens: (tokens, extra) => { + const email = + decodeXaiIdTokenEmail(tokens.id_token) || + extractEmailFromAccessToken(tokens.access_token) || + extra?.user?.email || + null; + const userId = + extra?.user?.userId || + extra?.user?.principalId || + null; + const displayName = [extra?.user?.firstName, extra?.user?.lastName] + .filter(Boolean) + .join(" ") + .trim() || null; + + const expiresAt = tokens.expires_in + ? new Date(Date.now() + tokens.expires_in * 1000).toISOString() + : null; + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || null, + expiresIn: tokens.expires_in, + // Surface an absolute expiry so the proactive refresh path + // (shouldRefreshCredentials / checkAndRefreshToken) can refresh the + // xAI token before it silently expires ~40-45 min after login. + // Without this, only the reactive 401 path in chatCore would refresh, + // causing intermittent "token expired" failures for Grok CLI. + expiresAt, + scope: tokens.scope, + // Top-level for dashboard connection cards + email: email || undefined, + displayName: displayName || undefined, + // Mirror identity into providerSpecificData so GrokCliExecutor can set + // x-email / x-userid without depending on top-level credential shape. + providerSpecificData: { + authMethod: "device_code", + idToken: tokens.id_token || null, + email: email || null, + userId, + hasGrokCodeAccess: extra?.user?.hasGrokCodeAccess ?? null, + subscriptionTier: extra?.user?.subscriptionTier ?? null, + }, + }; + }, +}; + +export default grokCli; diff --git a/src/lib/oauth/providers/iflow.js b/src/lib/oauth/providers/iflow.js new file mode 100644 index 00000000..7dcb32aa --- /dev/null +++ b/src/lib/oauth/providers/iflow.js @@ -0,0 +1,91 @@ +import { IFLOW_CONFIG } from "../constants/oauth.js"; + +const iflow = { + config: IFLOW_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri, state) => { + const params = new URLSearchParams({ + loginMethod: config.extraParams.loginMethod, + type: config.extraParams.type, + redirect: redirectUri, + state: state, + client_id: config.clientId, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + // Create Basic Auth header + const basicAuth = Buffer.from( + `${config.clientId}:${config.clientSecret}` + ).toString("base64"); + + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + Authorization: `Basic ${basicAuth}`, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: code, + redirect_uri: redirectUri, + client_id: config.clientId, + client_secret: config.clientSecret, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + postExchange: async (tokens) => { + // Fetch user info (MUST succeed to get API key) + const userInfoRes = await fetch( + `${IFLOW_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`, + { + headers: { + Accept: "application/json", + }, + } + ); + + if (!userInfoRes.ok) { + const errorText = await userInfoRes.text(); + throw new Error(`Failed to fetch user info: ${errorText}`); + } + + const result = await userInfoRes.json(); + if (!result.success) { + throw new Error(`User info request failed: ${result.message || 'Unknown error'}`); + } + + const userInfo = result.data || {}; + + // Validate API key (critical for iFlow) + if (!userInfo.apiKey || userInfo.apiKey.trim() === "") { + throw new Error("Empty API key returned from iFlow"); + } + + // Validate email/phone + const email = userInfo.email?.trim() || userInfo.phone?.trim(); + if (!email) { + throw new Error("Missing account email/phone in user info"); + } + + return { userInfo }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + apiKey: extra?.userInfo?.apiKey, + email: extra?.userInfo?.email || extra?.userInfo?.phone, + displayName: extra?.userInfo?.nickname || extra?.userInfo?.name, + }), +}; + +export default iflow; diff --git a/src/lib/oauth/providers/index.js b/src/lib/oauth/providers/index.js new file mode 100644 index 00000000..8449ecef --- /dev/null +++ b/src/lib/oauth/providers/index.js @@ -0,0 +1,241 @@ +// Ensure outbound fetch respects HTTP(S)_PROXY/ALL_PROXY in Node runtime +import "open-sse/index.js"; + +import { generatePKCE } from "../utils/pkce.js"; +import { extractCodexAccountInfo, fetchKiroProfileArn } from "../providerHelpers.js"; + +import claude from "./claude.js"; +import codex from "./codex.js"; +import xai from "./xai.js"; +import grokCli from "./grok-cli.js"; +import geminiCli from "./gemini-cli.js"; +import antigravity from "./antigravity.js"; +import iflow from "./iflow.js"; +import qoder from "./qoder.js"; +import qwen from "./qwen.js"; +import github from "./github.js"; +import kiro from "./kiro.js"; +import cursor from "./cursor.js"; +import kimi from "./kimi.js"; +import kilocode from "./kilocode.js"; +import cline from "./cline.js"; +import clinepass from "./clinepass.js"; +import gitlab from "./gitlab.js"; +import codebuddyCn from "./codebuddy-cn.js"; +import codebuddyIntl from "./codebuddy-intl.js"; +import kimchi from "./kimchi.js"; +import trae from "./trae.js"; +import windsurf from "./windsurf.js"; +import zed from "./zed.js"; + +// Provider configurations +const PROVIDERS = { + claude, + codex, + xai, + "grok-cli": grokCli, + "gemini-cli": geminiCli, + antigravity, + iflow, + qoder, + qwen, + github, + kiro, + cursor, + kimi, + kilocode, + cline, + clinepass, + gitlab, + "codebuddy-cn": codebuddyCn, + "codebuddy-intl": codebuddyIntl, + kimchi, + trae, + windsurf, + zed, +}; + +export { PROVIDERS }; + +// Re-export helpers that other files import from this path +export { extractCodexAccountInfo, fetchKiroProfileArn }; + +/** + * Get provider handler + */ +export function getProvider(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}`); + } + return provider; +} + +/** + * Get all provider names + */ +export function getProviderNames() { + return Object.keys(PROVIDERS); +} + +/** + * Generate auth data for a provider + * @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl) + */ +export async function generateAuthData(providerName, redirectUri, meta) { + const provider = getProvider(providerName); + const config = provider.prepareConfig + ? await provider.prepareConfig(provider.config, meta || {}) + : provider.config; + const { codeVerifier: pkceVerifier, codeChallenge, state: pkceState } = generatePKCE(provider.pkceVerifierBytes); + // Trae uses loginTraceID (set by prepareConfig) as the callback matcher, not PKCE state. + const state = config.loginTraceID || pkceState; + // Zed: codeVerifier carries the encoded RSA private key (from prepareConfig), not a PKCE verifier. + const codeVerifier = config.privateKeyVerifier || pkceVerifier; + + let authUrl; + if (provider.flowType === "device_code") { + // Device code flow doesn't have auth URL upfront + authUrl = null; + } else if (provider.flowType === "authorization_code_pkce") { + authUrl = provider.buildAuthUrl(config, redirectUri, state, codeChallenge, meta || {}); + } else { + authUrl = provider.buildAuthUrl(config, redirectUri, state, undefined, meta || {}); + } + + return { + authUrl, + state, + codeVerifier, + codeChallenge, + redirectUri, + flowType: provider.flowType, + fixedPort: provider.fixedPort, + callbackPath: provider.callbackPath || "/callback", + }; +} + +/** + * Exchange code for tokens + * @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl) + */ +export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state, meta) { + const provider = getProvider(providerName); + const config = provider.prepareConfig + ? await provider.prepareConfig(provider.config, meta || {}) + : provider.config; + + const tokens = await provider.exchangeToken(config, code, redirectUri, codeVerifier, state, meta || {}); + + let extra = null; + if (provider.postExchange) { + extra = await provider.postExchange(tokens); + } + + return provider.mapTokens(tokens, extra); +} + +/** + * Request device code (for device_code flow) + */ +export async function requestDeviceCode(providerName, codeChallenge, options) { + const provider = getProvider(providerName); + if (provider.flowType !== "device_code") { + throw new Error(`Provider ${providerName} does not support device code flow`); + } + return await provider.requestDeviceCode(provider.config, codeChallenge, options || {}); +} + +/** + * Poll for token (for device_code flow) + * @param {string} providerName - Provider name + * @param {string} deviceCode - Device code from requestDeviceCode + * @param {string} codeVerifier - PKCE code verifier (optional for some providers) + * @param {object} extraData - Extra data from device code response (e.g. clientId/clientSecret for Kiro) + */ +export async function pollForToken(providerName, deviceCode, codeVerifier, extraData) { + const provider = getProvider(providerName); + if (provider.flowType !== "device_code") { + throw new Error(`Provider ${providerName} does not support device code flow`); + } + + const result = await provider.pollToken(provider.config, deviceCode, codeVerifier, extraData); + + if (result.ok) { + // For device code flows, success is only when we have an access token + if (result.data.access_token) { + // Call postExchange to get additional data (copilotToken, userInfo, etc.) + let extra = null; + if (provider.postExchange) { + extra = await provider.postExchange(result.data); + } + const tokens = provider.mapTokens(result.data, extra); + // Kiro IDC/Builder-ID tokens lack profileArn; resolve it to avoid 403 + if (providerName === "kiro" && !tokens.providerSpecificData?.profileArn) { + const profileArn = await fetchKiroProfileArn(tokens.accessToken); + if (profileArn) tokens.providerSpecificData.profileArn = profileArn; + } + return { success: true, tokens }; + } else { + // Check if it's still pending authorization + if (result.data.error === 'authorization_pending' || result.data.error === 'slow_down') { + // This is not a failure, just still waiting + return { + success: false, + error: result.data.error, + errorDescription: result.data.error_description || result.data.message, + pending: result.data.error === 'authorization_pending' + }; + } else { + // Actual error + return { + success: false, + error: result.data.error || 'no_access_token', + errorDescription: result.data.error_description || result.data.message || 'No access token received' + }; + } + } + } + + return { success: false, error: result.data.error, errorDescription: result.data.error_description }; +} + +// Run-once guard across the process lifetime +let codexBackfillDone = false; + +// Backfill email + chatgpt account info for existing codex OAuth connections missing them +export async function backfillCodexEmails() { + if (codexBackfillDone) return; + codexBackfillDone = true; + try { + const { getProviderConnections, updateProviderConnection } = await import("@/lib/localDb"); + const connections = await getProviderConnections(); + const targets = connections.filter((c) => { + if (c.provider !== "codex" || c.authType !== "oauth" || !c.idToken) return false; + const hasEmail = !!c.email; + const hasAccountInfo = !!c.providerSpecificData?.chatgptAccountId; + return !hasEmail || !hasAccountInfo; + }); + for (const conn of targets) { + const info = extractCodexAccountInfo(conn.idToken); + if (!info.email && !info.chatgptAccountId) continue; + const patch = {}; + if (!conn.email && info.email) patch.email = info.email; + if (info.chatgptAccountId || info.chatgptPlanType) { + patch.providerSpecificData = { + ...(conn.providerSpecificData || {}), + chatgptAccountId: info.chatgptAccountId, + chatgptPlanType: info.chatgptPlanType, + }; + } + if (Object.keys(patch).length) { + await updateProviderConnection(conn.id, patch); + } + } + } catch (err) { + codexBackfillDone = false; + console.log("backfillCodexEmails failed:", err?.message || err); + } +} diff --git a/src/lib/oauth/providers/kilocode.js b/src/lib/oauth/providers/kilocode.js new file mode 100644 index 00000000..c74d7bbc --- /dev/null +++ b/src/lib/oauth/providers/kilocode.js @@ -0,0 +1,60 @@ +import { KILOCODE_CONFIG } from "../constants/oauth.js"; + +const kilocode = { + config: KILOCODE_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const response = await fetch(config.initiateUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + if (!response.ok) { + if (response.status === 429) { + throw new Error("Too many pending authorization requests. Please try again later."); + } + const error = await response.text(); + throw new Error(`Device auth initiation failed: ${error}`); + } + const data = await response.json(); + return { + device_code: data.code, + user_code: data.code, + verification_uri: data.verificationUrl, + verification_uri_complete: data.verificationUrl, + expires_in: data.expiresIn || 300, + interval: 3, + }; + }, + pollToken: async (config, deviceCode) => { + const response = await fetch(`${config.pollUrlBase}/${deviceCode}`); + if (response.status === 202) return { ok: false, data: { error: "authorization_pending" } }; + if (response.status === 403) return { ok: false, data: { error: "access_denied", error_description: "Authorization denied by user" } }; + if (response.status === 410) return { ok: false, data: { error: "expired_token", error_description: "Authorization code expired" } }; + if (!response.ok) return { ok: false, data: { error: "poll_failed", error_description: `Poll failed: ${response.status}` } }; + const data = await response.json(); + if (data.status === "approved" && data.token) { + // Fetch profile to get orgId for X-Kilocode-OrganizationID header + let orgId = null; + try { + const profileRes = await fetch(`${config.apiBaseUrl}/api/profile`, { + headers: { "Authorization": `Bearer ${data.token}` } + }); + if (profileRes.ok) { + const profile = await profileRes.json(); + orgId = profile.organizations?.[0]?.id || null; + } + } catch {} + return { ok: true, data: { access_token: data.token, _userEmail: data.userEmail, _orgId: orgId } }; + } + return { ok: false, data: { error: "authorization_pending" } }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: null, + expiresIn: null, + email: tokens._userEmail, + ...(tokens._orgId ? { providerSpecificData: { orgId: tokens._orgId } } : {}), + }), +}; + +export default kilocode; diff --git a/src/lib/oauth/providers/kimchi.js b/src/lib/oauth/providers/kimchi.js new file mode 100644 index 00000000..7920f85e --- /dev/null +++ b/src/lib/oauth/providers/kimchi.js @@ -0,0 +1,75 @@ +import { KIMCHI_CONFIG } from "../constants/oauth.js"; + +const kimchi = { + config: KIMCHI_CONFIG, + flowType: "browser_token", + buildAuthUrl: (config, redirectUri, state) => { + const baseUrl = (config.webAppUrl || "https://app.kimchi.dev").replace(/\/+$/, ""); + const params = new URLSearchParams({ + callback: redirectUri, + state, + }); + return `${baseUrl}/cli-auth?${params.toString()}`; + }, + exchangeToken: async (config, token) => { + const accessToken = String(token || "").trim(); + if (!accessToken) { + throw new Error("Missing Kimchi token"); + } + + const validationUrl = config.validationUrl || "https://api.cast.ai/v1/llm/openai/supported-providers"; + const validationRes = await fetch(validationUrl, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }); + if (!validationRes.ok) { + throw new Error(`Kimchi token validation failed: ${validationRes.status}`); + } + + let userInfo = {}; + if (config.userInfoUrl) { + try { + const userRes = await fetch(config.userInfoUrl, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }); + if (userRes.ok) { + userInfo = await userRes.json(); + } + } catch { + userInfo = {}; + } + } + + return { + access_token: accessToken, + token_type: "Bearer", + _kimchiUser: userInfo, + }; + }, + mapTokens: (tokens) => { + const user = tokens._kimchiUser || {}; + const userId = user.id ? String(user.id) : ""; + const username = user.username || ""; + const email = user.email || (userId ? `kimchi-user-${userId}` : null); + return { + accessToken: tokens.access_token, + refreshToken: null, + email, + displayName: user.name || username || null, + providerSpecificData: { + authMethod: "browser_token", + userId, + username, + }, + }; + }, +}; + +export default kimchi; diff --git a/src/lib/oauth/providers/kimi.js b/src/lib/oauth/providers/kimi.js new file mode 100644 index 00000000..613fc4e3 --- /dev/null +++ b/src/lib/oauth/providers/kimi.js @@ -0,0 +1,81 @@ +import crypto from "crypto"; +import { KIMI_CONFIG } from "../constants/oauth.js"; + +// Kimi Code device flow (CLIProxyAPI internal/auth/kimi). Id is `kimi`; +// `kimi-coding` remains an alias key so old UI/API routes still resolve. +const 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, + body: new URLSearchParams({ client_id: config.clientId }), + }); + if (!response.ok) { + const error = await response.text(); + 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 || authorizeDeviceUrl, + verification_uri_complete: + data.verification_uri_complete || + `${authorizeDeviceUrl}?user_code=${data.user_code}`, + expires_in: data.expires_in, + interval: data.interval || 5, + _kimiDeviceId: deviceId, + }; + }, + 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, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: config.clientId, + device_code: deviceCode, + }), + }); + let data; + try { + data = await response.json(); + } catch { + data = { error: "invalid_response", error_description: "non-json token response" }; + } + // 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 } : {}), + }, + }), +}; + +export default kimi; diff --git a/src/lib/oauth/providers/kiro.js b/src/lib/oauth/providers/kiro.js new file mode 100644 index 00000000..993cabeb --- /dev/null +++ b/src/lib/oauth/providers/kiro.js @@ -0,0 +1,151 @@ +import { KIRO_CONFIG, assertValidAwsRegion } from "../constants/oauth.js"; +import { extractEmailFromAccessToken } from "../providerHelpers.js"; + +const kiro = { + config: KIRO_CONFIG, + flowType: "device_code", + // Kiro uses AWS SSO OIDC - requires client registration first + requestDeviceCode: async (config, codeChallenge, options = {}) => { + const trimmedRegion = typeof options.region === "string" ? options.region.trim() : ""; + const region = trimmedRegion || "us-east-1"; + assertValidAwsRegion(region); + const trimmedStartUrl = typeof options.startUrl === "string" ? options.startUrl.trim() : ""; + const startUrl = trimmedStartUrl || config.startUrl; + const authMethod = options.authMethod === "idc" ? "idc" : "builder-id"; + const registerClientUrl = `https://oidc.${region}.amazonaws.com/client/register`; + const deviceAuthUrl = `https://oidc.${region}.amazonaws.com/device_authorization`; + + // Step 1: Register client with AWS SSO OIDC + const registerRes = await fetch(registerClientUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + clientName: config.clientName, + clientType: config.clientType, + scopes: config.scopes, + grantTypes: config.grantTypes, + issuerUrl: config.issuerUrl, + }), + }); + + if (!registerRes.ok) { + const error = await registerRes.text(); + throw new Error(`Client registration failed: ${error}`); + } + + const clientInfo = await registerRes.json(); + + // Step 2: Request device authorization + const deviceRes = await fetch(deviceAuthUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + clientId: clientInfo.clientId, + clientSecret: clientInfo.clientSecret, + startUrl, + }), + }); + + if (!deviceRes.ok) { + const error = await deviceRes.text(); + throw new Error(`Device authorization failed: ${error}`); + } + + const deviceData = await deviceRes.json(); + + // Return combined data for polling + return { + device_code: deviceData.deviceCode, + user_code: deviceData.userCode, + verification_uri: deviceData.verificationUri, + verification_uri_complete: deviceData.verificationUriComplete, + expires_in: deviceData.expiresIn, + interval: deviceData.interval || 5, + // Store client credentials for token exchange + _clientId: clientInfo.clientId, + _clientSecret: clientInfo.clientSecret, + _region: region, + _authMethod: authMethod, + _startUrl: startUrl, + }; + }, + pollToken: async (config, deviceCode, codeVerifier, extraData) => { + const region = extraData?._region || "us-east-1"; + assertValidAwsRegion(region); + const tokenUrl = `https://oidc.${region}.amazonaws.com/token`; + const response = await fetch(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + clientId: extraData?._clientId, + clientSecret: extraData?._clientSecret, + deviceCode: deviceCode, + grantType: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + let data; + try { + data = await response.json(); + } catch (e) { + const text = await response.text(); + data = { error: "invalid_response", error_description: text }; + } + + // AWS SSO OIDC returns camelCase + if (data.accessToken) { + return { + ok: true, + data: { + access_token: data.accessToken, + refresh_token: data.refreshToken, + expires_in: data.expiresIn, + profile_arn: data?.profileArn || null, + // Store client credentials for refresh + _clientId: extraData?._clientId, + _clientSecret: extraData?._clientSecret, + _region: extraData?._region, + _authMethod: extraData?._authMethod, + _startUrl: extraData?._startUrl, + }, + }; + } + + return { + ok: false, + data: { + error: data.error || "authorization_pending", + error_description: data.error_description || data.message, + }, + }; + }, + mapTokens: (tokens) => { + const email = extractEmailFromAccessToken(tokens.access_token); + const mapped = { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + email, + providerSpecificData: { + profileArn: tokens?.profile_arn || null, + clientId: tokens._clientId, + clientSecret: tokens._clientSecret, + region: tokens._region || "us-east-1", + authMethod: tokens._authMethod || "builder-id", + startUrl: tokens._startUrl || KIRO_CONFIG.startUrl, + }, + }; + return mapped; + }, +}; + +export default kiro; diff --git a/src/lib/oauth/providers/qoder.js b/src/lib/oauth/providers/qoder.js new file mode 100644 index 00000000..fa46a92d --- /dev/null +++ b/src/lib/oauth/providers/qoder.js @@ -0,0 +1,102 @@ +import { QODER_CONFIG } from "../constants/oauth.js"; + +const qoder = { + config: QODER_CONFIG, + flowType: "device_code", + // Qoder uses a custom device flow: PKCE + nonce + machine_id are generated + // locally, the user lands on qoder.com/device/selectAccounts in the + // browser, and we poll openapi.qoder.sh until a `dt-...` token appears. + requestDeviceCode: async (config) => { + const { QoderService } = await import("@/lib/oauth/services/qoder"); + const flow = new QoderService().initiateDeviceFlow(); + // Match the device_code shape the rest of the OAuthModal expects + // (device_code, user_code, verification_uri[_complete], interval). + // The poll endpoint identifies us by nonce+verifier, not by a + // server-issued device_code, so we plumb our own values through: + // device_code = nonce (modal forwards as deviceCode on poll) + // codeVerifier = our PKCE verifier (route forwards as codeVerifier) + return { + device_code: flow.nonce, + user_code: flow.nonce.slice(0, 8).toUpperCase(), + verification_uri: config.loginUrl, + verification_uri_complete: flow.verificationUriComplete, + expires_in: 300, + interval: 2, + codeVerifier: flow.codeVerifier, + _qoderNonce: flow.nonce, + _qoderMachineId: flow.machineId, + }; + }, + pollToken: async (config, deviceCode, codeVerifier, extraData) => { + const { QoderService } = await import("@/lib/oauth/services/qoder"); + const svc = new QoderService(); + const nonce = deviceCode || extraData?._qoderNonce; + const verifier = codeVerifier || extraData?._qoderVerifier; + if (!nonce || !verifier) { + return { + ok: false, + data: { error: "invalid_request", error_description: "Missing nonce/verifier" }, + }; + } + let result; + try { + result = await svc.pollDeviceToken({ nonce, codeVerifier: verifier }); + } catch (err) { + return { + ok: false, + data: { error: "poll_failed", error_description: err.message }, + }; + } + if (result.status === "pending") { + return { ok: false, data: { error: "authorization_pending" } }; + } + // Best-effort profile lookup so we have a name/email to display. + const userInfo = await svc.fetchUserInfo(result.accessToken); + // expireTime is a Unix-ms timestamp from QoderService.parseExpiry, + // which already falls back to "now + 30 days" when the upstream + // omits expiry. Floor to a sane minimum (1 day) so a stale or + // skewed upstream timestamp doesn't truncate the stored token below + // something useful. + const minSeconds = 24 * 60 * 60; + const remainingSeconds = Math.floor((result.expireTime - Date.now()) / 1000); + const expiresIn = Math.max(minSeconds, remainingSeconds); + return { + ok: true, + data: { + access_token: result.accessToken, + refresh_token: result.refreshToken, + expires_in: expiresIn, + _qoderUserId: result.userId, + _qoderMachineId: extraData?._qoderMachineId || "", + _qoderName: userInfo.name, + _qoderEmail: userInfo.email, + _qoderOrganizationId: userInfo.organizationId, + }, + }; + }, + mapTokens: (tokens) => { + const rawEmail = (tokens._qoderEmail || "").trim(); + const displayName = (tokens._qoderName || "").trim() || null; + const userId = tokens._qoderUserId || ""; + // Dedup in createProviderConnection requires a non-empty email. When + // fetchUserInfo silently fails (returns ""), fall back to a stable + // synthetic identifier derived from userId so re-logins update the + // existing row instead of accumulating "Account N" duplicates. + const email = rawEmail || (userId ? `qoder-user-${userId}` : null); + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || null, + expiresIn: tokens.expires_in, + email, + displayName, + providerSpecificData: { + authMethod: "device", + userId, + machineId: tokens._qoderMachineId || "", + organizationId: tokens._qoderOrganizationId || "", + }, + }; + }, +}; + +export default qoder; diff --git a/src/lib/oauth/providers/qwen.js b/src/lib/oauth/providers/qwen.js new file mode 100644 index 00000000..e8048654 --- /dev/null +++ b/src/lib/oauth/providers/qwen.js @@ -0,0 +1,56 @@ +import { QWEN_CONFIG } from "../constants/oauth.js"; + +const qwen = { + config: QWEN_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config, codeChallenge) => { + const response = await fetch(config.deviceCodeUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: config.clientId, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Device code request failed: ${error}`); + } + + return await response.json(); + }, + pollToken: async (config, deviceCode, codeVerifier) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: config.clientId, + device_code: deviceCode, + code_verifier: codeVerifier, + }), + }); + + return { + ok: response.ok, + data: await response.json(), + }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + providerSpecificData: { resourceUrl: tokens.resource_url }, + }), +}; + +export default qwen; diff --git a/src/lib/oauth/providers/trae.js b/src/lib/oauth/providers/trae.js new file mode 100644 index 00000000..5e7df52b --- /dev/null +++ b/src/lib/oauth/providers/trae.js @@ -0,0 +1,263 @@ +import crypto from "crypto"; +import { TRAE_CONFIG } from "../constants/oauth.js"; +import { extractJsonPath } from "./_shared.js"; + +// ─────────────────────────────────────────────────────────────────────────── +// Trae (ByteDance marscode) OAuth helpers +// ─────────────────────────────────────────────────────────────────────────── + +// Per-login device context. No IDE access in 9router, so use stable defaults. +function buildTraeDeviceContext() { + return { + plugin_version: TRAE_CONFIG.defaultPluginVersion, + machine_id: crypto.randomUUID(), + device_id: TRAE_CONFIG.defaultDeviceId, + x_device_brand: "unknown", + x_device_type: "unknown", + x_os_version: "unknown", + x_env: "", + x_app_version: TRAE_CONFIG.defaultAppVersion, + x_app_type: TRAE_CONFIG.defaultAppType, + }; +} + +// POST GetLoginGuidance → { Result: { LoginHost } } +async function fetchTraeLoginGuidance(loginTraceId) { + const body = JSON.stringify({ loginTraceID: loginTraceId, login_trace_id: loginTraceId }); + let lastErr = "no successful response"; + for (const url of TRAE_CONFIG.loginGuidanceUrls) { + try { + const res = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": TRAE_CONFIG.userAgent, + }, + body, + }); + if (!res.ok) { lastErr = `${url} HTTP ${res.status}`; continue; } + const data = await res.json(); + const loginHost = extractJsonPath(data, [ + ["Result", "LoginHost"], ["Result", "loginHost"], ["Result", "LoginURL"], + ["result", "loginHost"], ["data", "Result", "LoginHost"], ["data", "loginHost"], + ["LoginHost"], ["loginHost"], + ]); + if (loginHost) return loginHost; + lastErr = `${url} missing LoginHost`; + } catch (e) { lastErr = `${url} ${e.message}`; } + } + throw new Error(`Trae GetLoginGuidance failed: ${lastErr}`); +} + +// Build the browser verification URL the user opens to sign in. +function buildTraeVerificationUrl(loginHost, loginTraceId, callbackUrl, ctx) { + const url = new URL(loginHost.startsWith("http") ? loginHost : `https://${loginHost.replace(/^\/+/, "")}`); + url.pathname = TRAE_CONFIG.authorizationPath; + const p = new URLSearchParams(); + p.set("login_version", "1"); + p.set("auth_from", "trae"); + p.set("login_channel", "native_ide"); + p.set("plugin_version", ctx.plugin_version); + p.set("auth_type", "local"); + p.set("client_id", TRAE_CONFIG.clientId); + p.set("redirect", "0"); + p.set("login_trace_id", loginTraceId); + p.set("auth_callback_url", callbackUrl); + p.set("machine_id", ctx.machine_id); + p.set("device_id", ctx.device_id); + p.set("x_device_id", ctx.device_id); + p.set("x_machine_id", ctx.machine_id); + p.set("x_device_brand", ctx.x_device_brand); + p.set("x_device_type", ctx.x_device_type); + p.set("x_os_version", ctx.x_os_version); + p.set("x_env", ctx.x_env); + p.set("x_app_version", ctx.x_app_version); + p.set("x_app_type", ctx.x_app_type); + url.search = p.toString(); + return url.toString(); +} + +// Parse the Trae OAuth callback (query string or full URL). +// Expected: ?isRedirect=true&refreshToken=...&loginHost=...[&x-cloudide-token=...] +function parseTraeCallback(raw) { + const text = String(raw || "").trim(); + let queryStr = text; + if (text.includes("?")) queryStr = text.slice(text.indexOf("?") + 1); + if (text.startsWith("#")) queryStr = text.slice(1); + const params = Object.fromEntries(new URLSearchParams(queryStr)); + const pick = (keys) => { + for (const k of keys) { const v = params[k]; if (v && String(v).trim()) return String(v).trim(); } + return null; + }; + const err = pick(["error", "error_code", "errorCode"]); + if (err) { + const desc = pick(["error_description", "error_desc", "message"]); + throw new Error(desc ? `Trae auth failed: ${err} (${desc})` : `Trae auth failed: ${err}`); + } + const refreshToken = pick(["refreshToken", "refresh_token", "RefreshToken"]); + if (!refreshToken) throw new Error("Trae callback missing refreshToken"); + const loginHost = pick(["loginHost", "login_host", "LoginHost", "host", "consoleHost"]); + if (!loginHost) throw new Error("Trae callback missing loginHost"); + const cloudideToken = pick(["x-cloudide-token", "xCloudideToken", "accessToken", "access_token", "token"]); + return { refreshToken, loginHost, cloudideToken }; +} + +// Allowed API origins for ExchangeToken/GetUserInfo — hardcoded HTTPS allowlist only. +// loginHost from the callback is intentionally NOT honored (SSRF guard: a callback +// attacker could otherwise point this at internal hosts/cloud metadata). +function traeApiOrigins() { + return [...TRAE_CONFIG.apiOrigins]; +} + +// POST ExchangeToken {ClientID, RefreshToken, ClientSecret, UserID} → {Result:{AccessToken,RefreshToken,ExpiresAt}} +async function fetchTraeExchangeToken(refreshToken, cloudideToken) { + const body = JSON.stringify({ + ClientID: TRAE_CONFIG.clientId, + RefreshToken: refreshToken, + ClientSecret: TRAE_CONFIG.clientSecret, + UserID: "", + }); + let lastErr = "no successful response"; + for (const origin of traeApiOrigins()) { + const url = `${origin.replace(/\/$/, "")}${TRAE_CONFIG.exchangeTokenPath}`; + try { + const headers = { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": TRAE_CONFIG.userAgent, + }; + if (cloudideToken) headers["x-cloudide-token"] = cloudideToken; + const res = await fetch(url, { method: "POST", headers, body }); + const text = await res.text(); + if (!res.ok) { lastErr = `${url} HTTP ${res.status}`; continue; } + let data; try { data = JSON.parse(text); } catch { lastErr = `${url} invalid JSON`; continue; } + const accessToken = extractJsonPath(data, [ + ["Result", "AccessToken"], ["Result", "accessToken"], ["result", "access_token"], ["accessToken"], + ]); + if (!accessToken) { + const msg = extractJsonPath(data, [["message"], ["msg"], ["error"], ["Result", "Message"]]) || "missing AccessToken"; + lastErr = `${url} ${msg}`; + continue; + } + return { + accessToken, + refreshToken: extractJsonPath(data, [["Result", "RefreshToken"], ["result", "refresh_token"], ["refreshToken"]]) || refreshToken, + expiresIn: null, // ExchangeToken returns ExpiresAt (absolute), converted below + expiresAt: extractJsonPath(data, [["Result", "ExpiresAt"], ["Result", "expiresAt"], ["result", "expires_at"], ["expiresAt"]]), + }; + } catch (e) { lastErr = `${url} ${e.message}`; } + } + throw new Error(`Trae ExchangeToken failed: ${lastErr}`); +} + +// POST GetUserInfo with x-cloudide-token → identity fields used by SOLO common_params. +async function fetchTraeUserInfo(accessToken) { + for (const origin of traeApiOrigins()) { + const url = `${origin.replace(/\/$/, "")}${TRAE_CONFIG.getUserInfoPath}`; + try { + const res = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": TRAE_CONFIG.userAgent, + "x-cloudide-token": accessToken, + }, + body: JSON.stringify({}), + }); + if (!res.ok) continue; + const data = await res.json(); + return { + email: extractJsonPath(data, [ + ["Result", "NonPlainTextEmail"], ["Result", "Email"], ["Result", "email"], + ["email"], ["data", "email"], + ]), + name: extractJsonPath(data, [ + ["Result", "ScreenName"], ["Result", "Nickname"], ["Result", "Name"], + ["result", "nickname"], ["nickname"], ["name"], + ]), + aiRegion: extractJsonPath(data, [["Result", "AIRegion"], ["Result", "aiRegion"], ["aiRegion"]]), + region: extractJsonPath(data, [["Result", "Region"], ["Result", "region"], ["region"]]), + tenant: extractJsonPath(data, [["Result", "TenantID"], ["Result", "tenantId"], ["tenantId"]]), + userId: extractJsonPath(data, [["Result", "UserID"], ["Result", "userId"], ["userId"]]), + }; + } catch { /* try next origin */ } + } + return { email: null, name: null }; +} + +// Map AIRegion (e.g. "SG", "US") → SOLO scope used in common_params. +function traeScopeForRegion(aiRegion) { + const r = (aiRegion || "").toLowerCase(); + if (r === "sg" || r.includes("singapore")) return "marscode-sg"; + if (r === "cn" || r.includes("cn") || r.includes("china")) return "marscode-cn"; + return "marscode-us"; +} + +// Trae — browser OAuth: GetLoginGuidance → verification URL +// → local callback (refreshToken+loginHost) → ExchangeToken → GetUserInfo. +// state === config.loginTraceID so the proxy can match the callback. +const trae = { + config: TRAE_CONFIG, + flowType: "authorization_code", + callbackPath: TRAE_CONFIG.callbackPath, + prepareConfig: async (config) => { + const loginTraceID = crypto.randomUUID(); + const loginHost = await fetchTraeLoginGuidance(loginTraceID); + return { ...config, loginTraceID, loginHost }; + }, + buildAuthUrl: (config, redirectUri, state) => { + const ctx = buildTraeDeviceContext(); + const traceId = config.loginTraceID || state; + return buildTraeVerificationUrl(config.loginHost, traceId, redirectUri, ctx); + }, + exchangeToken: async (config, code) => { + const trimmed = String(code || "").trim(); + // Paste-token mode: raw Cloud-IDE-JWT (no refresh exchange) + const looksCallback = /[?=&]/.test(trimmed) && (trimmed.includes("refreshToken") || trimmed.includes("refresh_token")); + if (!looksCallback) { + // Strip "Cloud-IDE-JWT " / "Bearer " prefix users paste from the Authorization header + const clean = trimmed.replace(/^(Cloud-IDE-JWT|Bearer)\s+/i, ""); + return { accessToken: clean, refreshToken: null, expiresIn: TRAE_CONFIG.tokenLifetimeDays * 24 * 60 * 60, _authMethod: "imported" }; + } + const { refreshToken, cloudideToken } = parseTraeCallback(trimmed); + return { ...(await fetchTraeExchangeToken(refreshToken, cloudideToken)), _authMethod: "oauth" }; + }, + postExchange: async (tokens) => { + const userInfo = await fetchTraeUserInfo(tokens.accessToken); + return { userInfo }; + }, + mapTokens: (tokens, extra) => { + const expiresIn = tokens.expiresIn + || (tokens.expiresAt ? Math.max(60, Number(tokens.expiresAt) - Math.floor(Date.now() / 1000)) : TRAE_CONFIG.tokenLifetimeDays * 24 * 60 * 60); + const ui = extra?.userInfo || {}; + const aiRegion = ui.aiRegion || "US-East"; + // SOLO common_params defaults — identity fields web_id/biz_user_id are not + // exposed by GetUserInfo; empty strings are accepted upstream (verified). + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresIn, + email: ui.email || undefined, + displayName: ui.name || undefined, + providerSpecificData: { + authMethod: tokens._authMethod || "oauth", + aiRegion, + region: ui.region || aiRegion, + tenant: ui.tenant || "marscode", + userId: ui.userId || "", + scope: traeScopeForRegion(aiRegion), + webId: "", + bizUserId: "", + userUniqueId: "", + appLanguage: "en", + appVersion: TRAE_CONFIG.defaultAppVersion, + userRegion: aiRegion === "SG" ? "SG" : "US", + userIdentity: "Free", + }, + }; + }, +}; + +export default trae; diff --git a/src/lib/oauth/providers/windsurf.js b/src/lib/oauth/providers/windsurf.js new file mode 100644 index 00000000..b7635832 --- /dev/null +++ b/src/lib/oauth/providers/windsurf.js @@ -0,0 +1,132 @@ +import { WINDSURF_CONFIG } from "../constants/oauth.js"; +import { extractJsonPath } from "./_shared.js"; + +// ─────────────────────────────────────────────────────────────────────────── +// Windsurf OAuth helpers +// ─────────────────────────────────────────────────────────────────────────── + +async function windsurfSeatRequest(baseUrl, path, body) { + const url = `${baseUrl.replace(/\/$/, "")}${path}`; + const res = await fetch(url, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": WINDSURF_CONFIG.userAgent, + }, + body: JSON.stringify(body), + }); + const text = await res.text(); + if (!res.ok) throw new Error(`Windsurf ${path} HTTP ${res.status}: ${text.slice(0, 200)}`); + try { return JSON.parse(text); } catch { throw new Error(`Windsurf ${path} invalid JSON`); } +} + +// Parse Windsurf callback (query string or full URL): ?access_token=...&state=... +function parseWindsurfCallback(raw, expectedState) { + const text = String(raw || "").trim(); + let queryStr = text; + if (text.includes("?")) queryStr = text.slice(text.indexOf("?") + 1); + if (text.startsWith("#")) queryStr = text.slice(1); + const params = Object.fromEntries(new URLSearchParams(queryStr)); + const pick = (keys) => { + for (const k of keys) { const v = params[k]; if (v && String(v).trim()) return String(v).trim(); } + return null; + }; + const err = pick(["error"]); + if (err) { + const desc = pick(["error_description"]); + throw new Error(desc ? `Windsurf auth failed: ${err} (${desc})` : `Windsurf auth failed: ${err}`); + } + const accessToken = pick(["access_token", "token"]); + if (!accessToken) throw new Error("Windsurf callback missing access_token"); + const state = pick(["state"]); + if (expectedState && state && state !== expectedState) { + throw new Error("Windsurf callback state mismatch"); + } + return { firebaseIdToken: accessToken }; +} + +// POST RegisterUser {firebase_id_token} → {apiKey, apiServerUrl, name} +async function fetchWindsurfRegisterUser(firebaseIdToken) { + const data = await windsurfSeatRequest(WINDSURF_CONFIG.registerApiBaseUrl, WINDSURF_CONFIG.registerPath, { + firebase_id_token: firebaseIdToken, + }); + const apiKey = extractJsonPath(data, [["apiKey"], ["api_key"]]); + if (!apiKey) throw new Error("Windsurf RegisterUser missing apiKey"); + const apiServerUrl = extractJsonPath(data, [["apiServerUrl"], ["api_server_url"]]) || WINDSURF_CONFIG.defaultApiServerUrl; + const name = extractJsonPath(data, [["name"]]); + return { apiKey, apiServerUrl, name }; +} + +// Best-effort: GetOneTimeAuthToken → GetCurrentUser → email/name. +async function fetchWindsurfUserInfo(apiServerUrl, firebaseIdToken) { + try { + const authRes = await windsurfSeatRequest(apiServerUrl, WINDSURF_CONFIG.oneTimeAuthPath, { firebaseIdToken }); + const authToken = extractJsonPath(authRes, [["authToken"], ["auth_token"]]); + if (!authToken) return { email: null, name: null }; + const userRes = await windsurfSeatRequest(apiServerUrl, WINDSURF_CONFIG.currentUserPath, { + authToken, + includeSubscription: true, + }); + const user = userRes.user || userRes; + return { + email: extractJsonPath(user, [["email"]]), + name: extractJsonPath(user, [["name"]]), + }; + } catch { return { email: null, name: null }; } +} + +// Windsurf — browser OAuth: windsurf.com/signin → +// local callback (firebase JWT) → RegisterUser → apiKey (used as credential). +const windsurf = { + config: WINDSURF_CONFIG, + flowType: "authorization_code", + callbackPath: WINDSURF_CONFIG.callbackPath, + buildAuthUrl: (config, redirectUri, state) => { + const params = new URLSearchParams({ + response_type: "token", + client_id: config.clientId, + redirect_uri: redirectUri, + state, + prompt: "login", + redirect_parameters_type: "query", + workflow: "onboarding", + }); + return `${config.authBaseUrl}${config.signInPath}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri, codeVerifier, state) => { + const trimmed = String(code || "").trim(); + const looksCallback = trimmed.includes("?") || trimmed.includes("access_token="); + if (!looksCallback) { + // Paste-token mode: sk-ws-... apiKey OR firebase JWT (eyJ...). Strip "Bearer " if pasted. + const clean = trimmed.replace(/^Bearer\s+/i, ""); + if (clean.startsWith("sk-ws-")) { + return { accessToken: clean, refreshToken: null, expiresIn: null, apiServerUrl: config.defaultApiServerUrl, firebaseIdToken: null, _authMethod: "imported" }; + } + const reg = await fetchWindsurfRegisterUser(clean); + return { accessToken: reg.apiKey, refreshToken: null, expiresIn: null, apiServerUrl: reg.apiServerUrl, firebaseIdToken: clean, _authMethod: "imported" }; + } + const { firebaseIdToken } = parseWindsurfCallback(trimmed, state); + const reg = await fetchWindsurfRegisterUser(firebaseIdToken); + return { accessToken: reg.apiKey, refreshToken: null, expiresIn: null, apiServerUrl: reg.apiServerUrl, firebaseIdToken, _authMethod: "oauth" }; + }, + postExchange: async (tokens) => { + if (!tokens.firebaseIdToken) return { userInfo: { email: null, name: null } }; + const info = await fetchWindsurfUserInfo(tokens.apiServerUrl, tokens.firebaseIdToken); + return { userInfo: info }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.accessToken, + refreshToken: null, + expiresIn: null, + email: extra?.userInfo?.email || undefined, + displayName: extra?.userInfo?.name || undefined, + providerSpecificData: { + authMethod: tokens._authMethod || "oauth", + apiServerUrl: tokens.apiServerUrl, + firebaseIdToken: tokens.firebaseIdToken, + }, + }), +}; + +export default windsurf; diff --git a/src/lib/oauth/providers/xai.js b/src/lib/oauth/providers/xai.js new file mode 100644 index 00000000..6cf8b76d --- /dev/null +++ b/src/lib/oauth/providers/xai.js @@ -0,0 +1,96 @@ +import crypto from "crypto"; +import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "../constants/xai.js"; +import { validateXaiOAuthEndpoint, decodeXaiIdTokenEmail } from "../providerHelpers.js"; + +// Inlined from services/xai.js to keep web route bundle free of `open` (CLI-only) package +let cachedXaiDiscovery = null; + +async function discoverXaiEndpoints() { + if (cachedXaiDiscovery) return cachedXaiDiscovery; + try { + const res = await fetch(XAI_CONFIG.discoveryUrl, { headers: { Accept: "application/json" } }); + if (res.ok) { + const data = await res.json(); + cachedXaiDiscovery = { + authorizeUrl: validateXaiOAuthEndpoint(data.authorization_endpoint, "authorization_endpoint"), + tokenUrl: validateXaiOAuthEndpoint(data.token_endpoint, "token_endpoint"), + }; + return cachedXaiDiscovery; + } + } catch { /* fall through to static fallback */ } + cachedXaiDiscovery = { authorizeUrl: XAI_CONFIG.authorizeUrl, tokenUrl: XAI_CONFIG.tokenUrl }; + return cachedXaiDiscovery; +} + +const xai = { + config: XAI_CONFIG, + flowType: "authorization_code_pkce", + fixedPort: XAI_CONFIG.loopbackPort, + callbackPath: XAI_CONFIG.callbackPath, + pkceVerifierBytes: XAI_PKCE_VERIFIER_BYTES, + prepareConfig: async (config) => { + const endpoints = await discoverXaiEndpoints(); + return { + ...config, + authorizeUrl: endpoints.authorizeUrl, + tokenUrl: endpoints.tokenUrl, + }; + }, + buildAuthUrl: (config, redirectUri, state, codeChallenge) => { + // Mirror CLIProxyAPI BuildAuthorizeURL: includes nonce, plan, referrer + const nonce = crypto.randomBytes(16).toString("hex"); + const params = { + response_type: "code", + client_id: config.clientId, + redirect_uri: redirectUri, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + state, + nonce, + plan: "generic", + referrer: "cli-proxy-api", + }; + const qs = Object.entries(params) + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join("&"); + return `${config.authorizeUrl}?${qs}`; + }, + exchangeToken: async (config, code, redirectUri, codeVerifier) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + if (!response.ok) { + const error = await response.text(); + throw new Error(`xAI token exchange failed: ${error}`); + } + return await response.json(); + }, + mapTokens: (tokens) => { + const mapped = { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + }; + const email = decodeXaiIdTokenEmail(tokens.id_token); + if (email) mapped.email = email; + if (tokens.id_token) { + mapped.providerSpecificData = { idToken: tokens.id_token }; + } + return mapped; + }, +}; + +export default xai; diff --git a/src/lib/oauth/providers/zed.js b/src/lib/oauth/providers/zed.js new file mode 100644 index 00000000..3343976a --- /dev/null +++ b/src/lib/oauth/providers/zed.js @@ -0,0 +1,62 @@ +import { ZED_HOSTED_CONFIG } from "../constants/oauth.js"; +import { + createZedNativeAuthData, + parseZedCallbackPayload, + decryptZedAccessToken, + fetchZedAuthenticatedUser, + resolveZedOrganizationId, +} from "open-sse/shared/zedAuth.js"; + +// Zed — RSA keypair native-app flow (NOT OAuth). prepareConfig generates a fresh +// keypair; buildAuthUrl returns the native_app_signin URL; exchangeToken decrypts +// the RSA-encrypted access token from the local callback. +const zed = { + config: ZED_HOSTED_CONFIG, + flowType: "authorization_code", + callbackPath: "/", + prepareConfig: async (config, meta) => { + // native_app_port is the local callback port (passed via meta from start-proxy). + const nativeAppPort = Number(meta?.nativeAppPort) || ZED_HOSTED_CONFIG.defaultNativeAppPort; + const auth = createZedNativeAuthData(config, { nativeAppPort }); + return { ...config, ...auth }; + }, + buildAuthUrl: (config, redirectUri, state) => config.authUrl, + exchangeToken: async (config, code, redirectUri, codeVerifier, state) => { + // code = raw callback URL/query; codeVerifier = encoded private key verifier. + const { userId, encryptedAccessToken } = parseZedCallbackPayload(code); + const accessToken = decryptZedAccessToken(encryptedAccessToken, codeVerifier); + return { accessToken, userId, systemId: config.systemId }; + }, + postExchange: async (tokens) => { + const credentials = { + accessToken: tokens.accessToken, + providerSpecificData: { userId: tokens.userId, systemId: tokens.systemId }, + }; + let userInfo = null; + try { + userInfo = await fetchZedAuthenticatedUser(credentials, { config: ZED_HOSTED_CONFIG }); + } catch { /* best-effort */ } + const organizationId = resolveZedOrganizationId(credentials, userInfo); + return { + userInfo, + organizationId, + email: userInfo?.email || null, + name: userInfo?.name || userInfo?.display_name || null, + }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.accessToken, + refreshToken: null, + expiresIn: null, + email: extra?.email || undefined, + displayName: extra?.name || undefined, + providerSpecificData: { + authMethod: "oauth", + userId: tokens.userId, + systemId: tokens.systemId, + organizationId: extra?.organizationId || "", + }, + }), +}; + +export default zed; diff --git a/src/lib/oauth/services/kiro.js b/src/lib/oauth/services/kiro.js index a739661e..f82089d8 100644 --- a/src/lib/oauth/services/kiro.js +++ b/src/lib/oauth/services/kiro.js @@ -260,11 +260,9 @@ export class KiroService { } /** - * List available CodeWhisperer profiles for a token (or API key) and return - * the best-matching profileArn. AWS SSO OIDC logins return no profileArn, so - * it must be fetched separately — the same call works for API-key auth. - * Accepts both `arn` and `profileArn` response field names (the API-key - * JSON-1.0 surface returns `arn`). + * List available CodeWhisperer profiles for OAuth/IDC tokens and return the + * best-matching profileArn. API keys use the Amazon Q model catalog instead; + * ListAvailableProfiles does not support TokenType=API_KEY. */ async listAvailableProfiles(accessToken, region = "us-east-1") { assertValidAwsRegion(region); @@ -294,10 +292,41 @@ export class KiroService { } /** - * Validate an API-key credential by listing profiles with it. API keys are - * long-lived bearer tokens (no refresh), so the only way to validate one is - * to make an authenticated CodeWhisperer call. Returns a credential object - * ready to persist as a "kiro" connection with authMethod="api_key". + * Validate an API key against the Amazon Q model catalog. A bearer-only call + * to ListAvailableProfiles can return HTTP 200 with an empty list for an + * arbitrary key, so it is not proof that the key can run inference. + */ + async listAvailableApiKeyModels(apiKey, region = "us-east-1") { + assertValidAwsRegion(region); + const params = new URLSearchParams({ origin: "AI_EDITOR" }); + const endpoint = `https://q.${region}.amazonaws.com/ListAvailableModels?${params}`; + const response = await fetch(endpoint, { + method: "GET", + headers: { + "Authorization": `Bearer ${apiKey}`, + "TokenType": "API_KEY", + "Accept": "application/json", + "User-Agent": "AWS-SDK-JS/3.0.0 kiro-ide/1.0.0", + "X-Amz-User-Agent": "aws-sdk-js/3.0.0 kiro-ide/1.0.0", + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to list API-key models: ${error}`); + } + + const data = await response.json(); + const models = Array.isArray(data?.models) ? data.models : []; + if (models.length === 0) { + throw new Error("API key returned no available models"); + } + return models; + } + + /** + * Validate an API-key credential through the same Amazon Q surface used for + * inference. API keys are account-bound but do not require a profileArn. */ async validateApiKey(apiKey, region = "us-east-1") { if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) { @@ -305,9 +334,8 @@ export class KiroService { } const trimmed = apiKey.trim(); - let profileArn = null; try { - profileArn = await this.listAvailableProfiles(trimmed, region); + await this.listAvailableApiKeyModels(trimmed, region); } catch (error) { throw new Error(`API key validation failed: ${error.message}`); } @@ -315,7 +343,7 @@ export class KiroService { return { accessToken: trimmed, refreshToken: null, - profileArn, + profileArn: null, region, authMethod: "api_key", }; diff --git a/src/lib/oauth/utils/ideDetect.js b/src/lib/oauth/utils/ideDetect.js new file mode 100644 index 00000000..a1200bcf --- /dev/null +++ b/src/lib/oauth/utils/ideDetect.js @@ -0,0 +1,57 @@ +import fs from "fs/promises"; +import { exec } from "child_process"; +import { promisify } from "util"; +import path from "path"; +import os from "os"; + +const execAsync = promisify(exec); + +// Install paths per provider per platform — Trae and standard Windsurf IDE locations. +const IDE_PATHS = { + trae: { + darwin: ["/Applications/Trae.app"], + win32: [ + path.join(process.env.LOCALAPPDATA || "", "Programs", "Trae", "Trae.exe"), + path.join(process.env.ProgramFiles || "", "Trae", "Trae.exe"), + ], + linux: ["/usr/bin/trae", "/usr/local/bin/trae", "/opt/trae", "/opt/Trae"], + }, + windsurf: { + darwin: ["/Applications/Windsurf.app"], + win32: [ + path.join(process.env.LOCALAPPDATA || "", "Programs", "Windsurf", "Windsurf.exe"), + path.join(process.env.ProgramFiles || "", "Windsurf", "Windsurf.exe"), + ], + linux: ["/usr/bin/windsurf", "/usr/local/bin/windsurf", "/opt/windsurf", "/opt/Windsurf"], + }, +}; + +const IDE_BINARIES = { + trae: "trae", + windsurf: "windsurf", +}; + +async function pathExists(p) { + try { await fs.access(p); return true; } catch { return false; } +} + +async function checkBinary(bin) { + try { + const cmd = os.platform() === "win32" ? `where ${bin}` : `which ${bin}`; + await execAsync(cmd, { windowsHide: true }); + return true; + } catch { return false; } +} + +// Returns { installed: boolean, path: string|null } for the given provider's IDE. +export async function detectIdeInstalled(providerId) { + const platform = os.platform(); + const paths = IDE_PATHS[providerId]; + if (!paths) return { installed: false, path: null }; + for (const p of paths[platform] || []) { + if (p && await pathExists(p)) return { installed: true, path: p }; + } + const bin = IDE_BINARIES[providerId]; + if (bin && await checkBinary(bin)) return { installed: true, path: bin }; + return { installed: false, path: null }; +} diff --git a/src/lib/oauth/utils/server.js b/src/lib/oauth/utils/server.js index b11a9a44..56eb67b1 100644 --- a/src/lib/oauth/utils/server.js +++ b/src/lib/oauth/utils/server.js @@ -1,6 +1,16 @@ import http from "http"; import { URL } from "url"; -import { CODEX_CONFIG } from "../constants/oauth.js"; +import { CODEX_CONFIG, TRAE_CONFIG, WINDSURF_CONFIG, ZED_HOSTED_CONFIG } from "../constants/oauth.js"; + +// Loopback origin guard for local callback proxies. +// Legit OAuth redirects are top-level navigations (no `Origin` header); a cross-site +// page issuing `fetch(..., {mode:"no-cors"})` to scan + hit 127.0.0.1 always sends +// `Origin: https://attacker`. Reject any non-loopback Origin to block login-CSRF. +function isLoopbackOrigin(origin) { + if (!origin) return true; // navigation redirect — allow + return /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin); +} + /** * Start a local HTTP server to receive OAuth callback @@ -424,3 +434,324 @@ export function stopXaiProxy() { } } +// ─────────────────────────────────────────────────────────────────────────── +// Trae dynamic-port proxy. Singleton session (one connect at a time per provider). +// Callback path = /callback with params refreshToken + loginHost. +// ─────────────────────────────────────────────────────────────────────────── + +let traeProxyServer = null; +let traeProxyTimeout = null; +let traeProxyPort = null; +let traeSession = null; + +export function registerTraeSession({ state }) { + if (!state) return false; + traeSession = { state, status: "pending", createdAt: Date.now() }; + return true; +} +export function getTraeSessionStatus(state) { + if (!traeSession) return null; + if (state && traeSession.state !== state) return null; + return traeSession; +} +export function clearTraeSession(state) { + if (!state || (traeSession && traeSession.state === state)) traeSession = null; +} + +export function startTraeProxy() { + return new Promise((resolve) => { + if (traeProxyServer) { + resolve({ success: true, port: traeProxyPort, callbackUrl: `http://127.0.0.1:${traeProxyPort}${TRAE_CONFIG.callbackPath}` }); + return; + } + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + if (url.pathname !== TRAE_CONFIG.callbackPath && url.pathname !== "/auth/callback") { + res.writeHead(404); + res.end("Not found"); + return; + } + const session = traeSession; + if (!session) { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, "No active Trae login session")); + return; + } + // Anti-CSRF: reject cross-origin fetches (legit redirects send no Origin), + // and reject state mismatch when state is present. + if (!isLoopbackOrigin(req.headers.origin)) { + res.writeHead(403, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, "Cross-origin callback rejected")); + return; + } + const cbState = url.searchParams.get("state"); + if (cbState && session.state && cbState !== session.state) { + session.status = "error"; + session.error = "Trae callback state mismatch"; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, session.error)); + stopTraeProxy(); + return; + } + // Pass the raw callback query to exchangeTokens → parseTraeCallback + const rawCallback = `${url.pathname}?${url.searchParams.toString()}`; + try { + const { exchangeTokens } = await import("../providers.js"); + const { createProviderConnection } = await import("@/models"); + const tokenData = await exchangeTokens("trae", rawCallback); + const connection = await createProviderConnection({ + provider: "trae", + authType: "oauth", + ...tokenData, + expiresAt: tokenData.expiresIn + ? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString() + : null, + testStatus: "active", + }); + session.status = "done"; + session.connectionId = connection.id; + session.email = connection.email; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(true, "You can close this window.")); + } catch (err) { + session.status = "error"; + session.error = err.message; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, err.message)); + } finally { + stopTraeProxy(); + } + }); + server.listen(0, "127.0.0.1", () => { + traeProxyServer = server; + traeProxyPort = server.address().port; + traeProxyTimeout = setTimeout(() => stopTraeProxy(), TRAE_CONFIG.oauthTimeoutMs); + resolve({ success: true, port: traeProxyPort, callbackUrl: `http://127.0.0.1:${traeProxyPort}${TRAE_CONFIG.callbackPath}` }); + }); + server.on("error", (err) => resolve({ success: false, reason: err.message })); + }); +} + +export function stopTraeProxy() { + if (traeProxyTimeout) { clearTimeout(traeProxyTimeout); traeProxyTimeout = null; } + if (traeProxyServer) { traeProxyServer.close(); traeProxyServer = null; } + traeProxyPort = null; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Windsurf dynamic-port proxy. Singleton session. +// Callback path = /windsurf-auth-callback with params access_token (firebase JWT) + state. +// ─────────────────────────────────────────────────────────────────────────── + +let windsurfProxyServer = null; +let windsurfProxyTimeout = null; +let windsurfProxyPort = null; +let windsurfSession = null; + +export function registerWindsurfSession({ state }) { + if (!state) return false; + windsurfSession = { state, status: "pending", createdAt: Date.now() }; + return true; +} +export function getWindsurfSessionStatus(state) { + if (!windsurfSession) return null; + if (state && windsurfSession.state !== state) return null; + return windsurfSession; +} +export function clearWindsurfSession(state) { + if (!state || (windsurfSession && windsurfSession.state === state)) windsurfSession = null; +} + +export function startWindsurfProxy() { + return new Promise((resolve) => { + if (windsurfProxyServer) { + resolve({ success: true, port: windsurfProxyPort, callbackUrl: `http://127.0.0.1:${windsurfProxyPort}${WINDSURF_CONFIG.callbackPath}` }); + return; + } + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + if (url.pathname !== WINDSURF_CONFIG.callbackPath) { + res.writeHead(404); + res.end("Not found"); + return; + } + const session = windsurfSession; + if (!session) { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, "No active Windsurf login session")); + return; + } + // Anti-CSRF: reject cross-origin fetches, and require state present + matching. + if (!isLoopbackOrigin(req.headers.origin)) { + res.writeHead(403, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, "Cross-origin callback rejected")); + return; + } + const cbState = url.searchParams.get("state"); + if (!cbState || !session.state || cbState !== session.state) { + session.status = "error"; + session.error = "Windsurf callback state mismatch"; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, session.error)); + stopWindsurfProxy(); + return; + } + const rawCallback = `${url.pathname}?${url.searchParams.toString()}`; + try { + const { exchangeTokens } = await import("../providers.js"); + const { createProviderConnection } = await import("@/models"); + const tokenData = await exchangeTokens("windsurf", rawCallback, null, null, session.state); + const connection = await createProviderConnection({ + provider: "windsurf", + authType: "api_key", + ...tokenData, + testStatus: "active", + }); + session.status = "done"; + session.connectionId = connection.id; + session.email = connection.email; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(true, "You can close this window.")); + } catch (err) { + session.status = "error"; + session.error = err.message; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, err.message)); + } finally { + stopWindsurfProxy(); + } + }); + server.listen(0, "127.0.0.1", () => { + windsurfProxyServer = server; + windsurfProxyPort = server.address().port; + windsurfProxyTimeout = setTimeout(() => stopWindsurfProxy(), WINDSURF_CONFIG.oauthTimeoutMs); + resolve({ success: true, port: windsurfProxyPort, callbackUrl: `http://127.0.0.1:${windsurfProxyPort}${WINDSURF_CONFIG.callbackPath}` }); + }); + server.on("error", (err) => resolve({ success: false, reason: err.message })); + }); +} + +export function stopWindsurfProxy() { + if (windsurfProxyTimeout) { clearTimeout(windsurfProxyTimeout); windsurfProxyTimeout = null; } + if (windsurfProxyServer) { windsurfProxyServer.close(); windsurfProxyServer = null; } + windsurfProxyPort = null; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Zed RSA native-app proxy. Singleton session. +// Callback: GET http://127.0.0.1:/?user_id=...&access_token= +// The proxy decrypts the access token using the private key stored in session.codeVerifier. +// ─────────────────────────────────────────────────────────────────────────── + +let zedProxyServer = null; +let zedProxyTimeout = null; +let zedProxyPort = null; +let zedSession = null; + +export function registerZedSession({ state, codeVerifier }) { + if (!state || !codeVerifier) return false; + zedSession = { state, codeVerifier, status: "pending", createdAt: Date.now() }; + return true; +} +export function getZedSessionStatus(state) { + if (!zedSession) return null; + if (state && zedSession.state !== state) return null; + return zedSession; +} +export function clearZedSession(state) { + if (!state || (zedSession && zedSession.state === state)) zedSession = null; +} + +export function startZedProxy(preferredPort = 0) { + return new Promise((resolve) => { + if (zedProxyServer) { + resolve({ success: true, port: zedProxyPort, callbackUrl: `http://127.0.0.1:${zedProxyPort}/` }); + return; + } + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, "http://localhost"); + // Log path + redacted params (access_token is the RSA-encrypted credential). + const redacted = Object.fromEntries(url.searchParams); + for (const k of ["access_token", "user_id", "code_verifier", "state"]) { + if (redacted[k]) redacted[k] = ""; + } + console.log("[Zed proxy]", req.method, url.pathname, JSON.stringify(redacted)); + if (url.pathname !== "/" && url.pathname !== "/callback") { + res.writeHead(404); + res.end("Not found"); + return; + } + const session = zedSession; + if (!session) { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, "No active Zed login session")); + return; + } + // Anti-CSRF: Zed tokens are RSA-encrypted to our keypair so they can't be + // forged cross-site, but still reject cross-origin fetches for defense-in-depth. + if (!isLoopbackOrigin(req.headers.origin)) { + res.writeHead(403, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, "Cross-origin callback rejected")); + return; + } + // Pass raw callback path+query to exchangeTokens → parseZedCallbackPayload. + // codeVerifier carries the encoded RSA private key for decryption. + const rawCallback = url.search ? `${url.pathname}?${url.searchParams.toString()}` : url.pathname; + try { + const { exchangeTokens } = await import("../providers.js"); + const { createProviderConnection } = await import("@/models"); + const tokenData = await exchangeTokens("zed", rawCallback, null, session.codeVerifier, session.state); + const connection = await createProviderConnection({ + provider: "zed", + authType: "oauth", + ...tokenData, + testStatus: "active", + }); + session.status = "done"; + session.connectionId = connection.id; + session.email = connection.email; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(true, "You can close this window.")); + } catch (err) { + session.status = "error"; + session.error = err.message; + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(renderCodexResultPage(false, err.message)); + } finally { + stopZedProxy(); + } + }); + const tryPort = Number(preferredPort) || 0; + server.on("error", (err) => { + // If the preferred port (e.g. 58443) is busy, fall back to a random port. + if (err.code === "EADDRINUSE" && tryPort !== 0) { + console.log(`[Zed proxy] port ${tryPort} busy, falling back to random`); + server.listen(0, "127.0.0.1", () => { + zedProxyServer = server; + zedProxyPort = server.address().port; + zedProxyTimeout = setTimeout(() => stopZedProxy(), ZED_HOSTED_CONFIG.oauthTimeoutMs); + console.log(`[Zed proxy] listening on random port ${zedProxyPort}`); + resolve({ success: true, port: zedProxyPort, callbackUrl: `http://127.0.0.1:${zedProxyPort}/` }); + }); + } else { + console.log(`[Zed proxy] listen error: ${err.message}`); + resolve({ success: false, reason: err.message }); + } + }); + server.listen(tryPort, "127.0.0.1", () => { + zedProxyServer = server; + zedProxyPort = server.address().port; + zedProxyTimeout = setTimeout(() => { console.log("[Zed proxy] timeout, stopping"); stopZedProxy(); }, ZED_HOSTED_CONFIG.oauthTimeoutMs); + console.log(`[Zed proxy] listening on port ${zedProxyPort}`); + resolve({ success: true, port: zedProxyPort, callbackUrl: `http://127.0.0.1:${zedProxyPort}/` }); + }); + }); +} + +export function stopZedProxy() { + console.log(`[Zed proxy] stopping (port ${zedProxyPort || "-"})`); + if (zedProxyTimeout) { clearTimeout(zedProxyTimeout); zedProxyTimeout = null; } + if (zedProxyServer) { zedProxyServer.close(); zedProxyServer = null; } + zedProxyPort = null; +} + diff --git a/src/lib/tunnel/cloudflare/cloudflared.js b/src/lib/tunnel/cloudflare/cloudflared.js index 38e4bf74..09458fc5 100644 --- a/src/lib/tunnel/cloudflare/cloudflared.js +++ b/src/lib/tunnel/cloudflare/cloudflared.js @@ -239,8 +239,8 @@ export async function spawnCloudflared(tunnelToken) { }); child.on("exit", (code, signal) => { - cloudflaredProcess = null; - clearPid(); + if (cloudflaredProcess === child) cloudflaredProcess = null; + clearPid(child.pid); const wasConnected = resolved; // true = already connected successfully if (!resolved) { resolved = true; @@ -372,8 +372,8 @@ export async function spawnQuickTunnel(localPort, onUrlUpdate) { }); child.on("exit", (code, signal) => { - cloudflaredProcess = null; - clearPid(); + if (cloudflaredProcess === child) cloudflaredProcess = null; + clearPid(child.pid); // Deliberate kill (restart/disable) — exit silently, no error noise if (intentionalKill) { intentionalKill = false; diff --git a/src/lib/tunnel/cloudflare/pid.js b/src/lib/tunnel/cloudflare/pid.js index 919837c5..77c63a82 100644 --- a/src/lib/tunnel/cloudflare/pid.js +++ b/src/lib/tunnel/cloudflare/pid.js @@ -16,8 +16,12 @@ export function loadPid() { return null; } -export function clearPid() { +export function clearPid(expectedPid = null) { try { - if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE); + if (!fs.existsSync(PID_FILE)) return false; + if (expectedPid !== null && loadPid() !== expectedPid) return false; + fs.unlinkSync(PID_FILE); + return true; } catch { /* ignore */ } + return false; } diff --git a/src/mitm/config.js b/src/mitm/config.js index e109ec95..ffbe07e1 100644 --- a/src/mitm/config.js +++ b/src/mitm/config.js @@ -86,4 +86,45 @@ function getToolForHost(host) { return null; } -module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, LOG_BLACKLIST_URL_PARTS, getToolForHost }; +function isBinaryData(buffer) { + if (!buffer || buffer.length === 0) return false; + const sample = buffer.slice(0, Math.min(100, buffer.length)); + let nonPrintable = 0; + for (let i = 0; i < sample.length; i++) { + const byte = sample[i]; + if (byte < 0x20 && byte !== 0x09 && byte !== 0x0A && byte !== 0x0D) { + nonPrintable++; + } + if (byte > 0x7E) nonPrintable++; + } + return (nonPrintable / sample.length) > 0.3; +} + +// Extract model from URL path (Gemini), body (OpenAI/Anthropic), or Kiro conversationState. +function extractModel(url, body) { + const urlMatch = url.match(/\/models\/([^/:]+)/); + const urlModel = urlMatch?.[1] || null; + + if (isBinaryData(body)) return urlModel; + + try { + const parsed = JSON.parse(body.toString()); + if (parsed.conversationState) { + return parsed.conversationState.currentMessage?.userInputMessage?.modelId || null; + } + const model = urlModel || parsed.model || null; + if (String(model).replace(/^models\//, "") === "gemini-3.6-flash-tiered") { + const rawLevel = parsed.request?.generationConfig?.thinkingConfig?.thinkingLevel + || parsed.generationConfig?.thinkingConfig?.thinkingLevel; + const level = ["high", "medium", "low"].includes(String(rawLevel).toLowerCase()) + ? String(rawLevel).toLowerCase() + : "medium"; + return `gemini-3.6-flash-${level}`; + } + return model; + } catch { + return urlModel; + } +} + +module.exports = { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, LOG_BLACKLIST_URL_PARTS, getToolForHost, extractModel }; diff --git a/src/mitm/server.js b/src/mitm/server.js index d675e6a3..ce432eee 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -7,7 +7,7 @@ const dns = require("dns"); const { promisify } = require("util"); const { execSync } = require("child_process"); const { log, err, dumpRequest, createResponseDumper, clearDumpDir } = require("./logger"); -const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost } = require("./config"); +const { IS_DEV, LSOF_BIN, TARGET_HOSTS, URL_PATTERNS, MODEL_SYNONYMS, MODEL_PATTERNS, MODEL_NO_MAP, getToolForHost, extractModel } = require("./config"); const { DATA_DIR, MITM_DIR } = require("./paths"); const { generateCert, getCertForDomain } = require("./cert/generate"); const { getMitmAlias } = require("./dbReader"); @@ -96,42 +96,6 @@ function collectBodyRaw(req) { }); } -// Extract model from URL path (Gemini), body (OpenAI/Anthropic), or Kiro conversationState -function extractModel(url, body) { - const urlMatch = url.match(/\/models\/([^/:]+)/); - if (urlMatch) return urlMatch[1]; - - // Skip parsing if body is binary (AWS EventStream, Protocol Buffers, etc.) - if (isBinaryData(body)) return null; - - try { - const parsed = JSON.parse(body.toString()); - if (parsed.conversationState) { - return parsed.conversationState.currentMessage?.userInputMessage?.modelId || null; - } - return parsed.model || null; - } catch { return null; } -} - -// Detect binary data vs JSON text -function isBinaryData(buffer) { - if (!buffer || buffer.length === 0) return false; - // AWS EventStream signature: first 4 bytes = frame length (big-endian uint32) - // Check for non-printable chars in first 100 bytes (common in binary protocols) - const sample = buffer.slice(0, Math.min(100, buffer.length)); - let nonPrintable = 0; - for (let i = 0; i < sample.length; i++) { - const byte = sample[i]; - // Count non-ASCII printable chars (excluding whitespace) - if (byte < 0x20 && byte !== 0x09 && byte !== 0x0A && byte !== 0x0D) { - nonPrintable++; - } - if (byte > 0x7E) nonPrintable++; - } - // If >30% non-printable, treat as binary - return (nonPrintable / sample.length) > 0.3; -} - function getMappedModel(tool, model) { if (!model) return null; try { diff --git a/src/shared/components/ComboFormModal.js b/src/shared/components/ComboFormModal.js index d80732da..ca022f80 100644 --- a/src/shared/components/ComboFormModal.js +++ b/src/shared/components/ComboFormModal.js @@ -166,11 +166,13 @@ export default function ComboFormModal({ isOpen, combo, onClose, onSave, activeP
- setShowModelSelect(false)} - onSelect={handleAddModel} onDeselect={handleDeselectModel} - activeProviders={activeProviders} modelAliases={modelAliases} - title="Add Model to Combo" kindFilter={kindFilter} - addedModelValues={models} closeOnSelect={false} /> + {showModelSelect && ( + setShowModelSelect(false)} + onSelect={handleAddModel} onDeselect={handleDeselectModel} + activeProviders={activeProviders} modelAliases={modelAliases} + title="Add Model to Combo" kindFilter={kindFilter} + addedModelValues={models} closeOnSelect={false} /> + )} ); } diff --git a/src/shared/components/DonateModal.js b/src/shared/components/DonateModal.js index bf1e9af5..e4f3daea 100644 --- a/src/shared/components/DonateModal.js +++ b/src/shared/components/DonateModal.js @@ -106,6 +106,8 @@ function DonateChannelCard({ channel }) { src={qr} alt={`${label} QR`} className="w-full max-w-[180px] aspect-square object-contain rounded-lg bg-white p-1" + loading="lazy" + decoding="async" /> )} diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index f70a0128..c3bbe606 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -12,6 +12,7 @@ import DonateModal from "@/shared/components/DonateModal"; 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) => { @@ -30,7 +31,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) }, ], }; } @@ -62,7 +63,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 5708807b..3419140b 100644 --- a/src/shared/components/ModelSelectModal.js +++ b/src/shared/components/ModelSelectModal.js @@ -48,6 +48,48 @@ export default function ModelSelectModal({ const [providerNodes, setProviderNodes] = useState([]); const [customModels, setCustomModels] = useState([]); const [disabledModels, setDisabledModels] = 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 different accounts can + // have different model entitlements. + 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) => { + // Do not hide the static fallback when the account catalog is unavailable. + console.warn("Unable to load Cursor models for selector:", error); + if (!cancelled) setCursorModels([]); + }); + + return () => { cancelled = true; }; + }, [isOpen, cursorConnectionIds]); const fetchCombos = async () => { try { @@ -280,7 +322,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 @@ -349,7 +393,7 @@ export default function ModelSelectModal({ }); return groups; - }, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, kindFilter, activeProviders]); + }, [filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, disabledModels, 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 6d7b2bba..808a7a8b 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -5,6 +5,31 @@ import PropTypes from "prop-types"; import { Modal, Button, Input } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +// Providers using the dynamic-port local callback proxy. +// Browser OAuth: popup → auto callback → auto exchange → poll-status. +const PROXY_OAUTH_PROVIDERS = new Set(["trae", "windsurf", "zed"]); + +// Providers offering a paste-token fallback (import-token flow). +// UX warns if the IDE (which issues the token) is not installed. +const PASTE_TOKEN_PROVIDERS = { + trae: { + label: "Cloud-IDE-JWT", + instructions: + "Sign in at trae.ai (or solo.trae.ai), open DevTools → Network, copy the Cloud-IDE-JWT token from any request's Authorization header (~14-day lifetime).", + placeholder: "Paste Cloud-IDE-JWT here...", + ideName: "Trae", + ideOptional: true, // token can be grabbed from DevTools without the IDE + }, + windsurf: { + label: "Windsurf API key", + instructions: + "In the Windsurf/VS Code IDE, run the \"Windsurf: Provide Auth Token\" command, then copy the displayed sk-ws-... key.", + placeholder: "Paste sk-ws-... key here...", + ideName: "Windsurf", + ideOptional: false, + }, +}; + /** * OAuth Modal Component * - Localhost: Auto callback via popup message @@ -18,6 +43,10 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, const [isDeviceCode, setIsDeviceCode] = useState(false); const [deviceData, setDeviceData] = useState(null); const [polling, setPolling] = useState(false); + // trae/windsurf: choose between browser OAuth (proxy) and paste-token (import) + const [authMode, setAuthMode] = useState("browser"); // "browser" | "paste-token" + const [pasteToken, setPasteToken] = useState(""); + const [ideStatus, setIdeStatus] = useState(null); const popupRef = useRef(null); const pollingAbortRef = useRef(false); const openedRef = useRef(false); @@ -150,20 +179,60 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, setPolling(false); }, [provider, onSuccess]); + // Trae/Windsurf proxy OAuth flow: dynamic-port local callback → auto exchange. + const startProxyFlow = useCallback(async (providerId) => { + // 1. Start the local callback server (returns a dynamic port + callback URL). + const startRes = await fetch(`/api/oauth/${providerId}/start-proxy`); + const startData = await startRes.json(); + if (!startRes.ok || !startData.success || !startData.callbackUrl) { + throw new Error(startData.reason || startData.error || `Failed to start ${providerId} callback server`); + } + // 2. Build the authorize URL with redirect_uri = proxy callback URL. + const authorizeUrl = new URL(`/api/oauth/${providerId}/authorize`, window.location.origin); + authorizeUrl.searchParams.set("redirect_uri", startData.callbackUrl); + const authRes = await fetch(authorizeUrl); + const authData = await authRes.json(); + if (!authRes.ok) throw new Error(authData.error); + // 3. Register the session so the proxy can match the incoming callback. + // Zed also passes code_verifier (encodes the RSA private key for decrypt); + // sent via POST body so the private key never lands in URL/query logs. + const regBody = { state: authData.state }; + if (authData.codeVerifier) regBody.codeVerifier = authData.codeVerifier; + await fetch(`/api/oauth/${providerId}/register-session`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(regBody), + }); + // 4. Open popup; proxy auto-exchanges on callback, modal polls poll-status. + setAuthData({ ...authData, proxyProvider: providerId }); + setStep("waiting"); + popupRef.current = window.open(authData.authUrl, "oauth_popup", "width=600,height=700"); + if (!popupRef.current) setStep("input"); // popup blocked → fall back to manual paste + }, []); + // Start OAuth flow const startOAuthFlow = useCallback(async () => { if (!provider) return; try { setError(null); + // Trae/Windsurf: proxy OAuth (browser mode) — handled by dedicated flow. + // Paste-token mode is handled by handleManualSubmit (no /authorize call). + if (PROXY_OAUTH_PROVIDERS.has(provider) && authMode === "browser") { + await startProxyFlow(provider); + return; + } + // Device code flow providers (must match oauth providers with flowType: "device_code") const deviceCodeProviders = [ "github", "qwen", "kiro", + "kimi", "kimi-coding", "kilocode", "codebuddy-cn", + "codebuddy-intl", "qoder", "grok-cli", ]; @@ -206,6 +275,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, @@ -326,7 +397,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, setError(err.message); setStep("error"); } - }, [provider, isLocalhost, startPolling, oauthMeta, idcConfig]); + }, [provider, isLocalhost, startPolling, oauthMeta, idcConfig, authMode, startProxyFlow]); // Reset state and start OAuth when modal opens useEffect(() => { @@ -340,7 +411,17 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, setIsDeviceCode(false); setDeviceData(null); setPolling(false); + setAuthMode("browser"); + setPasteToken(""); + setIdeStatus(null); pollingAbortRef.current = false; + // Best-effort IDE detection for paste-token providers (Trae/Windsurf) + if (PASTE_TOKEN_PROVIDERS[provider]) { + fetch(`/api/oauth/${provider}/ide-status`) + .then((r) => r.json()) + .then((data) => setIdeStatus(data)) + .catch(() => setIdeStatus({ installed: false, path: null })); + } startOAuthFlow(); } else if (!isOpen) { // Abort polling and cleanup proxy when modal closes @@ -350,13 +431,26 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, fetch("/api/oauth/codex/stop-proxy").catch(() => {}); } else if (provider === "xai") { fetch("/api/oauth/xai/stop-proxy").catch(() => {}); + } else if (provider === "trae") { + fetch("/api/oauth/trae/stop-proxy").catch(() => {}); + } else if (provider === "windsurf") { + fetch("/api/oauth/windsurf/stop-proxy").catch(() => {}); + } else if (provider === "zed") { + fetch("/api/oauth/zed/stop-proxy").catch(() => {}); } } }, [isOpen, provider, startOAuthFlow]); - // Fixed-port server-side mode: poll status (proxy auto-exchanges + saves DB) + // Server-side proxy mode (codex/xai fixed-port + trae/windsurf dynamic-port): + // poll status until the proxy auto-exchanges and saves the connection. useEffect(() => { - const pollProvider = authData?.codexServerSide ? "codex" : authData?.xaiServerSide ? "xai" : null; + const pollProvider = authData?.codexServerSide + ? "codex" + : authData?.xaiServerSide + ? "xai" + : authData?.proxyProvider + ? authData.proxyProvider + : null; if (!pollProvider || !authData?.state) return; if (callbackProcessedRef.current) return; let cancelled = false; @@ -484,8 +578,38 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, try { setError(null); + // Paste-token mode (Trae/Windsurf): token goes straight to /exchange + if (authMode === "paste-token" && PASTE_TOKEN_PROVIDERS[provider]) { + const token = pasteToken.trim(); + if (!token) throw new Error("Missing token"); + const res = await fetch(`/api/oauth/${provider}/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: token }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + setStep("success"); + onSuccess?.(); + return; + } + const input = callbackUrl.trim(); + // Trae/Windsurf proxy flow fallback (popup blocked): paste the full callback URL + if (PROXY_OAUTH_PROVIDERS.has(provider) && input) { + const res = await fetch(`/api/oauth/${provider}/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: input, state: authData?.state }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + setStep("success"); + onSuccess?.(); + return; + } + // Detect raw JWT access token (starts with eyJ) — skip URL parsing if (input.startsWith("eyJ") && input.includes(".")) { await exchangeTokens(input, null); @@ -535,6 +659,12 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, fetch("/api/oauth/codex/stop-proxy").catch(() => {}); } else if (provider === "xai") { fetch("/api/oauth/xai/stop-proxy").catch(() => {}); + } else if (provider === "trae") { + fetch("/api/oauth/trae/stop-proxy").catch(() => {}); + } else if (provider === "windsurf") { + fetch("/api/oauth/windsurf/stop-proxy").catch(() => {}); + } else if (provider === "zed") { + fetch("/api/oauth/zed/stop-proxy").catch(() => {}); } onClose(); }, [onClose, provider]); @@ -553,8 +683,82 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, return (
- {/* Waiting + Manual Input combined (non-device-code) */} - {(step === "waiting" || step === "input") && !isDeviceCode && ( + {/* Trae/Windsurf: browser OAuth (proxy) + paste-token fallback */} + {PROXY_OAUTH_PROVIDERS.has(provider) && (step === "waiting" || step === "input" || step === "error") && ( + <> +
+ + +
+ + {authMode === "browser" && ( + <> + {step === "waiting" && ( +
+ progress_activity + Waiting for browser authorization… +
+ )} + {step === "input" && ( +
+

+ Popup was blocked. After authorizing in the browser, paste the full callback URL here: +

+ setCallbackUrl(e.target.value)} + placeholder="http://127.0.0.1:.../callback?..." + className="font-mono text-xs" + /> +
+ + +
+
+ )} + + )} + + {authMode === "paste-token" && ( +
+ {ideStatus && !ideStatus.installed && ( +
+ {PASTE_TOKEN_PROVIDERS[provider].ideName} IDE not detected. + {PASTE_TOKEN_PROVIDERS[provider].ideOptional + ? " You can still grab the token from DevTools." + : ` Install ${PASTE_TOKEN_PROVIDERS[provider].ideName} IDE to get the token, or use "Sign in with browser".`} +
+ )} +

{PASTE_TOKEN_PROVIDERS[provider].instructions}

+ setPasteToken(e.target.value)} + placeholder={PASTE_TOKEN_PROVIDERS[provider].placeholder} + className="font-mono text-xs" + /> +
+ + +
+
+ )} + + )} + + {/* Waiting + Manual Input combined (non-device-code, non-proxy) */} + {(step === "waiting" || step === "input") && !isDeviceCode && !PROXY_OAUTH_PROVIDERS.has(provider) && ( <> {/* Option A: Auto via popup */}
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/components/Sidebar.js b/src/shared/components/Sidebar.js index ef4fcba8..cded1824 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -302,9 +302,26 @@ export default function Sidebar({ onClose }) { computer - Remote + 9Remote + {/* 9English */} + + + translate + + 9English + + {/* Settings */} { + 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/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index 0bedc1bf..383bdec9 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -221,7 +221,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re // Ensure real project ID is available for providers that need it (P0 fix: cold miss) if ((provider === "antigravity" || provider === "gemini-cli") && !refreshedCredentials.projectId) { - const pid = await getProjectIdForConnection(credentials.connectionId, refreshedCredentials.accessToken); + const pid = await getProjectIdForConnection(credentials.connectionId, refreshedCredentials.accessToken, provider); if (pid) { refreshedCredentials.projectId = pid; // Persist to DB in background so subsequent requests have it immediately diff --git a/src/sse/handlers/embeddings.js b/src/sse/handlers/embeddings.js index f6a945b5..ecbc97b2 100644 --- a/src/sse/handlers/embeddings.js +++ b/src/sse/handlers/embeddings.js @@ -12,6 +12,16 @@ import { errorResponse, unavailableResponse } from "open-sse/utils/error.js"; import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js"; import * as log from "../utils/logger.js"; import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js"; +import { saveRequestUsage } from "@/lib/usageDb.js"; + +function exactEmbeddingUsage(raw) { + if (!raw || typeof raw !== "object" || Array.isArray(raw) || raw.estimated === true) return null; + const promptTokens = raw.prompt_tokens ?? raw.input_tokens; + const completionTokens = raw.completion_tokens ?? raw.output_tokens ?? 0; + const totalTokens = raw.total_tokens; + if (!Number.isSafeInteger(promptTokens) || promptTokens <= 0 || completionTokens !== 0 || totalTokens !== promptTokens) return null; + return { prompt_tokens: promptTokens, completion_tokens: 0, total_tokens: totalTokens }; +} /** * Handle embeddings request for the SSE/Next.js server. @@ -127,7 +137,21 @@ export async function handleEmbeddings(request) { } }); - if (result.success) return result.response; + if (result.success) { + const usage = exactEmbeddingUsage(result.usage); + if (usage) { + saveRequestUsage({ + provider, + model, + connectionId: credentials.connectionId, + apiKey, + endpoint: url.pathname, + tokens: usage, + status: "success", + }).catch(() => {}); + } + return result.response; + } const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model); diff --git a/src/sse/handlers/fetch.js b/src/sse/handlers/fetch.js index db62a8c7..0005095c 100644 --- a/src/sse/handlers/fetch.js +++ b/src/sse/handlers/fetch.js @@ -195,13 +195,11 @@ async function handleSingleProviderFetch(body, providerInput, request, apiKey, s providerSpecificData: newCreds.providerSpecificData, testStatus: "active" }); - }, - onRequestSuccess: async () => { - await clearAccountError(credentials.connectionId, credentials); } }); if (result.success) { + await clearAccountError(credentials.connectionId, credentials); return new Response(JSON.stringify(result.data), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }); diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index 2fdfbda6..25112228 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -291,7 +291,13 @@ export async function clearAccountError(connectionId, currentConnection, model = // Only reset error state if no active locks remain if (remainingActiveLocks.length === 0) { - Object.assign(clearObj, { testStatus: "active", lastError: null, lastErrorAt: null, backoffLevel: 0 }); + Object.assign(clearObj, { + testStatus: "active", + lastError: null, + errorCode: null, + lastErrorAt: null, + backoffLevel: 0 + }); } await updateProviderConnection(connectionId, clearObj); diff --git a/tests/__baseline__/alias-baseline.json b/tests/__baseline__/alias-baseline.json index c104335e..ad667294 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", @@ -93,16 +93,44 @@ "polly": "aws-polly", "aws-polly": "aws-polly", "bb": "blackbox", - "blackbox": "blackbox" + "blackbox": "blackbox", + "af": "api-airforce", + "airforce": "api-airforce", + "api-airforce": "api-airforce", + "llm7": "llm7", + "llm-7": "llm7", + "samba": "sambanova", + "sambanova": "sambanova", + "bm": "bluesminds", + "bluesminds": "bluesminds", + "bzl": "bazaarlink", + "bazaarlink": "bazaarlink", + "kgw": "kilo-gateway", + "kilo-gateway": "kilo-gateway", + "hunyuan": "tencent", + "tencent": "tencent", + "qianfan": "baidu", + "baidu": "baidu", + "ernie": "baidu", + "dv": "devin-cli", + "devin": "devin-cli", + "devin-cli": "devin-cli", + "morph": "morph", + "morphllm": "morph" }, "idToAlias": { "alicode": "alicode", "alicode-intl": "alicode-intl", + "alims-intl": "alims-intl", "anthropic": "anthropic", "antigravity": "ag", + "api-airforce": "af", "assemblyai": "assemblyai", "azure": "azure", + "baidu": "qianfan", + "bazaarlink": "bzl", "blackbox": "blackbox", + "bluesminds": "bm", "byteplus": "byteplus", "cerebras": "cerebras", "chutes": "chutes", @@ -111,12 +139,15 @@ "clinepass": "clinepass", "cloudflare-ai": "cloudflare-ai", "codebuddy-cn": "cbcn", + "codebuddy-intl": "cbai", "codex": "cx", "cohere": "cohere", "commandcode": "commandcode", "cursor": "cu", "deepgram": "deepgram", "deepseek": "deepseek", + "devin-cli": "dv", + "featherless": "featherless", "fireworks": "fireworks", "gemini": "gemini", "gemini-cli": "gc", @@ -129,16 +160,18 @@ "groq": "groq", "hyperbolic": "hyperbolic", "iflow": "if", + "kilo-gateway": "kgw", "kilocode": "kc", "kimchi": "kimchi", "kimi": "kimi", - "kimi-coding": "kmc", "kiro": "kr", + "llm7": "llm7", "mimo-free": "mmf", "minimax": "minimax", "minimax-cn": "minimax-cn", "mistral": "mistral", "mmf": "mmf", + "morph": "morph", "nanobanana": "nanobanana", "nebius": "nebius", "nvidia": "nvidia", @@ -149,10 +182,13 @@ "opencode-go": "opencode-go", "openrouter": "openrouter", "perplexity": "perplexity", + "perplexity-agent": "perplexity-agent", "perplexity-web": "perplexity-web", "qoder": "qd", "qwen": "qw", + "sambanova": "samba", "siliconflow": "siliconflow", + "tencent": "hunyuan", "together": "together", "venice": "venice", "vercel-ai-gateway": "vercel-ai-gateway", @@ -161,17 +197,23 @@ "volcengine-ark": "volcengine-ark", "xai": "xai", "xiaomi-mimo": "xiaomi-mimo", - "xiaomi-tokenplan": "xiaomi-tokenplan" + "xiaomi-tokenplan": "xiaomi-tokenplan", + "zed": "zd" }, "modelKeys": [ + "af", "ag", "alicode", "alicode-intl", + "alims-intl", "anthropic", "assemblyai", "black-forest-labs", "blackbox", + "bm", "byteplus", + "bzl", + "cbai", "cbcn", "cc", "cerebras", @@ -185,9 +227,11 @@ "cx", "deepgram", "deepseek", + "dv", "edge-tts", "elevenlabs-tts-models", "fal-ai", + "featherless", "fireworks", "gc", "gcli", @@ -201,18 +245,21 @@ "grok-web", "groq", "huggingface", + "hunyuan", "hyperbolic", "if", "kc", + "kgw", "kimchi", "kimi", - "kmc", "kr", + "llm7", "local-device", "minimax", "minimax-cn", "mistral", "mmf", + "morph", "nanobanana", "nebius", "nvidia", @@ -226,11 +273,14 @@ "openrouter-tts-models", "openrouter-tts-voices", "perplexity", + "perplexity-agent", "perplexity-web", "qd", + "qianfan", "qw", "recraft", "runwayml", + "samba", "sdwebui", "siliconflow", "stability-ai", @@ -242,6 +292,7 @@ "voyage-ai", "xai", "xiaomi-mimo", - "xiaomi-tokenplan" + "xiaomi-tokenplan", + "zd" ] } \ No newline at end of file diff --git a/tests/__baseline__/known-fails.txt b/tests/__baseline__/known-fails.txt index 9ffd63f9..49fd6a11 100644 --- a/tests/__baseline__/known-fails.txt +++ b/tests/__baseline__/known-fails.txt @@ -1,4 +1,3 @@ -tests/unit/antigravity-mitm.test.js :: Antigravity MITM model handling flags the out-of-box agent/Default model mandatory tests/unit/claude-header-forwarding.test.js :: proxyAwareFetch — api.anthropic.com routing routes api.anthropic.com to gotScraping (non-streaming) and returns ok response tests/unit/oauth-cursor-auto-import.test.js :: GET /api/oauth/cursor/auto-import extracts tokens using exact keys tests/unit/oauth-cursor-auto-import.test.js :: GET /api/oauth/cursor/auto-import falls back to fuzzy key matching on macOS when exact keys are missing 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..4ceb52a6 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -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://daily-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,103 @@ } } ] + }, + "alims-intl": { + "baseUrl": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", + "headers": {}, + "quirks": { + "preserveCacheControl": true + }, + "format": "openai" + }, + "codebuddy-intl": { + "baseUrl": "https://www.codebuddy.ai/v2/chat/completions", + "forceStream": true, + "thinkingFormat": "openai", + "headers": { + "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1", + "X-Product": "SaaS", + "X-IDE-Type": "IDE", + "X-IDE-Name": "IDE", + "x-requested-with": "XMLHttpRequest", + "x-codebuddy-request": "1" + }, + "auth": { + "combined": true, + "header": "Authorization", + "scheme": "bearer" + }, + "format": "openai", + "tokenUrl": "https://www.codebuddy.ai/v2/plugin/auth/token" + }, + "zed": { + "baseUrl": "https://cloud.zed.dev/completions", + "format": "openai", + "forceStream": true, + "headers": { + "content-type": "application/json" + }, + "auth": { + "combined": true, + "header": "Authorization", + "scheme": " " + }, + "usage": { + "url": "https://cloud.zed.dev/client/users/me" + }, + "modelsUrl": "https://cloud.zed.dev/models" + }, + "api-airforce": { + "baseUrl": "https://api.airforce/v1/chat/completions", + "validateUrl": "https://api.airforce/v1/models", + "headers": { + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy" + }, + "format": "openai" + }, + "baidu": { + "baseUrl": "https://qianfan.baidubce.com/v2/chat/completions", + "validateUrl": "https://qianfan.baidubce.com/v2/models", + "format": "openai" + }, + "bazaarlink": { + "baseUrl": "https://bazaarlink.ai/api/v1/chat/completions", + "validateUrl": "https://bazaarlink.ai/api/v1/models", + "format": "openai" + }, + "bluesminds": { + "baseUrl": "https://api.bluesminds.com/v1/chat/completions", + "validateUrl": "https://api.bluesminds.com/v1/models", + "format": "openai" + }, + "kilo-gateway": { + "baseUrl": "https://api.kilo.ai/api/gateway/chat/completions", + "validateUrl": "https://api.kilo.ai/api/gateway/models", + "format": "openai" + }, + "llm7": { + "baseUrl": "https://api.llm7.io/v1/chat/completions", + "validateUrl": "https://api.llm7.io/v1/models", + "format": "openai" + }, + "sambanova": { + "baseUrl": "https://api.sambanova.ai/v1/chat/completions", + "validateUrl": "https://api.sambanova.ai/v1/models", + "format": "openai" + }, + "tencent": { + "baseUrl": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions", + "validateUrl": "https://api.hunyuan.cloud.tencent.com/v1/models", + "format": "openai" + }, + "morph": { + "baseUrl": "https://api.morphllm.com/v1/chat/completions", + "validateUrl": "https://api.morphllm.com/v1/models", + "format": "openai" + }, + "devin-cli": { + "baseUrl": "devin://acp/stdio", + "format": "openai" } } \ No newline at end of file diff --git a/tests/__baseline__/verify-alias.mjs b/tests/__baseline__/verify-alias.mjs index 56caca53..a3fc1f91 100644 --- a/tests/__baseline__/verify-alias.mjs +++ b/tests/__baseline__/verify-alias.mjs @@ -20,6 +20,9 @@ const ALIAS_TOKENS = [ "xmtp","xiaomi-tokenplan","cf", "cloudflare-ai","fal","fal-ai","stability","stability-ai","bfl","black-forest-labs","recraft", "topaz","runway","runwayml","jina","jina-ai","polly","aws-polly","bb","blackbox", + "af","airforce","api-airforce","llm7","llm-7","samba","sambanova","bm","bluesminds", + "bzl","bazaarlink","kgw","kilo-gateway","hunyuan","tencent","qianfan","baidu","ernie", + "dv","devin","devin-cli","morph","morphllm", ]; // Sort idToAlias by key — runtime accesses by key, order is irrelevant (content-based) 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..3fca7be1 100644 --- a/tests/translator/claude-kiro-direct.test.js +++ b/tests/translator/claude-kiro-direct.test.js @@ -98,6 +98,18 @@ describe("Claude → Kiro (direct route)", () => { expect(out.systemPrompt).toContain("24576"); }); + it("normalizes an unsupported Kiro intensity suffix while preserving agentic behavior", () => { + const out = C2K( + { messages: [{ role: "user", content: "hello" }] }, + null, + "claude-sonnet-4.5-thinking-agentic(high)", + ); + + expect(out.conversationState.currentMessage.userInputMessage.modelId).toBe("claude-sonnet-4.5"); + expect(out.additionalModelRequestFields).toBeUndefined(); + expect(out.systemPrompt).toContain("CHUNKED WRITE PROTOCOL"); + }); + it("maps output_config.effort high to Kiro CLI-style additionalModelRequestFields for effort models", () => { const out = C2K({ output_config: { effort: "high" }, @@ -112,6 +124,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/antigravity-retry-hook.test.js b/tests/unit/antigravity-retry-hook.test.js index 989dd88f..adb8bd25 100644 --- a/tests/unit/antigravity-retry-hook.test.js +++ b/tests/unit/antigravity-retry-hook.test.js @@ -67,8 +67,8 @@ describe("antigravity computeRetryDelay hook (D3)", () => { expect(out.request.tools[0].functionDeclarations.map(fn => fn.name)).toEqual(["read_file"]); }); - it("registry uses the official IDE cloudcode host and user agent", () => { - expect(antigravity.transport.baseUrls).toEqual(["https://cloudcode-pa.googleapis.com"]); + it("registry uses the daily IDE cloudcode host and user agent", () => { + expect(antigravity.transport.baseUrls).toEqual(["https://daily-cloudcode-pa.googleapis.com"]); expect(antigravity.transport.headers["User-Agent"]).toBe("antigravity/ide/2.1.1 darwin/arm64"); }); diff --git a/tests/unit/antigravity-stream-options.test.js b/tests/unit/antigravity-stream-options.test.js new file mode 100644 index 00000000..47da8769 --- /dev/null +++ b/tests/unit/antigravity-stream-options.test.js @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js"; + +const credentials = { + projectId: "synthetic-project", + connectionId: "synthetic-connection", +}; + +function requestBody(stream) { + return { + stream, + stream_options: { include_usage: true }, + request: { + contents: [{ role: "user", parts: [{ text: "Reply only OK" }] }], + }, + }; +} + +describe("AntigravityExecutor stream_options normalization", () => { + it("removes stream_options from a non-streaming request", () => { + const executor = new AntigravityExecutor(); + const output = executor.transformRequest( + "gpt-oss-120b-medium", + requestBody(false), + false, + credentials, + ); + + expect(output.stream).toBe(false); + expect(output.stream_options).toBeUndefined(); + }); + + it("preserves stream_options for a streaming request", () => { + const executor = new AntigravityExecutor(); + const output = executor.transformRequest( + "gpt-oss-120b-medium", + requestBody(true), + true, + credentials, + ); + + expect(output.stream).toBe(true); + expect(output.stream_options).toEqual({ include_usage: true }); + }); +}); diff --git a/tests/unit/capabilities-opus-context.test.js b/tests/unit/capabilities-opus-context.test.js index 9bb7518f..2ef9f956 100644 --- a/tests/unit/capabilities-opus-context.test.js +++ b/tests/unit/capabilities-opus-context.test.js @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js"; // Claude Opus 4.6+ ships a 1M-token context window (GA, standard pricing). -// The registry exposes dashed ids (claude-opus-4-8, claude-opus-4-7), which +// The registry exposes dashed ids (claude-opus-5, claude-opus-4-8, claude-opus-4-7), which // must resolve to the 1M context + adaptive thinking caps rather than falling // through to the generic *claude*opus* pattern (200k / budget thinking). describe("Claude Opus 1M context capabilities", () => { @@ -17,6 +17,10 @@ describe("Claude Opus 1M context capabilities", () => { }; for (const model of [ + "claude-opus-5", + "claude-opus-5-thinking", + "claude-opus-5-agentic", + "claude-opus-5-thinking-agentic", "claude-opus-4-8", "claude-opus-4.8", "claude-opus-4-7", diff --git a/tests/unit/capabilities.test.js b/tests/unit/capabilities.test.js index ccfddfa5..a5c7b03d 100644 --- a/tests/unit/capabilities.test.js +++ b/tests/unit/capabilities.test.js @@ -20,6 +20,18 @@ describe("getCapabilitiesForModel", () => { search: true, }; + it("reports Kiro Claude Opus 5 variants as 1M adaptive-thinking models", () => { + for (const model of [ + "claude-opus-5", + "anthropic/claude-opus-5", + "claude-opus-5-thinking", + "claude-opus-5-agentic", + "claude-opus-5-thinking-agentic", + ]) { + expect(getCapabilitiesForModel("kiro", model)).toMatchObject(claudeSonnet5Expected); + } + }); + it("reports Kiro Claude Opus 4.8 as a 1M context model", () => { expect(getCapabilitiesForModel("kiro", "claude-opus-4.8").contextWindow).toBe(1000000); expect(getCapabilitiesForModel("kiro", "anthropic/claude-opus-4.8").contextWindow).toBe(1000000); 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-exec-request.test.js b/tests/unit/cursor-agent-exec-request.test.js new file mode 100644 index 00000000..347e159f --- /dev/null +++ b/tests/unit/cursor-agent-exec-request.test.js @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest"; + +import { CursorExecutor } from "../../open-sse/executors/cursor.js"; +import { encodeField, wrapConnectRPCFrame } from "../../open-sse/utils/cursorProtobuf.js"; + +const LEN = 2; + +// agent.v1.AgentServerMessage.exec_request (field 2) carrying one ExecServerMessage variant. +function execRequestFrame(execField) { + const execServerMessage = Buffer.from(encodeField(execField, LEN, new Uint8Array())); + return Buffer.from(wrapConnectRPCFrame(encodeField(2, LEN, execServerMessage))); +} + +// agent.v1.AgentServerMessage.interaction_update (field 1) → text delta. +function textFrame(text) { + const textPart = Buffer.from(encodeField(1, LEN, text)); + const update = Buffer.from(encodeField(1, LEN, textPart)); + return Buffer.from(wrapConnectRPCFrame(encodeField(1, LEN, update))); +} + +function stubAgentSession(executor, frames) { + const written = []; + const queue = [...frames]; + executor.openAgentHttp2Stream = () => ({ + responseHeaders: Promise.resolve({ ":status": 200 }), + write: (frame) => written.push(Buffer.from(frame)), + end() {}, + close() {}, + async read() { + if (!queue.length) return { value: undefined, done: true }; + return { value: queue.shift(), done: false }; + }, + }); + return written; +} + +const credentials = { + accessToken: "test-token", + providerSpecificData: { machineId: "a".repeat(64) }, +}; + +function parseSSE(text) { + return text + .split("\n\n") + .filter((chunk) => chunk.startsWith("data: ")) + .map((chunk) => chunk.slice("data: ".length)) + .filter((data) => data !== "[DONE]") + .map((data) => JSON.parse(data)); +} + +async function runAgent({ frames, stream }) { + const executor = new CursorExecutor(); + const written = stubAgentSession(executor, frames); + const result = await executor.executeAgent({ + model: "gpt-5.2", + body: { messages: [{ role: "user", content: "hi" }] }, + stream, + credentials, + }); + return { result, written }; +} + +describe("CursorExecutor AgentService exec_request handling", () => { + it("acknowledges a request-context exec request without ending the turn", async () => { + const { result, written } = await runAgent({ + frames: [execRequestFrame(10), textFrame("hello")], + stream: true, + }); + + expect(written.length).toBe(2); // run frame + request-context reply + const events = parseSSE(await result.response.text()); + const content = events.map((e) => e.choices?.[0]?.delta?.content || "").join(""); + expect(content).toBe("hello"); + }); + + it("does not render an unsupported exec request as assistant content", async () => { + const { result } = await runAgent({ + frames: [textFrame("partial answer"), execRequestFrame(2)], + stream: true, + }); + + const body = await result.response.text(); + expect(body).not.toContain("unsupported IDE tool\\n"); + const events = parseSSE(body); + const content = events.map((e) => e.choices?.[0]?.delta?.content || "").join(""); + expect(content).toBe("partial answer"); + + const errorEvent = events.find((e) => e.error); + expect(errorEvent?.error?.message).toContain("unsupported IDE tool"); + expect(events.some((e) => e.choices?.[0]?.finish_reason === "stop")).toBe(false); + }); + + it("drops frames batched behind an unsupported exec request in the same read", async () => { + const { result } = await runAgent({ + frames: [Buffer.concat([execRequestFrame(2), textFrame("late")])], + stream: true, + }); + + const body = await result.response.text(); + expect(body).toContain("unsupported IDE tool"); + expect(body).not.toContain("late"); + }); + + it("returns a non-200 error body for an unsupported exec request when not streaming", async () => { + const { result } = await runAgent({ + frames: [execRequestFrame(11)], + stream: false, + }); + + expect(result.response.status).not.toBe(200); + const payload = await result.response.json(); + expect(payload.error.message).toContain("unsupported IDE tool"); + }); +}); 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/deepseek-usage.test.js b/tests/unit/deepseek-usage.test.js new file mode 100644 index 00000000..0be26edc --- /dev/null +++ b/tests/unit/deepseek-usage.test.js @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: vi.fn(), +})); + +import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js"; +import { getUsageForProvider } from "../../open-sse/services/usage.js"; +import { + USAGE_SUPPORTED_PROVIDERS, + USAGE_APIKEY_PROVIDERS, +} from "../../src/shared/constants/providers.js"; +import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js"; + +const BALANCE_URL = "https://api.deepseek.com/user/balance"; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const ACTIVE_BALANCE = { + is_available: true, + balance_infos: [ + { + currency: "USD", + total_balance: "12.50", + granted_balance: "2.50", + topped_up_balance: "10.00", + }, + { + currency: "CNY", + total_balance: "0.00", + granted_balance: "0.00", + topped_up_balance: "0.00", + }, + ], +}; + +describe("deepseek registry usage flags", () => { + it("is listed for apikey quota dashboard", () => { + expect(USAGE_SUPPORTED_PROVIDERS).toContain("deepseek"); + expect(USAGE_APIKEY_PROVIDERS).toContain("deepseek"); + }); +}); + +describe("getUsageForProvider(deepseek)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("GETs /user/balance with Bearer apiKey", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_BALANCE)); + + const usage = await getUsageForProvider({ + provider: "deepseek", + apiKey: "sk-ds-test", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.plan).toBe("DeepSeek"); + expect(proxyAwareFetch).toHaveBeenCalledTimes(1); + const [url, opts] = proxyAwareFetch.mock.calls[0]; + expect(url).toBe(BALANCE_URL); + expect(opts.method).toBe("GET"); + expect(opts.headers.Authorization).toBe("Bearer sk-ds-test"); + }); + + it("maps balances without absolute remaining (UI treats remaining as %)", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_BALANCE)); + + const usage = await getUsageForProvider({ + provider: "deepseek", + apiKey: "sk-ds-test", + }); + + expect(usage.quotas["Balance (USD)"]).toMatchObject({ + used: 0, + total: 12.5, + remainingPercentage: 100, + }); + expect(usage.quotas["Balance (USD)"].remaining).toBeUndefined(); + // Zero CNY still listed so user sees currency row + expect(usage.quotas["Balance (CNY)"]).toMatchObject({ + used: 0, + total: 0, + remainingPercentage: 0, + }); + }); + + it("marks plan unavailable when is_available false", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ + is_available: false, + balance_infos: [ + { + currency: "USD", + total_balance: "0", + granted_balance: "0", + topped_up_balance: "0", + }, + ], + }), + ); + + const usage = await getUsageForProvider({ + provider: "deepseek", + apiKey: "sk-ds-test", + }); + + expect(usage.plan).toMatch(/insufficient|unavailable/i); + expect(usage.quotas["Balance (USD)"].remainingPercentage).toBe(0); + }); + + it("returns message on missing key / 401", async () => { + const missing = await getUsageForProvider({ provider: "deepseek" }); + expect(missing.message).toMatch(/api key/i); + expect(proxyAwareFetch).not.toHaveBeenCalled(); + + proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "no" }, 401)); + const auth = await getUsageForProvider({ + provider: "deepseek", + apiKey: "bad", + }); + expect(auth.message).toMatch(/auth|key|401/i); + }); +}); + +describe("parseQuotaData(deepseek)", () => { + it("forwards remainingPercentage for balance rows", () => { + const rows = parseQuotaData("deepseek", { + plan: "DeepSeek", + quotas: { + "Balance (USD)": { + used: 0, + total: 12.5, + remainingPercentage: 100, + }, + }, + }); + expect(rows[0]).toMatchObject({ + name: "Balance (USD)", + total: 12.5, + remainingPercentage: 100, + }); + }); +}); diff --git a/tests/unit/devin-cli-executor.test.js b/tests/unit/devin-cli-executor.test.js new file mode 100644 index 00000000..5fe37a4a --- /dev/null +++ b/tests/unit/devin-cli-executor.test.js @@ -0,0 +1,437 @@ +import { describe, it, expect, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import os from "node:os"; + +// `vi.hoisted` runs before the mocked module is evaluated, so the factory can +// safely reference the mock fn. +const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })); + +vi.mock("node:child_process", () => ({ + spawn: (...args) => spawnMock(...args), +})); + +const { default: DevinCliExecutor } = await import("open-sse/executors/devin-cli.js"); + +// Fake devin ACP subprocess. Mirrors the real CLI's session/new validation: +// it requires `mcpServers` to be an array, otherwise returns -32602 — this is +// the exact error the dashboard "test" button hit ("Invalid params"). +function makeFakeChild() { + const child = new EventEmitter(); + child.writes = []; + child.stdin = new EventEmitter(); + child.stdin.destroyed = false; + child.stdin.write = (data) => { + child.writes.push(String(data)); + try { + const msg = JSON.parse(String(data).trim()); + handle(msg); + } catch { + /* ignore */ + } + return true; + }; + child.stdin.end = () => { + child.stdin.destroyed = true; + }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.killed = false; + child.kill = () => { + child.killed = true; + }; + + const send = (obj) => + child.stdout.emit("data", Buffer.from(JSON.stringify(obj) + "\n")); + + function handle(msg) { + if (msg.method === "initialize") { + send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } }); + } else if (msg.method === "session/new") { + // Mirror devin 3000.2.x: `mcpServers` is a required sequence. + if (Array.isArray(msg.params && msg.params.mcpServers)) { + send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "fake-session" } }); + } else if (!msg.params || msg.params.mcpServers === undefined) { + send({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32602, message: "Invalid params", data: { error: "missing field `mcpServers`" } }, + }); + } else { + send({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32602, message: "Invalid params", data: { error: "invalid type: map, expected a sequence" } }, + }); + } + } else if (msg.method === "session/prompt") { + // devin 3000.2.x requires `prompt` (a sequence), not `content`. + if (Array.isArray(msg.params && msg.params.prompt)) { + // Agent requests permission to run a tool before replying. + send({ + jsonrpc: "2.0", + id: 777, + method: "session/request_permission", + params: { + sessionId: "fake-session", + options: [ + { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + ], + }, + }); + // New ACP shape: streaming via session/update with params.update.sessionUpdate. + send({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "fake-session", update: { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "(thinking)" } } }, + }); + send({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "fake-session", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "hello world" } } }, + }); + // Stop signal: _cognition.ai/agent_stopped notification. + send({ jsonrpc: "2.0", method: "_cognition.ai/agent_stopped", params: { cause: "complete" } }); + } else { + send({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32602, message: "Invalid params", data: { error: "missing field `prompt`" } }, + }); + } + } + } + + return child; +} + +async function runExecute(credentials = {}) { + const child = makeFakeChild(); + spawnMock.mockImplementation((bin, args, opts) => { + child.bin = bin; + child.args = args; + child.opts = opts; + return child; + }); + const exec = new DevinCliExecutor(); + const { response } = await exec.execute({ + model: "swe-1.6-fast", + body: { messages: [{ role: "user", content: "hi" }] }, + credentials, + log: { info() {}, debug() {} }, + }); + const reader = response.body.getReader(); + let acc = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + acc += new TextDecoder().decode(value); + } + return { acc, child }; +} + +describe("DevinCliExecutor ACP session/new", () => { + it("sends session/new with mcpServers as an array", async () => { + const { child } = await runExecute(); + const writes = child.writes.map((w) => JSON.parse(w.trim())); + const newMsg = writes.find((m) => m.method === "session/new"); + expect(newMsg).toBeTruthy(); + expect(Array.isArray(newMsg.params.mcpServers)).toBe(true); + }); + + it("defaults session/new cwd to os.tmpdir when request has no workspace cwd", async () => { + const { child } = await runExecute(); + const writes = child.writes.map((w) => JSON.parse(w.trim())); + const newMsg = writes.find((m) => m.method === "session/new"); + expect(newMsg.params.cwd).toBe(os.tmpdir()); + }); + + it("uses client env context for session/new and spawn", async () => { + const child = makeFakeChild(); + spawnMock.mockImplementation((bin, args, opts) => { + child.args = args; + child.opts = opts; + return child; + }); + const workspace = os.tmpdir(); // known existing absolute dir + const exec = new DevinCliExecutor(); + const { response } = await exec.execute({ + model: "swe-1.6-fast", + body: { + messages: [ + { + role: "user", + content: `\n ${workspace}\n\nhi`, + }, + ], + }, + credentials: {}, + log: { info() {}, debug() {} }, + }); + const reader = response.body.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } + expect(child.opts.cwd).toBe(workspace); + const writes = child.writes.map((w) => JSON.parse(w.trim())); + const newMsg = writes.find((m) => m.method === "session/new"); + expect(newMsg.params.cwd).toBe(workspace); + }); + + it("sends session/prompt with prompt (not content) as an array", async () => { + const { child } = await runExecute(); + const writes = child.writes.map((w) => JSON.parse(w.trim())); + const promptMsg = writes.find((m) => m.method === "session/prompt"); + expect(promptMsg).toBeTruthy(); + expect(Array.isArray(promptMsg.params.prompt)).toBe(true); + expect(promptMsg.params.content).toBeUndefined(); + }); + + it("completes the prompt without a -32602 Invalid params error", async () => { + const { acc } = await runExecute(); + expect(acc).not.toContain("-32602"); + expect(acc).not.toContain("Invalid params"); + expect(acc.toLowerCase()).toContain("hello world"); + }); + + it("emits agent_message_chunk content and skips agent_thought_chunk", async () => { + // devin 3000.2.x streams via params.update.sessionUpdate. + const { acc } = await runExecute(); + // Reply text is delivered, finish chunk present, thinking is not surfaced. + expect(acc.toLowerCase()).toContain("hello world"); + expect(acc).toContain("finish_reason"); + expect(acc.toLowerCase()).not.toContain("(thinking)"); + expect(acc).toContain("[DONE]"); + }); + + it("spawns the default agent (with built-in tools) by default", async () => { + const { child } = await runExecute(); + expect(child.args).toEqual(["acp"]); + }); + + it("seeds MCP with tool_result from prior client round-trip", async () => { + const fs = await import("node:fs"); + const child = makeFakeChild(); + let capturedCfg = null; + let capturedPrompt = null; + spawnMock.mockImplementation((bin, args, opts) => { + child.args = args; + child.opts = opts; + // Capture config at spawn time (finish() cleans the temp dir). + if (opts?.env?.XDG_CONFIG_HOME) { + capturedCfg = JSON.parse( + fs.readFileSync(opts.env.XDG_CONFIG_HOME + "/devin/config.json", "utf8") + ); + } + return child; + }); + const origWrite = child.stdin.write; + child.stdin.write = (data) => { + const s = String(data); + try { + const msg = JSON.parse(s.trim()); + if (msg.method === "session/prompt") { + capturedPrompt = msg.params.prompt[0].text; + } + } catch { + /* ignore */ + } + return origWrite.call(child.stdin, data); + }; + const exec = new DevinCliExecutor(); + const { response } = await exec.execute({ + model: "swe-1.6-fast", + body: { + messages: [ + { role: "user", content: "weather?" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "get_weather", arguments: '{"city":"Paris"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: "28C sunny" }, + ], + tools: [ + { + type: "function", + function: { + name: "get_weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, + ], + }, + credentials: {}, + log: { info() {}, debug() {} }, + }); + const reader = response.body.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } + expect(capturedCfg).toBeTruthy(); + const results = JSON.parse(capturedCfg.mcpServers.clientTools.env.DEVIN_MCP_RESULTS); + expect(results.mcp_get_weather).toBe("28C sunny"); + expect(capturedPrompt).toContain("get_weather"); + expect(capturedPrompt).toContain("28C sunny"); + }); + + it("bridges a client-tool MCP call to an OpenAI tool_use", async () => { + // Custom fake: on session/prompt, report devin calling our exposed MCP tool. + const child = new EventEmitter(); + child.writes = []; + child.stdin = new EventEmitter(); + child.stdin.destroyed = false; + child.stdin.write = (data) => { child.writes.push(String(data)); handle(JSON.parse(String(data).trim())); return true; }; + child.stdin.end = () => { child.stdin.destroyed = true; }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.killed = false; + child.kill = () => { child.killed = true; }; + child.args = ["acp"]; + child.opts = { env: {} }; + spawnMock.mockReturnValue(child); + const send = (o) => child.stdout.emit("data", Buffer.from(JSON.stringify(o) + "\n")); + function handle(msg) { + if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } }); + else if (msg.method === "session/new") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "s1" } }); + else if (msg.method === "session/prompt") { + // Mirror real ACP: title on first event, rawInput on a later update. + send({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "s1", update: { sessionUpdate: "tool_call", toolCallId: "call_abc", title: "Calling mcp_get_weather from clientTools" } }, + }); + send({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "s1", update: { sessionUpdate: "tool_call_update", toolCallId: "call_abc", rawInput: { city: "Paris" } } }, + }); + } + } + + const exec = new DevinCliExecutor(); + const { response } = await exec.execute({ + model: "swe-1.6-fast", + body: { + messages: [{ role: "user", content: "weather?" }], + tools: [{ type: "function", function: { name: "get_weather", parameters: { type: "object" } } }], + }, + credentials: {}, + log: { info() {}, debug() {} }, + }); + const reader = response.body.getReader(); + let acc = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + acc += new TextDecoder().decode(value); + if (acc.includes("[DONE]")) break; + } + const tc = JSON.parse(acc.match(/"tool_calls":\[(\{.*?\})\]/)?.[1] ?? "{}"); + expect(tc.function.name).toBe("get_weather"); // mcp_ prefix stripped, MCP-real untouched + expect(tc.id).toBe("call_abc"); + expect(JSON.parse(tc.function.arguments).city).toBe("Paris"); + expect(acc).toContain('"finish_reason":"tool_calls"'); + expect(acc).toContain("[DONE]"); + }); + + it("overrides the agent type via CLI_DEVIN_AGENT_TYPE", async () => { + process.env.CLI_DEVIN_AGENT_TYPE = "summarizer"; + try { + const { child } = await runExecute(); + expect(child.args).toEqual(["acp", "--agent-type", "summarizer"]); + } finally { + delete process.env.CLI_DEVIN_AGENT_TYPE; + } + }); + + it("sets DEVIN_PERMISSION_MODE=bypass so tool calls don't hang on permission prompts", async () => { + const { child } = await runExecute(); + expect(child.opts.env.DEVIN_PERMISSION_MODE).toBe("bypass"); + }); + + it("does not inject WINDSURF_API_KEY — devin-cli uses stored CLI creds (devin auth login)", async () => { + // Provider is noAuth; devin must fall back to ~/.local/share/devin/credentials.toml. + // Injecting a bogus WINDSURF_API_KEY makes devin reject stored creds → -32000. + const { child } = await runExecute({ accessToken: "bogus-token", apiKey: "bogus-key" }); + expect(child.opts.env.WINDSURF_API_KEY).toBeUndefined(); + }); + + it("respects an explicit DEVIN_PERMISSION_MODE override", async () => { + process.env.DEVIN_PERMISSION_MODE = "accept-edits"; + try { + const { child } = await runExecute(); + expect(child.opts.env.DEVIN_PERMISSION_MODE).toBe("accept-edits"); + } finally { + delete process.env.DEVIN_PERMISSION_MODE; + } + }); + + it("auto-approves session/request_permission with the first allow option", async () => { + const { child } = await runExecute(); + const writes = child.writes.map((w) => JSON.parse(w.trim())); + const resp = writes.find((m) => m.id === 777 && m.result); + expect(resp).toBeTruthy(); + expect(resp.result.outcome.outcome).toBe("selected"); + expect(resp.result.outcome.optionId).toBe("allow-once"); + }); + + it("sets XDG_CONFIG_HOME when DEVIN_MCP_SERVERS is provided", async () => { + process.env.DEVIN_MCP_SERVERS = JSON.stringify({ + echo: { command: "/usr/bin/node", args: ["/srv/echo.js"] }, + }); + try { + const { child } = await runExecute(); + expect(child.opts.env.XDG_CONFIG_HOME).toBeTruthy(); + // devin reads $XDG_CONFIG_HOME/devin/config.json (E2E verifies content). + } finally { + delete process.env.DEVIN_MCP_SERVERS; + } + }); + + it("does not set XDG_CONFIG_HOME when DEVIN_MCP_SERVERS is absent", async () => { + const { child } = await runExecute(); + expect(child.opts.env.XDG_CONFIG_HOME).toBeUndefined(); + }); + + it("exposes body.tools as an MCP server (sets XDG_CONFIG_HOME + writes script)", async () => { + const fs = await import("node:fs"); + const os = await import("node:os"); + const path = await import("node:path"); + const child = makeFakeChild(); + spawnMock.mockImplementation((bin, args, opts) => { + child.args = args; + child.opts = opts; + return child; + }); + const exec = new DevinCliExecutor(); + const { response } = await exec.execute({ + model: "swe-1.6-fast", + body: { + messages: [{ role: "user", content: "weather?" }], + tools: [ + { type: "function", function: { name: "get_weather", description: "Get weather", parameters: { type: "object", properties: { city: { type: "string" } } } } }, + ], + }, + credentials: {}, + log: { info() {}, debug() {} }, + }); + const reader = response.body.getReader(); + await reader.read(); + // XDG_CONFIG_HOME set so devin loads the generated config. + expect(child.opts.env.XDG_CONFIG_HOME).toBeTruthy(); + // Static MCP bridge script written to disk. + const scriptPath = path.join(os.tmpdir(), "9router-devin-client-tools.mjs"); + expect(fs.existsSync(scriptPath)).toBe(true); + expect(fs.readFileSync(scriptPath, "utf8")).toContain("clientTools"); + expect(fs.readFileSync(scriptPath, "utf8")).toContain("DEVIN_MCP_TOOLS"); + }); +}); diff --git a/tests/unit/embedding-usage-persistence.test.js b/tests/unit/embedding-usage-persistence.test.js new file mode 100644 index 00000000..9fcef0d7 --- /dev/null +++ b/tests/unit/embedding-usage-persistence.test.js @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + handleEmbeddingsCore: vi.fn(), + saveRequestUsage: vi.fn(), +})); + +vi.mock("../../src/sse/services/auth.js", () => ({ + getProviderCredentials: async () => ({ + apiKey: "provider-secret", + connectionId: "connection-a", + connectionName: "Provider A", + }), + markAccountUnavailable: vi.fn(), + clearAccountError: vi.fn(), + extractApiKey: () => "client-key", + isValidApiKey: vi.fn(), +})); +vi.mock("@/lib/localDb", () => ({ getSettings: async () => ({ requireApiKey: false }) })); +vi.mock("../../src/sse/services/model.js", () => ({ + getModelInfo: async () => ({ provider: "openai", model: "text-embedding-3-small" }), +})); +vi.mock("../../open-sse/handlers/embeddingsCore.js", () => ({ + handleEmbeddingsCore: mocks.handleEmbeddingsCore, +})); +vi.mock("../../open-sse/utils/error.js", () => ({ + errorResponse: (status, message) => Response.json({ error: message }, { status }), + unavailableResponse: (status, message) => Response.json({ error: message }, { status }), +})); +vi.mock("../../src/sse/utils/logger.js", () => ({ + request: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn(), maskKey: vi.fn(), +})); +vi.mock("../../src/sse/services/tokenRefresh.js", () => ({ + updateProviderCredentials: vi.fn(), + checkAndRefreshToken: async (_provider, credentials) => credentials, +})); +vi.mock("@/lib/usageDb.js", () => ({ saveRequestUsage: mocks.saveRequestUsage })); + +import { handleEmbeddings } from "../../src/sse/handlers/embeddings.js"; + +describe("embedding usage persistence", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.saveRequestUsage.mockResolvedValue(undefined); + mocks.handleEmbeddingsCore.mockResolvedValue({ + success: true, + usage: { prompt_tokens: 12, total_tokens: 12 }, + response: Response.json({ data: [] }), + }); + }); + + it("records exact provider usage for successful embedding requests", async () => { + await handleEmbeddings(new Request("http://localhost/v1/embeddings", { + method: "POST", + body: JSON.stringify({ model: "openai/text-embedding-3-small", input: "hello" }), + })); + + expect(mocks.saveRequestUsage).toHaveBeenCalledWith(expect.objectContaining({ + provider: "openai", + model: "text-embedding-3-small", + connectionId: "connection-a", + apiKey: "client-key", + endpoint: "/v1/embeddings", + status: "success", + tokens: { prompt_tokens: 12, completion_tokens: 0, total_tokens: 12 }, + })); + }); + + it.each([ + null, + {}, + { prompt_tokens: 0, total_tokens: 0 }, + { prompt_tokens: "12", total_tokens: 12 }, + { prompt_tokens: 12, total_tokens: 13 }, + { prompt_tokens: 12, completion_tokens: 1, total_tokens: 12 }, + { prompt_tokens: 12, total_tokens: 12, estimated: true }, + ])("does not record inexact usage %#", async (usage) => { + mocks.handleEmbeddingsCore.mockResolvedValue({ + success: true, + usage, + response: Response.json({ data: [] }), + }); + + await handleEmbeddings(new Request("http://localhost/v1/embeddings", { + method: "POST", + body: JSON.stringify({ model: "openai/text-embedding-3-small", input: "hello" }), + })); + + expect(mocks.saveRequestUsage).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/fetch-success-clears-account.test.js b/tests/unit/fetch-success-clears-account.test.js new file mode 100644 index 00000000..eb8aa267 --- /dev/null +++ b/tests/unit/fetch-success-clears-account.test.js @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getProviderCredentials: vi.fn(), + markAccountUnavailable: vi.fn(), + clearAccountError: vi.fn(), + extractApiKey: vi.fn(() => null), + isValidApiKey: vi.fn(), + getSettings: vi.fn(), + getCombos: vi.fn(), + handleFetchCore: vi.fn(), + checkAndRefreshToken: vi.fn(), +})); + +vi.mock("@/sse/services/auth.js", () => ({ + getProviderCredentials: mocks.getProviderCredentials, + markAccountUnavailable: mocks.markAccountUnavailable, + clearAccountError: mocks.clearAccountError, + extractApiKey: mocks.extractApiKey, + isValidApiKey: mocks.isValidApiKey, +})); + +vi.mock("@/lib/localDb", () => ({ + getSettings: mocks.getSettings, + getCombos: mocks.getCombos, +})); + +vi.mock("open-sse/handlers/fetch/index.js", () => ({ + handleFetchCore: mocks.handleFetchCore, +})); + +vi.mock("@/sse/services/tokenRefresh.js", () => ({ + checkAndRefreshToken: mocks.checkAndRefreshToken, + updateProviderCredentials: vi.fn(), +})); + +vi.mock("@/sse/utils/logger.js", () => ({ + request: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + maskKey: vi.fn(() => "masked"), +})); + +vi.mock("@/shared/utils/ssrfGuard.js", () => ({ + assertPublicUrl: vi.fn(), +})); + +import { handleFetch } from "@/sse/handlers/fetch.js"; + +describe("web fetch account state", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSettings.mockResolvedValue({ requireApiKey: false }); + mocks.getCombos.mockResolvedValue([]); + mocks.getProviderCredentials.mockResolvedValue({ + apiKey: "jina-test-key", + connectionId: "jina-connection", + connectionName: "Jina Test", + _connection: { + testStatus: "unavailable", + lastError: "old error", + modelLock___all: "2026-01-01T00:00:00.000Z", + }, + }); + mocks.checkAndRefreshToken.mockImplementation(async (_provider, credentials) => credentials); + mocks.handleFetchCore.mockResolvedValue({ + success: true, + data: { provider: "jina-reader", content: { text: "ok" } }, + }); + }); + + it("clears a stale provider lock after a successful fetch", async () => { + const response = await handleFetch(new Request("http://localhost/v1/web/fetch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "jina-reader", + url: "https://example.com/article", + }), + })); + + expect(response.status).toBe(200); + expect(mocks.clearAccountError).toHaveBeenCalledWith( + "jina-connection", + expect.objectContaining({ connectionName: "Jina Test" }), + ); + expect(mocks.markAccountUnavailable).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/gemini-36-integration.test.js b/tests/unit/gemini-36-integration.test.js new file mode 100644 index 00000000..e401f923 --- /dev/null +++ b/tests/unit/gemini-36-integration.test.js @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRequire } from "node:module"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import { getModelUpstreamId } from "../../open-sse/config/providerModels.js"; +import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js"; +import { applyThinking, stripThinkingSuffix } from "../../open-sse/translator/concerns/thinkingUnified.js"; +import antigravity from "../../open-sse/providers/registry/antigravity.js"; +import geminiCli from "../../open-sse/providers/registry/gemini-cli.js"; +import gemini from "../../open-sse/providers/registry/gemini.js"; +import { MODEL_PRICING } from "../../open-sse/providers/pricing.js"; +import { + getProjectIdForConnection, + removeConnection, +} from "../../open-sse/services/projectId.js"; + +const require = createRequire(import.meta.url); +const mitmConfig = require("../../src/mitm/config.js"); +const here = dirname(fileURLToPath(import.meta.url)); + +function cloudCodeResponse(projectId) { + return { + ok: true, + json: async () => ({ cloudaicompanionProject: { id: projectId } }), + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Gemini Cloud Code endpoint isolation", () => { + it("keeps Gemini CLI on the official cloudcode host", async () => { + const connectionId = "gemini-cli-endpoint-test"; + const fetchMock = vi.fn(async () => cloudCodeResponse("gemini-project")); + vi.stubGlobal("fetch", fetchMock); + + await getProjectIdForConnection(connectionId, "token", "gemini-cli"); + + expect(fetchMock).toHaveBeenCalledWith( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + expect.objectContaining({ method: "POST" }) + ); + expect(geminiCli.transport.baseUrl).toBe("https://cloudcode-pa.googleapis.com/v1internal"); + removeConnection(connectionId); + }); + + it("uses the prod cloudcode host for Antigravity discovery but daily for chat", async () => { + const connectionId = "antigravity-endpoint-test"; + const fetchMock = vi.fn(async () => cloudCodeResponse("antigravity-project")); + vi.stubGlobal("fetch", fetchMock); + + await getProjectIdForConnection(connectionId, "token", "antigravity"); + + // Discovery (loadCodeAssist) on PROD — daily host rejects auth/onboarding calls. + expect(fetchMock).toHaveBeenCalledWith( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + expect.objectContaining({ method: "POST" }) + ); + // Chat transport still uses the daily host to bypass prod 429. + expect(antigravity.transport.baseUrls).toEqual(["https://daily-cloudcode-pa.googleapis.com"]); + removeConnection(connectionId); + }); +}); + +describe("Gemini 3.6 Antigravity tiers", () => { + it.each(["high", "medium", "low"])( + "maps the %s tier to the shared upstream model with matching thinking level", + (tier) => { + const publicModel = `gemini-3.6-flash-${tier}`; + const upstreamModel = getModelUpstreamId("ag", publicModel); + const body = { + model: stripThinkingSuffix(upstreamModel), + request: { + contents: [{ role: "user", parts: [{ text: "hello" }] }], + generationConfig: {}, + }, + }; + + applyThinking("antigravity", upstreamModel, body, "antigravity"); + const finalBody = new AntigravityExecutor().transformRequest( + publicModel, + body, + true, + { projectId: "project", connectionId: "connection" } + ); + + expect(upstreamModel).toBe(`gemini-3.6-flash-tiered(${tier})`); + expect(finalBody.model).toBe("gemini-3.6-flash-tiered"); + expect(finalBody.request.generationConfig.thinkingConfig).toEqual({ + thinkingLevel: tier, + includeThoughts: true, + }); + } + ); +}); + +describe("Gemini 3.6 MITM model extraction", () => { + it("exports the model extractor from the side-effect-free MITM config module", () => { + expect(mitmConfig.extractModel).toBeTypeOf("function"); + }); + + it.each(["high", "medium", "low"])("extracts the %s thinking tier", (tier) => { + const body = Buffer.from(JSON.stringify({ + request: { generationConfig: { thinkingConfig: { thinkingLevel: tier } } }, + })); + + expect(mitmConfig.extractModel( + "/v1internal/models/gemini-3.6-flash-tiered:streamGenerateContent", + body + )).toBe(`gemini-3.6-flash-${tier}`); + }); + + it("defaults invalid or missing thinking levels to medium", () => { + const body = Buffer.from(JSON.stringify({ + request: { generationConfig: { thinkingConfig: { thinkingLevel: "unknown" } } }, + })); + + expect(mitmConfig.extractModel( + "/v1internal/models/gemini-3.6-flash-tiered:streamGenerateContent", + body + )).toBe("gemini-3.6-flash-medium"); + }); +}); + +describe("Gemini 3.6 catalogs and pricing", () => { + it("exposes the direct Gemini API models and their pricing", () => { + const ids = gemini.models.map((model) => model.id); + expect(ids).toContain("gemini-3.6-flash"); + expect(ids).toContain("gemini-3.5-flash-lite"); + expect(MODEL_PRICING["gemini-3.6-flash"]).toMatchObject({ input: 1.5, output: 7.5 }); + expect(MODEL_PRICING["gemini-3.5-flash-lite"]).toMatchObject({ input: 0.3, output: 2.5 }); + }); + + it("keeps the standalone CLI Gemini catalog synchronized", () => { + const source = readFileSync(join(here, "../../cli/src/cli/menus/providers.js"), "utf8"); + const geminiCatalog = source.match(/\n gemini: \[([\s\S]*?)\n \],/)?.[1] || ""; + + expect(geminiCatalog).toContain("gemini-3.6-flash"); + expect(geminiCatalog).toContain("gemini-3.5-flash-lite"); + }); +}); 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/grok-cli-quota-frame.test.js b/tests/unit/grok-cli-quota-frame.test.js new file mode 100644 index 00000000..cd86ab4a --- /dev/null +++ b/tests/unit/grok-cli-quota-frame.test.js @@ -0,0 +1,229 @@ +import { describe, it, expect } from "vitest"; +import { + decodeGrokCreditsFrame, + probeFrameHeader, +} from "../../open-sse/services/usage/grokCliQuotaFrame.js"; + +/** + * Minimal protobuf encoder for fixtures — real GetGrokCreditsConfig wire shape + * (nested field 1 / fixed32 ratio / Timestamp reset + optional trailer 0x80). + */ + +function encodeVarint(value) { + const bytes = []; + let v = BigInt(value); + do { + let byte = Number(v & 0x7fn); + v >>= 7n; + if (v !== 0n) byte |= 0x80; + bytes.push(byte); + } while (v !== 0n); + return Buffer.from(bytes); +} + +function encodeTag(fieldNumber, wireType) { + return encodeVarint((fieldNumber << 3) | wireType); +} + +function encodeFixed32Field(fieldNumber, value) { + const body = Buffer.alloc(4); + body.writeFloatLE(value, 0); + return Buffer.concat([encodeTag(fieldNumber, 5), body]); +} + +function encodeLengthDelimited(fieldNumber, body) { + return Buffer.concat([encodeTag(fieldNumber, 2), encodeVarint(body.length), body]); +} + +function encodeVarintField(fieldNumber, value) { + return Buffer.concat([encodeTag(fieldNumber, 0), encodeVarint(value)]); +} + +function encodeTimestampField(fieldNumber, seconds, nanos) { + const parts = []; + if (seconds !== 0) parts.push(encodeVarintField(1, seconds)); + if (nanos !== 0) parts.push(encodeVarintField(2, nanos)); + return encodeLengthDelimited(fieldNumber, Buffer.concat(parts)); +} + +function encodeCreditsInfo(shape) { + const parts = []; + if (shape.usageRatio !== undefined) parts.push(encodeFixed32Field(1, shape.usageRatio)); + if (shape.asOfSeconds !== undefined) { + parts.push(encodeTimestampField(4, shape.asOfSeconds, shape.asOfNanos ?? 0)); + } + if (shape.resetSeconds !== undefined) { + parts.push(encodeTimestampField(5, shape.resetSeconds, shape.resetNanos ?? 0)); + } + return Buffer.concat(parts); +} + +function encodeTopLevelMessage(creditsInfo) { + return encodeLengthDelimited(1, creditsInfo); +} + +function frameData(payload) { + const header = Buffer.alloc(5); + header[0] = 0x00; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +function frameTrailer(statusText = "grpc-status:0\r\n") { + const body = Buffer.from(statusText, "utf8"); + const header = Buffer.alloc(5); + header[0] = 0x80; + header.writeUInt32BE(body.length, 1); + return Buffer.concat([header, body]); +} + +const REAL_USAGE_RATIO = 1.0; +const REAL_ASOF_SECONDS = 1784221140; +const REAL_ASOF_NANOS = 867850000; +const REAL_RESET_SECONDS = 1784825940; +const REAL_RESET_NANOS = 867850000; +const PERCENT_TOLERANCE = 1e-4; + +function isoFromEpoch(seconds, nanos) { + return new Date(seconds * 1000 + Math.round(nanos / 1_000_000)).toISOString(); +} + +describe("decodeGrokCreditsFrame", () => { + it("decodes real GetGrokCreditsConfig shape (nested, fixed32, Timestamp, trailer)", () => { + const creditsInfo = encodeCreditsInfo({ + usageRatio: REAL_USAGE_RATIO, + asOfSeconds: REAL_ASOF_SECONDS, + asOfNanos: REAL_ASOF_NANOS, + resetSeconds: REAL_RESET_SECONDS, + resetNanos: REAL_RESET_NANOS, + }); + const buffer = Buffer.concat([frameData(encodeTopLevelMessage(creditsInfo)), frameTrailer()]); + + const result = decodeGrokCreditsFrame(buffer); + expect(result).toBeTruthy(); + expect(result.percentUsed).toBe(100); + expect(result.resetAt).toBe(isoFromEpoch(REAL_RESET_SECONDS, REAL_RESET_NANOS)); + }); + + it("ignores trailing gRPC-web trailer frame (flag 0x80)", () => { + const creditsInfo = encodeCreditsInfo({ + usageRatio: 0.5, + resetSeconds: REAL_RESET_SECONDS, + resetNanos: 0, + }); + const topMessage = encodeTopLevelMessage(creditsInfo); + const withoutTrailer = frameData(topMessage); + const withTrailer = Buffer.concat([frameData(topMessage), frameTrailer()]); + + const a = decodeGrokCreditsFrame(withoutTrailer); + const b = decodeGrokCreditsFrame(withTrailer); + expect(a).toBeTruthy(); + expect(b).toBeTruthy(); + expect(b.percentUsed).toBe(a.percentUsed); + expect(b.resetAt).toBe(a.resetAt); + expect(b.percentUsed).toBe(50); + }); + + it("decodes raw unframed protobuf payload", () => { + const creditsInfo = encodeCreditsInfo({ + usageRatio: 0.75, + resetSeconds: REAL_RESET_SECONDS, + resetNanos: REAL_RESET_NANOS, + }); + const payload = encodeTopLevelMessage(creditsInfo); + expect(probeFrameHeader(payload)).toBeNull(); + + const result = decodeGrokCreditsFrame(payload); + expect(result).toBeTruthy(); + expect(Math.abs(result.percentUsed - 75)).toBeLessThan(PERCENT_TOLERANCE); + expect(result.resetAt).toBe(isoFromEpoch(REAL_RESET_SECONDS, REAL_RESET_NANOS)); + }); + + it("treats omitted usage-ratio as 0% (proto3 default)", () => { + const creditsInfo = encodeCreditsInfo({ + resetSeconds: REAL_RESET_SECONDS, + resetNanos: REAL_RESET_NANOS, + }); + const result = decodeGrokCreditsFrame(frameData(encodeTopLevelMessage(creditsInfo))); + expect(result).toBeTruthy(); + expect(result.percentUsed).toBe(0); + expect(result.resetAt).toBe(isoFromEpoch(REAL_RESET_SECONDS, REAL_RESET_NANOS)); + }); + + it("clamps usage ratio above 1.0 to percentUsed 100", () => { + const creditsInfo = encodeCreditsInfo({ usageRatio: 1.5 }); + const result = decodeGrokCreditsFrame(frameData(encodeTopLevelMessage(creditsInfo))); + expect(result).toBeTruthy(); + expect(result.percentUsed).toBe(100); + }); + + it("returns null for negative usage ratio", () => { + const creditsInfo = encodeCreditsInfo({ usageRatio: -0.1 }); + expect(decodeGrokCreditsFrame(frameData(encodeTopLevelMessage(creditsInfo)))).toBeNull(); + }); + + it("returns null when top-level field 1 is not length-delimited", () => { + expect(decodeGrokCreditsFrame(frameData(encodeVarintField(1, 42)))).toBeNull(); + }); + + it("returns null when nested usage-ratio has unexpected wire type", () => { + const creditsInfo = encodeLengthDelimited(1, Buffer.from("not-a-float", "utf8")); + expect(decodeGrokCreditsFrame(frameData(encodeTopLevelMessage(creditsInfo)))).toBeNull(); + }); + + it("returns null when top-level has no field 1", () => { + expect(decodeGrokCreditsFrame(frameData(encodeVarintField(9, 1)))).toBeNull(); + }); + + it("returns null for truncated buffer", () => { + const creditsInfo = encodeCreditsInfo({ + usageRatio: 0.5, + resetSeconds: REAL_RESET_SECONDS, + resetNanos: REAL_RESET_NANOS, + }); + const buffer = frameData(encodeTopLevelMessage(creditsInfo)); + expect(decodeGrokCreditsFrame(buffer.subarray(0, buffer.length - 3))).toBeNull(); + }); + + it("returns null for trailer-only body", () => { + expect(decodeGrokCreditsFrame(frameTrailer())).toBeNull(); + }); + + it("returns null for empty buffer", () => { + expect(decodeGrokCreditsFrame(Buffer.alloc(0))).toBeNull(); + }); +}); + +describe("probeFrameHeader", () => { + it("rejects declared length that exceeds body", () => { + const header = Buffer.alloc(5); + header[0] = 0x00; + header.writeUInt32BE(9999, 1); + expect(probeFrameHeader(Buffer.concat([header, Buffer.from([0x01, 0x02])]))).toBeNull(); + }); + + it("rejects invalid compression flag", () => { + const header = Buffer.alloc(5); + header[0] = 0x07; + expect(probeFrameHeader(header)).toBeNull(); + }); + + it("accepts trailer frame header (flag 0x80)", () => { + const result = probeFrameHeader(frameTrailer()); + expect(result).toBeTruthy(); + expect(result.flag).toBe(0x80); + }); + + it("reads frame header at non-zero offset", () => { + const creditsInfo = encodeCreditsInfo({ usageRatio: 0.5 }); + const buffer = Buffer.concat([ + frameData(encodeTopLevelMessage(creditsInfo)), + frameTrailer(), + ]); + const first = probeFrameHeader(buffer); + expect(first).toBeTruthy(); + const second = probeFrameHeader(buffer, first.payloadStart + first.payloadLength); + expect(second).toBeTruthy(); + expect(second.flag).toBe(0x80); + }); +}); diff --git a/tests/unit/grok-cli-usage.test.js b/tests/unit/grok-cli-usage.test.js index 0c52a2cd..a0e28a5c 100644 --- a/tests/unit/grok-cli-usage.test.js +++ b/tests/unit/grok-cli-usage.test.js @@ -113,6 +113,43 @@ describe("parseGrokCliBilling", () => { expect(parsed.exhausted).toBe(false); }); + it("maps creditUsagePercent to a single Weekly SuperGrok bar (not productUsage)", () => { + const parsed = parseGrokCliBilling( + { + config: { + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + start: "2026-07-17T12:42:26.494595+00:00", + end: "2026-07-24T12:42:26.494595+00:00", + }, + creditUsagePercent: 99.0, + onDemandCap: { val: 0 }, + onDemandUsed: { val: 0 }, + productUsage: [ + { product: "GrokBuild", usagePercent: 97.0 }, + { product: "GrokImagine", usagePercent: 2.0 }, + ], + isUnifiedBillingUser: true, + prepaidBalance: { val: 0 }, + billingPeriodStart: "2026-07-17T12:42:26.494595+00:00", + billingPeriodEnd: "2026-07-24T12:42:26.494595+00:00", + }, + }, + { subscriptionTier: "XPremiumPlus", hasGrokCodeAccess: true }, + ); + // Single shared-pool bar from creditUsagePercent + expect(parsed.quotas["Weekly SuperGrok"]).toMatchObject({ + used: 99, + total: 100, + remainingPercentage: 1, + resetAt: "2026-07-24T12:42:26.494Z", + unlimited: false, + }); + // productUsage must NOT become independent quota bars + expect(Object.keys(parsed.quotas)).toEqual(["Weekly SuperGrok"]); + expect(parsed.exhausted).toBe(false); + }); + it("maps current monthly fields and snake-case subscription tier", () => { const parsed = parseGrokCliBilling({ monthlyLimit: { val: 1000 }, @@ -132,6 +169,67 @@ describe("parseGrokCliBilling", () => { }); }); +function encodeVarint(value) { + const bytes = []; + let v = BigInt(value); + do { + let byte = Number(v & 0x7fn); + v >>= 7n; + if (v !== 0n) byte |= 0x80; + bytes.push(byte); + } while (v !== 0n); + return Buffer.from(bytes); +} + +function encodeTag(fieldNumber, wireType) { + return encodeVarint((fieldNumber << 3) | wireType); +} + +function encodeFixed32Field(fieldNumber, value) { + const body = Buffer.alloc(4); + body.writeFloatLE(value, 0); + return Buffer.concat([encodeTag(fieldNumber, 5), body]); +} + +function encodeLengthDelimited(fieldNumber, body) { + return Buffer.concat([encodeTag(fieldNumber, 2), encodeVarint(body.length), body]); +} + +function encodeVarintField(fieldNumber, value) { + return Buffer.concat([encodeTag(fieldNumber, 0), encodeVarint(value)]); +} + +function encodeTimestampField(fieldNumber, seconds, nanos) { + const parts = []; + if (seconds !== 0) parts.push(encodeVarintField(1, seconds)); + if (nanos !== 0) parts.push(encodeVarintField(2, nanos)); + return encodeLengthDelimited(fieldNumber, Buffer.concat(parts)); +} + +/** Framed GetGrokCreditsConfig response for a usage ratio 0..1. */ +function buildCreditsResponseBuffer(usageRatio, resetSeconds = 1784825940, resetNanos = 867850000) { + const creditsInfo = Buffer.concat([ + encodeFixed32Field(1, usageRatio), + encodeTimestampField(5, resetSeconds, resetNanos), + ]); + const topMessage = encodeLengthDelimited(1, creditsInfo); + const header = Buffer.alloc(5); + header[0] = 0x00; + header.writeUInt32BE(topMessage.length, 1); + return Buffer.concat([header, topMessage]); +} + +function binaryResponse(buffer, status = 200) { + return new Response(buffer, { + status, + headers: { "content-type": "application/grpc-web+proto" }, + }); +} + +const EMPTY_GRPC_WEB_FRAME = Buffer.from([0, 0, 0, 0, 0]); +const GRPC_CREDITS_URL = + "https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig"; + describe("getUsageForProvider(grok-cli)", () => { beforeEach(() => { vi.clearAllMocks(); @@ -174,6 +272,8 @@ describe("getUsageForProvider(grok-cli)", () => { expect(billingCall[1].headers["x-userid"]).toBe( "d84768dd-224d-4052-ba49-0d336fa9160c", ); + // REST already has numeric quotas — do not hit gRPC fallback + expect(proxyAwareFetch.mock.calls).toHaveLength(2); }); it("surfaces auth-expired message on 401", async () => { @@ -187,6 +287,8 @@ describe("getUsageForProvider(grok-cli)", () => { }); expect(usage.message).toMatch(/expired|re-authorize/i); + // Auth failure must not attempt gRPC fallback + expect(proxyAwareFetch.mock.calls).toHaveLength(2); }); it("returns depleted on-demand bar without blocking message when cap is zero", async () => { @@ -204,15 +306,62 @@ describe("getUsageForProvider(grok-cli)", () => { expect(usage.message).toBeUndefined(); expect(usage.quotas["On-demand"].remainingPercentage).toBe(0); expect(usage.quotas["On-demand"].total).toBe(1); + // Exhausted free already has a quota bar — no gRPC fallback + expect(proxyAwareFetch.mock.calls).toHaveLength(2); }); - it("reports active paid access when provider exposes no numeric quota", async () => { + it("falls back to GetGrokCreditsConfig gRPC when paid sub has no REST numeric quota", async () => { + const resetSeconds = 1784825940; + const resetNanos = 867850000; + const resetAt = new Date( + resetSeconds * 1000 + Math.round(resetNanos / 1_000_000), + ).toISOString(); + proxyAwareFetch .mockResolvedValueOnce(jsonResponse(EXHAUSTED_BILLING)) - .mockResolvedValueOnce(jsonResponse({ - ...USER_PROFILE, - subscriptionTier: "XPremiumPlus", - })); + .mockResolvedValueOnce( + jsonResponse({ + ...USER_PROFILE, + subscriptionTier: "XPremiumPlus", + }), + ) + .mockResolvedValueOnce(binaryResponse(buildCreditsResponseBuffer(0.35, resetSeconds, resetNanos))); + + const usage = await getUsageForProvider({ + provider: "grok-cli", + accessToken: "test-token", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.plan).toBe("XPremiumPlus"); + expect(usage.quotas["Weekly SuperGrok"]).toMatchObject({ + used: 35, + total: 100, + remainingPercentage: 65, + resetAt, + unlimited: false, + }); + + const grpcCall = proxyAwareFetch.mock.calls[2]; + expect(grpcCall[0]).toBe(GRPC_CREDITS_URL); + expect(grpcCall[1].method).toBe("POST"); + expect(grpcCall[1].headers.Authorization).toBe("Bearer test-token"); + expect(grpcCall[1].headers["Content-Type"]).toBe("application/grpc-web+proto"); + expect(grpcCall[1].headers["X-Grpc-Web"]).toBe("1"); + // Empty gRPC-web request frame is required (flag 0 + length 0) + expect(Buffer.from(grpcCall[1].body)).toEqual(EMPTY_GRPC_WEB_FRAME); + }); + + it("keeps subscription message when REST empty and gRPC fails open", async () => { + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse(EXHAUSTED_BILLING)) + .mockResolvedValueOnce( + jsonResponse({ + ...USER_PROFILE, + subscriptionTier: "XPremiumPlus", + }), + ) + .mockResolvedValueOnce(binaryResponse(Buffer.alloc(0), 500)); const usage = await getUsageForProvider({ provider: "grok-cli", @@ -223,6 +372,26 @@ describe("getUsageForProvider(grok-cli)", () => { expect(usage.message).toMatch(/active.*numeric included quota/i); expect(usage.quotas).toEqual({}); }); + + it("does not throw when gRPC network fails after empty REST quotas", async () => { + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse(EXHAUSTED_BILLING)) + .mockResolvedValueOnce( + jsonResponse({ + ...USER_PROFILE, + subscriptionTier: "XPremiumPlus", + }), + ) + .mockRejectedValueOnce(new Error("network down")); + + const usage = await getUsageForProvider({ + provider: "grok-cli", + accessToken: "test-token", + }); + + expect(usage.message).toMatch(/active.*numeric included quota/i); + expect(usage.quotas).toEqual({}); + }); }); describe("parseQuotaData(grok-cli)", () => { diff --git a/tests/unit/jina-reader-fetch.test.js b/tests/unit/jina-reader-fetch.test.js new file mode 100644 index 00000000..3a6512da --- /dev/null +++ b/tests/unit/jina-reader-fetch.test.js @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { handleFetchCore } from "../../open-sse/handlers/fetch/index.js"; + +const originalFetch = global.fetch; + +describe("Jina Reader fetch", () => { + beforeEach(() => { + global.fetch = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("uses Jina's JSON POST API instead of embedding the URL in the path", async () => { + global.fetch.mockResolvedValueOnce(new Response([ + "Title: Example page", + "", + "URL Source: https://example.com/article", + "", + "Markdown Content:", + "Hello", + ].join("\n"))); + + const result = await handleFetchCore({ + url: "https://example.com/article", + format: "markdown", + provider: "jina-reader", + providerConfig: { timeoutMs: 30000 }, + credentials: { apiKey: "jina-test-key" }, + }); + + expect(result.success).toBe(true); + expect(result.data.title).toBe("Example page"); + expect(global.fetch).toHaveBeenCalledTimes(1); + + const [requestUrl, init] = global.fetch.mock.calls[0]; + expect(requestUrl).toBe("https://r.jina.ai/"); + expect(init.method).toBe("POST"); + expect(init.headers).toEqual({ + "content-type": "application/json", + authorization: "Bearer jina-test-key", + }); + expect(JSON.parse(init.body)).toEqual({ url: "https://example.com/article" }); + }); + + it("returns the upstream status and error body", async () => { + global.fetch.mockResolvedValueOnce(new Response( + JSON.stringify({ detail: "Payment required" }), + { status: 402, headers: { "Content-Type": "application/json" } }, + )); + + const result = await handleFetchCore({ + url: "https://example.com/article", + provider: "jina-reader", + providerConfig: { timeoutMs: 30000 }, + credentials: { apiKey: "jina-test-key" }, + }); + + expect(result).toMatchObject({ + success: false, + status: 402, + }); + expect(result.error).toContain("Payment required"); + }); +}); diff --git a/tests/unit/kimi-usage.test.js b/tests/unit/kimi-usage.test.js new file mode 100644 index 00000000..9d944a37 --- /dev/null +++ b/tests/unit/kimi-usage.test.js @@ -0,0 +1,299 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: vi.fn(), +})); + +import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js"; +import { getUsageForProvider } from "../../open-sse/services/usage.js"; +import { USAGE_SUPPORTED_PROVIDERS, USAGE_APIKEY_PROVIDERS } from "../../src/shared/constants/providers.js"; +import { PROVIDERS } from "../../open-sse/providers/index.js"; +import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js"; + +const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages"; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const ACTIVE_USAGE = { + user: { + membership: { level: "LEVEL_ADVANCED" }, + }, + usage: { + limit: "100", + used: "35", + remaining: "65", + resetTime: "2026-08-01T00:00:00Z", + }, + limits: [ + { + window: { type: "rate" }, + detail: { + limit: "60", + remaining: "40", + resetTime: "2026-07-29T12:00:00Z", + }, + }, + ], +}; + +describe("kimi registry usage flags", () => { + it("exposes usage + usageApikey so OAuth and apikey cards appear on /quota", () => { + expect(USAGE_SUPPORTED_PROVIDERS).toContain("kimi"); + expect(USAGE_APIKEY_PROVIDERS).toContain("kimi"); + }); + + it("registers transport.usage url when present (optional)", () => { + // Provider may or may not put usage url on transport; handler has its own constant. + expect(PROVIDERS.kimi).toBeTruthy(); + }); +}); + +describe("getUsageForProvider(kimi) auth selection", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("OAuth path: Bearer + X-Msh-* (not chat x-api-key)", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE)); + + const usage = await getUsageForProvider({ + provider: "kimi", + accessToken: "tok-abc", + providerSpecificData: { deviceId: "stable-device-1" }, + }); + + expect(usage.message).toBeUndefined(); + expect(usage.plan).toBe("Allegro"); + expect(usage.quotas.Weekly).toMatchObject({ + used: 35, + total: 100, + remainingPercentage: 65, + }); + + expect(proxyAwareFetch).toHaveBeenCalledTimes(1); + const [url, opts] = proxyAwareFetch.mock.calls[0]; + expect(url).toBe(KIMI_USAGE_URL); + expect(opts.method).toBe("GET"); + expect(opts.headers.Authorization).toBe("Bearer tok-abc"); + expect(opts.headers["x-api-key"]).toBeUndefined(); + expect(opts.headers["X-Msh-Platform"]).toBe("9router"); + expect(opts.headers["X-Msh-Device-Id"]).toBe("stable-device-1"); + expect(opts.headers["X-Msh-Version"]).toBeTruthy(); + }); + + it("apikey path: x-api-key only (no Bearer / X-Msh)", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE)); + + const usage = await getUsageForProvider({ + provider: "kimi", + apiKey: "sk-test-123", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.quotas.Weekly.used).toBe(35); + + const [, opts] = proxyAwareFetch.mock.calls[0]; + expect(opts.headers["x-api-key"]).toBe("sk-test-123"); + expect(opts.headers.Authorization).toBeUndefined(); + expect(opts.headers["X-Msh-Platform"]).toBeUndefined(); + }); + + it("prefers apiKey over accessToken when both present", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE)); + + await getUsageForProvider({ + provider: "kimi", + accessToken: "tok-abc", + apiKey: "sk-prefer-me", + }); + + const [, opts] = proxyAwareFetch.mock.calls[0]; + expect(opts.headers["x-api-key"]).toBe("sk-prefer-me"); + expect(opts.headers.Authorization).toBeUndefined(); + expect(opts.headers["X-Msh-Platform"]).toBeUndefined(); + }); + + it("maps membership levels to plan display names", async () => { + for (const [level, plan] of [ + ["LEVEL_BASIC", "Moderato"], + ["LEVEL_INTERMEDIATE", "Allegretto"], + ["LEVEL_ADVANCED", "Allegro"], + ["LEVEL_STANDARD", "Vivace"], + ]) { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ + user: { membership: { level } }, + usage: { limit: "10", used: "1", remaining: "9" }, + }), + ); + const usage = await getUsageForProvider({ + provider: "kimi", + accessToken: "t", + }); + expect(usage.plan).toBe(plan); + } + }); + + it("parses Weekly + Ratelimit; does not put absolute remaining on quota rows", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE)); + + const usage = await getUsageForProvider({ + provider: "kimi", + accessToken: "tok", + }); + + // Absolute remaining would break getRemainingPercentage (treats it as 0-100 %) + expect(usage.quotas.Weekly.remaining).toBeUndefined(); + expect(usage.quotas.Weekly.remainingPercentage).toBe(65); + expect(usage.quotas.Ratelimit).toMatchObject({ + used: 20, + total: 60, + remainingPercentage: expect.closeTo(40 / 60 * 100, 5), + }); + expect(usage.quotas.Ratelimit.remaining).toBeUndefined(); + }); + + it("surfaces re-authorize message only on 401 unauthenticated", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse( + { + code: "unauthenticated", + details: [ + { + debug: { + reason: "REASON_INVALID_AUTH_TOKEN", + localizedMessage: { message: "Invalid auth token" }, + }, + }, + ], + }, + 401, + ), + ); + + const usage = await getUsageForProvider({ + provider: "kimi", + accessToken: "expired", + }); + + expect(usage.message).toMatch(/expired|re-authorize/i); + expect(usage.message).not.toMatch(/subscribe|permission/i); + expect(usage.quotas).toBeUndefined(); + }); + + it("maps 403 REASON_FEATURE_NO_PERMISSION to subscribe message (not expired)", async () => { + // Live capture: valid OAuth JWT still returns 403 permission_denied when + // the account has no Kimi Code usage entitlement. + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse( + { + code: "permission_denied", + details: [ + { + type: "common.error.v1.ErrorDetail", + debug: { + reason: "REASON_FEATURE_NO_PERMISSION", + localizedMessage: { + locale: "en-US", + message: + "You do not have permission to use this feature. Please subscribe to access.", + }, + }, + }, + ], + }, + 403, + ), + ); + + const usage = await getUsageForProvider({ + provider: "kimi", + accessToken: "valid-but-no-sub", + providerSpecificData: { deviceId: "stable-device-1" }, + }); + + expect(usage.message).toMatch(/permission|subscribe/i); + expect(usage.message).not.toMatch(/expired|re-authorize/i); + // Must not trip usage-route AUTH_EXPIRED_PATTERNS force-refresh loop + expect(usage.message.toLowerCase()).not.toMatch(/expired|re-authorize|unauthorized|401/); + }); + + it("formatKimiUsageError distinguishes 401 vs 403 feature gate", async () => { + const { formatKimiUsageError } = await import( + "../../open-sse/services/usage/kimi.js" + ); + expect(formatKimiUsageError(401, '{"code":"unauthenticated"}')).toMatch( + /expired|re-authorize/i, + ); + expect( + formatKimiUsageError( + 403, + JSON.stringify({ + code: "permission_denied", + details: [ + { + debug: { + reason: "REASON_FEATURE_NO_PERMISSION", + localizedMessage: { + message: "You do not have permission to use this feature.", + }, + }, + }, + ], + }), + ), + ).toMatch(/permission|subscribe/i); + }); + + it("returns tracked-per-request message when usage limit missing", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ + user: { membership: { level: "LEVEL_BASIC" } }, + usage: {}, + }), + ); + + const usage = await getUsageForProvider({ + provider: "kimi", + accessToken: "tok", + }); + + expect(usage.plan).toBe("Moderato"); + expect(usage.message).toMatch(/tracked per request/i); + }); + + it("returns missing-credentials message when neither token nor key", async () => { + const usage = await getUsageForProvider({ provider: "kimi" }); + expect(usage.message).toMatch(/token|key|credential/i); + expect(proxyAwareFetch).not.toHaveBeenCalled(); + }); +}); + +describe("parseQuotaData(kimi)", () => { + it("forwards remainingPercentage for dashboard bars", () => { + const rows = parseQuotaData("kimi", { + plan: "Allegro", + quotas: { + Weekly: { + used: 35, + total: 100, + remainingPercentage: 65, + resetAt: "2026-08-01T00:00:00.000Z", + }, + }, + }); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + name: "Weekly", + used: 35, + total: 100, + remainingPercentage: 65, + }); + }); +}); diff --git a/tests/unit/kiro-api-key-endpoint-routing.test.js b/tests/unit/kiro-api-key-endpoint-routing.test.js new file mode 100644 index 00000000..a0750adc --- /dev/null +++ b/tests/unit/kiro-api-key-endpoint-routing.test.js @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { KiroExecutor } from "../../open-sse/executors/kiro.js"; + +const RUNTIME = "https://runtime.us-east-1.kiro.dev/generateAssistantResponse"; +const CODEWHISPERER = "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse"; +const Q = "https://q.us-east-1.amazonaws.com/generateAssistantResponse"; + +function credentials(authMethod, region = "us-east-1") { + return { providerSpecificData: { authMethod, region } }; +} + +describe("Kiro auth-aware endpoint routing", () => { + const executor = new KiroExecutor(); + + it("routes API-key inference through Amazon Q before other surfaces", () => { + expect(executor.getOrderedBaseUrls(credentials("api_key"))).toEqual([ + Q, + CODEWHISPERER, + RUNTIME, + ]); + }); + + it("keeps Builder ID OAuth on the Kiro runtime surface", () => { + expect(executor.getOrderedBaseUrls(credentials("builder-id"))).toEqual([ + RUNTIME, + CODEWHISPERER, + Q, + ]); + }); + + it("keeps external IdP on CodeWhisperer before Amazon Q", () => { + expect(executor.getOrderedBaseUrls(credentials("external_idp"))).toEqual([ + CODEWHISPERER, + Q, + RUNTIME, + ]); + }); + + it("regionalizes AWS endpoints for IDC without changing Kiro runtime", () => { + expect(executor.getOrderedBaseUrls(credentials("idc", "eu-west-1"))).toEqual([ + "https://codewhisperer.eu-west-1.amazonaws.com/generateAssistantResponse", + "https://q.eu-west-1.amazonaws.com/generateAssistantResponse", + RUNTIME, + ]); + }); + + it("retries only endpoint/auth-surface failures, not payload-invalid 400s", () => { + expect(executor.shouldRetry(400, 0)).toBe(false); + expect(executor.shouldRetry(401, 1)).toBe(true); + expect(executor.shouldRetry(403, 2)).toBe(false); + expect(executor.shouldRetry(422, 0)).toBe(false); + }); + + it("builds endpoint-specific headers", () => { + const auth = { accessToken: "test-key", providerSpecificData: { authMethod: "api_key" } }; + const qHeaders = executor.buildHeaders(auth, true, Q); + const codeWhispererHeaders = executor.buildHeaders(auth, true, CODEWHISPERER); + const runtimeHeaders = executor.buildHeaders(auth, true, RUNTIME); + + expect(qHeaders.TokenType).toBe("API_KEY"); + expect(qHeaders["X-Amz-Target"]).toBeUndefined(); + expect(codeWhispererHeaders["X-Amz-Target"]).toBe( + "AmazonCodeWhispererStreamingService.GenerateAssistantResponse" + ); + expect(runtimeHeaders["X-Amz-Target"]).toBeUndefined(); + }); +}); diff --git a/tests/unit/kiro-conversation-canonicalization.test.js b/tests/unit/kiro-conversation-canonicalization.test.js new file mode 100644 index 00000000..e19984d1 --- /dev/null +++ b/tests/unit/kiro-conversation-canonicalization.test.js @@ -0,0 +1,372 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + canonicalizeKiroConversation, + normalizeKiroToolSpecs, + validateKiroConversation, +} from "../../open-sse/translator/concerns/kiroConversation.js"; +import { clearKiroSessionReplayStore } from "../../open-sse/utils/kiroSessionReplay.js"; +import { clearSessionStore } from "../../open-sse/utils/sessionManager.js"; +import { claudeToKiroRequest } from "../../open-sse/translator/request/claude-to-kiro.js"; +import { openaiToKiroRequest } from "../../open-sse/translator/request/openai-to-kiro.js"; + +const modelId = "claude-opus-5"; + +function tool(name, schema = { type: "object", properties: {} }) { + return { name, description: `Tool ${name}`, input_schema: schema }; +} + +function specState(names = ["first", "second"]) { + const source = names.map((name) => tool(name)); + return normalizeKiroToolSpecs(source); +} + +function user(content, toolResults = []) { + return { + userInputMessage: { + content, + modelId, + ...(toolResults.length > 0 && { userInputMessageContext: { toolResults } }), + }, + }; +} + +function assistant(content, toolUses = []) { + return { + assistantResponseMessage: { + content, + ...(toolUses.length > 0 && { toolUses }), + }, + }; +} + +function result(toolUseId, value, status = "success") { + return { toolUseId, status, content: [{ text: value }] }; +} + +describe("Kiro conversation canonicalizer", () => { + beforeEach(() => { + clearKiroSessionReplayStore(); + clearSessionStore(); + }); + + it("keeps complete parallel tool pairs structured", () => { + const { specs, nameMap } = specState(); + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [ + { toolUseId: "t1", name: "first", input: { n: 1 } }, + { toolUseId: "t2", name: "second", input: { n: 2 } }, + ]), + ], + currentMessage: user("continue", [result("t1", "one"), result("t2", "two")]), + modelId, + toolSpecs: specs, + nameMap, + }); + + const calls = canonical.history[1].assistantResponseMessage.toolUses; + const results = canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults; + expect(calls.map((call) => call.toolUseId)).toEqual(["t1", "t2"]); + expect(results.map((item) => item.toolUseId)).toEqual(["t1", "t2"]); + expect(canonical.valid).toBe(true); + }); + + it("keeps the answered parallel call and flattens only the missing one", () => { + const { specs, nameMap } = specState(); + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [ + { toolUseId: "t1", name: "first", input: {} }, + { toolUseId: "t2", name: "second", input: {} }, + ]), + ], + currentMessage: user("continue", [result("t1", "one")]), + modelId, + toolSpecs: specs, + nameMap, + }); + + const assistantMessage = canonical.history[1].assistantResponseMessage; + expect(assistantMessage.toolUses).toHaveLength(1); + expect(assistantMessage.toolUses[0].toolUseId).toBe("t1"); + expect(assistantMessage.content).toContain("[Tool call: second("); + expect(canonical.repairs.missingResults).toBe(1); + expect(canonical.valid).toBe(true); + }); + + it("flattens non-adjacent and orphaned tool results", () => { + const { specs, nameMap } = specState(["first"]); + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]), + user("result missing here"), + assistant("later"), + ], + currentMessage: user("late result", [result("t1", "too late")]), + modelId, + toolSpecs: specs, + nameMap, + }); + + expect(JSON.stringify(canonical)).not.toContain('"toolUseId":"t1"'); + expect(canonical.history[1].assistantResponseMessage.content).toContain("[Tool call:"); + expect(canonical.currentMessage.userInputMessage.content).toContain("too late"); + expect(canonical.valid).toBe(true); + }); + + it("remaps duplicate tool IDs together with their adjacent results", () => { + const { specs, nameMap } = specState(); + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [ + { toolUseId: "duplicate", name: "first", input: {} }, + { toolUseId: "duplicate", name: "second", input: {} }, + ]), + ], + currentMessage: user("continue", [ + result("duplicate", "one"), + result("duplicate", "two"), + ]), + modelId, + toolSpecs: specs, + nameMap, + }); + + const calls = canonical.history[1].assistantResponseMessage.toolUses; + const results = canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults; + expect(new Set(calls.map((call) => call.toolUseId)).size).toBe(2); + expect(results.map((item) => item.toolUseId)).toEqual(calls.map((call) => call.toolUseId)); + expect(canonical.valid).toBe(true); + }); + + it("deduplicates extra results without losing their text", () => { + const { specs, nameMap } = specState(["first"]); + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]), + ], + currentMessage: user("continue", [result("t1", "one"), result("t1", "duplicate")]), + modelId, + toolSpecs: specs, + nameMap, + }); + + const current = canonical.currentMessage.userInputMessage; + expect(current.userInputMessageContext.toolResults).toHaveLength(1); + expect(current.content).toContain("duplicate"); + expect(canonical.valid).toBe(true); + }); + + it("flattens a trailing unanswered assistant tool call and creates a current user turn", () => { + const { specs, nameMap } = specState(["first"]); + const canonical = canonicalizeKiroConversation({ + history: [user("start")], + currentMessage: assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]), + modelId, + toolSpecs: specs, + nameMap, + }); + + expect(canonical.currentMessage.userInputMessage.content).toBe("continue"); + expect(canonical.history[1].assistantResponseMessage.toolUses).toBeUndefined(); + expect(canonical.history[1].assistantResponseMessage.content).toContain("[Tool call:"); + expect(canonical.valid).toBe(true); + }); + + it("flattens malformed input and tool uses missing from the current specs", () => { + const { specs, nameMap } = specState(["first"]); + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [ + { toolUseId: "t1", name: "first", input: "{bad json" }, + { toolUseId: "t2", name: "removed_tool", input: {} }, + ]), + ], + currentMessage: user("continue", [result("t1", "one"), result("t2", "two")]), + modelId, + toolSpecs: specs, + nameMap, + }); + + expect(canonical.history[1].assistantResponseMessage.toolUses).toBeUndefined(); + expect(canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults).toBeUndefined(); + expect(canonical.currentMessage.userInputMessage.content).toContain("one"); + expect(canonical.currentMessage.userInputMessage.content).toContain("two"); + expect(canonical.valid).toBe(true); + }); + + it("repairs a 30-call parallel turn with one missing result", () => { + const names = Array.from({ length: 30 }, (_, index) => `tool_${index}`); + const { specs, nameMap } = specState(names); + const calls = names.map((name, index) => ({ + toolUseId: `t${index}`, + name, + input: { index }, + })); + const results = names.slice(0, -1).map((_, index) => result(`t${index}`, `r${index}`)); + const canonical = canonicalizeKiroConversation({ + history: [user("start"), assistant("run", calls)], + currentMessage: user("continue", results), + modelId, + toolSpecs: specs, + nameMap, + }); + + expect(canonical.history[1].assistantResponseMessage.toolUses).toHaveLength(29); + expect(canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults).toHaveLength(29); + expect(canonical.repairs.missingResults).toBe(1); + expect(canonical.valid).toBe(true); + }); + + it("flattens structured history when the client sent no tool specs", () => { + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]), + ], + currentMessage: user("continue", [result("t1", "one")]), + modelId, + }); + + expect(JSON.stringify(canonical)).not.toContain("toolUses"); + expect(JSON.stringify(canonical)).not.toContain("toolResults"); + expect(canonical.history[1].assistantResponseMessage.content).toContain("[Tool call:"); + expect(canonical.currentMessage.userInputMessage.content).toContain("[Tool result:"); + }); + + it("normalizes names and recursively removes unsupported schema fields", () => { + const longDescription = "x".repeat(11000); + const { specs, nameMap } = normalizeKiroToolSpecs([{ + name: "bad tool/name", + description: longDescription, + input_schema: { + additionalProperties: false, + properties: { + nested: { + type: "object", + additionalProperties: true, + properties: {}, + required: [], + }, + }, + required: [], + }, + }]); + + const specification = specs[0].toolSpecification; + expect(nameMap.get("bad tool/name")).toBe("bad_tool_name"); + expect(specification.name.length).toBeLessThanOrEqual(64); + expect(specification.description.length).toBe(10237); + expect(JSON.stringify(specification.inputSchema.json)).not.toContain("additionalProperties"); + expect(JSON.stringify(specification.inputSchema.json)).not.toContain('"required":[]'); + }); + + it("does not mutate the source conversation or tool definitions", () => { + const sourceTools = [tool("first")]; + const sourceHistory = [ + user("start"), + assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]), + ]; + const sourceCurrent = user("continue", [result("t1", "one")]); + const before = JSON.stringify({ sourceTools, sourceHistory, sourceCurrent }); + const { specs, nameMap } = normalizeKiroToolSpecs(sourceTools); + + canonicalizeKiroConversation({ + history: sourceHistory, + currentMessage: sourceCurrent, + modelId, + toolSpecs: specs, + nameMap, + }); + + expect(JSON.stringify({ sourceTools, sourceHistory, sourceCurrent })).toBe(before); + }); + + it("preserves Claude tool_result errors", () => { + const output = claudeToKiroRequest(modelId, { + tools: [tool("first")], + messages: [ + { role: "user", content: "start" }, + { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "first", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", is_error: true, content: "failed" }] }, + ], + }, true, {}); + + const item = output.conversationState.currentMessage.userInputMessage + .userInputMessageContext.toolResults[0]; + expect(item.status).toBe("error"); + }); + + it("repairs partial parallel results in both direct translators", () => { + const claude = claudeToKiroRequest(modelId, { + tools: [tool("first"), tool("second")], + messages: [ + { role: "user", content: "start" }, + { role: "assistant", content: [ + { type: "tool_use", id: "t1", name: "first", input: {} }, + { type: "tool_use", id: "t2", name: "second", input: {} }, + ] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "one" }] }, + ], + }, true, {}); + const openai = openaiToKiroRequest(modelId, { + tools: [ + { type: "function", function: { name: "first", parameters: { type: "object", properties: {} } } }, + { type: "function", function: { name: "second", parameters: { type: "object", properties: {} } } }, + ], + messages: [ + { role: "user", content: "start" }, + { role: "assistant", content: "", tool_calls: [ + { id: "t1", type: "function", function: { name: "first", arguments: "{}" } }, + { id: "t2", type: "function", function: { name: "second", arguments: "{}" } }, + ] }, + { role: "tool", tool_call_id: "t1", content: "one" }, + ], + }, true, {}); + + for (const payload of [claude, openai]) { + const state = payload.conversationState; + const validation = validateKiroConversation( + state.history, + state.currentMessage, + state.currentMessage.userInputMessage.userInputMessageContext.tools + ); + expect(validation.valid).toBe(true); + expect(state.history[1].assistantResponseMessage.toolUses).toHaveLength(1); + } + }); + + it("does not let session replay replace a tool-result turn", () => { + const credentials = { + rawHeaders: { "x-session-id": "kiro-replay-tool-result-regression" }, + connectionId: "kiro-account", + }; + claudeToKiroRequest(modelId, { + messages: [{ role: "user", content: "frozen session start" }], + }, true, credentials); + + const output = claudeToKiroRequest(modelId, { + tools: [tool("first")], + messages: [ + { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "first", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "kept" }] }, + ], + }, true, credentials); + const state = output.conversationState; + const allText = JSON.stringify(state); + + expect(allText).toContain("frozen session start"); + expect(allText).toContain("kept"); + expect(validateKiroConversation( + state.history, + state.currentMessage, + state.currentMessage.userInputMessage.userInputMessageContext.tools + ).valid).toBe(true); + }); +}); 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-profile-arn.test.js b/tests/unit/kiro-profile-arn.test.js index 1925582b..bfa3906c 100644 --- a/tests/unit/kiro-profile-arn.test.js +++ b/tests/unit/kiro-profile-arn.test.js @@ -4,10 +4,8 @@ import { KiroService } from "../../src/lib/oauth/services/kiro.js"; /** * Regression tests for Kiro API-key auth. * - * KiroService.validateApiKey resolves a profileArn with the key (via - * CodeWhisperer ListAvailableProfiles) and returns a credential shaped for - * persistence with authMethod="api_key". The response profile field name - * varies (`arn` vs `profileArn`) — both are accepted by listAvailableProfiles. + * KiroService.validateApiKey validates against the Amazon Q model catalog and + * returns an account-bound credential without inventing a profileArn. * * Note: OAuth (Builder ID / IDC) profileArn resolution is handled upstream by * fetchKiroProfileArn in providers.js and is covered there — not here. @@ -16,11 +14,10 @@ describe("kiro API-key auth (KiroService.validateApiKey)", () => { beforeEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks()); - it("validates an API key and resolves a credential with profileArn", async () => { - const expectedArn = "arn:aws:codewhisperer:us-east-1:444:profile/KEY"; + it("validates an API key against Amazon Q without inventing profileArn", async () => { const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, - json: async () => ({ profiles: [{ arn: expectedArn }] }), + json: async () => ({ models: [{ modelId: "claude-opus-5" }] }), }); const svc = new KiroService(); @@ -29,17 +26,18 @@ describe("kiro API-key auth (KiroService.validateApiKey)", () => { expect(cred).toEqual({ accessToken: "my-secret-key", refreshToken: null, - profileArn: expectedArn, + profileArn: null, region: "us-east-1", authMethod: "api_key", }); const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe("https://codewhisperer.us-east-1.amazonaws.com"); - expect(init.headers.Authorization).toBe("Bearer my-secret-key"); - expect(init.headers["x-amz-target"]).toBe( - "AmazonCodeWhispererService.ListAvailableProfiles" + expect(url).toBe( + "https://q.us-east-1.amazonaws.com/ListAvailableModels?origin=AI_EDITOR" ); + expect(init.method).toBe("GET"); + expect(init.headers.Authorization).toBe("Bearer my-secret-key"); + expect(init.headers.TokenType).toBe("API_KEY"); }); it("rejects an empty API key without a network call", async () => { @@ -60,4 +58,15 @@ describe("kiro API-key auth (KiroService.validateApiKey)", () => { /API key validation failed/ ); }); + + it("rejects a 200 response with an empty model catalog", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ models: [] }), + }); + const svc = new KiroService(); + await expect(svc.validateApiKey("empty-key")).rejects.toThrow( + /returned no available models/ + ); + }); }); 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..17773bdb 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", @@ -328,6 +424,31 @@ describe("openaiToKiroRequest", () => { expect(result.additionalModelRequestFields).toBeUndefined(); }); + it.each([ + ["claude-sonnet-4.5-thinking-agentic(high)", "claude-sonnet-4.5"], + ["glm-5-thinking-agentic(medium)", "glm-5"], + ])("normalizes unsupported Kiro intensity suffix for %s", (model, upstream) => { + const result = openaiToKiroRequest(model, { + messages: [{ role: "user", content: "hello" }], + }, true, {}); + + expect(result.conversationState.currentMessage.userInputMessage.modelId).toBe(upstream); + expect(result.additionalModelRequestFields).toBeUndefined(); + expect(systemPromptOf(result)).toContain("CHUNKED WRITE PROTOCOL"); + }); + + it("maps a supported Kiro Claude intensity suffix to native effort fields", () => { + const result = openaiToKiroRequest("claude-sonnet-5-thinking-agentic(high)", { + messages: [{ role: "user", content: "hello" }], + }, true, {}); + + expect(result.conversationState.currentMessage.userInputMessage.modelId).toBe("claude-sonnet-5"); + expect(result.additionalModelRequestFields).toEqual({ + thinking: { type: "adaptive", display: "summarized" }, + output_config: { effort: "high" }, + }); + }); + it("does not send additionalModelRequestFields for date-suffixed Claude 4 model ids", () => { const body = { reasoning_effort: "high", diff --git a/tests/unit/thinking-levels-kiro.test.js b/tests/unit/thinking-levels-kiro.test.js new file mode 100644 index 00000000..edb3b7aa --- /dev/null +++ b/tests/unit/thinking-levels-kiro.test.js @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { getThinkingLevels } from "../../open-sse/providers/thinkingLevels.js"; + +describe("getThinkingLevels for Kiro", () => { + it("does not advertise native intensity for legacy Kiro models", () => { + expect(getThinkingLevels("kiro", "claude-sonnet-4.5")).toBeNull(); + expect(getThinkingLevels("kiro", "glm-5")).toBeNull(); + }); + + it("advertises native levels for supported Kiro models", () => { + expect(getThinkingLevels("kiro", "claude-sonnet-5")).toContain("high"); + expect(getThinkingLevels("kiro", "gpt-5.6-sol")).toContain("xhigh"); + }); +}); diff --git a/tests/unit/token-refresh-generic.test.js b/tests/unit/token-refresh-generic.test.js new file mode 100644 index 00000000..d4507c2e --- /dev/null +++ b/tests/unit/token-refresh-generic.test.js @@ -0,0 +1,146 @@ +/** + * Generic OAuth2 token refresh — config-driven profiles. + * + * Verifies refreshAccessToken() handles the 5 foldable providers + * (qwen, iflow, github, kimi, claude) via a REFRESH_PROFILES table, + * while preserving the legacy generic path for unknown providers. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const originalFetch = global.fetch; + +function mockFetchOnce(payload, { ok = true, status = 200 } = {}) { + const fn = vi.fn().mockResolvedValue({ + ok, + status, + json: () => Promise.resolve(payload), + text: () => Promise.resolve(JSON.stringify(payload)), + }); + global.fetch = fn; + return fn; +} + +describe("refreshAccessToken — config-driven profiles", () => { + beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); global.fetch = originalFetch; }); + afterEach(() => { global.fetch = originalFetch; }); + + it("qwen: form body + clientId, surfaces resource_url as providerSpecificData", async () => { + const fm = mockFetchOnce({ + access_token: "qw-acc", + refresh_token: "qw-refresh-rotated", + expires_in: 7200, + resource_url: "https://dashscope.aliyuncs.com", + }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + const out = await refreshAccessToken("qwen", "qw-old-refresh", {}, console); + + expect(out).toEqual({ + accessToken: "qw-acc", + refreshToken: "qw-refresh-rotated", + expiresIn: 7200, + providerSpecificData: { resourceUrl: "https://dashscope.aliyuncs.com" }, + }); + const [url, init] = fm.mock.calls[0]; + expect(init.method).toBe("POST"); + expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); + const body = new URLSearchParams(init.body); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("qw-old-refresh"); + expect(body.get("client_id")).toBeTruthy(); + }); + + it("iflow: Basic Auth header from clientId:clientSecret, form body keeps client_secret", async () => { + const fm = mockFetchOnce({ access_token: "if-acc", refresh_token: "if-rot", expires_in: 3600 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + await refreshAccessToken("iflow", "if-old", {}, console); + + const [, init] = fm.mock.calls[0]; + expect(init.headers["Authorization"]).toMatch(/^Basic /); + const body = new URLSearchParams(init.body); + expect(body.get("client_id")).toBeTruthy(); + expect(body.get("client_secret")).toBeTruthy(); + }); + + it("github: omits client_secret when config has none", async () => { + const fm = mockFetchOnce({ access_token: "gh-acc", expires_in: 28800 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + const out = await refreshAccessToken("github", "gh-old", {}, console); + + const body = new URLSearchParams(fm.mock.calls[0][1].body); + expect(body.get("client_secret")).toBeNull(); + expect(out.accessToken).toBe("gh-acc"); + expect(out.refreshToken).toBe("gh-old"); + }); + + it("kimi: merges X-Msh-* headers from credentials.providerSpecificData.deviceId", async () => { + const fm = mockFetchOnce({ access_token: "km-acc", expires_in: 86400 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + await refreshAccessToken("kimi", "km-old", { + providerSpecificData: { deviceId: "dev-xyz" }, + }, console); + + const headers = fm.mock.calls[0][1].headers; + // Kimi's buildKimiHeaders must contribute at least one X-Msh- header + const mshKeys = Object.keys(headers).filter((k) => k.toLowerCase().startsWith("x-msh-")); + expect(mshKeys.length).toBeGreaterThan(0); + }); + + it("claude: JSON body, client_id only (no client_secret)", async () => { + const fm = mockFetchOnce({ access_token: "cl-acc", refresh_token: "cl-rot", expires_in: 3600 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + await refreshAccessToken("claude", "cl-old", {}, console); + + const [, init] = fm.mock.calls[0]; + expect(init.headers["Content-Type"]).toBe("application/json"); + const parsed = JSON.parse(init.body); + expect(parsed.grant_type).toBe("refresh_token"); + expect(parsed.client_id).toBeTruthy(); + expect(parsed).not.toHaveProperty("client_secret"); + }); + + it("returns null on non-ok response", async () => { + mockFetchOnce({ error: "invalid_grant" }, { ok: false, status: 400 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + const out = await refreshAccessToken("qwen", "dead", {}, console); + expect(out).toBeNull(); + }); + + it("returns null when refreshToken missing", async () => { + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + const out = await refreshAccessToken("qwen", "", {}, console); + expect(out).toBeNull(); + }); + + it("dedupes concurrent calls with same refresh token (same dedupKey)", async () => { + const fm = mockFetchOnce({ access_token: "dd-acc", expires_in: 3600 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + const creds = { providerSpecificData: { deviceId: "d" } }; + await Promise.all([ + refreshAccessToken("kimi", "dup-refresh", creds, console), + refreshAccessToken("kimi", "dup-refresh", creds, console), + ]); + expect(fm).toHaveBeenCalledTimes(1); + }); +}); + +describe("refreshAccessToken — legacy generic path (no profile)", () => { + beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); global.fetch = originalFetch; }); + afterEach(() => { global.fetch = originalFetch; }); + + it("still works for an unprofiled provider via config.refreshUrl/clientId/clientSecret", async () => { + const fm = mockFetchOnce({ access_token: "gen-acc", expires_in: 3600 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + await refreshAccessToken("cline", "gen-old", {}, console); + + const body = new URLSearchParams(fm.mock.calls[0][1].body); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("client_id")).toBeTruthy(); + }); +}); diff --git a/tests/unit/tunnel-pid-ownership.test.js b/tests/unit/tunnel-pid-ownership.test.js new file mode 100644 index 00000000..57bc2bd0 --- /dev/null +++ b/tests/unit/tunnel-pid-ownership.test.js @@ -0,0 +1,39 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("cloudflared PID ownership", () => { + let dataDir; + + beforeEach(() => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-tunnel-pid-")); + process.env.DATA_DIR = dataDir; + vi.resetModules(); + }); + + afterEach(() => { + delete process.env.DATA_DIR; + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it("does not let an old child clear its successor PID", async () => { + const { clearPid, loadPid, savePid } = await import("../../src/lib/tunnel/cloudflare/pid.js"); + + savePid(100); + savePid(200); + clearPid(100); + + expect(loadPid()).toBe(200); + + clearPid(200); + expect(loadPid()).toBeNull(); + }); + + it("releases PID and process ownership for the exiting child only", () => { + const source = fs.readFileSync(new URL("../../src/lib/tunnel/cloudflare/cloudflared.js", import.meta.url), "utf8"); + + expect(source.match(/clearPid\(child\.pid\)/g)).toHaveLength(2); + expect(source.match(/cloudflaredProcess === child/g)).toHaveLength(2); + }); +}); diff --git a/tests/unit/usage-dispatch.test.js b/tests/unit/usage-dispatch.test.js index 4d63e815..d26741f1 100644 --- a/tests/unit/usage-dispatch.test.js +++ b/tests/unit/usage-dispatch.test.js @@ -15,7 +15,8 @@ const load = () => import("../../open-sse/services/usage.js"); const SUPPORTED = [ "github", "gemini-cli", "antigravity", "claude", "codex", "kiro", "qoder", "qwen", "iflow", "ollama", "glm", "glm-cn", - "minimax", "minimax-cn", "vercel-ai-gateway", "xai", "grok-cli", + "minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "kimi", "xai", + "deepseek", ]; describe("usage dispatch", () => { diff --git a/tests/unit/windsurf-executor.test.js b/tests/unit/windsurf-executor.test.js new file mode 100644 index 00000000..db8864f9 --- /dev/null +++ b/tests/unit/windsurf-executor.test.js @@ -0,0 +1,198 @@ +import { describe, it, expect } from "vitest"; +import { + resolveWsModelId, + buildGetChatMessageRequest, + grpcWebFrame, + decodeCompletionChunk, + default as WindsurfExecutor, +} from "open-sse/executors/windsurf.js"; +import { PROVIDERS } from "open-sse/config/providers.js"; + +// ─── Protobuf helpers for building expected wire bytes in tests ────────────── + +function encodeVarint(value) { + const bytes = []; + let v = value >>> 0; + while (v > 0x7f) { bytes.push((v & 0x7f) | 0x80); v >>>= 7; } + bytes.push(v & 0x7f); + return new Uint8Array(bytes); +} +function encodeLenField(fieldNum, payload) { + const tag = encodeVarint((fieldNum << 3) | 2); + const len = encodeVarint(payload.length); + const out = new Uint8Array(tag.length + len.length + payload.length); + out.set(tag, 0); out.set(len, tag.length); out.set(payload, tag.length + len.length); + return out; +} +function encodeStringField(fieldNum, str) { + return encodeLenField(fieldNum, new TextEncoder().encode(str)); +} + +describe("windsurf MODEL_ALIAS_MAP", () => { + it("maps SWE models to snake-case wire names", () => { + expect(resolveWsModelId("swe-1.6-fast")).toBe("swe-1-6-fast"); + expect(resolveWsModelId("swe-1.5")).toBe("swe-1-5"); + }); + it("maps Claude 4.5 to MODEL_PRIVATE_* aliases", () => { + expect(resolveWsModelId("claude-sonnet-4.5")).toBe("MODEL_PRIVATE_2"); + expect(resolveWsModelId("claude-opus-4.5")).toBe("MODEL_CLAUDE_4_5_OPUS"); + }); + it("applies default effort level for bare gpt-5.x ids", () => { + expect(resolveWsModelId("gpt-5.5")).toBe("gpt-5-5-medium"); + expect(resolveWsModelId("gpt-5.4")).toBe("gpt-5-4-medium"); + }); + it("passes through unknown ids as-is", () => { + expect(resolveWsModelId("custom-model")).toBe("custom-model"); + }); +}); + +describe("grpcWebFrame", () => { + it("prepends a 5-byte header: 0x00 flag + big-endian length", () => { + const payload = new Uint8Array([1, 2, 3, 4, 5]); + const frame = grpcWebFrame(payload); + expect(frame[0]).toBe(0x00); + const view = new DataView(frame.buffer); + expect(view.getUint32(1, false)).toBe(5); // big-endian length + expect(Array.from(frame.slice(5))).toEqual([1, 2, 3, 4, 5]); + }); + it("encodes empty payload as a 5-byte frame", () => { + const frame = grpcWebFrame(new Uint8Array(0)); + expect(frame.length).toBe(5); + expect(frame[0]).toBe(0x00); + }); +}); + +describe("buildGetChatMessageRequest", () => { + it("emits metadata (field 1), cascade_id (2), model (3), messages (4+)", () => { + const payload = buildGetChatMessageRequest("sk-ws-test", "swe-1.6", [ + { role: "user", content: "hello" }, + ]); + expect(payload.length).toBeGreaterThan(10); + // First byte 0x0a = field 1, wire type 2 (length-delimited) → metadata present + expect(payload[0]).toBe(0x0a); + }); + + it("embeds the apiKey inside the metadata sub-message", () => { + const payload = buildGetChatMessageRequest("sk-ws-secret", "gpt-5", []); + // The metadata bytes are the first length-delimited field — should contain the key. + const asString = new TextDecoder().decode(payload); + expect(asString).toContain("sk-ws-secret"); + // And the IDE identification fields. + expect(asString).toContain("windsurf"); + expect(asString).toContain("3.14.0"); + }); + + it("appends one field-4 message per chat message", () => { + // Proper top-level protobuf field counter (byte 0x22 collides with content bytes). + const countField = (buf, target) => { + let offset = 0; + let count = 0; + while (offset < buf.length) { + let result = 0, shift = 0; + while (offset < buf.length) { + const b = buf[offset++]; + result |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7; + } + const fieldNum = result >>> 3; + const wireType = result & 0x07; + if (wireType === 2) { + let len = 0, ls = 0; + while (offset < buf.length) { + const b = buf[offset++]; + len |= (b & 0x7f) << ls; + if ((b & 0x80) === 0) break; + ls += 7; + } + if (fieldNum === target) count++; + offset += len; + } else if (wireType === 0) { + while (offset < buf.length) { + const b = buf[offset++]; + if ((b & 0x80) === 0) break; + } + } else if (wireType === 1) { + offset += 8; + } else if (wireType === 5) { + offset += 4; + } else { + break; + } + } + return count; + }; + const one = buildGetChatMessageRequest("k", "m", [{ role: "user", content: "a" }]); + const two = buildGetChatMessageRequest("k", "m", [ + { role: "user", content: "a" }, + { role: "assistant", content: "b" }, + ]); + expect(countField(one, 4)).toBe(1); + expect(countField(two, 4)).toBe(2); + }); +}); + +describe("decodeCompletionChunk", () => { + it("decodes a ContentChunk (field 1 → text)", () => { + const chunk = encodeLenField(1, encodeStringField(1, "hello world")); + const decoded = decodeCompletionChunk(chunk); + expect(decoded).toEqual({ kind: "content", text: "hello world" }); + }); + + it("decodes an ErrorChunk (field 4 → message)", () => { + const chunk = encodeLenField(4, encodeStringField(1, "quota exhausted")); + const decoded = decodeCompletionChunk(chunk); + expect(decoded).toEqual({ kind: "error", message: "quota exhausted" }); + }); + + it("decodes a DoneChunk (field 3 → UsageStats with prompt/completion tokens)", () => { + // UsageStats: field 1 = prompt_tokens (varint), field 2 = completion_tokens (varint) + const usage = new Uint8Array([...encodeVarint((1 << 3) | 0), ...encodeVarint(42), ...encodeVarint((2 << 3) | 0), ...encodeVarint(99)]); + const doneChunk = encodeLenField(3, encodeLenField(1, usage)); + const decoded = decodeCompletionChunk(doneChunk); + expect(decoded.kind).toBe("done"); + expect(decoded.promptTokens).toBe(42); + expect(decoded.completionTokens).toBe(99); + }); + + it("returns { kind: 'unknown' } for empty buffer", () => { + expect(decodeCompletionChunk(new Uint8Array(0))).toEqual({ kind: "unknown" }); + }); +}); + +describe("WindsurfExecutor class", () => { + it("constructor wires config from PROVIDERS.windsurf", () => { + const ex = new WindsurfExecutor(); + expect(ex.provider).toBe("windsurf"); + expect(ex.config).toBeDefined(); + expect(ex.config.baseUrl).toContain("server.self-serve.windsurf.com"); + expect(typeof ex.execute).toBe("function"); + }); + + it("buildHeaders emits grpc-web+proto + Bearer token", () => { + const ex = new WindsurfExecutor(); + const h = ex.buildHeaders({ accessToken: "sk-ws-abc" }); + expect(h["Content-Type"]).toBe("application/grpc-web+proto"); + expect(h.Accept).toBe("application/grpc-web+proto"); + expect(h["X-Grpc-Web"]).toBe("1"); + expect(h.Authorization).toBe("Bearer sk-ws-abc"); + expect(h["User-Agent"]).toMatch(/^windsurf\//); + }); + + it("buildHeaders omits Authorization when no token", () => { + const ex = new WindsurfExecutor(); + const h = ex.buildHeaders({}); + expect(h.Authorization).toBeUndefined(); + }); + + it("buildUrl returns the GetChatMessage endpoint", () => { + const ex = new WindsurfExecutor(); + expect(ex.buildUrl()).toBe("https://server.self-serve.windsurf.com/exa.language_server_pb.LanguageServerService/GetChatMessage"); + }); + + it("PROVIDERS.windsurf baseUrl is the chat endpoint (registry in sync)", () => { + expect(PROVIDERS.windsurf.baseUrl).toBe( + "https://server.self-serve.windsurf.com/exa.language_server_pb.LanguageServerService/GetChatMessage" + ); + }); +});