merge: sync master into dev
Sync master into dev while preserving dev-specific feature logic and include the required co-author trailer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@@ -4,6 +4,25 @@
|
||||
- **Dokploy**: deploy production Compose from GitHub Actions with serialized rollout tracking, terminal-status handling, health verification, and deployment summaries
|
||||
- **GitHub Actions**: remove unrelated Docker image publishing and GitBook Pages deployment workflows
|
||||
|
||||
# v0.5.40 (2026-07-20)
|
||||
|
||||
## Features
|
||||
- **i18n**: add Khmer (km) translations
|
||||
- **CLI tools**: configure Grok Build subagent models
|
||||
- **Kimi**: merge OAuth into dual-auth provider, add K3 / K2.7 models
|
||||
- **Dashboard**: ProviderTopology flow animation
|
||||
|
||||
## Fixes
|
||||
- **DB**: resolve better-sqlite3 parameter binding crash
|
||||
- **Translator**: pass `service_tier` through OpenAI → Responses conversion
|
||||
- **Kiro**: map GPT-5.6 reasoning effort fields
|
||||
- **Kiro**: validate terminal streams before emitting output
|
||||
- **Kiro**: map GPT reasoning effort fields
|
||||
- **Codex**: current `client_version` + refresh-aware model sync
|
||||
- **Alicode-intl**: split into Coding Plan + Model Studio providers
|
||||
- **Cursor**: HTTP/2 AgentService support + version bump 3.12.17
|
||||
- **Dashboard**: cut duplicate API/icon spam, lazy-load provider assets
|
||||
|
||||
# v0.5.35 (2026-07-16)
|
||||
|
||||
## Fixes
|
||||
@@ -14,7 +33,7 @@
|
||||
- **User quota**: show remaining Orbit and Codex headroom in the users table with session and weekly usage details
|
||||
- **Orbit Provider**: add Anthropic-compatible API-key routing for Claude Opus 4.6–4.8 models
|
||||
- **xAI**: Grok Imagine video generation (`/v1/videos`) + CLI
|
||||
- **CLI tools**: Grok Build setup — writes `[model.9router]` to `~/.grok/config.toml`
|
||||
- **CLI tools**: Grok Build setup — choose separate main/general-purpose/explore/plan models and preserve each model's context window
|
||||
- **GitHub Copilot**: route Claude models through Copilot's native `/v1/messages`
|
||||
- **Kiro**: add GPT-5.6 model family (#2596)
|
||||
- **RTK**: `X-9Router-Token-Saver` header to bypass token savers per request
|
||||
|
||||
@@ -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:
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="320">
|
||||
<a href="https://www.youtube.com/watch?v=X69n5Lm06Yw">
|
||||
<img src="https://img.youtube.com/vi/X69n5Lm06Yw/maxresdefault.jpg" alt="Tiết kiệm chi phí LLM với 9Router" width="300"/>
|
||||
</a><br/>
|
||||
<b>🇻🇳 Tiếng Việt</b><br/>
|
||||
<sub>Tiết kiệm chi phí LLM cho OpenClaw với 9Router<br/>by <a href="https://www.youtube.com/c/M%C3%ACAIblog">Mì AI</a></sub>
|
||||
</td>
|
||||
<td align="center" width="320">
|
||||
<td align="center" width="320">
|
||||
<a href="https://www.youtube.com/watch?v=X69n5Lm06Yw">
|
||||
<img src="https://img.youtube.com/vi/X69n5Lm06Yw/maxresdefault.jpg" alt="Tiết kiệm chi phí LLM với 9Router" width="300"/>
|
||||
</a><br/>
|
||||
<b>🇻🇳 Tiếng Việt</b><br/>
|
||||
<sub>Tiết kiệm chi phí LLM cho OpenClaw với 9Router<br/>by <a href="https://www.youtube.com/c/M%C3%ACAIblog">Mì AI</a></sub>
|
||||
</td>
|
||||
<td align="center" width="320">
|
||||
<a href="https://youtu.be/VQAw612S27Y">
|
||||
<img src="https://img.youtube.com/vi/VQAw612S27Y/maxresdefault.jpg" alt="9Router + Claude Code FREE Unlimited Setup" width="300"/>
|
||||
</a><br/>
|
||||
@@ -148,10 +148,7 @@ Default URLs:
|
||||
<b>🇺🇸 English</b><br/>
|
||||
<sub>9Router + Claude Code FREE Setup<br/>by <a href="https://www.youtube.com/@BuildAIWithHamid">Build AI With Hamid</a></sub>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="320">
|
||||
<td align="center" width="320">
|
||||
<a href="https://youtu.be/3dF5GIYMrcQ?si=bAyfyiHbARJQAHj_">
|
||||
<img src="https://img.youtube.com/vi/3dF5GIYMrcQ/hqdefault.jpg" alt="9Router Setup Tutorial" width="300"/>
|
||||
</a><br/>
|
||||
@@ -165,6 +162,8 @@ Default URLs:
|
||||
<b>🇺🇸 English</b><br/>
|
||||
<sub>Claude Code FREE Forever — Unlimited Models<br/>by <a href="https://www.youtube.com/@BuildAIWithHamid">Build AI With Hamid</a></sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="320">
|
||||
<a href="https://www.youtube.com/watch?v=Ttpc26m39Dw">
|
||||
<img src="https://img.youtube.com/vi/Ttpc26m39Dw/maxresdefault.jpg" alt="Claude CLI Free Setup" width="300"/>
|
||||
@@ -172,10 +171,7 @@ Default URLs:
|
||||
<b>🇺🇸 English</b><br/>
|
||||
<sub>Claude CLI Free Setup with 9Router 🚀<br/>by <a href="https://www.youtube.com/@CodeVerseSoban">CodeVerse Soban</a></sub>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="320">
|
||||
<td align="center" width="320">
|
||||
<a href="https://www.youtube.com/watch?v=G-5A_D5Pm6Y">
|
||||
<img src="https://img.youtube.com/vi/G-5A_D5Pm6Y/maxresdefault.jpg" alt="Cài đặt OpenClaw Free A-Z" width="300"/>
|
||||
</a><br/>
|
||||
@@ -196,17 +192,15 @@ Default URLs:
|
||||
<b>🇮🇩 Indonesia</b><br/>
|
||||
<sub>Koding 24 Jam Anti Rate Limit! Hemat Token AI 65% | Tutorial Quick Setup 9Router 🚀<br/>by <a href="https://www.youtube.com/@krisswuh">Krisswuh</a></sub>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center" width="320">
|
||||
<td align="center" width="320">
|
||||
<a href="https://www.youtube.com/watch?v=TXGv4eofe1I">
|
||||
<img src="https://img.youtube.com/vi/TXGv4eofe1I/mqdefault.jpg" alt="Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB" width="300"/>
|
||||
</a><br/>
|
||||
<b>🇮🇩 Indonesia</b><br/>
|
||||
<sub>Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB<br/>by <a href="https://www.youtube.com/@krisswuh">Krisswuh</a></sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="320">
|
||||
<a href="https://www.youtube.com/watch?v=GyX-DLvePW8">
|
||||
<img src="https://img.youtube.com/vi/GyX-DLvePW8/hqdefault.jpg" alt="این شکلی از هر API ای استفاده کن برای هوش مصنوعی" width="300"/>
|
||||
@@ -214,8 +208,17 @@ Default URLs:
|
||||
<b>🇮🇷 Persian-فارسی</b><br/>
|
||||
<sub dir="rtl">این شکلی از هر API ای استفاده کن برای هوش مصنوعی<br/>by <a href="https://www.youtube.com/@Matin_SenPai">Matin SenPai</a></sub>
|
||||
</td>
|
||||
<td align="center" width="320">
|
||||
<a href="https://www.youtube.com/watch?v=hPusYX-5Pmw">
|
||||
<img src="https://img.youtube.com/vi/hPusYX-5Pmw/maxresdefault.jpg" alt="Hướng Dẫn Setup OpenClaw + 9Router: Tạo Bot Zalo AI Tự Động Từ A-Z" width="300"/>
|
||||
</a><br/>
|
||||
<b>🇻🇳 Tiếng Việt</b><br/>
|
||||
<sub>Hướng Dẫn Setup OpenClaw + 9Router: Tạo Bot Zalo AI Tự Động Từ A-Z<br/>by <a href="https://github.com/tuanminhhole">tuanminhhole</a></sub>
|
||||
</td>
|
||||
<td align="center" width="320"></td>
|
||||
<td align="center" width="320"></td>
|
||||
<td align="center" width="320"></td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</div>
|
||||
@@ -330,12 +333,12 @@ Default URLs:
|
||||
<td align="center" width="150">
|
||||
<img src="./public/providers/kiro.png" width="70" alt="Kiro"/><br/>
|
||||
<b>Kiro AI</b><br/>
|
||||
<sub>Claude 4.5 + GLM-5 + MiniMax<br/>Unlimited FREE</sub>
|
||||
<sub>Claude 4.5 + GLM-5 + MiniMax<br/>50 credits/month free</sub>
|
||||
</td>
|
||||
<td align="center" width="150">
|
||||
<img src="./public/providers/opencode.png" width="70" alt="OpenCode Free"/><br/>
|
||||
<b>OpenCode Free</b><br/>
|
||||
<sub>No auth • Auto-fetch models<br/>Unlimited FREE</sub>
|
||||
<sub>No auth • Auto-fetch models<br/>Free (model list varies)</sub>
|
||||
</td>
|
||||
<td align="center" width="150">
|
||||
<img src="./public/providers/gemini.png" width="70" alt="Vertex AI"/><br/>
|
||||
@@ -346,7 +349,11 @@ Default URLs:
|
||||
</table>
|
||||
</div>
|
||||
|
||||
> **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/<auto> (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
|
||||
<details>
|
||||
<summary><b>🆓 Are FREE providers really unlimited?</b></summary>
|
||||
|
||||
**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.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
<td align="center" width="150">
|
||||
<img src="./public/providers/kiro.png" width="70" alt="Kiro"/><br/>
|
||||
<b>Kiro AI</b><br/>
|
||||
<sub>Claude 4.5 + GLM-5 + MiniMax<br/>无限免费</sub>
|
||||
<sub>Claude 4.5 + GLM-5 + MiniMax<br/>每月 50 积分免费</sub>
|
||||
</td>
|
||||
<td align="center" width="150">
|
||||
<img src="./public/providers/opencode.png" width="70" alt="OpenCode Free"/><br/>
|
||||
<b>OpenCode Free</b><br/>
|
||||
<sub>无需认证 • 自动获取模型<br/>无限免费</sub>
|
||||
<sub>无需认证 • 自动获取模型<br/>免费(模型列表会变)</sub>
|
||||
</td>
|
||||
<td align="center" width="150">
|
||||
<img src="./public/providers/gemini.png" width="70" alt="Vertex AI"/><br/>
|
||||
@@ -295,7 +295,11 @@ PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run
|
||||
</table>
|
||||
</div>
|
||||
|
||||
> **注意:** 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/<auto> (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
|
||||
<details>
|
||||
<summary><b>🆓 免费提供商真的是无限量的吗?</b></summary>
|
||||
|
||||
**是的!** 当前的免费提供商(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 取代)。已停止 — 请勿使用。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const { spawn, exec, execSync } = require("child_process");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
const net = require("net");
|
||||
const os = require("os");
|
||||
|
||||
@@ -25,6 +26,42 @@ function waitServerReady(port, { timeoutMs = 15000, intervalMs = 150 } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// Native spinner - no external dependency
|
||||
function createSpinner(text) {
|
||||
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
let i = 0;
|
||||
let interval = null;
|
||||
let currentText = text;
|
||||
return {
|
||||
start() {
|
||||
if (process.stdout.isTTY) {
|
||||
process.stdout.write(`\r${frames[0]} ${currentText}`);
|
||||
interval = setInterval(() => {
|
||||
process.stdout.write(`\r${frames[i++ % frames.length]} ${currentText}`);
|
||||
}, 80);
|
||||
}
|
||||
return this;
|
||||
},
|
||||
stop() {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
if (process.stdout.isTTY) {
|
||||
process.stdout.write("\r\x1b[K");
|
||||
}
|
||||
},
|
||||
succeed(msg) {
|
||||
this.stop();
|
||||
console.log(`✅ ${msg}`);
|
||||
},
|
||||
fail(msg) {
|
||||
this.stop();
|
||||
console.log(`❌ ${msg}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const pkg = require("./package.json");
|
||||
const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRuntime");
|
||||
const { ensureTrayRuntime } = require("./hooks/trayRuntime");
|
||||
@@ -53,6 +90,7 @@ try { ensureTrayRuntime({ silent: true }); } catch {}
|
||||
|
||||
// Configuration constants
|
||||
const APP_NAME = pkg.name; // Use from package.json
|
||||
const INSTALL_CMD_LATEST = `npm i -g ${APP_NAME}@latest --prefer-online`;
|
||||
|
||||
const DEFAULT_PORT = 20128;
|
||||
const DEFAULT_HOST = "0.0.0.0";
|
||||
@@ -81,6 +119,7 @@ const PROCESS_IDENTIFIERS = [
|
||||
let port = DEFAULT_PORT;
|
||||
let host = DEFAULT_HOST;
|
||||
let noBrowser = false;
|
||||
let skipUpdate = false;
|
||||
let showLog = false;
|
||||
let trayMode = false;
|
||||
|
||||
@@ -95,6 +134,8 @@ for (let i = 0; i < args.length; i++) {
|
||||
noBrowser = true;
|
||||
} else if (args[i] === "--log" || args[i] === "-l") {
|
||||
showLog = true;
|
||||
} else if (args[i] === "--skip-update") {
|
||||
skipUpdate = true;
|
||||
} else if (args[i] === "--tray" || args[i] === "-t") {
|
||||
trayMode = true;
|
||||
process.env.TRAY_MODE = "1";
|
||||
@@ -108,6 +149,7 @@ Options:
|
||||
-n, --no-browser Don't open browser automatically
|
||||
-l, --log Show server logs (default: hidden)
|
||||
-t, --tray Run in system tray mode (background)
|
||||
--skip-update Skip auto-update check
|
||||
-h, --help Show this help message
|
||||
-v, --version Show version
|
||||
|
||||
@@ -123,9 +165,26 @@ Commands:
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-relaunch after update: detached process has no TTY → fallback to tray
|
||||
if (skipUpdate && !trayMode && !process.stdin.isTTY) {
|
||||
trayMode = true;
|
||||
process.env.TRAY_MODE = "1";
|
||||
}
|
||||
|
||||
// Always use Node.js runtime with absolute path
|
||||
const RUNTIME = process.execPath;
|
||||
|
||||
// Compare semver versions: returns 1 if a > b, -1 if a < b, 0 if equal
|
||||
function compareVersions(a, b) {
|
||||
const partsA = a.split(".").map(Number);
|
||||
const partsB = b.split(".").map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (partsA[i] > partsB[i]) return 1;
|
||||
if (partsA[i] < partsB[i]) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get app data dir (matches app/src/lib/dataDir.js convention)
|
||||
function getAppDataDir() {
|
||||
return process.platform === "win32"
|
||||
@@ -150,14 +209,54 @@ function killByPidFile(pidFile) {
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// Kill tunnel processes (cloudflared/tailscale) by their PID files
|
||||
function killTunnelByPidFile() {
|
||||
const tunnelDir = path.join(getAppDataDir(), "tunnel");
|
||||
killByPidFile(path.join(tunnelDir, "cloudflared.pid"));
|
||||
killByPidFile(path.join(tunnelDir, "tailscale.pid"));
|
||||
}
|
||||
|
||||
// Kill cloudflared whose --url targets this app's port (covers stale PID file case)
|
||||
function killCloudflaredByAppPort(appPort) {
|
||||
if (!appPort) return [];
|
||||
const portMatchers = [`localhost:${appPort}`, `127.0.0.1:${appPort}`];
|
||||
const pids = [];
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"cloudflared.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`;
|
||||
const output = execSync(psCmd, { encoding: "utf8", windowsHide: true, timeout: 5000 });
|
||||
const lines = output.split("\n").slice(1).filter(l => l.trim());
|
||||
lines.forEach(line => {
|
||||
if (portMatchers.some(m => line.includes(m))) {
|
||||
const match = line.match(/^"(\d+)"/);
|
||||
if (match && match[1]) pids.push(match[1]);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const output = execSync("ps -eo pid,command 2>/dev/null", { encoding: "utf8", timeout: 5000 });
|
||||
output.split("\n").forEach(line => {
|
||||
if (line.includes("cloudflared") && portMatchers.some(m => line.includes(m))) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
const pid = parts[0];
|
||||
if (pid && !isNaN(pid)) pids.push(pid);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch { }
|
||||
return pids;
|
||||
}
|
||||
|
||||
// Kill all 9router processes
|
||||
function killAllAppProcesses(appPort) {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
// MITM runs on a separate process and does not block the critical path.
|
||||
// Background: MITM + tunnel/cloudflared run on separate ports/processes —
|
||||
// killing them doesn't free the app port, so don't block the critical path.
|
||||
// Server-side MITM manager has stale-lock recovery and starts deferred (~3s).
|
||||
setImmediate(() => {
|
||||
try { killProxyByPidFile(); } catch {}
|
||||
try { killTunnelByPidFile(); } catch {}
|
||||
try { killCloudflaredByAppPort(appPort); } catch {}
|
||||
});
|
||||
|
||||
const platform = process.platform;
|
||||
@@ -356,6 +455,55 @@ function isRestrictedEnvironment() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if new version available, return latest version or null
|
||||
function checkForUpdate() {
|
||||
return new Promise((resolve) => {
|
||||
if (skipUpdate) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = createSpinner("Checking for updates...").start();
|
||||
let resolved = false;
|
||||
|
||||
const safetyTimeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
spinner.stop();
|
||||
resolve(null);
|
||||
}
|
||||
}, 8000);
|
||||
|
||||
const done = (version) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
clearTimeout(safetyTimeout);
|
||||
spinner.stop();
|
||||
resolve(version);
|
||||
};
|
||||
|
||||
const req = https.get(`https://registry.npmjs.org/${pkg.name}/latest`, { timeout: 3000 }, (res) => {
|
||||
let data = "";
|
||||
res.on("data", chunk => data += chunk);
|
||||
res.on("end", () => {
|
||||
try {
|
||||
const latest = JSON.parse(data);
|
||||
if (latest.version && compareVersions(latest.version, pkg.version) > 0) {
|
||||
done(latest.version);
|
||||
} else {
|
||||
done(null);
|
||||
}
|
||||
} catch (e) {
|
||||
done(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", () => done(null));
|
||||
req.on("timeout", () => { req.destroy(); done(null); });
|
||||
});
|
||||
}
|
||||
|
||||
// Open browser
|
||||
function openBrowser(url) {
|
||||
const platform = process.platform;
|
||||
@@ -390,24 +538,39 @@ if (!fs.existsSync(serverPath)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Start server immediately; run update check in parallel (not on the critical path).
|
||||
const updatePromise = checkForUpdate();
|
||||
killAllAppProcesses(port)
|
||||
.then(() => killProcessOnPort(port))
|
||||
.then(() => startServer());
|
||||
.then(() => startServer(updatePromise));
|
||||
|
||||
// Show interface selection menu
|
||||
async function showInterfaceMenu() {
|
||||
async function showInterfaceMenu(latestVersion) {
|
||||
const { selectMenu } = require("./src/cli/utils/input");
|
||||
const { clearScreen } = require("./src/cli/utils/display");
|
||||
const { getEndpoint } = require("./src/cli/utils/endpoint");
|
||||
|
||||
clearScreen();
|
||||
|
||||
const displayHost = getDisplayHost();
|
||||
|
||||
const serverUrl = `http://${displayHost}:${port}`;
|
||||
// Detect tunnel/local mode for server URL display
|
||||
let serverUrl;
|
||||
try {
|
||||
const { endpoint, tunnelEnabled } = await getEndpoint(port);
|
||||
serverUrl = tunnelEnabled ? endpoint.replace(/\/v1$/, "") : `http://${displayHost}:${port}`;
|
||||
} catch (e) {
|
||||
serverUrl = `http://${displayHost}:${port}`;
|
||||
}
|
||||
|
||||
const subtitle = `🚀 Server: \x1b[32m${serverUrl}\x1b[0m`;
|
||||
|
||||
const menuItems = [];
|
||||
|
||||
if (latestVersion) {
|
||||
menuItems.push({ label: `Update to v${latestVersion} (current: v${pkg.version})`, icon: "⬆" });
|
||||
}
|
||||
|
||||
menuItems.push(
|
||||
{ label: "Web UI (Open in Browser)", icon: "🌐" },
|
||||
{ label: "Terminal UI (Interactive CLI)", icon: "💻" },
|
||||
@@ -417,16 +580,21 @@ async function showInterfaceMenu() {
|
||||
|
||||
const selected = await selectMenu(`Choose Interface (v${pkg.version})`, menuItems, 0, subtitle);
|
||||
|
||||
if (selected === 0) return "web";
|
||||
if (selected === 1) return "terminal";
|
||||
if (selected === 2) return "hide";
|
||||
const offset = latestVersion ? 1 : 0;
|
||||
|
||||
if (latestVersion && selected === 0) return "update";
|
||||
if (selected === offset) return "web";
|
||||
if (selected === offset + 1) return "terminal";
|
||||
if (selected === offset + 2) return "hide";
|
||||
return "exit";
|
||||
}
|
||||
|
||||
const MAX_RESTARTS = 2;
|
||||
const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s
|
||||
|
||||
function startServer() {
|
||||
function startServer(updatePromise) {
|
||||
// Accept either a Promise (parallel update check) or a resolved value.
|
||||
const latestVersionPromise = Promise.resolve(updatePromise);
|
||||
const displayHost = getDisplayHost();
|
||||
const url = `http://${displayHost}:${port}/dashboard`;
|
||||
// Surface real network exposure when bound to all interfaces (default 0.0.0.0).
|
||||
@@ -444,7 +612,7 @@ function startServer() {
|
||||
function spawnServer() {
|
||||
serverStartTime = Date.now();
|
||||
crashLog = [];
|
||||
const child = spawn(RUNTIME, ["--max-old-space-size=6144", serverPath], {
|
||||
const child = spawn(RUNTIME, ["--dns-result-order=ipv4first", "--max-old-space-size=6144", serverPath], {
|
||||
cwd: standaloneDir,
|
||||
stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"],
|
||||
detached: true,
|
||||
@@ -480,6 +648,8 @@ function startServer() {
|
||||
} catch (e) { }
|
||||
// Kill MIT server (privileged process) via PID file
|
||||
killProxyByPidFile();
|
||||
// Kill cloudflared/tailscale via PID file (only this app's tunnel)
|
||||
killTunnelByPidFile();
|
||||
// Kill server process directly
|
||||
if (server.pid) {
|
||||
process.kill(server.pid, "SIGKILL");
|
||||
@@ -556,14 +726,28 @@ function startServer() {
|
||||
|
||||
// Wait for server to be ready, then show interface menu loop + tray
|
||||
waitServerReady(port).then(async () => {
|
||||
// Resolve parallel update check (already running); don't block server start on it.
|
||||
const latestVersion = await latestVersionPromise;
|
||||
// Start tray icon alongside TUI
|
||||
initTrayIcon();
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const choice = await showInterfaceMenu();
|
||||
const choice = await showInterfaceMenu(latestVersion);
|
||||
|
||||
if (choice === "web") {
|
||||
if (choice === "update") {
|
||||
isShuttingDown = true;
|
||||
const { clearScreen } = require("./src/cli/utils/display");
|
||||
clearScreen();
|
||||
console.log(`\n⬆ Update v${pkg.version} → v${latestVersion}\n`);
|
||||
console.log(`Run this after exit:\n`);
|
||||
console.log(` \x1b[33m${INSTALL_CMD_LATEST}\x1b[0m\n`);
|
||||
cleanup();
|
||||
await killAllAppProcesses(port);
|
||||
await killProcessOnPort(port);
|
||||
setTimeout(() => process.exit(0), 200);
|
||||
return;
|
||||
} else if (choice === "web") {
|
||||
openBrowser(url);
|
||||
// Wait for user to come back
|
||||
const { pause } = require("./src/cli/utils/input");
|
||||
@@ -601,7 +785,7 @@ function startServer() {
|
||||
// Windows/Linux: spawn detached bgProcess (systray works fine in child)
|
||||
console.log(`\n⏳ Starting background process... (tray icon will appear in ~3s)`);
|
||||
|
||||
const bgProcess = spawn(process.execPath, [__filename, "--tray", "-p", port.toString()], {
|
||||
const bgProcess = spawn(process.execPath, ["--dns-result-order=ipv4first", __filename, "--tray", "--skip-update", "-p", port.toString()], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "9router",
|
||||
"version": "0.5.35",
|
||||
"version": "0.5.40",
|
||||
"description": "9Router CLI - Start and manage 9Router server",
|
||||
"bin": {
|
||||
"9router": "./cli.js"
|
||||
|
||||
@@ -131,7 +131,7 @@ const APIKEY_PROVIDERS = {
|
||||
openrouter: { id: "openrouter", name: "OpenRouter" },
|
||||
glm: { id: "glm", name: "GLM Coding" },
|
||||
minimax: { id: "minimax", name: "Minimax Coding" },
|
||||
kimi: { id: "kimi", name: "Kimi Coding" },
|
||||
kimi: { id: "kimi", name: "Kimi" },
|
||||
openai: { id: "openai", name: "OpenAI" },
|
||||
anthropic: { id: "anthropic", name: "Anthropic" },
|
||||
gemini: { id: "gemini", name: "Gemini" },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { platform, arch } from "os";
|
||||
import { platform, arch, hostname } from "os";
|
||||
import { PROVIDERS, PROVIDER_OAUTH } from "./providers.js";
|
||||
import { ANTIGRAVITY_IDE_USER_AGENT } from "../providers/shared.js";
|
||||
import { createRequire } from "module";
|
||||
|
||||
// === Gemini CLI === derive từ registry gemini-cli.transport
|
||||
export const GEMINI_CLI_VERSION = PROVIDERS["gemini-cli"]?.cliVersion;
|
||||
@@ -171,12 +172,44 @@ export const OAUTH_ENDPOINTS = {
|
||||
github: { token: PROVIDER_OAUTH["github"]?.tokenUrl, auth: PROVIDER_OAUTH["github"]?.authorizeUrl, deviceCode: PROVIDER_OAUTH["github"]?.deviceCodeUrl },
|
||||
};
|
||||
|
||||
// Generate Kimi OAuth custom headers
|
||||
export function buildKimiHeaders() {
|
||||
let _appVersion;
|
||||
function getAppPackageVersion() {
|
||||
if (_appVersion) return _appVersion;
|
||||
try {
|
||||
const require = createRequire(import.meta.url);
|
||||
_appVersion = require("../../package.json").version || "0.0.0";
|
||||
} catch {
|
||||
_appVersion = process.env.npm_package_version || "0.0.0";
|
||||
}
|
||||
return _appVersion;
|
||||
}
|
||||
|
||||
// Kimi Code OAuth / API headers (CLIProxyAPI internal/auth/kimi commonHeaders parity).
|
||||
// deviceId must stay stable per connection for the whole OAuth session.
|
||||
export function buildKimiHeaders(deviceId) {
|
||||
const osName = platform();
|
||||
const architecture = arch();
|
||||
let deviceModel = `${osName} ${architecture}`;
|
||||
if (osName === "darwin") deviceModel = `macOS ${architecture}`;
|
||||
else if (osName === "win32") deviceModel = `Windows ${architecture}`;
|
||||
else if (osName === "linux") deviceModel = `Linux ${architecture}`;
|
||||
|
||||
let deviceName = "unknown";
|
||||
try {
|
||||
deviceName = hostname() || "unknown";
|
||||
} catch {
|
||||
deviceName = "unknown";
|
||||
}
|
||||
|
||||
const resolvedId = (typeof deviceId === "string" && deviceId.trim())
|
||||
? deviceId.trim()
|
||||
: `kimi-${Date.now()}`;
|
||||
|
||||
return {
|
||||
"X-Msh-Platform": "9router",
|
||||
"X-Msh-Version": "2.1.2",
|
||||
"X-Msh-Device-Model": typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown",
|
||||
"X-Msh-Device-Id": `kimi-${Date.now()}`
|
||||
"X-Msh-Version": getAppPackageVersion(),
|
||||
"X-Msh-Device-Name": deviceName,
|
||||
"X-Msh-Device-Model": deviceModel,
|
||||
"X-Msh-Device-Id": resolvedId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* - `-agentic` model suffix detection + chunked-write system prompt
|
||||
* - reasoning / thinking trigger detection (Anthropic-Beta header,
|
||||
* Claude `thinking`, OpenAI `reasoning_effort`, AMP/Cursor magic tag)
|
||||
* - the `<thinking_mode>enabled</thinking_mode>` system-prompt injection
|
||||
* that turns Kiro reasoning on
|
||||
* - schema-specific native effort fields for supported GPT and Claude models
|
||||
* - legacy `<thinking_mode>` system-prompt injection for other models
|
||||
*
|
||||
* Kiro upstream does not advertise `-agentic` model IDs; they are a 9router
|
||||
* fiction. The suffix is stripped before the request leaves this process.
|
||||
@@ -109,6 +109,7 @@ export function resolveKiroThinkingBudget(body, headers, model) {
|
||||
const cfg = extractThinking(body);
|
||||
if (cfg) {
|
||||
if (cfg.mode === "none") return null;
|
||||
if (cfg.mode === "level" && cfg.level === "disabled") return null;
|
||||
if (cfg.mode === "budget") return cfg.budget;
|
||||
if (cfg.mode === "level") return effortToBudget(cfg.level) ?? KIRO_THINKING_BUDGET_DEFAULT;
|
||||
return KIRO_THINKING_BUDGET_DEFAULT;
|
||||
@@ -144,9 +145,30 @@ export function extractKiroEffortLevel(body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildKiroAdditionalModelRequestFields(body) {
|
||||
const effort = extractKiroEffortLevel(body);
|
||||
function extractKiroGptEffortLevel(body) {
|
||||
const effort =
|
||||
body?.output_config?.effort ??
|
||||
body?.reasoning_effort ??
|
||||
(typeof body?.reasoning === "object" ? body.reasoning?.effort : null);
|
||||
if (typeof effort !== "string") return null;
|
||||
const normalized = effort.toLowerCase();
|
||||
if (normalized === "max") return "xhigh";
|
||||
// Kiro CLI does not advertise an explicit GPT "none" wire value; omit it.
|
||||
if (["low", "medium", "high", "xhigh"].includes(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildKiroAdditionalModelRequestFields(body, effortPath = "output_config") {
|
||||
const effort = effortPath === "reasoning"
|
||||
? extractKiroGptEffortLevel(body)
|
||||
: extractKiroEffortLevel(body);
|
||||
if (!effort) return undefined;
|
||||
if (effortPath === "reasoning") {
|
||||
// Mirrors Kiro CLI/KAS buildEffortRequestFields("reasoning") for GPT.
|
||||
return { reasoning: { effort } };
|
||||
}
|
||||
// Mirrors Kiro CLI/KAS buildEffortRequestFields("output_config").
|
||||
return {
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
@@ -154,12 +176,15 @@ export function buildKiroAdditionalModelRequestFields(body) {
|
||||
};
|
||||
}
|
||||
|
||||
export function supportsKiroAdditionalModelRequestFields(model) {
|
||||
if (typeof model !== "string") return false;
|
||||
export function resolveKiroEffortPath(model) {
|
||||
if (typeof model !== "string") return null;
|
||||
const normalized = model.toLowerCase().replace(/-/g, ".");
|
||||
if (!normalized.includes("claude")) return false;
|
||||
if (/(?:^|[/.])gpt[/.]5[/.]6(?:[/.]|$)/.test(normalized)) {
|
||||
return "reasoning";
|
||||
}
|
||||
if (!normalized.includes("claude")) return null;
|
||||
const match = normalized.match(/(?:^|[/.])claude(?:[/.][a-z]+)*[/.](\d+)(?:[/.](\d+))?(?:[/.]|$)/);
|
||||
if (!match) return false;
|
||||
if (!match) return null;
|
||||
const [, majorText, minorText] = match;
|
||||
const major = Number(majorText);
|
||||
const minor = minorText === undefined ? null : Number(minorText);
|
||||
@@ -167,12 +192,24 @@ export function supportsKiroAdditionalModelRequestFields(model) {
|
||||
// Kiro rejected additionalModelRequestFields on legacy 4.5 models in live smoke.
|
||||
// Default future Claude/Kiro models to supported so new model releases do not
|
||||
// need a code allowlist update.
|
||||
return !(major < 4 || (major === 4 && (minor === null || minor <= 5 || dateSuffixMinor)));
|
||||
return major < 4 || (major === 4 && (minor === null || minor <= 5 || dateSuffixMinor))
|
||||
? null
|
||||
: "output_config";
|
||||
}
|
||||
|
||||
export function supportsKiroAdditionalModelRequestFields(model) {
|
||||
return resolveKiroEffortPath(model) !== null;
|
||||
}
|
||||
|
||||
export function usesKiroNativeGptEffort(body, model) {
|
||||
return resolveKiroEffortPath(model) === "reasoning"
|
||||
&& extractKiroGptEffortLevel(body) !== null;
|
||||
}
|
||||
|
||||
export function buildKiroAdditionalModelRequestFieldsForModel(body, model) {
|
||||
if (!supportsKiroAdditionalModelRequestFields(model)) return undefined;
|
||||
return buildKiroAdditionalModelRequestFields(body);
|
||||
const effortPath = resolveKiroEffortPath(model);
|
||||
if (!effortPath) return undefined;
|
||||
return buildKiroAdditionalModelRequestFields(body, effortPath);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { PROVIDERS, PROVIDER_OAUTH } from "../config/providers.js";
|
||||
import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||
import {
|
||||
generateCursorBody,
|
||||
encodeField,
|
||||
wrapConnectRPCFrame,
|
||||
decodeMessage,
|
||||
parseConnectRPCFrame,
|
||||
extractTextFromResponse
|
||||
} from "../utils/cursorProtobuf.js";
|
||||
@@ -13,6 +16,7 @@ import { chatChunkSse } from "../utils/sse.js";
|
||||
import { FORMATS } from "../translator/formats.js";
|
||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
import zlib from "zlib";
|
||||
import crypto from "crypto";
|
||||
|
||||
// Detect cloud environment
|
||||
const isCloudEnv = () => {
|
||||
@@ -38,6 +42,130 @@ const COMPRESS_FLAG = {
|
||||
GZIP_TRAILER: 0x03
|
||||
};
|
||||
|
||||
const AGENT_RUN_PATH = "/agent.v1.AgentService/Run";
|
||||
const PROTOBUF_LEN = 2;
|
||||
const PROTOBUF_VARINT = 0;
|
||||
|
||||
function concatBuffers(...parts) {
|
||||
const length = parts.reduce((total, part) => total + part.length, 0);
|
||||
const result = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
result.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const agentString = (field, value) => encodeField(field, PROTOBUF_LEN, value);
|
||||
const agentMessage = (field, value) => encodeField(field, PROTOBUF_LEN, value);
|
||||
const agentBool = (field, value) => encodeField(field, PROTOBUF_VARINT, value ? 1 : 0);
|
||||
|
||||
function textFromContent(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
||||
.map((part) => part.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function isAgentTextRequest(body) {
|
||||
// Many compatible clients always attach their built-in tool schemas, even
|
||||
// for a normal text turn. Cursor's retired ChatService rejects those
|
||||
// requests; AgentService can still answer the text turn, so ignore schemas
|
||||
// here. A real tool-call/result conversation is kept on the legacy path
|
||||
// until its AgentService tool protocol is implemented.
|
||||
return Array.isArray(body?.messages) && body.messages.every((message) => {
|
||||
if (message?.tool_calls?.length || message?.role === "tool") return false;
|
||||
return typeof message?.content === "string"
|
||||
|| Array.isArray(message?.content) && message.content.every((part) => part?.type === "text");
|
||||
});
|
||||
}
|
||||
|
||||
function encodeHistoryMessage(message) {
|
||||
const content = textFromContent(message?.content);
|
||||
if (!content) return null;
|
||||
|
||||
// ConversationHistoryMessage.user / .assistant -> repeated content -> text.
|
||||
const text = agentString(1, content);
|
||||
if (message.role === "assistant") {
|
||||
return agentMessage(2, agentMessage(1, agentMessage(1, text)));
|
||||
}
|
||||
return agentMessage(1, agentMessage(1, agentMessage(1, text)));
|
||||
}
|
||||
|
||||
function buildAgentRunFrame(messages, model) {
|
||||
const system = messages
|
||||
.filter((message) => message?.role === "system")
|
||||
.map((message) => textFromContent(message.content))
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const chatMessages = messages.filter((message) => message?.role !== "system");
|
||||
const currentIndex = [...chatMessages].map((message) => message?.role).lastIndexOf("user");
|
||||
const current = currentIndex >= 0 ? chatMessages[currentIndex] : chatMessages.at(-1);
|
||||
const history = chatMessages
|
||||
.slice(0, currentIndex >= 0 ? currentIndex : -1)
|
||||
.map(encodeHistoryMessage)
|
||||
.filter(Boolean);
|
||||
const userText = textFromContent(current?.content) || "Continue.";
|
||||
|
||||
// agent.v1.UserMessageAction.user_message and its optional history.
|
||||
const userMessage = concatBuffers(
|
||||
agentString(1, userText),
|
||||
agentString(2, crypto.randomUUID()),
|
||||
);
|
||||
const conversationHistory = history.length
|
||||
? concatBuffers(...history.map((entry) => agentMessage(1, entry)))
|
||||
: null;
|
||||
const userAction = concatBuffers(
|
||||
agentMessage(1, userMessage),
|
||||
...(conversationHistory ? [agentMessage(7, conversationHistory)] : []),
|
||||
);
|
||||
const conversationAction = agentMessage(1, userAction);
|
||||
const requestedModel = concatBuffers(agentString(1, model), agentBool(7, true));
|
||||
const runRequest = concatBuffers(
|
||||
// An empty ConversationStateStructure starts a fresh local agent session.
|
||||
agentMessage(1, new Uint8Array()),
|
||||
agentMessage(2, conversationAction),
|
||||
...(system ? [agentString(8, system)] : []),
|
||||
agentMessage(9, requestedModel),
|
||||
);
|
||||
|
||||
// agent.v1.AgentClientMessage.run_request.
|
||||
return wrapConnectRPCFrame(agentMessage(1, runRequest));
|
||||
}
|
||||
|
||||
function extractAgentString(message, field) {
|
||||
const value = message?.get(field)?.[0]?.value;
|
||||
return value ? Buffer.from(value).toString("utf8") : "";
|
||||
}
|
||||
|
||||
function decodeAgentFrames(buffer, onFrame) {
|
||||
let pending = Buffer.from(buffer || []);
|
||||
while (pending.length >= 5) {
|
||||
const flags = pending[0];
|
||||
const length = pending.readUInt32BE(1);
|
||||
if (pending.length < 5 + length) break;
|
||||
let payload = pending.subarray(5, 5 + length);
|
||||
pending = pending.subarray(5 + length);
|
||||
if (flags & COMPRESS_FLAG.GZIP) {
|
||||
payload = zlib.gunzipSync(payload);
|
||||
}
|
||||
if (!(flags & COMPRESS_FLAG.TRAILER)) onFrame(payload);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
function createRequestContextResponse() {
|
||||
// AgentService asks every run for client context. 9router has no IDE file
|
||||
// context, so acknowledge with an empty RequestContext.
|
||||
const requestContextSuccess = agentMessage(1, new Uint8Array());
|
||||
const requestContextResult = agentMessage(1, requestContextSuccess);
|
||||
const execClientMessage = agentMessage(10, requestContextResult);
|
||||
return wrapConnectRPCFrame(agentMessage(2, execClientMessage));
|
||||
}
|
||||
|
||||
const CURSOR_STREAM_DEBUG = process.env.CURSOR_STREAM_DEBUG === "1";
|
||||
const debugLog = (...args) => {
|
||||
if (CURSOR_STREAM_DEBUG) console.log(...args);
|
||||
@@ -253,7 +381,293 @@ export class CursorExecutor extends BaseExecutor {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentService (agent.api5.cursor.sh) is HTTP/2-only. Node's fetch/undici speaks
|
||||
* HTTP/1.1 and fails with HTTPParserError on the h2 preface — use http2 duplex.
|
||||
*/
|
||||
openAgentHttp2Stream(url, headers, signal) {
|
||||
if (!http2) {
|
||||
throw new Error("HTTP/2 is required for Cursor AgentService (endpoint is h2-only)");
|
||||
}
|
||||
|
||||
const urlObj = new URL(url);
|
||||
const client = http2.connect(`https://${urlObj.host}`);
|
||||
const chunkQueue = [];
|
||||
let waiting = null;
|
||||
let ended = false;
|
||||
let streamError = null;
|
||||
let req = null;
|
||||
|
||||
const wake = (result) => {
|
||||
if (!waiting) return;
|
||||
const resolve = waiting;
|
||||
waiting = null;
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const fail = (error) => {
|
||||
if (streamError) return;
|
||||
streamError = error;
|
||||
ended = true;
|
||||
wake(null);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
try { req?.destroy(); } catch {}
|
||||
try { client.close(); } catch {}
|
||||
};
|
||||
|
||||
client.on("error", fail);
|
||||
|
||||
req = client.request({
|
||||
":method": "POST",
|
||||
":path": urlObj.pathname,
|
||||
":authority": urlObj.host,
|
||||
":scheme": "https",
|
||||
...headers,
|
||||
});
|
||||
|
||||
req.on("error", fail);
|
||||
req.on("data", (chunk) => {
|
||||
if (waiting) wake({ value: chunk, done: false });
|
||||
else chunkQueue.push(chunk);
|
||||
});
|
||||
req.on("end", () => {
|
||||
ended = true;
|
||||
wake({ value: undefined, done: true });
|
||||
});
|
||||
|
||||
if (signal) {
|
||||
const onAbort = () => {
|
||||
fail(new Error("Request aborted"));
|
||||
close();
|
||||
};
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
const responseHeaders = new Promise((resolve, reject) => {
|
||||
const onEarlyError = (error) => reject(error);
|
||||
client.once("error", onEarlyError);
|
||||
req.once("error", onEarlyError);
|
||||
req.once("response", (hdrs) => {
|
||||
client.off("error", onEarlyError);
|
||||
req.off("error", onEarlyError);
|
||||
resolve(hdrs);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
responseHeaders,
|
||||
write(frame) {
|
||||
if (req && !req.destroyed) req.write(Buffer.from(frame));
|
||||
},
|
||||
end() {
|
||||
try { if (req && !req.destroyed) req.end(); } catch {}
|
||||
},
|
||||
close,
|
||||
async read() {
|
||||
if (chunkQueue.length) return { value: chunkQueue.shift(), done: false };
|
||||
if (ended) {
|
||||
if (streamError) throw streamError;
|
||||
return { value: undefined, done: true };
|
||||
}
|
||||
const result = await new Promise((resolve) => { waiting = resolve; });
|
||||
if (streamError) throw streamError;
|
||||
return result || { value: undefined, done: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async executeAgent({ model, body, stream, credentials, signal }) {
|
||||
const agentEndpoint = PROVIDER_OAUTH.cursor?.agentEndpoint;
|
||||
if (!agentEndpoint) throw new Error("Cursor AgentService endpoint is not configured");
|
||||
|
||||
const url = `${agentEndpoint}${AGENT_RUN_PATH}`;
|
||||
const headers = this.buildHeaders(credentials);
|
||||
const requestController = new AbortController();
|
||||
if (signal?.addEventListener) {
|
||||
signal.addEventListener("abort", () => requestController.abort(signal.reason), { once: true });
|
||||
}
|
||||
|
||||
let session;
|
||||
try {
|
||||
session = this.openAgentHttp2Stream(url, headers, requestController.signal);
|
||||
session.write(buildAgentRunFrame(body.messages || [], model));
|
||||
} catch (error) {
|
||||
throw new Error(`Cursor AgentService request failed: ${error.message}`);
|
||||
}
|
||||
|
||||
let responseHeaders;
|
||||
try {
|
||||
responseHeaders = await session.responseHeaders;
|
||||
} catch (error) {
|
||||
session.close();
|
||||
throw new Error(`Cursor AgentService request failed: ${error.message}`);
|
||||
}
|
||||
|
||||
const status = Number(responseHeaders[":status"] || 0);
|
||||
if (status !== 200) {
|
||||
let errorText = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await session.read();
|
||||
if (done) break;
|
||||
errorText += Buffer.from(value).toString("utf8");
|
||||
}
|
||||
} catch {}
|
||||
session.close();
|
||||
return {
|
||||
response: new Response(JSON.stringify({
|
||||
error: { message: `Cursor AgentService ${status}: ${errorText || "request failed"}`, type: "api_error" },
|
||||
}), { status: status || HTTP_STATUS.SERVER_ERROR, headers: { "Content-Type": "application/json" } }),
|
||||
url,
|
||||
headers,
|
||||
transformedBody: body,
|
||||
responseFormat: FORMATS.OPENAI,
|
||||
};
|
||||
}
|
||||
|
||||
// The Claude SSE translator derives Anthropic's message ID by stripping
|
||||
// `chatcmpl-`. Keep the remaining ID in Anthropic's required `msg_` form
|
||||
// so strict clients such as Claude Code accept the completed stream.
|
||||
const responseId = `chatcmpl-msg_${Date.now()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
let pending = Buffer.alloc(0);
|
||||
let finished = false;
|
||||
|
||||
const consume = async (onEvent) => {
|
||||
try {
|
||||
while (!finished) {
|
||||
const { done, value } = await session.read();
|
||||
if (done) break;
|
||||
pending = Buffer.concat([pending, Buffer.from(value)]);
|
||||
pending = decodeAgentFrames(pending, (payload) => {
|
||||
const serverMessage = decodeMessage(payload);
|
||||
|
||||
// agent.v1.AgentServerMessage.interaction_update
|
||||
if (serverMessage.has(1)) {
|
||||
const update = decodeMessage(serverMessage.get(1)[0].value);
|
||||
if (update.has(1)) {
|
||||
const textDelta = extractAgentString(decodeMessage(update.get(1)[0].value), 1);
|
||||
if (textDelta) onEvent({ type: "text", value: textDelta });
|
||||
}
|
||||
// Cursor's AgentService emits internal reasoning without the
|
||||
// cryptographic signature required by Anthropic thinking blocks.
|
||||
// Forwarding it makes strict Anthropic clients (Claude Code)
|
||||
// discard or wait on an otherwise complete response. Keep the
|
||||
// reasoning upstream-only and emit the normal answer text.
|
||||
if (update.has(14)) {
|
||||
finished = true;
|
||||
onEvent({ type: "done" });
|
||||
}
|
||||
}
|
||||
|
||||
// AgentService requests IDE context before producing a response.
|
||||
// Return an empty context; 9router is not coupled to an editor.
|
||||
if (serverMessage.has(2)) {
|
||||
const execRequest = decodeMessage(serverMessage.get(2)[0].value);
|
||||
if (execRequest.has(10)) {
|
||||
session.write(createRequestContextResponse());
|
||||
} else {
|
||||
finished = true;
|
||||
onEvent({ type: "error", value: "Cursor AgentService requested an unsupported IDE tool" });
|
||||
onEvent({ type: "done" });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
try { session.end(); } catch {}
|
||||
try { session.close(); } catch {}
|
||||
if (!finished) onEvent({ type: "done" });
|
||||
}
|
||||
};
|
||||
|
||||
if (stream === false) {
|
||||
let content = "";
|
||||
let reasoning = "";
|
||||
let agentError = null;
|
||||
await consume((event) => {
|
||||
if (event.type === "text") content += event.value;
|
||||
else if (event.type === "thinking") reasoning += event.value;
|
||||
else if (event.type === "error") agentError = event.value;
|
||||
});
|
||||
if (agentError) {
|
||||
return {
|
||||
response: new Response(JSON.stringify({ error: { message: agentError, type: "api_error" } }), {
|
||||
status: HTTP_STATUS.BAD_REQUEST,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
url,
|
||||
headers,
|
||||
transformedBody: body,
|
||||
responseFormat: FORMATS.OPENAI,
|
||||
};
|
||||
}
|
||||
return {
|
||||
response: new Response(JSON.stringify({
|
||||
id: responseId,
|
||||
object: "chat.completion",
|
||||
created,
|
||||
model,
|
||||
choices: [{ index: 0, message: { role: "assistant", content: content || null, ...(reasoning ? { reasoning_content: reasoning } : {}) }, finish_reason: "stop" }],
|
||||
usage: estimateUsage(body, content.length, FORMATS.OPENAI),
|
||||
}), { headers: { "Content-Type": "application/json" } }),
|
||||
url,
|
||||
headers,
|
||||
transformedBody: body,
|
||||
responseFormat: FORMATS.OPENAI,
|
||||
};
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const responseStream = new ReadableStream({
|
||||
start(controller) {
|
||||
consume((event) => {
|
||||
if (event.type === "text") {
|
||||
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { content: event.value } })));
|
||||
} else if (event.type === "thinking") {
|
||||
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { reasoning_content: event.value } })));
|
||||
} else if (event.type === "error") {
|
||||
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { content: `\n[${event.value}]` } })));
|
||||
} else if (event.type === "done") {
|
||||
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: {}, finishReason: "stop" })));
|
||||
controller.enqueue(encoder.encode(SSE_DONE));
|
||||
controller.close();
|
||||
}
|
||||
}).catch((error) => controller.error(error));
|
||||
},
|
||||
cancel() {
|
||||
requestController.abort();
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
response: new Response(responseStream, { headers: SSE_HEADERS }),
|
||||
url,
|
||||
headers,
|
||||
transformedBody: body,
|
||||
responseFormat: FORMATS.OPENAI,
|
||||
};
|
||||
}
|
||||
|
||||
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
|
||||
if (isAgentTextRequest(body)) {
|
||||
try {
|
||||
return await this.executeAgent({ model, body, stream, credentials, signal });
|
||||
} catch (error) {
|
||||
return {
|
||||
response: new Response(JSON.stringify({
|
||||
error: { message: error.message, type: "connection_error", code: "" },
|
||||
}), { status: HTTP_STATUS.SERVER_ERROR, headers: { "Content-Type": "application/json" } }),
|
||||
url: `${PROVIDER_OAUTH.cursor?.agentEndpoint || ""}${AGENT_RUN_PATH}`,
|
||||
headers: {},
|
||||
transformedBody: body,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const url = this.buildUrl();
|
||||
const headers = this.buildHeaders(credentials);
|
||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -291,12 +291,16 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
|
||||
// Execute request
|
||||
let providerResponse, providerUrl, providerHeaders, finalBody;
|
||||
// Most executors return their registry format. Cursor AgentService is an
|
||||
// exception: it is decoded by the executor into OpenAI-compatible output.
|
||||
let providerResponseFormat = targetFormat;
|
||||
try {
|
||||
const result = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions });
|
||||
providerResponse = result.response;
|
||||
providerUrl = result.url;
|
||||
providerHeaders = result.headers;
|
||||
finalBody = result.transformedBody;
|
||||
providerResponseFormat = result.responseFormat || targetFormat;
|
||||
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
|
||||
} catch (error) {
|
||||
trackPendingRequest(model, provider, connectionId, false, true);
|
||||
@@ -335,7 +339,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
}
|
||||
try {
|
||||
const retryResult = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions });
|
||||
if (retryResult.response.ok) { providerResponse = retryResult.response; providerUrl = retryResult.url; }
|
||||
if (retryResult.response.ok) {
|
||||
providerResponse = retryResult.response;
|
||||
providerUrl = retryResult.url;
|
||||
providerResponseFormat = retryResult.responseFormat || targetFormat;
|
||||
}
|
||||
} catch { log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); }
|
||||
} else {
|
||||
log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`);
|
||||
@@ -382,14 +390,14 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
|
||||
// True non-streaming response
|
||||
if (!stream) {
|
||||
const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, reqLogger, toolNameMap, trackDone, appendLog });
|
||||
const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, reqLogger, toolNameMap, trackDone, appendLog });
|
||||
streamController.handleComplete();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Streaming response
|
||||
const { onStreamComplete, streamDetailId } = buildOnStreamComplete({ ...sharedCtx });
|
||||
return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId });
|
||||
return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete, streamDetailId });
|
||||
}
|
||||
|
||||
export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -96,10 +96,23 @@ export const MODEL_CAPABILITIES = {
|
||||
// Qwen plain coder/text (no vision) — registry "vision-model" / "coder-model" aliases
|
||||
"vision-model": { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 },
|
||||
"coder-model": { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 },
|
||||
|
||||
// Kimi flagship + coding (platform + Kimi Code ids) — vision/video native
|
||||
"kimi-k3": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 131072 },
|
||||
"k3": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 131072 },
|
||||
"kimi-for-coding": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 },
|
||||
"kimi-for-coding-highspeed": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 },
|
||||
"kimi-k2.7-code": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 },
|
||||
"kimi-k2.7-code-highspeed": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 },
|
||||
};
|
||||
|
||||
const KIRO_GPT_5_6_CAPABILITIES = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 };
|
||||
|
||||
// Codex OAuth (ChatGPT backend) — per-model context window reported by upstream
|
||||
// (lower than OpenAI API's 1.05M). Sol differs from Terra/Luna. #2720
|
||||
const CODEX_GPT_56_SOL_CAPS = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 372000, maxOutput: 128000 };
|
||||
const CODEX_GPT_56_DEFAULT_CAPS = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 };
|
||||
|
||||
/**
|
||||
* Provider-specific capability overrides. Keyed by provider alias/id.
|
||||
*/
|
||||
@@ -113,6 +126,14 @@ export const PROVIDER_CAPABILITIES = {
|
||||
"deepseek-ai/deepseek-v4-pro": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 65536 },
|
||||
"deepseek-ai/deepseek-v4-flash": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 65536 },
|
||||
},
|
||||
"codex": {
|
||||
"gpt-5.6-sol": CODEX_GPT_56_SOL_CAPS,
|
||||
"gpt-5.6-sol-review": CODEX_GPT_56_SOL_CAPS,
|
||||
"gpt-5.6-terra": CODEX_GPT_56_DEFAULT_CAPS,
|
||||
"gpt-5.6-terra-review": CODEX_GPT_56_DEFAULT_CAPS,
|
||||
"gpt-5.6-luna": CODEX_GPT_56_DEFAULT_CAPS,
|
||||
"gpt-5.6-luna-review": CODEX_GPT_56_DEFAULT_CAPS,
|
||||
},
|
||||
"kiro": {
|
||||
"gpt-5.6-sol": KIRO_GPT_5_6_CAPABILITIES,
|
||||
"gpt-5.6-terra": KIRO_GPT_5_6_CAPABILITIES,
|
||||
@@ -222,7 +243,9 @@ export const PATTERN_CAPABILITIES = [
|
||||
{ pattern: "*qwen*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } },
|
||||
|
||||
// ── Kimi (enabled→reasoning_effort; K2.7-code cannot disable) ─────
|
||||
{ pattern: "*kimi*k2.7*code*", caps: { vision: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 262144 } },
|
||||
{ pattern: "*kimi*k3*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 131072 } },
|
||||
{ pattern: "*kimi*for-coding*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 } },
|
||||
{ pattern: "*kimi*k2.7*code*", caps: { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 } },
|
||||
{ pattern: "*kimi*k2*", caps: { vision: true, reasoning: true, thinkingFormat: "kimi", contextWindow: 262144, maxOutput: 262144 } },
|
||||
{ pattern: "*kimi*", caps: { reasoning: true, thinkingFormat: "kimi", contextWindow: 262144 } },
|
||||
|
||||
|
||||
@@ -75,10 +75,18 @@ export const MODEL_PRICING = {
|
||||
"qwen3-coder-flash": { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 },
|
||||
|
||||
// === Kimi ===
|
||||
// Official platform.kimi.ai: cache-hit / cache-miss / output per 1M tokens
|
||||
"kimi-k3": { input: 3.00, output: 15.00, cached: 0.30, reasoning: 15.00, cache_creation: 3.00 },
|
||||
"k3": { input: 3.00, output: 15.00, cached: 0.30, reasoning: 15.00, cache_creation: 3.00 },
|
||||
"kimi-k2.7-code": { input: 0.95, output: 4.00, cached: 0.19, reasoning: 4.00, cache_creation: 0.95 },
|
||||
"kimi-k2.7-code-highspeed": { input: 1.90, output: 8.00, cached: 0.38, reasoning: 8.00, cache_creation: 1.90 },
|
||||
"kimi-for-coding": { input: 0.95, output: 4.00, cached: 0.19, reasoning: 4.00, cache_creation: 0.95 },
|
||||
"kimi-for-coding-highspeed": { input: 1.90, output: 8.00, cached: 0.38, reasoning: 8.00, cache_creation: 1.90 },
|
||||
"kimi-k2": { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 },
|
||||
"kimi-k2-thinking": { input: 1.50, output: 6.00, cached: 0.75, reasoning: 9.00, cache_creation: 1.50 },
|
||||
"kimi-k2.5": { input: 1.20, output: 4.80, cached: 0.60, reasoning: 7.20, cache_creation: 1.20 },
|
||||
"kimi-k2.5-thinking": { input: 1.80, output: 7.20, cached: 0.90, reasoning: 10.80, cache_creation: 1.80 },
|
||||
"kimi-k2.6": { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 },
|
||||
"kimi-latest": { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 },
|
||||
|
||||
// === DeepSeek ===
|
||||
@@ -185,6 +193,7 @@ export const PATTERN_PRICING = [
|
||||
|
||||
// --- Kimi ---
|
||||
{ pattern: "kimi-*-thinking", pricing: { input: 1.80, output: 7.20, cached: 0.90, reasoning: 10.80, cache_creation: 1.80 } },
|
||||
{ pattern: "kimi-k3*", pricing: { input: 3.00, output: 15.00, cached: 0.30, reasoning: 15.00, cache_creation: 3.00 } },
|
||||
{ pattern: "kimi-k2*", pricing: { input: 1.20, output: 4.80, cached: 0.60, reasoning: 7.20, cache_creation: 1.20 } },
|
||||
{ pattern: "kimi-*", pricing: { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 } },
|
||||
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -52,53 +52,53 @@ import p49 from "./jina-ai.js";
|
||||
import p50 from "./jina-reader.js";
|
||||
import p51 from "./kilocode.js";
|
||||
import p52 from "./kimchi.js";
|
||||
import p53 from "./kimi-coding.js";
|
||||
import p54 from "./kimi.js";
|
||||
import p55 from "./kiro.js";
|
||||
import p56 from "./linkup.js";
|
||||
import p57 from "./local-device.js";
|
||||
import p58 from "./mimo-free.js";
|
||||
import p59 from "./minimax-cn.js";
|
||||
import p60 from "./minimax.js";
|
||||
import p61 from "./mistral.js";
|
||||
import p62 from "./mmf.js";
|
||||
import p63 from "./nanobanana.js";
|
||||
import p64 from "./nebius.js";
|
||||
import p65 from "./nvidia.js";
|
||||
import p66 from "./ollama-local.js";
|
||||
import p67 from "./ollama.js";
|
||||
import p68 from "./openai.js";
|
||||
import p69 from "./opencode-go.js";
|
||||
import p70 from "./opencode.js";
|
||||
import p71 from "./openrouter.js";
|
||||
import p72 from "./perplexity-web.js";
|
||||
import p73 from "./perplexity.js";
|
||||
import p74 from "./perplexity-agent.js";
|
||||
import p75 from "./playht.js";
|
||||
import p76 from "./qoder.js";
|
||||
import p77 from "./qwen.js";
|
||||
import p78 from "./recraft.js";
|
||||
import p79 from "./runwayml.js";
|
||||
import p80 from "./sdwebui.js";
|
||||
import p81 from "./searchapi.js";
|
||||
import p82 from "./searxng.js";
|
||||
import p83 from "./serper.js";
|
||||
import p84 from "./siliconflow.js";
|
||||
import p85 from "./stability-ai.js";
|
||||
import p86 from "./tavily.js";
|
||||
import p87 from "./together.js";
|
||||
import p88 from "./topaz.js";
|
||||
import p89 from "./tortoise.js";
|
||||
import p90 from "./venice.js";
|
||||
import p91 from "./vercel-ai-gateway.js";
|
||||
import p92 from "./vertex-partner.js";
|
||||
import p93 from "./vertex.js";
|
||||
import p94 from "./volcengine-ark.js";
|
||||
import p95 from "./voyage-ai.js";
|
||||
import p96 from "./xai.js";
|
||||
import p97 from "./xiaomi-mimo.js";
|
||||
import p98 from "./xiaomi-tokenplan.js";
|
||||
import p99 from "./youcom.js";
|
||||
import p53 from "./kimi.js";
|
||||
import p54 from "./kiro.js";
|
||||
import p55 from "./linkup.js";
|
||||
import p56 from "./local-device.js";
|
||||
import p57 from "./mimo-free.js";
|
||||
import p58 from "./minimax-cn.js";
|
||||
import p59 from "./minimax.js";
|
||||
import p60 from "./mistral.js";
|
||||
import p61 from "./mmf.js";
|
||||
import p62 from "./nanobanana.js";
|
||||
import p63 from "./nebius.js";
|
||||
import p64 from "./nvidia.js";
|
||||
import p65 from "./ollama-local.js";
|
||||
import p66 from "./ollama.js";
|
||||
import p67 from "./openai.js";
|
||||
import p68 from "./opencode-go.js";
|
||||
import p69 from "./opencode.js";
|
||||
import p70 from "./openrouter.js";
|
||||
import p71 from "./perplexity-web.js";
|
||||
import p72 from "./perplexity.js";
|
||||
import p73 from "./perplexity-agent.js";
|
||||
import p74 from "./playht.js";
|
||||
import p75 from "./qoder.js";
|
||||
import p76 from "./qwen.js";
|
||||
import p77 from "./recraft.js";
|
||||
import p78 from "./runwayml.js";
|
||||
import p79 from "./sdwebui.js";
|
||||
import p80 from "./searchapi.js";
|
||||
import p81 from "./searxng.js";
|
||||
import p82 from "./serper.js";
|
||||
import p83 from "./siliconflow.js";
|
||||
import p84 from "./stability-ai.js";
|
||||
import p85 from "./tavily.js";
|
||||
import p86 from "./together.js";
|
||||
import p87 from "./topaz.js";
|
||||
import p88 from "./tortoise.js";
|
||||
import p89 from "./venice.js";
|
||||
import p90 from "./vercel-ai-gateway.js";
|
||||
import p91 from "./vertex-partner.js";
|
||||
import p92 from "./vertex.js";
|
||||
import p93 from "./volcengine-ark.js";
|
||||
import p94 from "./voyage-ai.js";
|
||||
import p95 from "./xai.js";
|
||||
import p96 from "./xiaomi-mimo.js";
|
||||
import p97 from "./xiaomi-tokenplan.js";
|
||||
import p98 from "./youcom.js";
|
||||
import p99 from "./alims-intl.js";
|
||||
import p100 from "./orbit-provider.js";
|
||||
|
||||
export default [
|
||||
@@ -202,5 +202,5 @@ export default [
|
||||
p97,
|
||||
p98,
|
||||
p99,
|
||||
p100
|
||||
p100,
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -1,9 +1,14 @@
|
||||
import { CLAUDE_API_HEADERS, KIMI_CODING_BASE_URL } from "../shared.js";
|
||||
import { CLAUDE_API_HEADERS } from "../shared.js";
|
||||
|
||||
// Dual auth (same pattern as xai): OAuth = Kimi Code subscription (device code),
|
||||
// API key = platform.moonshot / api.kimi.com. Transport is shared.
|
||||
// CLIProxyAPI parity: client_id, auth.kimi.com device+token, X-Msh-* headers, device_id.
|
||||
export default {
|
||||
id: "kimi",
|
||||
priority: 170,
|
||||
alias: "kimi",
|
||||
// Legacy id + short alias from former kimi-coding registry entry
|
||||
aliases: ["kimi-coding", "kmc"],
|
||||
display: {
|
||||
name: "Kimi",
|
||||
icon: "psychology",
|
||||
@@ -12,18 +17,25 @@ export default {
|
||||
website: "https://kimi.moonshot.cn",
|
||||
notice: {
|
||||
apiKeyUrl: "https://platform.moonshot.ai/console/api-keys",
|
||||
signupUrl: "https://www.kimi.com/code",
|
||||
},
|
||||
},
|
||||
category: "apikey",
|
||||
category: "oauth",
|
||||
authModes: ["oauth", "apikey"],
|
||||
hasOAuth: true,
|
||||
transport: {
|
||||
baseUrl: "https://api.kimi.com/coding/v1/messages",
|
||||
format: "claude",
|
||||
urlSuffix: "?beta=true",
|
||||
headers: { ...CLAUDE_API_HEADERS },
|
||||
clientId: "17e5f671-d194-4dfb-9706-5516cb48c098",
|
||||
tokenUrl: "https://auth.kimi.com/api/oauth/token",
|
||||
refreshUrl: "https://auth.kimi.com/api/oauth/token",
|
||||
auth: {
|
||||
combined: true,
|
||||
header: "x-api-key",
|
||||
scheme: "raw",
|
||||
hooks: ["kimiHeaders"],
|
||||
},
|
||||
},
|
||||
// Multi-endpoint: pick the transport matching client sourceFormat to skip translation.
|
||||
@@ -31,26 +43,47 @@ export default {
|
||||
{
|
||||
format: "openai",
|
||||
baseUrl: "https://api.kimi.com/coding/v1/chat/completions",
|
||||
auth: { combined: true, header: "Authorization", scheme: "bearer" },
|
||||
auth: { combined: true, header: "Authorization", scheme: "bearer", hooks: ["kimiHeaders"] },
|
||||
},
|
||||
{
|
||||
format: "claude",
|
||||
baseUrl: "https://api.kimi.com/coding/v1/messages",
|
||||
urlSuffix: "?beta=true",
|
||||
headers: { ...CLAUDE_API_HEADERS },
|
||||
auth: { combined: true, header: "x-api-key", scheme: "raw" },
|
||||
auth: { combined: true, header: "x-api-key", scheme: "raw", hooks: ["kimiHeaders"] },
|
||||
},
|
||||
],
|
||||
models: [
|
||||
// Flagship K3 — platform.kimi.ai id `kimi-k3`, Kimi Code OAuth id `k3` (up to 1M)
|
||||
{ id: "kimi-k3", name: "Kimi K3" },
|
||||
{ id: "k3", name: "Kimi K3 (Code)" },
|
||||
// Kimi Code subscription stable ids (map to K2.7 Code backend)
|
||||
{ id: "kimi-for-coding", name: "Kimi for Coding" },
|
||||
{ id: "kimi-for-coding-highspeed", name: "Kimi for Coding Highspeed" },
|
||||
// Pay-as-you-go platform ids
|
||||
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code" },
|
||||
{ id: "kimi-k2.7-code-highspeed", name: "Kimi K2.7 Code Highspeed" },
|
||||
{ id: "kimi-k2.6", name: "Kimi K2.6" },
|
||||
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||
{ id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" },
|
||||
{ id: "kimi-latest", name: "Kimi Latest" },
|
||||
],
|
||||
serviceKinds: ["llm","webSearch"],
|
||||
serviceKinds: ["llm", "webSearch"],
|
||||
searchViaChat: {
|
||||
defaultModel: "kimi-k2.5",
|
||||
defaultModel: "kimi-k3",
|
||||
endpoint: "https://api.moonshot.cn/v1/chat/completions",
|
||||
pricingUrl: "https://platform.moonshot.ai/docs/pricing/chat",
|
||||
pricingUrl: "https://platform.kimi.ai/docs/pricing/chat",
|
||||
},
|
||||
oauth: {
|
||||
clientId: "17e5f671-d194-4dfb-9706-5516cb48c098",
|
||||
deviceCodeUrl: "https://auth.kimi.com/api/oauth/device_authorization",
|
||||
tokenUrl: "https://auth.kimi.com/api/oauth/token",
|
||||
refreshUrl: "https://auth.kimi.com/api/oauth/token",
|
||||
// CLIProxyAPI refreshThresholdSeconds = 300
|
||||
refreshLeadMs: 300000,
|
||||
authorizeDeviceUrl: "https://www.kimi.com/code/authorize_device",
|
||||
},
|
||||
features: {
|
||||
usage: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<string, { expiresAt: number, models: { id: string, name: string }[] }>} */
|
||||
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();
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { OAUTH_ENDPOINTS, REFRESH_LEAD_MS } from "../config/appConstants.js";
|
||||
import {
|
||||
refreshXaiToken,
|
||||
refreshAccessToken,
|
||||
refreshKimiToken,
|
||||
refreshClaudeOAuthToken,
|
||||
refreshGoogleToken,
|
||||
refreshQwenToken,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
// Re-export all provider refresh functions (preserves public API for all consumers)
|
||||
export {
|
||||
refreshAccessToken,
|
||||
refreshKimiToken,
|
||||
refreshClaudeOAuthToken,
|
||||
refreshGoogleToken,
|
||||
refreshQwenToken,
|
||||
@@ -44,7 +46,10 @@ export function isUnrecoverableRefreshError(result) {
|
||||
}
|
||||
|
||||
export function getRefreshLeadMs(provider) {
|
||||
return REFRESH_LEAD_MS[provider] || TOKEN_EXPIRY_BUFFER_MS;
|
||||
if (REFRESH_LEAD_MS[provider]) return REFRESH_LEAD_MS[provider];
|
||||
// Legacy id after kimi-coding → kimi merge
|
||||
if (provider === "kimi-coding" && REFRESH_LEAD_MS.kimi) return REFRESH_LEAD_MS.kimi;
|
||||
return TOKEN_EXPIRY_BUFFER_MS;
|
||||
}
|
||||
|
||||
export function parseVertexSaJson(apiKey) {
|
||||
@@ -133,6 +138,9 @@ const REFRESH_HANDLERS = {
|
||||
"grok-cli": (c, log) => refreshXaiToken(c.refreshToken, log),
|
||||
gcli: (c, log) => refreshXaiToken(c.refreshToken, log),
|
||||
"codebuddy-cn": (c, log) => refreshCodebuddyToken(c.refreshToken, log),
|
||||
// Kimi Code OAuth (merged into id `kimi`); legacy id still routes here
|
||||
kimi: (c, log) => refreshKimiToken(c.refreshToken, c, log),
|
||||
"kimi-coding": (c, log) => refreshKimiToken(c.refreshToken, c, log),
|
||||
vertex: vertexRefreshHandler,
|
||||
"vertex-partner": vertexRefreshHandler
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PROVIDERS, PROVIDER_OAUTH } from "../../config/providers.js";
|
||||
import { OAUTH_ENDPOINTS, GITHUB_COPILOT } from "../../config/appConstants.js";
|
||||
import { OAUTH_ENDPOINTS, GITHUB_COPILOT, buildKimiHeaders } from "../../config/appConstants.js";
|
||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||
import { dedupRefresh } from "./dedup.js";
|
||||
import { buildExternalIdpRefreshParams } from "../../../src/lib/oauth/kiroExternalIdp.js";
|
||||
@@ -91,6 +91,52 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo
|
||||
}, log);
|
||||
}
|
||||
|
||||
// CLIProxyAPI DeviceFlowClient.RefreshToken: form body (no client_secret) + X-Msh-* headers
|
||||
export async function refreshKimiToken(refreshToken, credentials, log) {
|
||||
const config = PROVIDERS.kimi;
|
||||
if (!config?.refreshUrl || !config?.clientId) {
|
||||
log?.warn?.("TOKEN_REFRESH", "No Kimi refresh URL/clientId configured");
|
||||
return null;
|
||||
}
|
||||
if (!refreshToken) return null;
|
||||
|
||||
return dedupRefresh("kimi", refreshToken, async () => {
|
||||
try {
|
||||
const headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
...buildKimiHeaders(credentials?.providerSpecificData?.deviceId),
|
||||
};
|
||||
const response = await fetch(config.refreshUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: config.clientId,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", `Failed to refresh token for kimi`, {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const tokens = await response.json();
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", `Error refreshing token for kimi`, { error: error.message });
|
||||
return null;
|
||||
}
|
||||
}, log);
|
||||
}
|
||||
|
||||
export async function refreshClaudeOAuthToken(refreshToken, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("claude", refreshToken, async () => {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
KIRO_AGENTIC_SYSTEM_PROMPT,
|
||||
resolveDefaultProfileArn,
|
||||
buildKiroAdditionalModelRequestFieldsForModel,
|
||||
usesKiroNativeGptEffort,
|
||||
} from "../../config/kiroConstants.js";
|
||||
import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
|
||||
import { ROLE, CLAUDE_BLOCK } from "../schema/index.js";
|
||||
@@ -390,6 +391,8 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
|
||||
|
||||
const { upstream: upstreamModel, agentic } = resolveKiroModel(model);
|
||||
const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model);
|
||||
const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel);
|
||||
const usesNativeGptEffort = usesKiroNativeGptEffort(body, upstreamModel);
|
||||
|
||||
// Guard 1: no client tools → flatten all tool interactions to text.
|
||||
if (!clientProvidedTools) {
|
||||
@@ -421,7 +424,9 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
|
||||
// enforce top-level systemPrompt for direct calls.
|
||||
const timestamp = new Date().toISOString();
|
||||
const systemPromptParts = [];
|
||||
if (thinkingBudget !== null) systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget));
|
||||
if (thinkingBudget !== null && !usesNativeGptEffort) {
|
||||
systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget));
|
||||
}
|
||||
if (agentic) systemPromptParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
|
||||
const systemInstruction = extractClaudeSystemText(body.system);
|
||||
if (systemInstruction) systemPromptParts.push(systemInstruction);
|
||||
@@ -481,7 +486,6 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
|
||||
|
||||
if (profileArn) payload.profileArn = profileArn;
|
||||
if (systemPrompt) payload.systemPrompt = systemPrompt;
|
||||
const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel);
|
||||
if (additionalModelRequestFields) {
|
||||
payload.additionalModelRequestFields = additionalModelRequestFields;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
buildThinkingSystemPrefix,
|
||||
KIRO_AGENTIC_SYSTEM_PROMPT,
|
||||
resolveDefaultProfileArn,
|
||||
buildKiroAdditionalModelRequestFieldsForModel
|
||||
buildKiroAdditionalModelRequestFieldsForModel,
|
||||
usesKiroNativeGptEffort
|
||||
} from "../../config/kiroConstants.js";
|
||||
import { parseDataUri } from "../concerns/image.js";
|
||||
import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
|
||||
@@ -511,12 +512,10 @@ function convertMessages(messages, tools, model) {
|
||||
* Kiro's 2-3 minute server timeout. The suffix is stripped before being
|
||||
* sent upstream.
|
||||
*
|
||||
* 2. Thinking / reasoning. Kiro does not accept `thinking.type` or
|
||||
* `reasoning_effort` natively. The only way to enable reasoning is to
|
||||
* inject `<thinking_mode>enabled</thinking_mode>` into the user content
|
||||
* sent upstream. Detection covers Anthropic-Beta header, Claude API
|
||||
* 2. Thinking / reasoning. Detection covers Anthropic-Beta header, Claude API
|
||||
* `thinking`, OpenAI `reasoning_effort`, AMP/Cursor magic tags, and model
|
||||
* name hints.
|
||||
* name hints. Supported models receive Kiro's schema-specific effort fields;
|
||||
* legacy prompt tags remain only for models that need them.
|
||||
*/
|
||||
export function openaiToKiroRequest(model, body, stream, credentials) {
|
||||
const messages = body.messages || [];
|
||||
@@ -527,6 +526,8 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
|
||||
|
||||
const { upstream: upstreamModel, agentic } = resolveKiroModel(model);
|
||||
const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model);
|
||||
const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel);
|
||||
const usesNativeGptEffort = usesKiroNativeGptEffort(body, upstreamModel);
|
||||
|
||||
const { history, currentMessage } = convertMessages(messages, tools, upstreamModel);
|
||||
|
||||
@@ -554,7 +555,7 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
|
||||
// too because the CodeWhisperer surface does not always enforce top-level
|
||||
// systemPrompt for direct calls.
|
||||
const systemPromptParts = [];
|
||||
if (thinkingBudget !== null) {
|
||||
if (thinkingBudget !== null && !usesNativeGptEffort) {
|
||||
systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget));
|
||||
}
|
||||
if (agentic) {
|
||||
@@ -612,7 +613,6 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
|
||||
payload.profileArn = profileArn;
|
||||
}
|
||||
if (systemPrompt) payload.systemPrompt = systemPrompt;
|
||||
const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel);
|
||||
if (additionalModelRequestFields) {
|
||||
payload.additionalModelRequestFields = additionalModelRequestFields;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "9router-app",
|
||||
"version": "0.5.35",
|
||||
"version": "0.5.40",
|
||||
"description": "9Router web dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
|
After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
@@ -792,7 +792,7 @@ export default function BasicChatPageClient() {
|
||||
<div className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-3 mt-2">
|
||||
{message.attachments.map((attachment) => (
|
||||
<a key={attachment.id} href={attachment.dataUrl} target="_blank" rel="noreferrer" className="overflow-hidden rounded-[18px] border border-white/10 bg-black/20">
|
||||
<img src={attachment.dataUrl} alt={attachment.name} className="h-28 w-full object-cover" />
|
||||
<img src={attachment.dataUrl} alt={attachment.name} className="h-28 w-full object-cover" loading="lazy" decoding="async" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
@@ -467,15 +464,17 @@ export default function AntigravityToolCard({
|
||||
</Modal>
|
||||
|
||||
{/* Model Select Modal */}
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title={`Select model for ${currentEditingAlias}`}
|
||||
/>
|
||||
{modalOpen && (
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
title={`Select model for ${currentEditingAlias}`}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Card, ModelSelectModal } from "@/shared/components";
|
||||
import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import Image from "next/image";
|
||||
import ApiKeySelect from "./ApiKeySelect";
|
||||
@@ -332,21 +333,32 @@ export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, active
|
||||
className="size-8 object-contain rounded-lg"
|
||||
sizes="32px"
|
||||
onError={(e) => { e.target.style.display = "none"; }}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (tool.icon) {
|
||||
return <span className="material-symbols-outlined text-xl" style={{ color: tool.color }}>{tool.icon}</span>;
|
||||
}
|
||||
const iconSrc = getProviderIconSrc(toolId);
|
||||
if (!iconSrc) {
|
||||
return <span className="text-xs font-bold" style={{ color: tool.color }}>{(toolId || "?").slice(0, 2).toUpperCase()}</span>;
|
||||
}
|
||||
return (
|
||||
<Image
|
||||
src={`/providers/${toolId}.png`}
|
||||
src={iconSrc}
|
||||
alt={tool.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-8 object-contain rounded-lg"
|
||||
sizes="32px"
|
||||
onError={(e) => { e.target.style.display = "none"; }}
|
||||
onError={(e) => {
|
||||
markProviderIconMissing(toolId);
|
||||
e.target.style.display = "none";
|
||||
}}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -377,18 +389,20 @@ export default function DefaultToolCard({ toolId, tool, baseUrl, apiKeys, active
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={showModelModal}
|
||||
onClose={() => setShowModelModal(false)}
|
||||
onSelect={tool.modelSelection === "multiple" ? addSelectedModel : handleSelectModel}
|
||||
selectedModel={modelValue}
|
||||
activeProviders={activeProviders}
|
||||
title={tool.modelSelection === "multiple" ? "Add Cursor custom model" : "Select Model"}
|
||||
closeOnSelect={tool.modelSelection !== "multiple"}
|
||||
addedModelValues={tool.modelSelection === "multiple" ? selectedModels : []}
|
||||
allowDuplicates={tool.modelSelection === "multiple"}
|
||||
availableModels={availableModels}
|
||||
/>
|
||||
{showModelModal && (
|
||||
<ModelSelectModal
|
||||
isOpen={showModelModal}
|
||||
onClose={() => setShowModelModal(false)}
|
||||
onSelect={tool.modelSelection === "multiple" ? addSelectedModel : handleSelectModel}
|
||||
selectedModel={modelValue}
|
||||
activeProviders={activeProviders}
|
||||
title={tool.modelSelection === "multiple" ? "Add Cursor custom model" : "Select Model"}
|
||||
closeOnSelect={tool.modelSelection !== "multiple"}
|
||||
addedModelValues={tool.modelSelection === "multiple" ? selectedModels : []}
|
||||
allowDuplicates={tool.modelSelection === "multiple"}
|
||||
availableModels={availableModels}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ export default function MitmLinkCard({ tool }) {
|
||||
className="size-8 object-contain rounded-lg"
|
||||
sizes="32px"
|
||||
onError={(e) => { e.target.style.display = "none"; }}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -146,6 +146,8 @@ export default function MitmToolCard({
|
||||
className="size-8 object-contain rounded-lg"
|
||||
sizes="32px"
|
||||
onError={(e) => { e.target.style.display = "none"; }}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
@@ -307,16 +309,18 @@ export default function MitmToolCard({
|
||||
)}
|
||||
|
||||
{/* Model Select Modal */}
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
availableModels={availableModels}
|
||||
title={`Select model for ${currentEditingAlias}`}
|
||||
/>
|
||||
{modalOpen && (
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
|
||||
activeProviders={activeProviders}
|
||||
modelAliases={modelAliases}
|
||||
availableModels={availableModels}
|
||||
title={`Select model for ${currentEditingAlias}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function ToolSummaryCard({ toolId, tool }) {
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-8 flex items-center justify-center shrink-0">
|
||||
{tool.image ? (
|
||||
<Image src={tool.image} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
<Image src={tool.image} alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
|
||||
) : tool.icon ? (
|
||||
<span className="material-symbols-outlined text-[28px]" style={{ color: tool.color }}>{tool.icon}</span>
|
||||
) : null}
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function CombosPage() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
@@ -205,23 +205,27 @@ export default function CombosPage() {
|
||||
)}
|
||||
|
||||
{/* Create Modal - Use key to force remount and reset state */}
|
||||
<ComboFormModal
|
||||
key="create"
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSave={handleCreate}
|
||||
availableModels={selectableModels}
|
||||
/>
|
||||
{showCreateModal && (
|
||||
<ComboFormModal
|
||||
key="create"
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSave={handleCreate}
|
||||
availableModels={selectableModels}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Modal - Use key to force remount and reset state */}
|
||||
<ComboFormModal
|
||||
key={editingCombo?.id || "new"}
|
||||
isOpen={!!editingCombo}
|
||||
combo={editingCombo}
|
||||
onClose={() => setEditingCombo(null)}
|
||||
onSave={(data) => handleUpdate(editingCombo.id, data)}
|
||||
availableModels={selectableModels}
|
||||
/>
|
||||
{editingCombo && (
|
||||
<ComboFormModal
|
||||
key={editingCombo.id}
|
||||
isOpen={true}
|
||||
combo={editingCombo}
|
||||
onClose={() => setEditingCombo(null)}
|
||||
onSave={(data) => handleUpdate(editingCombo.id, data)}
|
||||
availableModels={selectableModels}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirm Delete Modal */}
|
||||
<ConfirmModal
|
||||
@@ -346,15 +350,17 @@ function ComboCard({ combo, modelCaps = {}, availableModels = [], copied, onCopy
|
||||
</div>
|
||||
|
||||
{/* Judge model picker (single-select; combo members make natural judges too) */}
|
||||
<ModelSelectModal
|
||||
isOpen={showJudgeSelect}
|
||||
onClose={() => setShowJudgeSelect(false)}
|
||||
onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }}
|
||||
availableModels={availableModels}
|
||||
title="Select Judge Model"
|
||||
addedModelValues={judge ? [judge] : []}
|
||||
closeOnSelect={true}
|
||||
/>
|
||||
{showJudgeSelect && (
|
||||
<ModelSelectModal
|
||||
isOpen={showJudgeSelect}
|
||||
onClose={() => setShowJudgeSelect(false)}
|
||||
onSelect={(m) => { onSetStrategy({ judgeModel: m?.value || "" }); setShowJudgeSelect(false); }}
|
||||
availableModels={availableModels}
|
||||
title="Select Judge Model"
|
||||
addedModelValues={judge ? [judge] : []}
|
||||
closeOnSelect={true}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -631,17 +637,19 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, availableModels = [],
|
||||
</Modal>
|
||||
|
||||
{/* Model Select Modal */}
|
||||
<ModelSelectModal
|
||||
isOpen={showModelSelect}
|
||||
onClose={() => setShowModelSelect(false)}
|
||||
onSelect={handleAddModel}
|
||||
onDeselect={handleDeselectModel}
|
||||
availableModels={availableModels}
|
||||
title="Add Model to Combo"
|
||||
kindFilter={kindFilter}
|
||||
addedModelValues={models}
|
||||
closeOnSelect={false}
|
||||
/>
|
||||
{showModelSelect && (
|
||||
<ModelSelectModal
|
||||
isOpen={showModelSelect}
|
||||
onClose={() => setShowModelSelect(false)}
|
||||
onSelect={handleAddModel}
|
||||
onDeselect={handleDeselectModel}
|
||||
availableModels={availableModels}
|
||||
title="Add Model to Combo"
|
||||
kindFilter={kindFilter}
|
||||
addedModelValues={models}
|
||||
closeOnSelect={false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -332,6 +332,8 @@ export function GenericExampleCard({ providerId, kind }) {
|
||||
className="max-h-40 rounded-lg border border-border object-contain bg-sidebar"
|
||||
onError={(e) => { e.currentTarget.style.display = "none"; }}
|
||||
onLoad={(e) => { e.currentTarget.style.display = "block"; }}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -365,6 +367,8 @@ export function GenericExampleCard({ providerId, kind }) {
|
||||
className="max-h-40 rounded-lg border border-border object-contain bg-sidebar"
|
||||
onError={(e) => { e.currentTarget.style.display = "none"; }}
|
||||
onLoad={(e) => { e.currentTarget.style.display = "block"; }}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -469,6 +473,8 @@ export function GenericExampleCard({ providerId, kind }) {
|
||||
src={`data:image/png;base64,${partialImage.b64_json}`}
|
||||
alt="Partial"
|
||||
className="max-w-full rounded-lg border border-border mt-1.5 opacity-80"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -511,6 +517,8 @@ export function GenericExampleCard({ providerId, kind }) {
|
||||
src={binaryImageUrl || (result?.data?.data?.[0]?.b64_json ? `data:image/png;base64,${result.data.data[0].b64_json}` : result?.data?.data?.[0]?.url)}
|
||||
alt="Generated"
|
||||
className="max-w-full rounded-lg border border-border"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -352,7 +352,7 @@ export default function ComboDetailPage() {
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
<img src={testResult.imageUrl} alt="Generated" className="max-w-full rounded-lg border border-border" />
|
||||
<img src={testResult.imageUrl} alt="Generated" className="max-w-full rounded-lg border border-border" loading="lazy" decoding="async" />
|
||||
</div>
|
||||
)}
|
||||
{testResult.audioUrl && (
|
||||
@@ -388,18 +388,20 @@ export default function ComboDetailPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<ModelSelectModal
|
||||
isOpen={showPicker}
|
||||
onClose={() => setShowPicker(false)}
|
||||
onSelect={handleAddModel}
|
||||
onDeselect={handleDeselectModel}
|
||||
activeProviders={connections}
|
||||
modelAliases={modelAliases}
|
||||
title={`Add ${kindLabel} Model`}
|
||||
kindFilter={combo.kind}
|
||||
addedModelValues={providers}
|
||||
closeOnSelect={false}
|
||||
/>
|
||||
{showPicker && (
|
||||
<ModelSelectModal
|
||||
isOpen={showPicker}
|
||||
onClose={() => setShowPicker(false)}
|
||||
onSelect={handleAddModel}
|
||||
onDeselect={handleDeselectModel}
|
||||
activeProviders={connections}
|
||||
modelAliases={modelAliases}
|
||||
title={`Add ${kindLabel} Model`}
|
||||
kindFilter={combo.kind}
|
||||
addedModelValues={providers}
|
||||
closeOnSelect={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/providerIcon";
|
||||
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
|
||||
@@ -66,6 +67,7 @@ export default function ProviderDetailPage() {
|
||||
const [thinkingMode, setThinkingMode] = useState("auto");
|
||||
const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} });
|
||||
const [suggestedModels, setSuggestedModels] = useState([]);
|
||||
const [liveModels, setLiveModels] = useState([]);
|
||||
const [kiloFreeModels, setKiloFreeModels] = useState([]);
|
||||
const [deletedModelIds, setDeletedModelIds] = useState([]);
|
||||
const [confirmState, setConfirmState] = useState(null);
|
||||
@@ -141,7 +143,10 @@ export default function ProviderDetailPage() {
|
||||
const isOAuth = !!OAUTH_PROVIDERS[providerId] || !!FREE_PROVIDERS[providerId] || authModes.includes("oauth");
|
||||
const supportsApiKeyAuth = !!APIKEY_PROVIDERS[providerId] || authModes.includes("apikey");
|
||||
const isFreeNoAuth = !!FREE_PROVIDERS[providerId]?.noAuth;
|
||||
const models = getModelsByProviderId(providerId);
|
||||
const staticModels = getModelsByProviderId(providerId);
|
||||
const models = providerId === "cursor" && liveModels.length > 0
|
||||
? liveModels
|
||||
: staticModels;
|
||||
const providerAlias = getProviderAlias(providerId);
|
||||
|
||||
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
|
||||
@@ -151,8 +156,12 @@ export default function ProviderDetailPage() {
|
||||
const oauthConnectionLabel =
|
||||
providerId === "xai" ? "Grok Build OAuth"
|
||||
: providerId === "grok-cli" ? "Grok CLI Device Login"
|
||||
: providerId === "kimi" ? "Kimi Coding OAuth"
|
||||
: "OAuth";
|
||||
const apiKeyConnectionLabel = providerId === "xai" ? "xAI API Key" : "API Key";
|
||||
const apiKeyConnectionLabel =
|
||||
providerId === "xai" ? "xAI API Key"
|
||||
: providerId === "kimi" ? "Kimi API Key"
|
||||
: "API Key";
|
||||
// Resolve suffix "(level)" for a model when a thinking level is picked and the model supports it.
|
||||
const resolveThinkingSuffix = (modelId) => {
|
||||
if (!thinkingMode || thinkingMode === "auto") return null;
|
||||
@@ -427,6 +436,34 @@ export default function ProviderDetailPage() {
|
||||
fetchDeletedModels();
|
||||
}, [fetchConnections, fetchAliases, fetchCustomModels, fetchDeletedModels]);
|
||||
|
||||
// Cursor's model availability is account-specific and changes frequently.
|
||||
// Load the active account's live catalog for the dashboard; the static
|
||||
// registry remains the fallback while the request is pending or unavailable.
|
||||
useEffect(() => {
|
||||
if (providerId !== "cursor") {
|
||||
setLiveModels([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = connections.find((item) => item.isActive !== false);
|
||||
if (!connection?.id) {
|
||||
setLiveModels([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
fetch(`/api/providers/${connection.id}/models`, { cache: "no-store" })
|
||||
.then(async (res) => ({ ok: res.ok, data: await res.json() }))
|
||||
.then(({ ok, data }) => {
|
||||
if (!cancelled && ok && Array.isArray(data.models) && data.models.length > 0) {
|
||||
setLiveModels(data.models);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [providerId, connections]);
|
||||
|
||||
// Fetch suggested models from provider's public API (if configured)
|
||||
useEffect(() => {
|
||||
const fetcher = (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId] || FREE_TIER_PROVIDERS[providerId])?.modelsFetcher;
|
||||
@@ -1196,7 +1233,7 @@ export default function ProviderDetailPage() {
|
||||
if (isAnthropicCompatible) {
|
||||
return "/providers/anthropic-m.png";
|
||||
}
|
||||
return `/providers/${providerInfo.id}.png`;
|
||||
return getProviderIconSrc(providerInfo.id);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1215,7 +1252,7 @@ export default function ProviderDetailPage() {
|
||||
className="flex size-12 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: `${providerInfo.color}15` }}
|
||||
>
|
||||
{headerImgError ? (
|
||||
{headerImgError || !getHeaderIconPath() ? (
|
||||
<span className="text-sm font-bold" style={{ color: providerInfo.color }}>
|
||||
{providerInfo.textIcon || providerInfo.id.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
@@ -1227,7 +1264,12 @@ export default function ProviderDetailPage() {
|
||||
height={48}
|
||||
className="max-h-12 max-w-12 rounded-lg object-contain"
|
||||
sizes="48px"
|
||||
onError={() => setHeaderImgError(true)}
|
||||
onError={() => {
|
||||
markProviderIconMissing(providerInfo.id);
|
||||
setHeaderImgError(true);
|
||||
}}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Toggle,
|
||||
} from "@/shared/components";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import { getProviderIconSrc } from "@/shared/utils/providerIcon";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||
import {
|
||||
FREE_PROVIDERS,
|
||||
@@ -761,12 +762,12 @@ function ApiKeyProviderCard({
|
||||
};
|
||||
|
||||
const getIconPath = () => {
|
||||
if (isCompatible)
|
||||
if (isCompatible && provider.apiType)
|
||||
return provider.apiType === "responses"
|
||||
? "/providers/oai-r.png"
|
||||
: "/providers/oai-cc.png";
|
||||
if (isAnthropicCompatible) return "/providers/anthropic-m.png";
|
||||
return `/providers/${provider.id}.png`;
|
||||
return getProviderIconSrc(provider.id);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1236,22 +1236,24 @@ export default function ProviderLimits() {
|
||||
/>
|
||||
)}
|
||||
{hiddenQuotaRows.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1 border-t border-black/5 pt-2 text-[10px] text-text-muted dark:border-white/5">
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
<div className="mt-2 flex min-w-0 items-center gap-1 border-t border-black/5 pt-2 text-[10px] text-text-muted dark:border-white/5">
|
||||
<span className="material-symbols-outlined shrink-0 text-[14px]">
|
||||
visibility_off
|
||||
</span>
|
||||
<span>Hidden:</span>
|
||||
{hiddenQuotaRows.map((quotaRow) => (
|
||||
<button
|
||||
key={getQuotaVisibilityKey(quotaRow)}
|
||||
type="button"
|
||||
onClick={() => handleShowQuota(conn.provider, quotaRow)}
|
||||
className="rounded-md border border-black/10 px-1.5 py-0.5 transition-colors hover:bg-black/5 hover:text-text-primary dark:border-white/10 dark:hover:bg-white/5"
|
||||
title="Show this quota row"
|
||||
>
|
||||
{quotaRow.name}
|
||||
</button>
|
||||
))}
|
||||
<span className="shrink-0">Hidden:</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto whitespace-nowrap">
|
||||
{hiddenQuotaRows.map((quotaRow) => (
|
||||
<button
|
||||
key={getQuotaVisibilityKey(quotaRow)}
|
||||
type="button"
|
||||
onClick={() => handleShowQuota(conn.provider, quotaRow)}
|
||||
className="shrink-0 rounded-md border border-black/10 px-1.5 py-0.5 transition-colors hover:bg-black/5 hover:text-text-primary dark:border-white/10 dark:hover:bg-white/5"
|
||||
title="Show this quota row"
|
||||
>
|
||||
{quotaRow.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 ? (
|
||||
<img src={imageUrl} alt={label} className="w-6 h-6 rounded-sm object-contain" onError={() => setImgError(true)} />
|
||||
{imageUrl && !imgError ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={label}
|
||||
className="w-6 h-6 rounded-sm object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => {
|
||||
const m = imageUrl?.match(/^\/providers\/([^/]+)\.png$/i);
|
||||
if (m) markProviderIconMissing(m[1]);
|
||||
setImgError(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm font-bold" style={{ color }}>{textIcon}</span>
|
||||
)}
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-center px-5 py-3 rounded-xl border-2 border-primary bg-primary/5 shadow-md min-w-[130px]">
|
||||
<div
|
||||
className={`relative z-[1] flex items-center justify-center px-5 py-3 rounded-xl border-2 min-w-[130px] ${
|
||||
powering
|
||||
? "topology-router-core border-yellow-300 bg-gradient-to-br from-primary/30 via-yellow-400/20 to-cyan-400/25"
|
||||
: "border-primary bg-primary/5 shadow-md"
|
||||
}`}
|
||||
>
|
||||
<Handle type="source" position={Position.Top} id="top" className="!bg-transparent !border-0 !w-0 !h-0" />
|
||||
<Handle type="source" position={Position.Bottom} id="bottom" className="!bg-transparent !border-0 !w-0 !h-0" />
|
||||
<Handle type="source" position={Position.Left} id="left" className="!bg-transparent !border-0 !w-0 !h-0" />
|
||||
<Handle type="source" position={Position.Right} id="right" className="!bg-transparent !border-0 !w-0 !h-0" />
|
||||
|
||||
<img src="/favicon.svg" alt="9Router (Remake)" className="w-6 h-6 mr-2" />
|
||||
<span className="text-sm font-bold text-primary">9Router (Remake)</span>
|
||||
<img
|
||||
src="/favicon.svg"
|
||||
alt="9Router"
|
||||
className={`w-6 h-6 mr-2 ${powering ? "topology-router-icon" : ""}`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span className={`text-sm font-bold ${powering ? "topology-router-label text-yellow-300" : "text-primary"}`}>
|
||||
9Router
|
||||
</span>
|
||||
{data.activeCount > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 rounded-full bg-primary text-white text-xs font-bold">
|
||||
<span className="ml-2 px-1.5 py-0.5 rounded-full bg-yellow-400 text-black text-xs font-bold topology-router-badge">
|
||||
{data.activeCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -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 <BaseEdge id={id} path={edgePath} style={{ ...style, stroke }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<g className="topology-edge-electric">
|
||||
<defs>
|
||||
<filter id={filterId} x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="2" result="noise">
|
||||
<animate attributeName="baseFrequency" values="0.8;1.4;0.8" dur="0.25s" repeatCount="indefinite" />
|
||||
</feTurbulence>
|
||||
<feDisplacementMap in="SourceGraphic" in2="noise" scale="3.5" xChannelSelector="R" yChannelSelector="G" />
|
||||
</filter>
|
||||
</defs>
|
||||
{/* Outer electric halo */}
|
||||
<path
|
||||
d={edgePath}
|
||||
fill="none"
|
||||
stroke="#22d3ee"
|
||||
strokeWidth={10}
|
||||
strokeOpacity={0.35}
|
||||
strokeLinecap="round"
|
||||
filter={`url(#${filterId})`}
|
||||
className="topology-edge-halo"
|
||||
/>
|
||||
{/* Mid plasma */}
|
||||
<path
|
||||
d={edgePath}
|
||||
fill="none"
|
||||
stroke="#4ade80"
|
||||
strokeWidth={5}
|
||||
strokeOpacity={0.85}
|
||||
strokeLinecap="round"
|
||||
filter={`url(#${filterId})`}
|
||||
className="topology-edge-plasma"
|
||||
/>
|
||||
{/* Hot white core */}
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={{ stroke: "#f8fafc", strokeWidth: 2.2, opacity: 1 }}
|
||||
className="topology-edge-kame"
|
||||
/>
|
||||
{/* Energy orbs */}
|
||||
{Array.from({ length: KAME_PARTICLE_COUNT }, (_, i) => (
|
||||
<circle
|
||||
key={`${id}-p-${i}`}
|
||||
r={i % 2 === 0 ? 4 : 2.5}
|
||||
fill={i % 3 === 0 ? "#fde047" : i % 3 === 1 ? "#67e8f9" : "#fff"}
|
||||
opacity={0.95}
|
||||
style={{ filter: "drop-shadow(0 0 4px #22d3ee)" }}
|
||||
>
|
||||
<animateMotion
|
||||
dur={`${0.4 + i * 0.08}s`}
|
||||
repeatCount="indefinite"
|
||||
path={edgePath}
|
||||
begin={`${i * 0.09}s`}
|
||||
/>
|
||||
</circle>
|
||||
))}
|
||||
{/* Electric sparks (short-lived blink along path) */}
|
||||
{Array.from({ length: SPARK_COUNT }, (_, i) => (
|
||||
<circle
|
||||
key={`${id}-s-${i}`}
|
||||
r={1.8}
|
||||
fill="#e0f2fe"
|
||||
opacity={0}
|
||||
>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;1;0;0;1;0"
|
||||
dur={`${0.35 + (i % 3) * 0.1}s`}
|
||||
begin={`${i * 0.07}s`}
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
<animateMotion
|
||||
dur={`${0.28 + i * 0.05}s`}
|
||||
repeatCount="indefinite"
|
||||
path={edgePath}
|
||||
begin={`${i * 0.11}s`}
|
||||
/>
|
||||
</circle>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
@@ -21,12 +21,21 @@ export async function GET() {
|
||||
})
|
||||
.map((m) => {
|
||||
const fullModel = `${m.provider}/${m.model}`;
|
||||
const providerAlias = getProviderAlias(m.provider) || m.provider;
|
||||
const routedModel = `${providerAlias}/${m.model}`;
|
||||
const c = getCapabilitiesForModel(m.provider, m.model);
|
||||
return {
|
||||
...m,
|
||||
fullModel,
|
||||
routedModel,
|
||||
alias: modelAliases[fullModel] || m.model,
|
||||
caps: { vision: c.vision, search: c.search, reasoning: c.reasoning },
|
||||
caps: {
|
||||
vision: c.vision,
|
||||
search: c.search,
|
||||
reasoning: c.reasoning,
|
||||
contextWindow: c.contextWindow,
|
||||
maxOutput: c.maxOutput,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -157,6 +157,7 @@ export async function GET(request, { params }) {
|
||||
const noPkceDeviceProviders = [
|
||||
"github",
|
||||
"kiro",
|
||||
"kimi",
|
||||
"kimi-coding",
|
||||
"kilocode",
|
||||
"codebuddy-cn",
|
||||
@@ -291,10 +292,11 @@ export async function POST(request, { params }) {
|
||||
}
|
||||
|
||||
// Providers that don't use PKCE for device code
|
||||
const noPkceProviders = ["github", "kimi-coding", "kilocode", "codebuddy-cn"];
|
||||
const noPkceProviders = ["github", "kimi", "kimi-coding", "kilocode", "codebuddy-cn"];
|
||||
let result;
|
||||
if (noPkceProviders.includes(provider)) {
|
||||
result = await pollForToken(provider, deviceCode);
|
||||
// kimi needs extraData._kimiDeviceId for stable X-Msh-Device-Id (CLIProxyAPI parity)
|
||||
result = await pollForToken(provider, deviceCode, null, extraData);
|
||||
} else if (provider === "kiro") {
|
||||
// Kiro needs extraData (clientId, clientSecret) from device code response
|
||||
result = await pollForToken(provider, deviceCode, null, extraData);
|
||||
@@ -315,9 +317,10 @@ export async function POST(request, { params }) {
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
// Save to database
|
||||
// Save to database (legacy kimi-coding OAuth → dual-auth kimi)
|
||||
const providerId = provider === "kimi-coding" ? "kimi" : provider;
|
||||
const connection = await createProviderConnection({
|
||||
provider,
|
||||
provider: providerId,
|
||||
authType: "oauth",
|
||||
ownerId: user.id,
|
||||
...result.tokens,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getProviderConnectionById } from "@/models";
|
||||
import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
import { GEMINI_CONFIG } from "@/lib/oauth/constants/oauth";
|
||||
import { refreshGoogleToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
|
||||
import { refreshGoogleToken, refreshCodexToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
|
||||
import { resolveOllamaLocalHost } from "open-sse/config/providers.js";
|
||||
import { getModelsByProviderId } from "open-sse/config/providerModels.js";
|
||||
import { resolveKiroModels } from "open-sse/services/kiroModels.js";
|
||||
@@ -11,9 +11,17 @@ import { resolveKimchiModels } from "open-sse/services/kimchiModels.js";
|
||||
import { resolveQoderModels } from "open-sse/services/qoderModels.js";
|
||||
import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js";
|
||||
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
||||
import { resolveCursorModels } from "open-sse/services/cursorModels.js";
|
||||
|
||||
const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels";
|
||||
|
||||
// The /codex/models endpoint gates each entry by minimal_client_version against this
|
||||
// value, and codex CLI's own manifest (openai/codex codex-rs/models-manager/models.json)
|
||||
// already requires 0.144.0 for its newest models, so a stale client_version here comes
|
||||
// back 200 with those entries quietly missing instead of erroring.
|
||||
const CODEX_CLIENT_VERSION = "0.144.6";
|
||||
const CODEX_MODELS_URL = `https://chatgpt.com/backend-api/codex/models?client_version=${CODEX_CLIENT_VERSION}`;
|
||||
|
||||
const parseOpenAIStyleModels = (data) => {
|
||||
if (Array.isArray(data)) return data;
|
||||
return data?.data || data?.models || data?.results || [];
|
||||
@@ -158,12 +166,20 @@ const PROVIDER_MODELS_CONFIG = {
|
||||
parseResponse: (data) => data.data || []
|
||||
},
|
||||
codex: {
|
||||
url: "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: parseCodexModels
|
||||
customResolver: buildOAuthResolver({
|
||||
refreshFn: (conn) => refreshCodexToken(conn.refreshToken),
|
||||
fetchFn: (token) => fetch(CODEX_MODELS_URL, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"originator": "codex_cli_rs"
|
||||
}
|
||||
}),
|
||||
parseFn: parseCodexModels,
|
||||
errorLabel: "Failed to fetch Codex models"
|
||||
})
|
||||
},
|
||||
antigravity: {
|
||||
url: "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:models",
|
||||
@@ -230,6 +246,14 @@ const PROVIDER_MODELS_CONFIG = {
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.data || []
|
||||
},
|
||||
"alims-intl": {
|
||||
url: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.data || []
|
||||
},
|
||||
"volcengine-ark": createOpenAIModelsConfig("https://ark.cn-beijing.volces.com/api/coding/v3/models"),
|
||||
byteplus: createOpenAIModelsConfig("https://ark.ap-southeast.bytepluses.com/api/coding/v3/models"),
|
||||
|
||||
@@ -270,6 +294,19 @@ const PROVIDER_MODELS_CONFIG = {
|
||||
};
|
||||
}
|
||||
},
|
||||
cursor: {
|
||||
customResolver: async (connection) => {
|
||||
const result = await resolveCursorModels({
|
||||
accessToken: connection.accessToken,
|
||||
providerSpecificData: connection.providerSpecificData || {},
|
||||
}, { forceRefresh: true, log: console });
|
||||
if (result?.models?.length) return { models: result.models };
|
||||
return {
|
||||
models: getStaticProviderModels("cursor"),
|
||||
warning: "Cursor returned no live models; falling back to static catalog.",
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// Custom resolvers (non-OpenAI-shaped APIs / token-refresh flows)
|
||||
kiro: {
|
||||
|
||||
@@ -76,7 +76,8 @@ const OAUTH_TEST_CONFIG = {
|
||||
authPrefix: "Bearer ",
|
||||
refreshable: false,
|
||||
},
|
||||
"kimi-coding": { checkExpiry: true, refreshable: false },
|
||||
kimi: { checkExpiry: true, refreshable: true },
|
||||
"kimi-coding": { checkExpiry: true, refreshable: true },
|
||||
cursor: { tokenExists: true },
|
||||
kilocode: {
|
||||
url: `${KILOCODE_CONFIG.apiBaseUrl}/api/profile`,
|
||||
@@ -627,10 +628,13 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
|
||||
return { valid, error: valid ? null : "Invalid API key" };
|
||||
}
|
||||
case "alicode":
|
||||
case "alicode-intl": {
|
||||
// Aliyun Coding Plan uses OpenAI-compatible API
|
||||
case "alicode-intl":
|
||||
case "alims-intl": {
|
||||
// Aliyun Coding Plan uses OpenAI-compatible API; alims-intl uses Model Studio compatible-mode
|
||||
const aliBaseUrl = connection.provider === "alicode-intl"
|
||||
? "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions"
|
||||
: connection.provider === "alims-intl"
|
||||
? "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions"
|
||||
: "https://coding.dashscope.aliyuncs.com/v1/chat/completions";
|
||||
const res = await fetchWithConnectionProxy(aliBaseUrl, {
|
||||
method: "POST",
|
||||
|
||||
@@ -311,11 +311,12 @@ export async function POST(request) {
|
||||
case "minimax":
|
||||
case "minimax-cn":
|
||||
case "alicode-intl":
|
||||
case "alims-intl":
|
||||
case "alicode":
|
||||
case "agentrouter": {
|
||||
// Use baseUrl from PROVIDERS (DRY); separate openai-format vs claude-format flow
|
||||
const cfg = PROVIDERS[provider];
|
||||
const isOpenAiFormat = provider === "glm-cn" || provider === "alicode" || provider === "alicode-intl";
|
||||
const isOpenAiFormat = provider === "glm-cn" || provider === "alicode" || provider === "alicode-intl" || provider === "alims-intl";
|
||||
|
||||
if (isOpenAiFormat) {
|
||||
const testModel = getDefaultModel(provider);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { resolveQoderModels } from "open-sse/services/qoderModels.js";
|
||||
import { resolveCopilotModels } from "open-sse/services/copilotModels.js";
|
||||
import { resolveClinepassModels } from "open-sse/services/clinepassModels.js";
|
||||
import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js";
|
||||
import { resolveCursorModels } from "open-sse/services/cursorModels.js";
|
||||
import { updateProviderCredentials } from "@/sse/services/tokenRefresh";
|
||||
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
||||
import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
|
||||
@@ -97,6 +98,13 @@ const LIVE_MODEL_RESOLVERS = {
|
||||
});
|
||||
return result?.models?.length ? { models: result.models } : null;
|
||||
},
|
||||
cursor: async (conn) => {
|
||||
const result = await resolveCursorModels({
|
||||
accessToken: conn.accessToken,
|
||||
providerSpecificData: conn.providerSpecificData || {},
|
||||
}, { log: console });
|
||||
return result?.models?.length ? { models: result.models } : null;
|
||||
}
|
||||
};
|
||||
|
||||
const parseOpenAIStyleModels = (data) => {
|
||||
|
||||
@@ -470,6 +470,68 @@ button:disabled,
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Topology: router card pulse (no flying rings) + electric edge beam */
|
||||
@keyframes topology-router-pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow:
|
||||
0 0 8px #fde047,
|
||||
0 0 20px color-mix(in srgb, var(--color-primary) 65%, transparent),
|
||||
0 0 32px rgba(34, 211, 238, 0.4),
|
||||
inset 0 0 10px rgba(253, 224, 71, 0.3);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.06);
|
||||
box-shadow:
|
||||
0 0 14px #fef08a,
|
||||
0 0 28px #facc15,
|
||||
0 0 44px rgba(34, 211, 238, 0.6),
|
||||
inset 0 0 14px rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
@keyframes topology-router-icon-shake {
|
||||
0%, 100% { transform: rotate(0deg) scale(1); filter: drop-shadow(0 0 2px #fde047); }
|
||||
25% { transform: rotate(-5deg) scale(1.08); filter: drop-shadow(0 0 6px #facc15); }
|
||||
75% { transform: rotate(5deg) scale(1.08); filter: drop-shadow(0 0 6px #22d3ee); }
|
||||
}
|
||||
@keyframes topology-router-label-flicker {
|
||||
0%, 100% { text-shadow: 0 0 6px #fde047, 0 0 12px #22d3ee; }
|
||||
50% { text-shadow: 0 0 10px #fff, 0 0 18px #facc15; }
|
||||
}
|
||||
@keyframes topology-edge-dash {
|
||||
to { stroke-dashoffset: -36; }
|
||||
}
|
||||
@keyframes topology-edge-flicker {
|
||||
0%, 100% { opacity: 0.85; }
|
||||
40% { opacity: 1; }
|
||||
55% { opacity: 0.55; }
|
||||
70% { opacity: 1; }
|
||||
}
|
||||
.topology-router-core {
|
||||
animation: topology-router-pulse 0.75s ease-in-out infinite;
|
||||
border-color: #fde047 !important;
|
||||
}
|
||||
.topology-router-icon {
|
||||
animation: topology-router-icon-shake 0.45s ease-in-out infinite;
|
||||
}
|
||||
.topology-router-label {
|
||||
animation: topology-router-label-flicker 0.7s ease-in-out infinite;
|
||||
}
|
||||
.topology-router-badge {
|
||||
box-shadow: 0 0 10px #fde047, 0 0 16px rgba(34, 211, 238, 0.6);
|
||||
}
|
||||
.topology-edge-kame {
|
||||
stroke-dasharray: 8 6;
|
||||
animation: topology-edge-dash 0.22s linear infinite;
|
||||
}
|
||||
.topology-edge-halo {
|
||||
animation: topology-edge-flicker 0.18s steps(2) infinite;
|
||||
}
|
||||
.topology-edge-plasma {
|
||||
stroke-dasharray: 14 10;
|
||||
animation: topology-edge-dash 0.18s linear infinite, topology-edge-flicker 0.22s steps(3) infinite;
|
||||
}
|
||||
|
||||
/* React Flow controls: match app theme */
|
||||
.react-flow-controls-custom {
|
||||
background: var(--color-surface);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 {} },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -89,12 +89,18 @@ export const CURSOR_CONFIG = {
|
||||
},
|
||||
};
|
||||
|
||||
// Kimi Coding OAuth Configuration (Device Code Flow)
|
||||
// clientId uses env override — dynamic, not stored in registry
|
||||
export const KIMI_CODING_CONFIG = {
|
||||
...PROVIDER_OAUTH["kimi-coding"],
|
||||
clientId: process.env.KIMI_CODING_OAUTH_CLIENT_ID || REGISTRY_PROVIDERS["kimi-coding"]?.clientId,
|
||||
// Kimi Code OAuth (Device Code Flow) — merged into provider id `kimi` (dual auth)
|
||||
// clientId: registry first, env override for forks
|
||||
export const KIMI_CONFIG = {
|
||||
...PROVIDER_OAUTH["kimi"],
|
||||
clientId:
|
||||
process.env.KIMI_CODING_OAUTH_CLIENT_ID ||
|
||||
process.env.KIMI_OAUTH_CLIENT_ID ||
|
||||
REGISTRY_PROVIDERS["kimi"]?.clientId ||
|
||||
PROVIDER_OAUTH["kimi"]?.clientId,
|
||||
};
|
||||
// Back-compat alias for any remaining KIMI_CODING_CONFIG imports
|
||||
export const KIMI_CODING_CONFIG = KIMI_CONFIG;
|
||||
|
||||
// KiloCode OAuth Configuration (Custom Device Auth Flow)
|
||||
export const KILOCODE_CONFIG = { ...PROVIDER_OAUTH["kilocode"] };
|
||||
@@ -134,7 +140,8 @@ export const PROVIDERS = {
|
||||
GITHUB: "github",
|
||||
KIRO: "kiro",
|
||||
CURSOR: "cursor",
|
||||
KIMI_CODING: "kimi-coding",
|
||||
KIMI: "kimi",
|
||||
KIMI_CODING: "kimi",
|
||||
KILOCODE: "kilocode",
|
||||
CLINE: "cline",
|
||||
CLINEPASS: "clinepass",
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
KIRO_CONFIG,
|
||||
assertValidAwsRegion,
|
||||
CURSOR_CONFIG,
|
||||
KIMI_CODING_CONFIG,
|
||||
KIMI_CONFIG,
|
||||
KILOCODE_CONFIG,
|
||||
CLINE_CONFIG,
|
||||
CLINEPASS_CONFIG,
|
||||
@@ -1081,13 +1081,22 @@ const PROVIDERS = {
|
||||
}),
|
||||
},
|
||||
|
||||
"kimi-coding": {
|
||||
config: KIMI_CODING_CONFIG,
|
||||
// Kimi Code device flow (CLIProxyAPI internal/auth/kimi). Id is `kimi`;
|
||||
// `kimi-coding` remains an alias key so old UI/API routes still resolve.
|
||||
kimi: {
|
||||
config: KIMI_CONFIG,
|
||||
flowType: "device_code",
|
||||
requestDeviceCode: async (config) => {
|
||||
const { buildKimiHeaders } = await import("open-sse/config/appConstants.js");
|
||||
const deviceId = crypto.randomUUID();
|
||||
const headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
...buildKimiHeaders(deviceId),
|
||||
};
|
||||
const response = await fetch(config.deviceCodeUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
||||
headers,
|
||||
body: new URLSearchParams({ client_id: config.clientId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -1095,21 +1104,30 @@ const PROVIDERS = {
|
||||
throw new Error(`Device code request failed: ${error}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const authorizeDeviceUrl = config.authorizeDeviceUrl || "https://www.kimi.com/code/authorize_device";
|
||||
return {
|
||||
device_code: data.device_code,
|
||||
user_code: data.user_code,
|
||||
verification_uri: data.verification_uri || "https://www.kimi.com/code/authorize_device",
|
||||
verification_uri: data.verification_uri || authorizeDeviceUrl,
|
||||
verification_uri_complete:
|
||||
data.verification_uri_complete ||
|
||||
`https://www.kimi.com/code/authorize_device?user_code=${data.user_code}`,
|
||||
`${authorizeDeviceUrl}?user_code=${data.user_code}`,
|
||||
expires_in: data.expires_in,
|
||||
interval: data.interval || 5,
|
||||
_kimiDeviceId: deviceId,
|
||||
};
|
||||
},
|
||||
pollToken: async (config, deviceCode) => {
|
||||
pollToken: async (config, deviceCode, _codeVerifier, extraData) => {
|
||||
const { buildKimiHeaders } = await import("open-sse/config/appConstants.js");
|
||||
const deviceId = extraData?._kimiDeviceId;
|
||||
const headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
...buildKimiHeaders(deviceId),
|
||||
};
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
||||
headers,
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
client_id: config.clientId,
|
||||
@@ -1119,19 +1137,26 @@ const PROVIDERS = {
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (e) {
|
||||
const text = await response.text();
|
||||
data = { error: "invalid_response", error_description: text };
|
||||
} catch {
|
||||
data = { error: "invalid_response", error_description: "non-json token response" };
|
||||
}
|
||||
return { ok: response.ok, data };
|
||||
// CLIProxyAPI: Kimi returns 200 for pending states with error field
|
||||
if (data.error === "authorization_pending" || data.error === "slow_down") {
|
||||
return { ok: true, data };
|
||||
}
|
||||
if (data.access_token && deviceId) data._kimiDeviceId = deviceId;
|
||||
return { ok: response.ok || !!data.access_token || !!data.error, data };
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
providerSpecificData: {
|
||||
authMethod: "device_code",
|
||||
...(tokens._kimiDeviceId ? { deviceId: tokens._kimiDeviceId } : {}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
kilocode: {
|
||||
config: KILOCODE_CONFIG,
|
||||
flowType: "device_code",
|
||||
@@ -1520,7 +1545,9 @@ const PROVIDERS = {
|
||||
* Get provider handler
|
||||
*/
|
||||
export function getProvider(name) {
|
||||
const provider = PROVIDERS[name];
|
||||
// Legacy kimi-coding → kimi (dual-auth merge)
|
||||
const key = name === "kimi-coding" ? "kimi" : name;
|
||||
const provider = PROVIDERS[key];
|
||||
if (!provider) {
|
||||
throw new Error(`Unknown provider: ${name}`);
|
||||
}
|
||||
|
||||
@@ -176,12 +176,14 @@ export default function ComboFormModal({ isOpen, combo, onClose, onSave, activeP
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ModelSelectModal isOpen={showModelSelect} onClose={() => setShowModelSelect(false)}
|
||||
onSelect={handleAddModel} onDeselect={handleDeselectModel}
|
||||
activeProviders={activeProviders} modelAliases={modelAliases}
|
||||
availableModels={availableModels}
|
||||
title="Add Model to Combo" kindFilter={kindFilter}
|
||||
addedModelValues={models} closeOnSelect={false} />
|
||||
{showModelSelect && (
|
||||
<ModelSelectModal isOpen={showModelSelect} onClose={() => setShowModelSelect(false)}
|
||||
onSelect={handleAddModel} onDeselect={handleDeselectModel}
|
||||
activeProviders={activeProviders} modelAliases={modelAliases}
|
||||
availableModels={availableModels}
|
||||
title="Add Model to Combo" kindFilter={kindFilter}
|
||||
addedModelValues={models} closeOnSelect={false} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import ThemeToggle from "@/shared/components/ThemeToggle";
|
||||
import { useHeaderSearchStore } from "@/store/headerSearchStore";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { getProviderIconSrc } from "@/shared/utils/providerIcon";
|
||||
import { translate } from "@/i18n/runtime";
|
||||
|
||||
const getPageInfo = (pathname) => {
|
||||
@@ -29,7 +30,7 @@ const getPageInfo = (pathname) => {
|
||||
breadcrumbs: [
|
||||
{ label: "Media Providers", href: `/dashboard/media-providers/${kindId}` },
|
||||
{ label: kindConfig?.label || kindId, href: `/dashboard/media-providers/${kindId}` },
|
||||
{ label: provider?.name || providerId, image: `/providers/${providerId}.png` },
|
||||
{ label: provider?.name || providerId, image: getProviderIconSrc(providerId) },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -61,7 +62,7 @@ const getPageInfo = (pathname) => {
|
||||
{ label: "Providers", href: "/dashboard/providers" },
|
||||
{
|
||||
label: providerInfo.name,
|
||||
image: `/providers/${providerInfo.id}.png`,
|
||||
image: getProviderIconSrc(providerInfo.id),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -154,7 +154,7 @@ export default function McpMarketplaceModal({ isOpen, onClose, onAdd, addedNames
|
||||
<div className="flex items-start gap-2 px-2 py-2 hover:bg-black/5 dark:hover:bg-white/5">
|
||||
{s.iconUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={s.iconUrl} alt="" className="size-7 rounded shrink-0 object-contain" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
<img src={s.iconUrl} alt="" className="size-7 rounded shrink-0 object-contain" onError={(e) => { e.target.style.display = "none"; }} loading="lazy" decoding="async" />
|
||||
) : (
|
||||
<div className="size-7 rounded bg-surface shrink-0" />
|
||||
)}
|
||||
|
||||
@@ -50,6 +50,46 @@ export default function ModelSelectModal({
|
||||
const [providerNodes, setProviderNodes] = useState([]);
|
||||
const [customModels, setCustomModels] = useState([]);
|
||||
const [deletedModels, setDeletedModels] = useState({});
|
||||
const [cursorModels, setCursorModels] = useState([]);
|
||||
|
||||
// Cursor exposes the usable catalog per account. Keep the static catalog only
|
||||
// as a fallback, since it quickly becomes stale and account entitlements vary.
|
||||
const cursorConnectionIds = useMemo(
|
||||
() => activeProviders
|
||||
.filter((provider) => provider.provider === "cursor" && provider.id)
|
||||
.map((provider) => provider.id),
|
||||
[activeProviders],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || cursorConnectionIds.length === 0) {
|
||||
setCursorModels([]);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
Promise.all(cursorConnectionIds.map(async (connectionId) => {
|
||||
const response = await fetch(`/api/providers/${connectionId}/models`, { cache: "no-store" });
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return Array.isArray(data.models) ? data.models : [];
|
||||
}))
|
||||
.then((modelLists) => {
|
||||
if (cancelled) return;
|
||||
const seen = new Set();
|
||||
setCursorModels(modelLists.flat().filter((model) => {
|
||||
if (!model?.id || seen.has(model.id)) return false;
|
||||
seen.add(model.id);
|
||||
return true;
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("Unable to load Cursor models for selector:", error);
|
||||
if (!cancelled) setCursorModels([]);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [isOpen, cursorConnectionIds]);
|
||||
|
||||
const fetchCombos = async () => {
|
||||
try {
|
||||
@@ -315,7 +355,9 @@ export default function ModelSelectModal({
|
||||
hasModels: mergedModels.length > 0,
|
||||
};
|
||||
} else {
|
||||
const hardcodedModels = getModelsByProviderId(providerId);
|
||||
const hardcodedModels = providerId === "cursor" && cursorModels.length > 0
|
||||
? cursorModels
|
||||
: getModelsByProviderId(providerId);
|
||||
const hardcodedIds = new Set(hardcodedModels.map((m) => m.id));
|
||||
|
||||
// Custom models: if no hardcoded models (e.g. openrouter), show all aliases for this provider
|
||||
@@ -387,7 +429,7 @@ export default function ModelSelectModal({
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [availableModels, filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, deletedModels, kindFilter, activeProviders]);
|
||||
}, [availableModels, filteredActiveProviders, modelAliases, allProviders, providerNodes, customModels, deletedModels, kindFilter, activeProviders, cursorModels]);
|
||||
|
||||
// Filter combos by search query (and hide combos when kindFilter is set — combos are LLM-only by design)
|
||||
const filteredCombos = useMemo(() => {
|
||||
|
||||
@@ -165,6 +165,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
"github",
|
||||
"qwen",
|
||||
"kiro",
|
||||
"kimi",
|
||||
"kimi-coding",
|
||||
"kilocode",
|
||||
"codebuddy-cn",
|
||||
@@ -210,6 +211,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
_qoderMachineId: data._qoderMachineId,
|
||||
_qoderVerifier: data.codeVerifier,
|
||||
}
|
||||
: (provider === "kimi" || provider === "kimi-coding")
|
||||
? { _kimiDeviceId: data._kimiDeviceId }
|
||||
: null;
|
||||
startPolling(
|
||||
data.device_code,
|
||||
|
||||
@@ -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 (
|
||||
<span
|
||||
className={`inline-flex items-center justify-center font-bold rounded-lg ${className}`.trim()}
|
||||
@@ -31,18 +42,26 @@ export default function ProviderIcon({
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
src={effectiveSrc}
|
||||
alt={alt}
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
onError={() => 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,
|
||||
|
||||
@@ -22,6 +22,7 @@ export const LOCALE_FLAGS = {
|
||||
"tl": "🇵🇭",
|
||||
"id": "🇮🇩",
|
||||
"th": "🇹🇭",
|
||||
"km": "🇰🇭",
|
||||
"hi": "🇮🇳",
|
||||
"bn": "🇧🇩",
|
||||
"ur": "🇵🇰",
|
||||
|
||||
@@ -1,44 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
|
||||
|
||||
// Fetch model capabilities once and expose a lookup by fullModel ("provider/model") or bare model id.
|
||||
// Module cache: one /api/models fetch shared by every useModelCaps instance.
|
||||
let cache = null; // { byFull, byId } | null
|
||||
let inflight = null;
|
||||
|
||||
function buildMaps(models) {
|
||||
const byFull = {};
|
||||
const byId = {};
|
||||
for (const m of models || []) {
|
||||
if (!m.caps) continue;
|
||||
if (m.fullModel) byFull[m.fullModel] = m.caps;
|
||||
if (m.routedModel) byFull[m.routedModel] = m.caps;
|
||||
if (m.model) byId[m.model] = m.caps;
|
||||
}
|
||||
return { byFull, byId };
|
||||
}
|
||||
|
||||
function loadModelCaps() {
|
||||
if (cache) return Promise.resolve(cache);
|
||||
if (inflight) return inflight;
|
||||
inflight = fetch("/api/models")
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`models ${res.status}`);
|
||||
const data = await res.json();
|
||||
cache = buildMaps(data.models);
|
||||
return cache;
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep null so a later mount can retry
|
||||
return { byFull: {}, byId: {} };
|
||||
})
|
||||
.finally(() => { inflight = null; });
|
||||
return inflight;
|
||||
}
|
||||
|
||||
// Resolve caps from a "provider/model" string or a bare model id.
|
||||
function resolveCaps(byFull, byId, key) {
|
||||
if (!key) return null;
|
||||
if (byFull[key]) return byFull[key];
|
||||
const bare = key.includes("/") ? key.slice(key.indexOf("/") + 1) : key;
|
||||
if (byId[bare]) return byId[bare];
|
||||
const provider = key.includes("/") ? key.slice(0, key.indexOf("/")) : null;
|
||||
const c = getCapabilitiesForModel(provider, bare);
|
||||
return {
|
||||
vision: c.vision,
|
||||
search: c.search,
|
||||
reasoning: c.reasoning,
|
||||
contextWindow: c.contextWindow,
|
||||
maxOutput: c.maxOutput,
|
||||
};
|
||||
}
|
||||
|
||||
export function useModelCaps() {
|
||||
const [byFull, setByFull] = useState({});
|
||||
const [byId, setById] = useState({});
|
||||
const [byFull, setByFull] = useState(() => cache?.byFull || {});
|
||||
const [byId, setById] = useState(() => cache?.byId || {});
|
||||
|
||||
useEffect(() => {
|
||||
if (cache) {
|
||||
setByFull(cache.byFull);
|
||||
setById(cache.byId);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const full = {};
|
||||
const id = {};
|
||||
for (const m of data.models || []) {
|
||||
if (!m.caps) continue;
|
||||
if (m.fullModel) full[m.fullModel] = m.caps;
|
||||
if (m.model) id[m.model] = m.caps;
|
||||
}
|
||||
if (alive) { setByFull(full); setById(id); }
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
loadModelCaps().then((maps) => {
|
||||
if (alive) { setByFull(maps.byFull); setById(maps.byId); }
|
||||
});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
// Resolve caps from a "provider/model" string or a bare model id.
|
||||
const getCaps = (key) => {
|
||||
if (!key) return null;
|
||||
if (byFull[key]) return byFull[key];
|
||||
const bare = key.includes("/") ? key.slice(key.indexOf("/") + 1) : key;
|
||||
if (byId[bare]) return byId[bare];
|
||||
// Fallback: compute caps for dynamic models (passthrough/custom/suggested) not in static list
|
||||
const provider = key.includes("/") ? key.slice(0, key.indexOf("/")) : null;
|
||||
const c = getCapabilitiesForModel(provider, bare);
|
||||
return { vision: c.vision, search: c.search, reasoning: c.reasoning };
|
||||
};
|
||||
const getCaps = useCallback(
|
||||
(key) => resolveCaps(byFull, byId, key),
|
||||
[byFull, byId],
|
||||
);
|
||||
|
||||
return { getCaps };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
"kr": "kiro",
|
||||
"cu": "cursor",
|
||||
"kc": "kilocode",
|
||||
"kmc": "kimi-coding",
|
||||
"kmc": "kimi",
|
||||
"cl": "cline",
|
||||
"oc": "opencode",
|
||||
"ocg": "opencode-go",
|
||||
@@ -98,6 +98,7 @@
|
||||
"idToAlias": {
|
||||
"alicode": "alicode",
|
||||
"alicode-intl": "alicode-intl",
|
||||
"alims-intl": "alims-intl",
|
||||
"anthropic": "anthropic",
|
||||
"antigravity": "ag",
|
||||
"assemblyai": "assemblyai",
|
||||
@@ -117,6 +118,7 @@
|
||||
"cursor": "cu",
|
||||
"deepgram": "deepgram",
|
||||
"deepseek": "deepseek",
|
||||
"featherless": "featherless",
|
||||
"fireworks": "fireworks",
|
||||
"gemini": "gemini",
|
||||
"gemini-cli": "gc",
|
||||
@@ -132,7 +134,6 @@
|
||||
"kilocode": "kc",
|
||||
"kimchi": "kimchi",
|
||||
"kimi": "kimi",
|
||||
"kimi-coding": "kmc",
|
||||
"kiro": "kr",
|
||||
"mimo-free": "mmf",
|
||||
"minimax": "minimax",
|
||||
@@ -149,6 +150,7 @@
|
||||
"opencode-go": "opencode-go",
|
||||
"openrouter": "openrouter",
|
||||
"perplexity": "perplexity",
|
||||
"perplexity-agent": "perplexity-agent",
|
||||
"perplexity-web": "perplexity-web",
|
||||
"qoder": "qd",
|
||||
"qwen": "qw",
|
||||
@@ -167,6 +169,7 @@
|
||||
"ag",
|
||||
"alicode",
|
||||
"alicode-intl",
|
||||
"alims-intl",
|
||||
"anthropic",
|
||||
"assemblyai",
|
||||
"black-forest-labs",
|
||||
@@ -188,6 +191,7 @@
|
||||
"edge-tts",
|
||||
"elevenlabs-tts-models",
|
||||
"fal-ai",
|
||||
"featherless",
|
||||
"fireworks",
|
||||
"gc",
|
||||
"gcli",
|
||||
@@ -206,7 +210,6 @@
|
||||
"kc",
|
||||
"kimchi",
|
||||
"kimi",
|
||||
"kmc",
|
||||
"kr",
|
||||
"local-device",
|
||||
"minimax",
|
||||
@@ -226,6 +229,7 @@
|
||||
"openrouter-tts-models",
|
||||
"openrouter-tts-voices",
|
||||
"perplexity",
|
||||
"perplexity-agent",
|
||||
"perplexity-web",
|
||||
"qd",
|
||||
"qw",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"alicode-intl": {
|
||||
"baseUrl": "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions",
|
||||
"baseUrl": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
|
||||
"headers": {},
|
||||
"quirks": {
|
||||
"preserveCacheControl": true
|
||||
@@ -19,18 +19,17 @@
|
||||
"baseUrl": "https://api.anthropic.com/v1/messages",
|
||||
"format": "claude",
|
||||
"headers": {
|
||||
"Anthropic-Version": "2023-06-01",
|
||||
"anthropic-version": "2023-06-01",
|
||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14"
|
||||
}
|
||||
},
|
||||
"antigravity": {
|
||||
"baseUrls": [
|
||||
"https://daily-cloudcode-pa.googleapis.com",
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com"
|
||||
"https://cloudcode-pa.googleapis.com"
|
||||
],
|
||||
"format": "antigravity",
|
||||
"headers": {
|
||||
"User-Agent": "antigravity/1.107.0 darwin/arm64"
|
||||
"User-Agent": "antigravity/ide/2.1.1 darwin/arm64"
|
||||
},
|
||||
"retry": {
|
||||
"429": {
|
||||
@@ -232,7 +231,7 @@
|
||||
"Content-Type": "application/connect+proto",
|
||||
"User-Agent": "connect-es/1.6.1"
|
||||
},
|
||||
"clientVersion": "3.1.0"
|
||||
"clientVersion": "3.12.17"
|
||||
},
|
||||
"deepgram": {
|
||||
"baseUrl": "https://api.deepgram.com/v1/listen",
|
||||
@@ -270,6 +269,11 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"featherless": {
|
||||
"baseUrl": "https://api.featherless.ai/v1/chat/completions",
|
||||
"validateUrl": "https://api.featherless.ai/v1/models",
|
||||
"format": "openai"
|
||||
},
|
||||
"fireworks": {
|
||||
"baseUrl": "https://api.fireworks.ai/inference/v1/chat/completions",
|
||||
"validateUrl": "https://api.fireworks.ai/inference/v1/models",
|
||||
@@ -307,6 +311,7 @@
|
||||
"github": {
|
||||
"baseUrl": "https://api.githubcopilot.com/chat/completions",
|
||||
"responsesUrl": "https://api.githubcopilot.com/responses",
|
||||
"messagesUrl": "https://api.githubcopilot.com/v1/messages",
|
||||
"headers": {
|
||||
"copilot-integration-id": "vscode-chat",
|
||||
"editor-version": "vscode/1.110.0",
|
||||
@@ -398,17 +403,18 @@
|
||||
"modelsUrl": "https://cli-chat-proxy.grok.com/v1/models",
|
||||
"userUrl": "https://cli-chat-proxy.grok.com/v1/user",
|
||||
"billingUrl": "https://cli-chat-proxy.grok.com/v1/billing",
|
||||
"clientVersion": "0.2.93",
|
||||
"clientIdentifier": "grok-pager",
|
||||
"clientVersion": "0.2.99",
|
||||
"clientIdentifier": "grok-shell",
|
||||
"tokenAuth": "xai-grok-cli",
|
||||
"headers": {
|
||||
"User-Agent": "grok-pager/0.2.93 grok-shell/0.2.93 (linux; x86_64)",
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
"x-grok-client-identifier": "grok-pager",
|
||||
"x-grok-client-version": "0.2.93",
|
||||
"x-authenticateresponse": "authenticate-response"
|
||||
"User-Agent": "grok-shell/0.2.99 (linux; x86_64)",
|
||||
"x-grok-client-identifier": "grok-shell",
|
||||
"x-grok-client-version": "0.2.99"
|
||||
},
|
||||
"usage": {
|
||||
"url": "https://cli-chat-proxy.grok.com/v1/billing?format=credits",
|
||||
"userUrl": "https://cli-chat-proxy.grok.com/v1/user?include=subscription"
|
||||
},
|
||||
"compactionAt": 400000,
|
||||
"retry": {
|
||||
"429": {
|
||||
"attempts": 2,
|
||||
@@ -477,7 +483,7 @@
|
||||
"scheme": "bearer"
|
||||
}
|
||||
},
|
||||
"kimi-coding": {
|
||||
"kimi": {
|
||||
"baseUrl": "https://api.kimi.com/coding/v1/messages",
|
||||
"format": "claude",
|
||||
"urlSuffix": "?beta=true",
|
||||
@@ -528,45 +534,6 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"kimi": {
|
||||
"baseUrl": "https://api.kimi.com/coding/v1/messages",
|
||||
"format": "claude",
|
||||
"urlSuffix": "?beta=true",
|
||||
"headers": {
|
||||
"Anthropic-Version": "2023-06-01",
|
||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14"
|
||||
},
|
||||
"auth": {
|
||||
"combined": true,
|
||||
"header": "x-api-key",
|
||||
"scheme": "raw"
|
||||
},
|
||||
"transports": [
|
||||
{
|
||||
"format": "openai",
|
||||
"baseUrl": "https://api.kimi.com/coding/v1/chat/completions",
|
||||
"auth": {
|
||||
"combined": true,
|
||||
"header": "Authorization",
|
||||
"scheme": "bearer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"format": "claude",
|
||||
"baseUrl": "https://api.kimi.com/coding/v1/messages",
|
||||
"urlSuffix": "?beta=true",
|
||||
"headers": {
|
||||
"Anthropic-Version": "2023-06-01",
|
||||
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14"
|
||||
},
|
||||
"auth": {
|
||||
"combined": true,
|
||||
"header": "x-api-key",
|
||||
"scheme": "raw"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"kiro": {
|
||||
"baseUrl": "https://runtime.us-east-1.kiro.dev/generateAssistantResponse",
|
||||
"baseUrls": [
|
||||
@@ -774,6 +741,11 @@
|
||||
"validateUrl": "https://api.perplexity.ai/models",
|
||||
"format": "openai"
|
||||
},
|
||||
"perplexity-agent": {
|
||||
"baseUrl": "https://api.perplexity.ai/v1/responses",
|
||||
"validateUrl": "https://api.perplexity.ai/v1/models",
|
||||
"format": "openai-responses"
|
||||
},
|
||||
"qoder": {
|
||||
"baseUrl": "https://api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation",
|
||||
"headers": {},
|
||||
@@ -900,5 +872,13 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"alims-intl": {
|
||||
"baseUrl": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
|
||||
"headers": {},
|
||||
"quirks": {
|
||||
"preserveCacheControl": true
|
||||
},
|
||||
"format": "openai"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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("<thinking_mode>");
|
||||
expect(out.systemPrompt || "").not.toContain("<max_thinking_length>");
|
||||
});
|
||||
|
||||
// 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(() =>
|
||||
|
||||
@@ -112,6 +112,59 @@ describe("Claude → Kiro (direct route)", () => {
|
||||
expect(out.systemPrompt).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
});
|
||||
|
||||
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("<thinking_mode>");
|
||||
expect(out.systemPrompt || "").not.toContain("<max_thinking_length>");
|
||||
});
|
||||
|
||||
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("<thinking_mode>enabled</thinking_mode>");
|
||||
expect(out.systemPrompt).toContain("<max_thinking_length>");
|
||||
}
|
||||
);
|
||||
|
||||
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("<thinking_mode>");
|
||||
expect(out.systemPrompt || "").not.toContain("<max_thinking_length>");
|
||||
}
|
||||
);
|
||||
|
||||
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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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\"");
|
||||
});
|
||||
|
||||
@@ -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("<thinking_mode>");
|
||||
expect(systemPromptOf(result)).not.toContain("<max_thinking_length>");
|
||||
expect(contentOf(result)).not.toContain("<thinking_mode>");
|
||||
expect(contentOf(result)).not.toContain("<max_thinking_length>");
|
||||
});
|
||||
|
||||
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("<thinking_mode>");
|
||||
expect(systemPromptOf(result)).not.toContain("<max_thinking_length>");
|
||||
});
|
||||
|
||||
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("<thinking_mode>");
|
||||
expect(systemPromptOf(result)).not.toContain("<max_thinking_length>");
|
||||
});
|
||||
|
||||
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("<thinking_mode>enabled</thinking_mode>");
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>");
|
||||
}
|
||||
);
|
||||
|
||||
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("<thinking_mode>");
|
||||
expect(systemPromptOf(result)).not.toContain("<max_thinking_length>");
|
||||
}
|
||||
);
|
||||
|
||||
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("<thinking_mode>enabled</thinking_mode>");
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>");
|
||||
});
|
||||
|
||||
it("does not send additionalModelRequestFields for legacy Kiro model ids", () => {
|
||||
const body = {
|
||||
reasoning_effort: "high",
|
||||
|
||||