merge(master): sync master into dev

This commit is contained in:
2026-08-04 17:58:24 +07:00
174 changed files with 14405 additions and 3136 deletions
+31
View File
@@ -4,6 +4,37 @@
- **Dokploy**: deploy production Compose from GitHub Actions with serialized rollout tracking, terminal-status handling, health verification, and deployment summaries - **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 - **GitHub Actions**: remove unrelated Docker image publishing and GitBook Pages deployment workflows
# v0.5.45 (2026-07-30)
## Features
- **Providers**: add Poolside (OpenAI-compatible)
- **Providers**: add api-airforce, baidu, bazaarlink, bluesminds, kilo-gateway, llm7, morph, sambanova, tencent
- **OAuth**: zed / trae / windsurf providers + harden callback proxies
- **CLI tools**: set Claude Code max context tokens
- **Qoder**: PAT auth + refresh model list
- **Gemini**: Gemini 3.6 Flash tier routing + Gemini 3.5 Flash Lite
- **Claude**: bump default Opus to `claude-opus-5`
- **Kiro**: add Claude Opus 5 models
- **Usage**: Kimi and DeepSeek usage handlers
- **Usage**: SuperGrok weekly pool via gRPC-web
## Fixes
- **Refresh**: rotate `refresh_token` between retry attempts
- **Kiro**: canonicalize tool history and route API keys correctly
- **Kiro**: normalize dashboard thinking intensity models
- **Cursor**: stop leaking agent tool errors as text
- **Gemini**: fill empty tool schemas after `$ref` strip
- **Antigravity**: strip `stream_options` from non-stream requests
- **Jina-reader**: recover after transient errors, use JSON POST API
- **Usage**: record exact embedding tokens
- **Console-log**: initialize capture at server boot + prevent SSE proxy buffering
- **Dashboard**: count dual-auth, free-tier OAuth and API-key connections correctly
- **Dashboard**: flex quota rows, thin global scrollbars, no hidden-row overflow
## Docs
- **i18n**: expand pt-BR translation to 986 terms
- README: Indonesian translation
# v0.5.40 (2026-07-20) # v0.5.40 (2026-07-20)
## Features ## Features
+1 -1
View File
@@ -17,7 +17,7 @@
[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com) [🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com)
[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) [🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) • [🇮🇩 Indonesia](./i18n/README.id-ID.md)
</div> </div>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "9router", "name": "9router",
"version": "0.5.40", "version": "0.5.45",
"description": "9Router CLI - Start and manage 9Router server", "description": "9Router CLI - Start and manage 9Router server",
"bin": { "bin": {
"9router": "./cli.js" "9router": "./cli.js"
+5
View File
@@ -53,6 +53,9 @@ const PROVIDER_MODELS = {
{ id: "glm-4.7" }, { id: "glm-4.7" },
], ],
ag: [ ag: [
{ id: "gemini-3.6-flash-high" },
{ id: "gemini-3.6-flash-medium" },
{ id: "gemini-3.6-flash-low" },
{ id: "gemini-3-flash-agent" }, { id: "gemini-3-flash-agent" },
{ id: "gemini-3.5-flash-low" }, { id: "gemini-3.5-flash-low" },
{ id: "gemini-3.5-flash-extra-low" }, { id: "gemini-3.5-flash-extra-low" },
@@ -95,6 +98,8 @@ const PROVIDER_MODELS = {
{ id: "claude-3-5-sonnet-20241022" }, { id: "claude-3-5-sonnet-20241022" },
], ],
gemini: [ gemini: [
{ id: "gemini-3.6-flash" },
{ id: "gemini-3.5-flash-lite" },
{ id: "gemini-3-pro-preview" }, { id: "gemini-3-pro-preview" },
{ id: "gemini-2.5-pro" }, { id: "gemini-2.5-pro" },
{ id: "gemini-2.5-flash" }, { id: "gemini-2.5-flash" },
+951
View File
@@ -0,0 +1,951 @@
<div align="center">
<img src="../images/9router.png?1" alt="9Router Dashboard" width="800"/>
# 9Router - Router AI Gratis
**Jangan berhenti ngoding. Otomatis dialihkan ke model AI gratis & murah dengan smart fallback.**
**Hubungkan semua tool AI coding (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) ke 40+ provider AI dan 100+ model.**
[![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router)
[![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router)
[![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE)
[🚀 Mulai Cepat](#-mulai-cepat) • [💡 Fitur](#-fitur-utama) • [📖 Setup](#-panduan-setup) • [🌐 Website](https://9router.com)
[🇻🇳 Tiếng Việt](./README.vi.md) • [🇨🇳 中文](./README.zh-CN.md) • [🇯🇵 日本語](./README.ja-JP.md) • [🇮🇩 Bahasa Indonesia](./README.id-ID.md)
</div>
---
## 🤔 Kenapa 9Router?
**Berhenti buang-buang uang dan terhambat limit:**
- ❌ Kuota langganan hangus tiap bulan tanpa terpakai
- ❌ Rate limit bikin ngoding berhenti di tengah jalan
- ❌ API mahal ($2050/bulan per provider)
- ❌ Harus gonta-ganti provider secara manual
**9Router menyelesaikan itu semua:**
-**Maksimalkan langganan** - lacak kuota dan habiskan sebelum reset
-**Fallback otomatis** - langganan → murah → gratis, tanpa downtime
-**Multi-akun** - round-robin antar akun untuk tiap provider
-**Universal** - mendukung Claude Code, Codex, Gemini CLI, Cursor, Cline, dan tool CLI apa pun
---
## 🔄 Cara Kerja
```
┌─────────────┐
│ Tool CLI │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...)
│ kamu │
└──────┬──────┘
│ http://localhost:20128/v1
┌─────────────────────────────────────────┐
│ 9Router (Smart Router) │
│ • Konversi format (OpenAI ↔ Claude) │
│ • Pelacakan kuota │
│ • Refresh token otomatis │
└──────┬──────────────────────────────────┘
├─→ [Tier 1: Langganan] Claude Code, Codex, Gemini CLI
│ ↓ kuota habis
├─→ [Tier 2: Murah] GLM ($0.6/1M), MiniMax ($0.2/1M)
│ ↓ batas budget tercapai
└─→ [Tier 3: Gratis] iFlow, Qwen, Kiro (unlimited)
Hasil: ngoding tanpa berhenti, biaya minimum
```
---
## ⚡ Mulai Cepat
**1. Install secara global:**
```bash
npm install -g 9router
9router
```
🎉 Dashboard terbuka di `http://localhost:20128`
**2. Hubungkan provider gratis (tanpa perlu daftar):**
Dashboard → Providers → hubungkan **Claude Code** atau **Antigravity** → login OAuth → selesai!
**3. Pakai di tool CLI kamu:**
```
Konfigurasi Claude Code/Codex/Gemini CLI/OpenClaw/Cursor/Cline:
Endpoint: http://localhost:20128/v1
API Key: [salin dari dashboard]
Model: if/kimi-k2-thinking
```
**Cuma itu!** Mulai ngoding dengan model AI gratis.
**Alternatif: jalankan dari source (repo ini):**
Paket repo ini bersifat privat (`9router-app`), jadi menjalankan dari source/Docker adalah jalur yang diharapkan untuk pengembangan lokal.
```bash
cp .env.example .env
npm install
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
```
Mode produksi:
```bash
npm run build
PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start
```
URL default:
- Dashboard: `http://localhost:20128/dashboard`
- API kompatibel OpenAI: `http://localhost:20128/v1`
---
## 🎥 Video Tutorial
<div align="center">
### 📺 Panduan Setup Lengkap - 9Router + Claude Code Gratis
[![9Router + Claude Code Setup](https://img.youtube.com/vi/raEyZPg5xE0/maxresdefault.jpg)](https://www.youtube.com/watch?v=raEyZPg5xE0)
**🎬 Tonton tutorial langkah demi langkah:**
- ✅ Install dan setup 9Router
- ✅ Konfigurasi Claude Sonnet 4.5 gratis
- ✅ Integrasi dengan Claude Code
- ✅ Demo live coding
**⏱️ Durasi:** 20 menit | **👥 Dibuat oleh:** Developer Community
[▶️ Tonton di YouTube](https://www.youtube.com/watch?v=o3qYCyjrFYg)
</div>
---
## 🛠️ Tool CLI yang Didukung
9Router bekerja mulus dengan semua tool AI coding utama:
<div align="center">
<table>
<tr>
<td align="center" width="120">
<img src="../public/providers/claude.png" width="60" alt="Claude Code"/><br/>
<b>Claude-Code</b>
</td>
<td align="center" width="120">
<img src="../public/providers/openclaw.png" width="60" alt="OpenClaw"/><br/>
<b>OpenClaw</b>
</td>
<td align="center" width="120">
<img src="../public/providers/codex.png" width="60" alt="Codex"/><br/>
<b>Codex</b>
</td>
<td align="center" width="120">
<img src="../public/providers/opencode.png" width="60" alt="OpenCode"/><br/>
<b>OpenCode</b>
</td>
<td align="center" width="120">
<img src="../public/providers/cursor.png" width="60" alt="Cursor"/><br/>
<b>Cursor</b>
</td>
<td align="center" width="120">
<img src="../public/providers/antigravity.png" width="60" alt="Antigravity"/><br/>
<b>Antigravity</b>
</td>
</tr>
<tr>
<td align="center" width="120">
<img src="../public/providers/cline.png" width="60" alt="Cline"/><br/>
<b>Cline</b>
</td>
<td align="center" width="120">
<img src="../public/providers/continue.png" width="60" alt="Continue"/><br/>
<b>Continue</b>
</td>
<td align="center" width="120">
<img src="../public/providers/droid.png" width="60" alt="Droid"/><br/>
<b>Droid</b>
</td>
<td align="center" width="120">
<img src="../public/providers/roo.png" width="60" alt="Roo"/><br/>
<b>Roo</b>
</td>
<td align="center" width="120">
<img src="../public/providers/copilot.png" width="60" alt="Copilot"/><br/>
<b>Copilot</b>
</td>
<td align="center" width="120">
<img src="../public/providers/kilocode.png" width="60" alt="Kilo Code"/><br/>
<b>Kilo Code</b>
</td>
</tr>
</table>
</div>
---
## 🌐 Provider yang Didukung
### 🔐 Provider OAuth
<div align="center">
<table>
<tr>
<td align="center" width="120">
<img src="../public/providers/claude.png" width="60" alt="Claude Code"/><br/>
<b>Claude-Code</b>
</td>
<td align="center" width="120">
<img src="../public/providers/antigravity.png" width="60" alt="Antigravity"/><br/>
<b>Antigravity</b>
</td>
<td align="center" width="120">
<img src="../public/providers/codex.png" width="60" alt="Codex"/><br/>
<b>Codex</b>
</td>
<td align="center" width="120">
<img src="../public/providers/github.png" width="60" alt="GitHub"/><br/>
<b>GitHub</b>
</td>
<td align="center" width="120">
<img src="../public/providers/cursor.png" width="60" alt="Cursor"/><br/>
<b>Cursor</b>
</td>
</tr>
</table>
</div>
### 🆓 Provider Gratis
<div align="center">
<table>
<tr>
<td align="center" width="150">
<img src="../public/providers/iflow.png" width="70" alt="iFlow"/><br/>
<b>iFlow AI</b><br/>
<sub>8+ model • unlimited</sub>
</td>
<td align="center" width="150">
<img src="../public/providers/qwen.png" width="70" alt="Qwen"/><br/>
<b>Qwen Code</b><br/>
<sub>3+ model • unlimited</sub>
</td>
<td align="center" width="150">
<img src="../public/providers/gemini-cli.png" width="70" alt="Gemini CLI"/><br/>
<b>Gemini CLI</b><br/>
<sub>180 ribu request/bulan gratis</sub>
</td>
<td align="center" width="150">
<img src="../public/providers/kiro.png" width="70" alt="Kiro"/><br/>
<b>Kiro AI</b><br/>
<sub>Claude • unlimited</sub>
</td>
</tr>
</table>
</div>
### 🔑 Provider API Key (40+)
<div align="center">
<table>
<tr>
<td align="center" width="100">
<img src="../public/providers/openrouter.png" width="50" alt="OpenRouter"/><br/>
<sub>OpenRouter</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/glm.png" width="50" alt="GLM"/><br/>
<sub>GLM</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/kimi.png" width="50" alt="Kimi"/><br/>
<sub>Kimi</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/minimax.png" width="50" alt="MiniMax"/><br/>
<sub>MiniMax</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/openai.png" width="50" alt="OpenAI"/><br/>
<sub>OpenAI</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/anthropic.png" width="50" alt="Anthropic"/><br/>
<sub>Anthropic</sub>
</td>
</tr>
<tr>
<td align="center" width="100">
<img src="../public/providers/gemini.png" width="50" alt="Gemini"/><br/>
<sub>Gemini</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/deepseek.png" width="50" alt="DeepSeek"/><br/>
<sub>DeepSeek</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/groq.png" width="50" alt="Groq"/><br/>
<sub>Groq</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/xai.png" width="50" alt="xAI"/><br/>
<sub>xAI</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/mistral.png" width="50" alt="Mistral"/><br/>
<sub>Mistral</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/perplexity.png" width="50" alt="Perplexity"/><br/>
<sub>Perplexity</sub>
</td>
</tr>
<tr>
<td align="center" width="100">
<img src="../public/providers/together.png" width="50" alt="Together"/><br/>
<sub>Together AI</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/fireworks.png" width="50" alt="Fireworks"/><br/>
<sub>Fireworks</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/cerebras.png" width="50" alt="Cerebras"/><br/>
<sub>Cerebras</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/cohere.png" width="50" alt="Cohere"/><br/>
<sub>Cohere</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/nvidia.png" width="50" alt="NVIDIA"/><br/>
<sub>NVIDIA</sub>
</td>
<td align="center" width="100">
<img src="../public/providers/siliconflow.png" width="50" alt="SiliconFlow"/><br/>
<sub>SiliconFlow</sub>
</td>
</tr>
</table>
<p><i>...dan 20+ provider lain seperti Nebius, Chutes, Hyperbolic, serta endpoint custom yang kompatibel dengan OpenAI/Anthropic</i></p>
</div>
---
## 💡 Fitur Utama
| Fitur | Ringkasan | Manfaat |
|-------|-----------|---------|
| 🎯 **Smart Fallback 3 Tingkat** | Routing otomatis: langganan → murah → gratis | Ngoding tanpa berhenti, zero downtime |
| 📊 **Pelacakan Kuota Real-time** | Hitungan token live + hitung mundur reset | Nilai langganan termanfaatkan maksimal |
| 🔄 **Konversi Format** | OpenAI ↔ Claude ↔ Gemini mulus | Bekerja dengan tool CLI apa pun |
| 👥 **Dukungan Multi-akun** | Beberapa akun per provider | Load balancing + redundansi |
| 🔄 **Auto Refresh Token** | Token OAuth diperbarui otomatis | Tidak perlu login ulang manual |
| 🎨 **Combo Kustom** | Buat kombinasi model tanpa batas | Fallback sesuai kebutuhanmu |
| 📝 **Log Request** | Log lengkap request/response | Troubleshooting jadi mudah |
| 💾 **Cloud Sync** | Sinkronkan pengaturan antar perangkat | Setup sama di mana pun |
| 📊 **Analitik Penggunaan** | Lacak token, biaya, dan tren | Optimalkan pengeluaran |
| 🌐 **Deploy di Mana Saja** | Localhost, VPS, Docker, Cloudflare Workers | Opsi deployment fleksibel |
<details>
<summary><b>📖 Detail Fitur</b></summary>
### 🎯 Smart Fallback 3 Tingkat
Buat combo dengan fallback otomatis:
```
Combo: "my-coding-stack"
1. cc/claude-opus-4-6 (langganan)
2. glm/glm-4.7 (backup murah, $0.6/1M)
3. if/kimi-k2-thinking (fallback gratis)
→ Otomatis beralih saat kuota habis atau terjadi error
```
### 📊 Pelacakan Kuota Real-time
- Konsumsi token per provider
- Hitung mundur reset (5 jam, harian, mingguan)
- Estimasi biaya untuk tier berbayar
- Laporan pengeluaran bulanan
### 🔄 Konversi Format
Konversi mulus antar format:
- **OpenAI** ↔ **Claude****Gemini****OpenAI Responses**
- Tool CLI mengirim dalam format OpenAI → 9Router mengonversi → provider menerima dalam format nativenya
- Bekerja dengan semua tool yang mendukung custom OpenAI endpoint
### 👥 Dukungan Multi-akun
- Tambahkan beberapa akun per provider
- Round-robin otomatis atau routing berbasis prioritas
- Saat satu akun mencapai kuota, fallback ke akun berikutnya
### 🔄 Auto Refresh Token
- Token OAuth di-refresh otomatis sebelum kedaluwarsa
- Tidak perlu autentikasi ulang manual
- Pengalaman mulus di semua provider
### 🎨 Combo Kustom
- Buat kombinasi model tanpa batas
- Campur tier langganan, murah, dan gratis
- Beri nama combo agar mudah diakses
- Bagikan combo antar perangkat lewat cloud sync
### 📝 Log Request
- Log lengkap request/response dalam mode debug
- Lacak API call, header, dan payload
- Troubleshoot masalah integrasi
- Ekspor log untuk dianalisis
### 💾 Cloud Sync
- Sinkronkan provider, combo, dan pengaturan antar perangkat
- Sinkronisasi latar belakang otomatis
- Penyimpanan terenkripsi yang aman
- Akses setup dari mana saja
#### Catatan tentang cloud runtime
- Untuk produksi, disarankan memakai variabel cloud sisi server:
- `BASE_URL` (URL callback internal yang dipakai scheduler sinkronisasi)
- `CLOUD_URL` (base URL endpoint cloud sync)
- `NEXT_PUBLIC_BASE_URL` dan `NEXT_PUBLIC_CLOUD_URL` masih didukung untuk kompatibilitas/UI, tetapi runtime server memprioritaskan `BASE_URL`/`CLOUD_URL`.
- Request cloud sync memakai timeout + perilaku fail-fast untuk menghindari UI menggantung saat DNS/jaringan cloud tidak tersedia.
### 📊 Analitik Penggunaan
- Lacak pemakaian token per provider dan per model
- Estimasi biaya dan tren pengeluaran
- Laporan dan insight bulanan
- Optimalkan pengeluaran AI
> **💡 PENTING - tentang biaya di dashboard:**
>
> "Biaya" yang ditampilkan pada analitik penggunaan **hanya untuk pelacakan dan perbandingan**.
> 9Router sendiri **tidak menagih apa pun**. Kamu hanya membayar langsung ke provider jika memakai layanan berbayar.
>
> **Contoh:** jika dashboard menampilkan "Total biaya $290" untuk pemakaian model iFlow,
> itu adalah jumlah yang seharusnya kamu bayar bila memakai API berbayar secara langsung. Biaya sebenarnya = **$0** (iFlow gratis tanpa batas).
>
> Anggap saja ini "pelacak penghematan" yang menunjukkan berapa banyak yang kamu hemat lewat model gratis dan routing 9Router!
### 🌐 Deploy di Mana Saja
- 💻 **Localhost** - default, jalan offline
- ☁️ **VPS/Cloud** - berbagi antar perangkat
- 🐳 **Docker** - deploy satu perintah
- 🚀 **Cloudflare Workers** - jaringan edge global
</details>
---
## 💰 Ringkasan Harga
| Tier | Provider | Biaya | Reset Kuota | Cocok Untuk |
|------|----------|-------|-------------|-------------|
| **💳 Langganan** | Claude Code (Pro) | $20/bulan | 5 jam + mingguan | Yang sudah punya langganan |
| | Codex (Plus/Pro) | $20-200/bulan | 5 jam + mingguan | Pengguna OpenAI |
| | Gemini CLI | **Gratis** | 180rb/bulan + 1rb/hari | Semua orang! |
| | GitHub Copilot | $10-19/bulan | Bulanan | Pengguna GitHub |
| **💰 Murah** | GLM-4.7 | $0.6/1M | Setiap hari jam 10.00 | Backup hemat |
| | MiniMax M2.1 | $0.2/1M | Rolling 5 jam | Opsi paling murah |
| | Kimi K2 | $9/bulan flat | 10 juta token/bulan | Biaya yang bisa diprediksi |
| **🆓 Gratis** | iFlow | $0 | Unlimited | 8 model gratis |
| | Qwen | $0 | Unlimited | 3 model gratis |
| | Kiro | $0 | Unlimited | Claude gratis |
**💡 Tips pro:** combo Gemini CLI (180rb request/bulan gratis) + iFlow (gratis unlimited) = biaya $0!
---
### 📊 Tentang Biaya dan Penagihan 9Router
**Fakta soal penagihan 9Router:**
**Software 9Router = gratis selamanya** (open source, tanpa tagihan)
**"Biaya" di dashboard = tampilan/pelacakan saja** (bukan tagihan sungguhan)
**Pembayaran langsung ke provider** (langganan atau biaya API)
**Provider gratis tetap gratis** (iFlow, Kiro, Qwen = $0 unlimited)
**9Router tidak mengirim invoice** atau menagih kartumu
**Cara kerja tampilan biaya:**
Dashboard menampilkan **estimasi biaya** seandainya kamu memakai API berbayar secara langsung. Ini **bukan tagihan**, melainkan alat pembanding yang menunjukkan penghematanmu.
**Contoh skenario:**
```
Tampilan dashboard:
• Total request: 1.662
• Total token: 47 juta
• Biaya tertampil: $290
Kenyataannya:
• Provider: iFlow (gratis unlimited)
• Yang benar-benar dibayar: $0.00
• Arti $290: jumlah yang kamu hemat dengan memakai model gratis!
```
**Aturan pembayaran:**
- **Provider langganan** (Claude Code, Codex): bayar langsung di website masing-masing
- **Provider murah** (GLM, MiniMax): bayar langsung, 9Router hanya melakukan routing
- **Provider gratis** (iFlow, Kiro, Qwen): benar-benar gratis selamanya, tanpa biaya tersembunyi
- **9Router**: tidak menagih apa pun
---
## 🎯 Studi Kasus
### Kasus 1: "Saya punya langganan Claude Pro"
**Masalah:** kuota hangus tanpa terpakai, kena rate limit saat ngoding berat
**Solusi:**
```
Combo: "maximize-claude"
1. cc/claude-opus-4-6 (manfaatkan langganan semaksimal mungkin)
2. glm/glm-4.7 (backup murah saat kuota habis)
3. if/kimi-k2-thinking (fallback darurat gratis)
Biaya bulanan: $20 (langganan) + ~$5 (backup) = total $25
vs. $20 + kena limit = frustrasi
```
### Kasus 2: "Saya mau biaya nol"
**Masalah:** tidak mampu bayar langganan, tapi butuh AI coding yang andal
**Solusi:**
```
Combo: "free-forever"
1. gc/gemini-3-flash (180rb request/bulan gratis)
2. if/kimi-k2-thinking (gratis unlimited)
3. qw/qwen3-coder-plus (gratis unlimited)
Biaya bulanan: $0
Kualitas: model siap produksi
```
### Kasus 3: "Ngoding 24/7 tanpa terputus"
**Masalah:** deadline mepet, downtime tidak dapat ditoleransi
**Solusi:**
```
Combo: "always-on"
1. cc/claude-opus-4-6 (kualitas terbaik)
2. cx/gpt-5.2-codex (langganan kedua)
3. glm/glm-4.7 (murah, reset harian)
4. minimax/MiniMax-M2.1 (paling murah, reset 5 jam)
5. if/kimi-k2-thinking (gratis unlimited)
Hasil: 5 lapis fallback = zero downtime
Biaya bulanan: $20-200 (langganan) + $10-20 (backup)
```
### Kasus 4: "Saya mau pakai AI gratis di OpenClaw"
**Masalah:** butuh asisten AI di aplikasi pesan (WhatsApp, Telegram, Slack...), sepenuhnya gratis
**Solusi:**
```
Combo: "openclaw-free"
1. if/glm-4.7 (gratis unlimited)
2. if/minimax-m2.1 (gratis unlimited)
3. if/kimi-k2-thinking (gratis unlimited)
Biaya bulanan: $0
Cara akses: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
```
---
## ❓ FAQ
<details>
<summary><b>📊 Kenapa dashboard menampilkan biaya yang besar?</b></summary>
Dashboard melacak pemakaian token dan menampilkan **estimasi biaya** seandainya kamu memakai API berbayar secara langsung. Ini **bukan tagihan nyata**, melainkan acuan untuk melihat berapa banyak yang kamu hemat dengan memakai model gratis atau langganan yang sudah ada lewat 9Router.
**Contoh:**
- **Tampilan dashboard:** "Total biaya $290"
- **Kenyataan:** sedang memakai iFlow (gratis unlimited)
- **Biaya sebenarnya:** **$0.00**
- **Arti $290:** jumlah yang **dihemat** karena memakai model gratis alih-alih API berbayar!
Tampilan biaya adalah "pelacak penghematan" untuk memahami pola pemakaian dan peluang optimasi.
</details>
<details>
<summary><b>💳 Apakah 9Router menagih saya?</b></summary>
**Tidak.** 9Router adalah software open source gratis yang berjalan di komputermu sendiri. Tidak ada penagihan sama sekali.
**Kamu membayar ke:**
-**Provider langganan** (Claude Code $20/bulan, Codex $20-200/bulan) → bayar langsung di website masing-masing
-**Provider murah** (GLM, MiniMax) → bayar langsung, 9Router hanya me-routing request
-**9Router sendiri****tidak menagih apa pun**
9Router adalah proxy/router lokal. Ia tidak menyimpan informasi kartu kredit, tidak bisa mengirim invoice, dan tidak punya sistem penagihan. Sepenuhnya software gratis.
</details>
<details>
<summary><b>🆓 Apakah provider gratis benar-benar unlimited?</b></summary>
**Ya!** Provider yang ditandai gratis (iFlow, Kiro, Qwen) benar-benar unlimited dan **tanpa biaya tersembunyi**.
Ini adalah layanan gratis yang disediakan masing-masing perusahaan:
- **iFlow**: akses gratis unlimited ke 8+ model via OAuth
- **Kiro**: model Claude gratis unlimited via AWS Builder ID
- **Qwen**: akses gratis unlimited ke model Qwen via device authentication
9Router hanya me-routing request — tidak ada "jebakan" atau tagihan di kemudian hari. Layanannya memang gratis, dan 9Router membuatnya lebih mudah dipakai dengan dukungan fallback.
**Catatan:** beberapa provider langganan (Antigravity, GitHub Copilot) punya masa preview gratis dan bisa jadi berbayar nanti, tetapi hal itu diumumkan secara jelas oleh provider tersebut, bukan oleh 9Router.
</details>
<details>
<summary><b>💰 Bagaimana cara menekan biaya AI seminimal mungkin?</b></summary>
**Strategi free-first:**
1. **Mulai dari combo 100% gratis:**
```
1. gc/gemini-3-flash (180rb/bulan gratis dari Google)
2. if/kimi-k2-thinking (gratis unlimited dari iFlow)
3. qw/qwen3-coder-plus (gratis unlimited dari Qwen)
```
**Biaya: $0/bulan**
2. **Tambahkan backup murah hanya bila perlu:**
```
4. glm/glm-4.7 ($0.6 per 1 juta token)
```
**Tambahan biaya: bayar sesuai pemakaian saja**
3. **Gunakan provider langganan paling akhir:**
- Hanya jika kamu memang sudah punya
- 9Router memaksimalkan nilainya lewat pelacakan kuota
**Hasil:** sebagian besar pengguna bisa jalan dengan $0/bulan hanya dengan tier gratis!
</details>
<details>
<summary><b>📈 Bagaimana kalau pemakaian tiba-tiba melonjak?</b></summary>
Smart fallback 9Router mencegah tagihan tak terduga:
**Skenario:** kuota habis di tengah sprint coding
**Tanpa 9Router:**
- ❌ Kena rate limit → kerja berhenti → frustrasi
- ❌ Atau: tagihan API mahal tanpa disengaja
**Dengan 9Router:**
- ✅ Langganan mencapai batas → otomatis fallback ke tier murah
- ✅ Tier murah jadi mahal → otomatis fallback ke tier gratis
- ✅ Ngoding tidak berhenti → biaya tetap terprediksi
**Kamu yang pegang kendali:** atur batas pengeluaran per provider di dashboard, dan 9Router akan mematuhinya.
</details>
---
## 📖 Panduan Setup
<details>
<summary><b>🔐 Provider Langganan (maksimalkan nilainya)</b></summary>
### Claude Code (Pro/Max)
```bash
Dashboard → Providers → hubungkan Claude Code
→ login OAuth → refresh token otomatis
→ pelacakan kuota 5 jam + mingguan
Model:
cc/claude-opus-4-6
cc/claude-sonnet-4-5-20250929
cc/claude-haiku-4-5-20251001
```
**Tips pro:** pakai Opus untuk tugas kompleks, Sonnet kalau mengutamakan kecepatan. 9Router melacak kuota per model!
### OpenAI Codex (Plus/Pro)
```bash
Dashboard → Providers → hubungkan Codex
→ login OAuth (port 1455)
→ reset 5 jam + mingguan
Model:
cx/gpt-5.2-codex
cx/gpt-5.1-codex-max
```
### Gemini CLI (180rb request/bulan gratis!)
```bash
Dashboard → Providers → hubungkan Gemini CLI
→ Google OAuth
→ 180rb/bulan + 1rb/hari
Model:
gc/gemini-3-flash-preview
gc/gemini-2.5-pro
```
**Value terbaik:** free tier-nya besar sekali! Pakai ini sebelum tier berbayar.
### GitHub Copilot
```bash
Dashboard → Providers → hubungkan GitHub
→ OAuth via GitHub
→ reset bulanan (tanggal 1 tiap bulan)
Model:
gh/gpt-5
gh/claude-4.5-sonnet
gh/gemini-3-pro
```
</details>
<details>
<summary><b>💰 Provider Murah (backup)</b></summary>
### GLM-4.7 (reset harian, $0.6/1M)
1. Daftar: [Zhipu AI](https://open.bigmodel.cn/)
2. Ambil API key dari Coding Plan
3. Dashboard → tambahkan API key:
- Provider: `glm`
- API Key: `your-key`
**Pemakaian:** `glm/glm-4.7`
**Tips pro:** Coding Plan memberi kuota 3x lipat dengan biaya 1/7! Reset setiap hari jam 10.00.
### MiniMax M2.1 (reset 5 jam, $0.20/1M)
1. Daftar: [MiniMax](https://www.minimax.io/)
2. Ambil API key
3. Dashboard → tambahkan API key
**Pemakaian:** `minimax/MiniMax-M2.1`
**Tips pro:** opsi termurah dengan konteks panjang (1 juta token)!
### Kimi K2 ($9/bulan flat)
1. Berlangganan: [Moonshot AI](https://platform.moonshot.ai/)
2. Ambil API key
3. Dashboard → tambahkan API key
**Pemakaian:** `kimi/kimi-latest`
**Tips pro:** $9/bulan flat untuk 10 juta token = biaya efektif $0.90/1M!
</details>
<details>
<summary><b>🆓 Provider Gratis (backup darurat)</b></summary>
### iFlow (8 model gratis)
```bash
Dashboard → hubungkan iFlow
→ login OAuth iFlow
→ pemakaian unlimited
Model:
if/kimi-k2-thinking
if/qwen3-coder-plus
if/glm-4.7
if/minimax-m2
if/deepseek-r1
```
### Qwen (3 model gratis)
```bash
Dashboard → hubungkan Qwen
→ autentikasi device code
→ pemakaian unlimited
Model:
qw/qwen3-coder-plus
qw/qwen3-coder-flash
```
### Kiro (Claude gratis)
```bash
Dashboard → hubungkan Kiro
→ AWS Builder ID atau Google/GitHub
→ pemakaian unlimited
Model:
kr/claude-sonnet-4.5
kr/claude-haiku-4.5
```
</details>
<details>
<summary><b>🎨 Membuat Combo</b></summary>
### Contoh 1: maksimalkan langganan → backup murah
```
Dashboard → Combos → buat baru
Nama: premium-coding
Model:
1. cc/claude-opus-4-6 (langganan, utama)
2. glm/glm-4.7 (backup murah, $0.6/1M)
3. minimax/MiniMax-M2.1 (fallback termurah, $0.20/1M)
Pemakaian di CLI: premium-coding
Contoh biaya bulanan (100 juta token):
80 juta lewat Claude (langganan): tambahan $0
15 juta lewat GLM: $9
5 juta lewat MiniMax: $1
Total: $10
```
### Contoh 2: combo 100% gratis
```
Nama: free-forever
Model:
1. gc/gemini-3-flash (180rb request/bulan gratis)
2. if/kimi-k2-thinking (gratis unlimited)
3. qw/qwen3-coder-plus (gratis unlimited)
4. kr/claude-sonnet-4.5 (gratis unlimited)
Biaya bulanan: $0
```
### Tips membuat combo
- Urutkan dari kualitas/prioritas tertinggi ke fallback paling murah
- Selalu taruh minimal satu provider gratis di posisi terakhir
- Pakai nama combo yang deskriptif agar mudah dipilih dari CLI
- Aktifkan cloud sync agar combo ikut tersedia di perangkat lain
</details>
---
## 🐳 Deployment
<details>
<summary><b>Docker</b></summary>
```bash
docker run -d \
--name 9router \
-p 20128:20128 \
-v 9router-data:/app/data \
-e PORT=20128 \
-e BASE_URL=http://localhost:20128 \
ghcr.io/decolua/9router:latest
```
Dashboard: `http://localhost:20128/dashboard`
</details>
<details>
<summary><b>VPS / Cloud</b></summary>
```bash
npm install -g 9router
PORT=20128 HOSTNAME=0.0.0.0 BASE_URL=https://your-domain.com 9router
```
Disarankan menaruhnya di belakang reverse proxy (Nginx/Caddy) dengan HTTPS, dan membatasi akses hanya untuk dirimu sendiri.
</details>
<details>
<summary><b>Cloudflare Workers</b></summary>
```bash
npm run build
npx wrangler deploy
```
Atur `BASE_URL` dan `CLOUD_URL` sebagai environment variable di dashboard Cloudflare.
</details>
---
## 🧪 Troubleshooting
| Masalah | Kemungkinan Penyebab | Solusi |
|---------|----------------------|--------|
| Tool CLI tidak bisa konek | Endpoint salah | Pastikan `http://localhost:20128/v1` |
| 401 / Unauthorized | API key salah | Salin ulang key dari dashboard |
| Model tidak ditemukan | Prefix provider salah | Pakai format `provider/model`, mis. `if/kimi-k2-thinking` |
| Selalu fallback ke gratis | Kuota langganan habis | Cek hitung mundur reset di dashboard |
| OAuth gagal | Port callback terpakai | Tutup proses lain (mis. port 1455 untuk Codex) |
| UI menggantung saat sync | DNS/jaringan cloud bermasalah | Cek `CLOUD_URL`; sync memakai timeout fail-fast |
Aktifkan mode debug di dashboard untuk melihat log lengkap request/response.
---
## 🤝 Kontribusi
Kontribusi sangat diterima!
1. Fork repo ini
2. Buat branch fitur (`git checkout -b feature/nama-fitur`)
3. Commit perubahanmu (`git commit -m 'feat: tambah fitur X'`)
4. Push ke branch (`git push origin feature/nama-fitur`)
5. Buka Pull Request
---
## 📄 Lisensi
MIT License — lihat [LICENSE](https://github.com/decolua/9router/blob/main/LICENSE) untuk detailnya.
---
<div align="center">
**Kalau 9Router membantumu, kasih ⭐ di [GitHub](https://github.com/decolua/9router)!**
[🌐 Website](https://9router.com) • [📦 npm](https://www.npmjs.com/package/9router) • [🐛 Laporkan Bug](https://github.com/decolua/9router/issues)
</div>
+12 -3
View File
@@ -134,10 +134,19 @@ export const ANTIGRAVITY_HEADERS = {
"User-Agent": ANTIGRAVITY_IDE_USER_AGENT "User-Agent": ANTIGRAVITY_IDE_USER_AGENT
}; };
// Cloud Code Assist API // Cloud Code Assist API endpoints differ by client ecosystem.
export const CLOUD_CODE_API = { export const CLOUD_CODE_API = {
loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", "gemini-cli": {
onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
},
// Project discovery (loadCodeAssist/onboardUser) stays on PROD — the daily host
// rejects these auth/onboarding calls. Only chat traffic uses the daily host
// (see transport.apiEndpoint in registry/antigravity.js, set to bypass prod 429).
antigravity: {
loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
},
}; };
export const LOAD_CODE_ASSIST_HEADERS = { export const LOAD_CODE_ASSIST_HEADERS = {
+40 -1
View File
@@ -15,11 +15,17 @@
* fiction. The suffix is stripped before the request leaves this process. * fiction. The suffix is stripped before the request leaves this process.
*/ */
import { extractThinking } from "../translator/concerns/thinkingUnified.js"; import { extractThinking, parseSuffix } from "../translator/concerns/thinkingUnified.js";
import { effortToBudget } from "../translator/concerns/thinking.js"; import { effortToBudget } from "../translator/concerns/thinking.js";
export const KIRO_AGENTIC_SUFFIX = "-agentic"; export const KIRO_AGENTIC_SUFFIX = "-agentic";
export const KIRO_THINKING_SUFFIX = "-thinking"; export const KIRO_THINKING_SUFFIX = "-thinking";
export const KIRO_TOOL_NAME_MAX_LENGTH = 64;
export const KIRO_TOOL_DESCRIPTION_MAX_LENGTH = 10237;
export const KIRO_TOOL_ID_MAX_LENGTH = 64;
export const KIRO_CODEWHISPERER_TARGET =
"AmazonCodeWhispererStreamingService.GenerateAssistantResponse";
export const KIRO_ENDPOINT_FALLBACK_STATUSES = new Set([401, 403, 404]);
// Public default CodeWhisperer profile ARNs (us-east-1), keyed by auth method. // Public default CodeWhisperer profile ARNs (us-east-1), keyed by auth method.
// Used when an account cannot resolve its own profileArn. Builder ID and social // Used when an account cannot resolve its own profileArn. Builder ID and social
@@ -40,6 +46,39 @@ export function resolveDefaultProfileArn(authMethod) {
export const KIRO_THINKING_BUDGET_DEFAULT = 16000; export const KIRO_THINKING_BUDGET_DEFAULT = 16000;
/**
* Resolve a Kiro model after consuming the generic model(level) suffix.
* The suffix is a 9router request override, not part of Kiro's upstream model id.
*/
export function resolveKiroModelIntent(model) {
const { cleanModel, override } = parseSuffix(model);
return {
model: cleanModel,
...resolveKiroModel(cleanModel),
thinkingOverride: override,
};
}
/** Apply a parsed model(level) override without mutating the caller's body. */
export function applyKiroThinkingOverride(body, override) {
if (!override) return body;
const next = { ...body };
if (override.mode === "budget") {
delete next.output_config;
delete next.reasoning_effort;
delete next.reasoning;
next.thinking = { type: "enabled", budget_tokens: override.budget };
return next;
}
next.output_config = {
...(body.output_config || {}),
effort: override.mode === "level" ? override.level : override.mode,
};
return next;
}
export const KIRO_AGENTIC_SYSTEM_PROMPT = ` export const KIRO_AGENTIC_SYSTEM_PROMPT = `
# CRITICAL: CHUNKED WRITE PROTOCOL (MANDATORY) # CRITICAL: CHUNKED WRITE PROTOCOL (MANDATORY)
+7 -3
View File
@@ -4,7 +4,6 @@ import REGISTRY from "../providers/registry/index.js";
import { PROVIDER_MODELS } from "../providers/index.js"; import { PROVIDER_MODELS } from "../providers/index.js";
import { modelQuotaFamily, modelStrip, modelTargetFormat, normalizeModelId } from "../providers/models/schema.js"; import { modelQuotaFamily, modelStrip, modelTargetFormat, normalizeModelId } from "../providers/models/schema.js";
import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js"; import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js";
export { PROVIDER_MODELS }; export { PROVIDER_MODELS };
@@ -70,8 +69,13 @@ export function getModelUpstreamId(aliasOrId, modelId) {
const baseId = suffix ? modelId.slice(0, sufMatch.index).trim() : modelId; const baseId = suffix ? modelId.slice(0, sufMatch.index).trim() : modelId;
const models = PROVIDER_MODELS[aliasOrId]; const models = PROVIDER_MODELS[aliasOrId];
const found = findModel(models, baseId, aliasOrId); const found = findModel(models, baseId, aliasOrId);
if (found?.upstreamModelId) return found.upstreamModelId + suffix; const resolvedId = found?.upstreamModelId || found?.id;
if (found?.id) return found.id + suffix; if (resolvedId) {
const presetMatch = resolvedId.match(/\([^()]+\)\s*$/);
const presetSuffix = presetMatch?.[0] || "";
const resolvedBase = presetSuffix ? resolvedId.slice(0, presetMatch.index).trim() : resolvedId;
return resolvedBase + (suffix || presetSuffix);
}
if (aliasOrId === "cx" && typeof baseId === "string" && baseId.endsWith(CODEX_REVIEW_SUFFIX)) { if (aliasOrId === "cx" && typeof baseId === "string" && baseId.endsWith(CODEX_REVIEW_SUFFIX)) {
return baseId.slice(0, -CODEX_REVIEW_SUFFIX.length) + suffix; return baseId.slice(0, -CODEX_REVIEW_SUFFIX.length) + suffix;
} }
+5 -1
View File
@@ -136,6 +136,10 @@ export class AntigravityExecutor extends BaseExecutor {
transformRequest(model, body, stream, credentials) { transformRequest(model, body, stream, credentials) {
const projectId = credentials?.projectId || this.generateProjectId(); const projectId = credentials?.projectId || this.generateProjectId();
// OpenAI clients may include stream_options even for non-streaming calls.
// Google generateContent rejects that combination before processing the request.
if (stream !== true) delete body.stream_options;
// ─── Image generation: completely different request structure ─── // ─── Image generation: completely different request structure ───
if (isImageModel(model)) { if (isImageModel(model)) {
const imageConfig = parseImageConfig(model); const imageConfig = parseImageConfig(model);
@@ -264,7 +268,7 @@ export class AntigravityExecutor extends BaseExecutor {
return { return {
...body, ...body,
project: projectId, project: projectId,
model: model, model: body.model || model,
userAgent: "antigravity", userAgent: "antigravity",
requestType: "agent", requestType: "agent",
requestId: buildIdeRequestId({ body, request: transformedRequest, credentials, model, requestType: "agent" }), requestId: buildIdeRequestId({ body, request: transformedRequest, credentials, model, requestType: "agent" }),
+1 -1
View File
@@ -126,7 +126,7 @@ export class BaseExecutor {
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
const url = this.buildUrl(model, stream, urlIndex, credentials); const url = this.buildUrl(model, stream, urlIndex, credentials);
const transformedBody = this.transformRequest(model, body, stream, credentials); const transformedBody = this.transformRequest(model, body, stream, credentials);
const headers = this.buildHeaders(credentials, stream); const headers = this.buildHeaders(credentials, stream, url);
if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0; if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0;
+30
View File
@@ -0,0 +1,30 @@
import { DefaultExecutor } from "./default.js";
/**
* CodeBuddyIntlExecutor — talks to https://www.codebuddy.ai/v2/chat/completions
*
* Same OpenAI-compatible-but-stream-only gateway behavior as codebuddy-cn:
* non-stream requests are rejected, and reasoning is surfaced only when the
* request carries the IDE's OpenAI-style reasoning params. Force stream and
* mirror reasoning_summary exactly like CodeBuddyExecutor.
*/
export class CodeBuddyIntlExecutor extends DefaultExecutor {
constructor() {
super("codebuddy-intl");
}
transformRequest(model, body, stream, credentials) {
const transformed = super.transformRequest(model, body, stream, credentials);
transformed.stream = true;
const eff = transformed.reasoning_effort;
if (eff === "none" || eff === "off") {
delete transformed.reasoning_effort;
} else if (eff) {
transformed.reasoning_summary = "auto";
}
return transformed;
}
}
export default CodeBuddyIntlExecutor;
+14 -3
View File
@@ -12,7 +12,7 @@ import {
import { buildCursorHeaders } from "../utils/cursorChecksum.js"; import { buildCursorHeaders } from "../utils/cursorChecksum.js";
import { estimateUsage } from "../utils/usageTracking.js"; import { estimateUsage } from "../utils/usageTracking.js";
import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js"; import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js";
import { chatChunkSse } from "../utils/sse.js"; import { chatChunkSse, sseChunk } from "../utils/sse.js";
import { FORMATS } from "../translator/formats.js"; import { FORMATS } from "../translator/formats.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js";
import zlib from "zlib"; import zlib from "zlib";
@@ -543,6 +543,9 @@ export class CursorExecutor extends BaseExecutor {
if (done) break; if (done) break;
pending = Buffer.concat([pending, Buffer.from(value)]); pending = Buffer.concat([pending, Buffer.from(value)]);
pending = decodeAgentFrames(pending, (payload) => { pending = decodeAgentFrames(pending, (payload) => {
// A single read can carry several frames; once the turn is over the
// rest of the batch must not reach the already-closed controller.
if (finished) return;
const serverMessage = decodeMessage(payload); const serverMessage = decodeMessage(payload);
// agent.v1.AgentServerMessage.interaction_update // agent.v1.AgentServerMessage.interaction_update
@@ -570,9 +573,12 @@ export class CursorExecutor extends BaseExecutor {
if (execRequest.has(10)) { if (execRequest.has(10)) {
session.write(createRequestContextResponse()); session.write(createRequestContextResponse());
} else { } else {
// Every other ExecServerMessage variant is an editor-backed tool
// (shell, read, write, …) that 9router cannot service. Fail the
// turn rather than narrating protocol state as assistant text.
debugLog(`[CURSOR AGENT] Unsupported exec request fields: ${[...execRequest.keys()].join(",")}`);
finished = true; finished = true;
onEvent({ type: "error", value: "Cursor AgentService requested an unsupported IDE tool" }); onEvent({ type: "error", value: "Cursor AgentService requested an unsupported IDE tool" });
onEvent({ type: "done" });
} }
} }
}); });
@@ -630,7 +636,12 @@ export class CursorExecutor extends BaseExecutor {
} else if (event.type === "thinking") { } else if (event.type === "thinking") {
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { reasoning_content: event.value } }))); controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { reasoning_content: event.value } })));
} else if (event.type === "error") { } else if (event.type === "error") {
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { content: `\n[${event.value}]` } }))); // An SSE error frame, not a content delta: a protocol failure must not
// be rendered to the user as the assistant's reply, and downstream
// usage tracking must not record the turn as a success.
controller.enqueue(encoder.encode(sseChunk({ error: { message: event.value, type: "api_error" } })));
controller.enqueue(encoder.encode(SSE_DONE));
controller.close();
} else if (event.type === "done") { } else if (event.type === "done") {
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: {}, finishReason: "stop" }))); controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: {}, finishReason: "stop" })));
controller.enqueue(encoder.encode(SSE_DONE)); controller.enqueue(encoder.encode(SSE_DONE));
+847
View File
@@ -0,0 +1,847 @@
/**
* DevinCliExecutor — routes completions through the official Devin CLI binary
* via the Agent Client Protocol (ACP) JSON-RPC 2.0 over stdio.
*
* Protocol flow:
* 1. Spawn `devin acp` (default agent = full built-in tools: fs/shell/search).
* Set CLI_DEVIN_AGENT_TYPE=summarizer for a tool-less, text-only mode.
* 2. Send: initialize → session/new (with model + cwd + mcpServers) → session/prompt.
* 3. Receive: session/update notifications (agent_message_chunk = reply text,
* tool_call/tool_call_update = built-in tool invocations, surfaced as text).
* When devin calls a client-tool from the exposed MCP ("Calling mcp_X from
* clientTools"), it is bridged to an OpenAI tool_use and the turn ends.
* 4. Emit deltas as OpenAI-compatible SSE chunks.
* 5. Kill subprocess on _cognition.ai/agent_stopped or error.
*
* Auth: noAuth — the subprocess inherits the parent env and uses credentials
* stored by `devin auth login` (~/.local/share/devin/credentials.toml).
*
* Binary discovery: CLI_DEVIN_BIN env → PATH lookup → platform installer paths.
*/
import { spawn } from "node:child_process";
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import { BaseExecutor } from "./base.js";
// ─── Binary discovery ────────────────────────────────────────────────────────
function resolveDevinBin() {
// 1. Explicit override
const envBin = process.env.CLI_DEVIN_BIN?.trim();
if (envBin) return envBin;
const isWin = process.platform === "win32";
const home = os.homedir();
// 2. Known installer / package-manager locations. spawn uses shell:false on
// macOS/Linux, so process.env.PATH alone may miss ~/.local/bin, Homebrew,
// Scoop, etc. when the server runs detached (tray/daemon/launchd) without
// a login shell — probe these explicitly before falling back to PATH.
const candidates = isWin
? [
// Official installer: %LOCALAPPDATA%\devin\cli\bin\devin.exe
path.join(process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "devin", "cli", "bin", "devin.exe"),
path.join(home, ".local", "bin", "devin.exe"),
path.join(home, "scoop", "shims", "devin.exe"),
path.join(process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Programs", "devin", "devin.exe"),
]
: [
path.join(home, ".local", "share", "devin", "bin", "devin"),
path.join(home, ".devin", "bin", "devin"),
path.join(home, ".local", "bin", "devin"), // pipx / user install
"/opt/homebrew/bin/devin", // Homebrew (Apple Silicon)
"/usr/local/bin/devin", // Homebrew (Intel) / manual
"/usr/bin/devin",
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
// 3. Fallback — rely on process.env.PATH
return isWin ? "devin.exe" : "devin";
}
// ─── ACP JSON-RPC helper ────────────────────────────────────────────────────
function rpc(method, params, id) {
const msg = { jsonrpc: "2.0", method, params };
if (id !== undefined) msg.id = id;
return JSON.stringify(msg) + "\n";
}
// ─── Client-tools → MCP bridge ───────────────────────────────────────────────
// devin only invokes built-in + MCP tools, not OpenAI function-calling schemas.
// body.tools are exposed as a stdio MCP server "clientTools" so devin can call
// them. When devin calls one, we emit OpenAI tool_use and end the turn; the
// client executes and returns tool_result on the next request. That next request
// re-spawns with the full history (including tool_calls + tool results) and
// seeds the MCP server with those results so a re-call gets the real data.
// Tool schemas via DEVIN_MCP_TOOLS; prior results via DEVIN_MCP_RESULTS.
const CLIENT_TOOLS_MCP_SCRIPT = `
import readline from "node:readline";
const TOOLS = JSON.parse(process.env.DEVIN_MCP_TOOLS || "[]");
const RESULTS = JSON.parse(process.env.DEVIN_MCP_RESULTS || "{}");
const rl = readline.createInterface({ input: process.stdin });
function send(o){ process.stdout.write(JSON.stringify(o) + "\\n"); }
rl.on("line", (line) => {
let m; try { m = JSON.parse(line); } catch { return; }
if (m.method === "initialize") {
send({ jsonrpc: "2.0", id: m.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "clientTools", version: "1.0" } } });
} else if (m.method === "tools/list") {
send({ jsonrpc: "2.0", id: m.id, result: { tools: TOOLS } });
} else if (m.method === "tools/call") {
const name = m.params?.name || "";
const seeded = RESULTS[name];
const text = seeded !== undefined
? String(seeded)
: "(awaiting client tool_result)";
process.stderr.write("[client-tools] tool_call name=" + name + " seeded=" + (seeded !== undefined) + "\\n");
send({ jsonrpc: "2.0", id: m.id, result: { content: [{ type: "text", text }] } });
}
});
`.trimStart();
function ensureClientToolsScript() {
const scriptPath = path.join(os.tmpdir(), "9router-devin-client-tools.mjs");
// Always rewrite so script upgrades land without a process restart.
fs.writeFileSync(scriptPath, CLIENT_TOOLS_MCP_SCRIPT);
return scriptPath;
}
// Map OpenAI tools ([{type:"function",function:{name,description,parameters}}])
// to MCP tool declarations ([{name,description,inputSchema}]).
// devin only discovers MCP tools whose name carries the `mcp_` prefix, so we
// add it here and strip it back when bridging the call to the client.
const MCP_TOOL_PREFIX = "mcp_";
function toMcpToolName(name) {
return name.startsWith(MCP_TOOL_PREFIX) ? name : MCP_TOOL_PREFIX + name;
}
function fromMcpToolName(name) {
return name.startsWith(MCP_TOOL_PREFIX) ? name.slice(MCP_TOOL_PREFIX.length) : name;
}
function buildClientToolsMcp(tools, resultMap) {
const mcpTools = [];
for (const t of tools) {
if (!t) continue;
const f = t.function || t;
if (!f?.name) continue;
mcpTools.push({
name: toMcpToolName(f.name),
description: f.description || "",
inputSchema: f.parameters || f.input_schema || { type: "object", properties: {} },
});
}
if (!mcpTools.length) return null;
const env = { DEVIN_MCP_TOOLS: JSON.stringify(mcpTools) };
if (resultMap && Object.keys(resultMap).length) {
env.DEVIN_MCP_RESULTS = JSON.stringify(resultMap);
}
return {
command: process.execPath,
args: [ensureClientToolsScript()],
env,
};
}
// Extract tool_result content keyed by MCP tool name (mcp_<original>).
// Walks messages: assistant.tool_calls id→name, role=tool tool_call_id→content.
function extractClientToolResults(messages) {
const idToMcpName = new Map();
const results = {};
for (const m of messages) {
if (m?.role === "assistant" && Array.isArray(m.tool_calls)) {
for (const tc of m.tool_calls) {
const name = tc?.function?.name || tc?.name;
if (tc?.id && name) idToMcpName.set(tc.id, toMcpToolName(name));
}
}
// Claude-style tool_use blocks in content
if (m?.role === "assistant" && Array.isArray(m.content)) {
for (const b of m.content) {
if (b?.type === "tool_use" && b.id && b.name) {
idToMcpName.set(b.id, toMcpToolName(b.name));
}
}
}
if (m?.role === "tool" && m.tool_call_id) {
const mcpName = idToMcpName.get(m.tool_call_id);
if (mcpName) {
results[mcpName] =
typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? "");
}
}
// Claude-style tool_result blocks in user content
if (m?.role === "user" && Array.isArray(m.content)) {
for (const b of m.content) {
if (b?.type === "tool_result" && b.tool_use_id) {
const mcpName = idToMcpName.get(b.tool_use_id);
if (mcpName) {
const c = b.content;
results[mcpName] =
typeof c === "string" ? c : JSON.stringify(c ?? "");
}
}
}
}
}
return results;
}
// Resolve workspace cwd from client request (Codex/CLI env context, body fields).
// Prefer an absolute existing path so agent file tools hit the user's project
// instead of os.tmpdir() (which made relative create/delete inconsistent).
function resolveWorkspaceCwd(body) {
const candidates = [];
const push = (v) => {
if (typeof v === "string" && v.trim()) candidates.push(v.trim());
};
push(body?.cwd);
push(body?.working_directory);
push(body?.workdir);
push(body?.workspace);
push(body?.metadata?.cwd);
push(body?.metadata?.working_directory);
const scanText = (text) => {
if (typeof text !== "string") return;
for (const m of text.matchAll(/<cwd>\s*([^<]+?)\s*<\/cwd>/gi)) push(m[1]);
};
const scanMessages = (msgs) => {
if (!Array.isArray(msgs)) return;
for (const msg of msgs) {
if (!msg) continue;
if (typeof msg.content === "string") scanText(msg.content);
else if (Array.isArray(msg.content)) {
for (const p of msg.content) {
if (typeof p === "string") scanText(p);
else if (p && typeof p === "object") {
scanText(p.text);
scanText(p.input_text);
scanText(p.content);
}
}
}
// Responses API input items
if (typeof msg === "string") scanText(msg);
if (msg.type === "message" && Array.isArray(msg.content)) {
for (const p of msg.content) scanText(p?.text || p?.input_text);
}
}
};
scanMessages(body?.messages);
scanMessages(body?.input);
for (const c of candidates) {
try {
if (path.isAbsolute(c) && fs.existsSync(c) && fs.statSync(c).isDirectory()) {
return c;
}
} catch {
/* ignore */
}
}
return os.tmpdir();
}
// ─── Multi-turn message → single prompt builder ─────────────────────────────
function buildPromptText(messages) {
// Inline the whole conversation so the model has full context, including
// prior tool_calls / tool_results so it can continue after a client round-trip.
const lines = [];
for (const m of messages) {
const role = String(m.role || "user");
let text = "";
if (typeof m.content === "string") {
text = m.content;
} else if (Array.isArray(m.content)) {
for (const p of m.content) {
if (!p || typeof p !== "object") continue;
if (p.type === "text") text += String(p.text || "");
else if (p.type === "tool_use") {
text += `\n[Tool call ${p.name} id=${p.id}]\n${JSON.stringify(p.input ?? {})}\n`;
} else if (p.type === "tool_result") {
const c =
typeof p.content === "string" ? p.content : JSON.stringify(p.content ?? "");
text += `\n[Tool result id=${p.tool_use_id}]\n${c}\n`;
}
}
}
// OpenAI tool_calls on assistant messages
if (role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length) {
const parts = m.tool_calls.map((tc) => {
const name = tc.function?.name || tc.name || "tool";
const args = tc.function?.arguments ?? tc.arguments ?? {};
const argStr = typeof args === "string" ? args : JSON.stringify(args);
return `[Tool call ${name} id=${tc.id}]\n${argStr}`;
});
text = [text, ...parts].filter(Boolean).join("\n\n");
}
// OpenAI role=tool messages
if (role === "tool") {
const c = typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? "");
text = `[Tool result id=${m.tool_call_id || ""}]\n${c}`;
}
if (!text.trim()) continue;
if (role === "system") {
lines.push(`[System]\n${text}`);
} else if (role === "assistant") {
lines.push(`[Assistant]\n${text}`);
} else if (role === "tool") {
lines.push(`[Tool]\n${text}`);
} else {
lines.push(`[User]\n${text}`);
}
}
return lines.join("\n\n") || "(empty)";
}
// ─── DevinCliExecutor ─────────────────────────────────────────────────────────
export class DevinCliExecutor extends BaseExecutor {
constructor() {
super("devin-cli", { id: "devin-cli", baseUrl: "devin://acp/stdio" });
}
buildUrl() {
return "devin://acp/stdio";
}
buildHeaders() {
return {};
}
transformRequest() {
return null;
}
async execute({ model, body, credentials, signal, log }) {
const b = body ?? {};
const messages = Array.isArray(b.messages)
? b.messages
: Array.isArray(b.input)
? b.input
: [];
const promptText = buildPromptText(messages);
const workspaceCwd = resolveWorkspaceCwd(b);
const devinBin = resolveDevinBin();
log?.info?.(
"DEVIN",
`devin acp → model=${model}, bin=${devinBin}, cwd=${workspaceCwd}`
);
// Optional MCP servers via DEVIN_MCP_SERVERS (JSON object, devin config format):
// {"echo":{"command":"/abs/node","args":["/srv/echo.js"],"env":{"K":"V"}}}
// Plus body.tools (OpenAI schema) → exposed as a "clientTools" MCP
// server so devin can invoke client-defined tools (bridged back in Phase 2).
// When any are present, a throwaway XDG_CONFIG_HOME holds devin/config.json so
// the agent auto-connects them (session/new mcpServers alone doesn't spawn
// them — see ACP mcp/connect, still unstable). Cleaned up on finish.
// NOTE: this replaces the user's global devin MCP config for the subprocess.
let mcpConfigDir = null;
const mcpServers = {};
const mcpJson = process.env.DEVIN_MCP_SERVERS?.trim();
if (mcpJson) {
try {
Object.assign(mcpServers, JSON.parse(mcpJson));
} catch (e) {
log?.info?.("DEVIN", `DEVIN_MCP_SERVERS parse failed: ${e.message}`);
}
}
const clientTools = Array.isArray(b.tools) ? b.tools.filter(Boolean) : [];
const clientToolResults = extractClientToolResults(messages);
const clientToolsMcp = buildClientToolsMcp(clientTools, clientToolResults);
const hasClientTools = !!clientToolsMcp;
if (clientToolsMcp) {
mcpServers["clientTools"] = clientToolsMcp;
const seeded = Object.keys(clientToolResults).length;
log?.info?.(
"DEVIN",
`exposing ${clientTools.length} client tool(s) as MCP` +
(seeded ? ` (seeded ${seeded} result(s))` : "")
);
}
if (Object.keys(mcpServers).length) {
try {
mcpConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "devin-mcp-"));
const cfgDev = path.join(mcpConfigDir, "devin");
fs.mkdirSync(cfgDev, { recursive: true });
fs.writeFileSync(
path.join(cfgDev, "config.json"),
JSON.stringify({ mcpServers })
);
log?.info?.("DEVIN", `mcp config written → ${mcpConfigDir}`);
} catch (e) {
log?.info?.("DEVIN", `mcp config write failed: ${e.message}`);
mcpConfigDir = null;
}
}
const cleanupMcp = () => {
if (!mcpConfigDir) return;
try {
fs.rmSync(mcpConfigDir, { recursive: true, force: true });
} catch {
/* ignore */
}
mcpConfigDir = null;
};
const sseStream = new ReadableStream({
start(controller) {
const enc = new TextEncoder();
const emit = (data) => controller.enqueue(enc.encode(data));
// Inherit the parent environment so devin resolves stored CLI credentials
// (~/.local/share/devin/credentials.toml from `devin auth login`). Do NOT
// inject WINDSURF_API_KEY: this provider is noAuth, and a bogus/leaked key
// overrides stored creds and makes devin return -32000 "invalid api key".
const env = { ...process.env };
// Auto-approve tool execution so the agent doesn't block waiting for a
// session/request_permission response we never send (default mode would
// hang the stream on the first shell/exec tool call). Override via env.
// WARNING: bypass lets the agent run shell/modify FS unattended — local only.
env.DEVIN_PERMISSION_MODE = process.env.DEVIN_PERMISSION_MODE || "bypass";
if (mcpConfigDir) env.XDG_CONFIG_HOME = mcpConfigDir;
// Agent type: default (omitted) = full agent with built-in tools
// (fs/shell/search) so the model can actually perform tasks. Override to
// `summarizer` (no tools, text-only) via CLI_DEVIN_AGENT_TYPE for a safer,
// tool-less mode. WARNING: the default agent can run shell commands and
// modify the filesystem on the host running 9router — only expose locally.
const agentType = process.env.CLI_DEVIN_AGENT_TYPE?.trim();
const acpArgs = ["acp"];
if (agentType) acpArgs.push("--agent-type", agentType);
// Spawn in the client workspace cwd (from <cwd> env context) so built-in
// file tools create/delete relative paths in the user's project.
// MCP config still comes from XDG_CONFIG_HOME (throwaway), not project .devin/.
const child = spawn(devinBin, acpArgs, {
env,
cwd: workspaceCwd,
stdio: ["pipe", "pipe", "pipe"],
// On Windows, devin.exe may need shell resolution
shell: process.platform === "win32",
});
let spawnError = null;
let stdinClosed = false;
child.on("error", (err) => {
spawnError = err;
const msg =
err.message.includes("ENOENT") || err.message.includes("not found")
? `Devin CLI not found: ${devinBin}. Install via https://cli.devin.ai or set CLI_DEVIN_BIN env var.`
: `Devin CLI spawn error: ${err.message}`;
emit(
`data: ${JSON.stringify({ error: { message: msg, type: "devin_cli_error", code: "spawn_failed" } })}\n\n`
);
emit("data: [DONE]\n\n");
controller.close();
});
if (signal) {
signal.addEventListener("abort", () => {
if (!child.killed) child.kill("SIGTERM");
});
}
// ── JSON-RPC state machine ──────────────────────────────────────────
let idCounter = 1;
let sessionId = null;
let initDone = false;
let sessionCreated = false;
let promptSent = false;
const responseId = `chatcmpl-devin-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
let roleEmitted = false;
let totalText = "";
let finished = false;
const sendRpc = (method, params) => {
if (stdinClosed || child.stdin.destroyed) return;
const id = idCounter++;
try {
child.stdin.write(rpc(method, params, id));
} catch {
/* ignore write errors after close */
}
return id;
};
// Emit a content delta as an OpenAI-compatible SSE chunk (handles the
// leading role chunk once).
const emitDelta = (delta) => {
if (!roleEmitted) {
emit(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }],
})}\n\n`
);
roleEmitted = true;
}
totalText += delta;
emit(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { content: delta }, finish_reason: null }],
})}\n\n`
);
};
// Emit an OpenAI tool_call delta (function calling). Ends the turn with
// finish_reason "tool_calls" so the client executes and returns tool_result.
let toolUseEmitted = false;
// ACP tool_call is upsert-by-id: the first event has title, a later update
// may only carry rawInput (title omitted). Track pending client-tool calls.
const pendingClientTools = new Map(); // toolCallId → original tool name
const emitToolUse = (toolName, args, toolCallId) => {
const argsStr = typeof args === "string" ? args : JSON.stringify(args ?? {});
if (!roleEmitted) {
emit(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { role: "assistant", content: null }, finish_reason: null }],
})}\n\n`
);
roleEmitted = true;
}
emit(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: toolCallId,
type: "function",
function: { name: toolName, arguments: argsStr },
},
],
},
finish_reason: null,
},
],
})}\n\n`
);
};
const finish = (error, finishReason = "stop") => {
if (finished) return;
finished = true;
if (error) {
emit(
`data: ${JSON.stringify({ error: { message: error, type: "devin_cli_error" } })}\n\n`
);
} else {
// Emit finish chunk
emit(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: {}, finish_reason: finishReason }],
usage: {
prompt_tokens: Math.ceil(promptText.length / 4),
completion_tokens: Math.ceil(totalText.length / 4),
total_tokens: Math.ceil((promptText.length + totalText.length) / 4),
estimated: true,
},
})}\n\n`
);
}
emit("data: [DONE]\n\n");
// Gracefully close stdin → devin will exit
try {
if (!stdinClosed) {
stdinClosed = true;
child.stdin.end();
}
} catch {
/* ignore */
}
// Give it 2s to exit cleanly, then SIGKILL
const killTimer = setTimeout(() => {
if (!child.killed) child.kill("SIGKILL");
}, 2000);
killTimer.unref?.();
controller.close();
cleanupMcp();
};
// ── stdout reader (NDJSON) ──────────────────────────────────────────
let buffer = "";
child.stdout.on("data", (chunk) => {
buffer += chunk.toString("utf8");
let nl;
// Each ACP message is a newline-terminated JSON line
while ((nl = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (!line) continue;
let msg;
try {
msg = JSON.parse(line);
} catch {
continue; // ignore non-JSON lines (banner text, etc.)
}
// ── Initialize response ───────────────────────────────────────
if (!initDone && msg.result !== undefined && !msg.method) {
initDone = true;
// Create session with the client workspace cwd so agent file tools
// resolve relative paths against the project (not /tmp).
// `mcpServers` is required by devin 3000.2.x (must be a sequence);
// omitting it returns -32602 "Invalid params: missing field mcpServers".
sendRpc("session/new", {
cwd: workspaceCwd,
mcpServers: [],
model: model || undefined,
});
continue;
}
// ── session/new response → get sessionId ──────────────────────
if (initDone && !sessionCreated && msg.result !== undefined && !msg.method) {
const res = msg.result || {};
sessionId = res.sessionId || null;
if (!sessionId) {
finish("Devin ACP: session/new returned no sessionId");
return;
}
sessionCreated = true;
// Send the prompt. devin 3000.2.x expects `prompt` (a sequence),
// not `content` — using `content` returns -32602 "missing field prompt".
promptSent = true;
sendRpc("session/prompt", {
sessionId,
prompt: [{ type: "text", text: promptText }],
});
continue;
}
// ── session/prompt response (ack / final result) ────────────
if (sessionCreated && promptSent && msg.result !== undefined && !msg.method) {
// Devin 3000.2.x only resolves session/prompt with the final result
// (stopReason) after streaming completes. Streaming notifications are
// handled below; nothing to do here unless we never streamed.
if (!roleEmitted) {
const res = msg.result || undefined;
const content = extractResultText(res);
if (content) {
totalText = content;
emitDelta(content);
}
const stopReason = (res && res.stopReason) || "";
if (stopReason && stopReason !== "cancelled") {
finish();
return;
}
}
continue;
}
// ── Permission requests → auto-approve the first allow option ──
// Devi asks before running shell/exec tools; as a headless proxy we
// grant once. (DEVIN_PERMISSION_MODE=bypass usually prevents these,
// but some tool kinds still prompt, so handle them here too.)
if (msg.method === "session/request_permission" && msg.id !== undefined) {
const options = msg.params?.options || [];
const allow =
options.find((o) => /allow/i.test(String(o.kind || ""))) || options[0];
if (allow) {
child.stdin.write(
JSON.stringify({
jsonrpc: "2.0",
id: msg.id,
result: { outcome: { outcome: "selected", optionId: allow.optionId } },
}) + "\n"
);
}
continue;
}
// ── Agent stopped notification (devin 3000.2.x stop signal) ───
if (msg.method === "_cognition.ai/agent_stopped" || msg.method === "$/agent_stopped") {
const cause = msg.params?.cause;
if (cause === "error") {
// devin uses errorMessage on this notification (not message/error).
const errText =
msg.params?.errorMessage ||
msg.params?.message ||
msg.params?.error ||
"Devin agent error";
finish(String(errText));
} else {
finish();
}
return;
}
// ── Streaming notifications (session/update) ──────────────────
if (msg.method === "session/update" || msg.method === "$/update") {
const params = msg.params;
if (!params) continue;
// devin 3000.2.x nests the payload under params.update.sessionUpdate;
// older devin used a flat params.type.
const update = params.update || {};
const type = update.sessionUpdate || params.type;
const contentField = update.content !== undefined ? update.content : params.content;
const deltaText =
typeof contentField === "string"
? contentField
: contentField?.text ?? params.delta ?? params.text ?? "";
// ── Client-tool bridge: devin calling a tool from our exposed MCP ──
// ACP title shape: "Calling mcp_<name> from clientTools".
// tool_call is upsert-by-id: title may only appear on the first event,
// rawInput on a later tool_call_update. Track pending ids so we don't
// require both fields on the same notification.
if (
hasClientTools &&
!toolUseEmitted &&
(type === "tool_call" || type === "tool_call_update")
) {
const tcId = update.toolCallId;
if (typeof update.title === "string" && update.title.startsWith("Calling mcp_") && /from clientTools\b/.test(update.title)) {
const nameMatch = update.title.match(/^Calling (mcp_\S+)\b/);
const mcpName = nameMatch ? nameMatch[1] : "";
const origName = fromMcpToolName(mcpName);
if (tcId && origName) pendingClientTools.set(tcId, origName);
}
const origName = tcId ? pendingClientTools.get(tcId) : null;
if (origName && update.rawInput) {
toolUseEmitted = true;
pendingClientTools.delete(tcId);
emitToolUse(origName, update.rawInput, tcId || `call_${Date.now()}`);
finish(null, "tool_calls");
return;
}
continue;
}
if (type === "agent_message_chunk" || type === "message_delta" || type === "text_delta" || type === "content_delta") {
if (deltaText) emitDelta(deltaText);
} else if (type === "agent_thought_chunk") {
// Internal reasoning — not surfaced to the client.
} else if (type === "message_stop" || type === "stop" || type === "done") {
finish();
return;
} else if (type === "error") {
finish(String(params.message || params.error || "Devin ACP error"));
return;
}
continue;
}
// ── Error responses ───────────────────────────────────────────
if (msg.error) {
finish(`Devin ACP error ${msg.error.code}: ${msg.error.message}`);
return;
}
}
});
child.stderr.on("data", (chunk) => {
log?.debug?.("DEVIN", `stderr: ${chunk.toString("utf8").slice(0, 200)}`);
});
child.on("close", (code) => {
if (!finished) {
if (code !== 0 && !spawnError) {
finish(roleEmitted ? undefined : `Devin CLI exited with code ${code}`);
} else {
finish();
}
} else {
cleanupMcp();
}
});
// ── Send initialize ───────────────────────────────────────────────
sendRpc("initialize", {
protocolVersion: "0.3",
clientInfo: { name: "9router", version: "1.0" },
capabilities: {},
});
},
});
return {
response: new Response(sseStream, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
}),
url: "devin://acp/stdio",
headers: {},
transformedBody: {
model,
cwd: workspaceCwd,
clientTools: clientTools.map((t) => t?.function?.name || t?.name).filter(Boolean),
clientToolResults: Object.keys(clientToolResults),
mcpServers: Object.keys(mcpServers),
promptLength: Array.isArray(body?.messages)
? body.messages.length
: Array.isArray(body?.input)
? body.input.length
: 0,
},
};
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
// Extract text from a final ACP session/prompt result object across common shapes.
function extractResultText(result) {
// { message: { content: "..." } }
// { messages: [{ content: "..." }] }
// { content: "..." }
// { text: "..." }
if (typeof result.content === "string") return result.content;
if (typeof result.text === "string") return result.text;
const msg = result.message;
if (msg && typeof msg.content === "string") return msg.content;
const msgs = result.messages;
if (Array.isArray(msgs)) {
return msgs
.filter((m) => m.role === "assistant")
.map((m) => String(m.content || ""))
.join("\n");
}
return "";
}
export default DevinCliExecutor;
+15
View File
@@ -20,7 +20,12 @@ import { CommandCodeExecutor } from "./commandcode.js";
import { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; import { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js";
import { MimoFreeExecutor } from "./mimo-free.js"; import { MimoFreeExecutor } from "./mimo-free.js";
import { CodeBuddyExecutor } from "./codebuddy-cn.js"; import { CodeBuddyExecutor } from "./codebuddy-cn.js";
import { CodeBuddyIntlExecutor } from "./codebuddy-intl.js";
import TraeExecutor from "./trae.js";
import ZedExecutor from "./zed.js";
import WindsurfExecutor from "./windsurf.js";
import { DefaultExecutor } from "./default.js"; import { DefaultExecutor } from "./default.js";
import { DevinCliExecutor } from "./devin-cli.js";
const executors = { const executors = {
antigravity: new AntigravityExecutor(), antigravity: new AntigravityExecutor(),
@@ -50,6 +55,11 @@ const executors = {
"mimo-free": new MimoFreeExecutor(), "mimo-free": new MimoFreeExecutor(),
mmf: new MimoFreeExecutor(), // Alias for mimo-free mmf: new MimoFreeExecutor(), // Alias for mimo-free
"codebuddy-cn": new CodeBuddyExecutor(), "codebuddy-cn": new CodeBuddyExecutor(),
"codebuddy-intl": new CodeBuddyIntlExecutor(),
trae: new TraeExecutor(),
zed: new ZedExecutor(),
windsurf: new WindsurfExecutor(),
"devin-cli": new DevinCliExecutor(),
}; };
const defaultCache = new Map(); const defaultCache = new Map();
@@ -88,3 +98,8 @@ export { CommandCodeExecutor } from "./commandcode.js";
export { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; export { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js";
export { MimoFreeExecutor } from "./mimo-free.js"; export { MimoFreeExecutor } from "./mimo-free.js";
export { CodeBuddyExecutor } from "./codebuddy-cn.js"; export { CodeBuddyExecutor } from "./codebuddy-cn.js";
export { CodeBuddyIntlExecutor } from "./codebuddy-intl.js";
export { default as TraeExecutor } from "./trae.js";
export { default as ZedExecutor } from "./zed.js";
export { default as WindsurfExecutor } from "./windsurf.js";
export { DevinCliExecutor } from "./devin-cli.js";
+36 -11
View File
@@ -1,6 +1,10 @@
import { BaseExecutor } from "./base.js"; import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js"; import { PROVIDERS } from "../config/providers.js";
import { resolveKiroModel } from "../config/kiroConstants.js"; import {
KIRO_CODEWHISPERER_TARGET,
KIRO_ENDPOINT_FALLBACK_STATUSES,
resolveKiroModel,
} from "../config/kiroConstants.js";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { refreshKiroToken } from "../services/tokenRefresh.js"; import { refreshKiroToken } from "../services/tokenRefresh.js";
import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js"; import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js";
@@ -216,12 +220,17 @@ export class KiroExecutor extends BaseExecutor {
super("kiro", PROVIDERS.kiro); super("kiro", PROVIDERS.kiro);
} }
buildHeaders(credentials, stream = true) { buildHeaders(credentials, stream = true, url = "") {
const headers = { const headers = {
...this.config.headers, ...this.config.headers,
"Amz-Sdk-Request": "attempt=1; max=3", "Amz-Sdk-Request": "attempt=1; max=3",
"Amz-Sdk-Invocation-Id": uuidv4() "Amz-Sdk-Invocation-Id": uuidv4()
}; };
if (url.includes("://codewhisperer.")) {
headers["X-Amz-Target"] = KIRO_CODEWHISPERER_TARGET;
} else {
delete headers["X-Amz-Target"];
}
// API-key auth: the key is stored as accessToken and sent as a bearer token // API-key auth: the key is stored as accessToken and sent as a bearer token
// exactly like an OAuth access token, but with an extra `tokentype: API_KEY` // exactly like an OAuth access token, but with an extra `tokentype: API_KEY`
@@ -236,8 +245,8 @@ export class KiroExecutor extends BaseExecutor {
const apiKey = credentials?.apiKey || (isApiKey ? credentials?.accessToken : null); const apiKey = credentials?.apiKey || (isApiKey ? credentials?.accessToken : null);
if (isApiKey && apiKey) { if (isApiKey && apiKey) {
headers["Authorization"] = `Bearer ${apiKey}`; headers["Authorization"] = `Bearer ${apiKey}`;
headers["tokentype"] = "API_KEY"; headers["TokenType"] = "API_KEY";
} else if (credentials.accessToken) { } else if (credentials?.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`; headers["Authorization"] = `Bearer ${credentials.accessToken}`;
if (isExternalIdp) { if (isExternalIdp) {
headers["TokenType"] = "EXTERNAL_IDP"; headers["TokenType"] = "EXTERNAL_IDP";
@@ -250,14 +259,14 @@ export class KiroExecutor extends BaseExecutor {
/** /**
* Auth-aware endpoint ordering. * Auth-aware endpoint ordering.
* *
* API-key Kiro connections store a raw CodeWhisperer credential (validated * API-key Kiro connections use the Amazon Q surface. The legacy
* against codewhisperer.us-east-1.amazonaws.com via ListAvailableProfiles). * codewhisperer.* GenerateAssistantResponse endpoint can authenticate the key
* but rejects the same valid payload with REQUEST_BODY_INVALID. Since a 400
* is terminal in BaseExecutor, putting CodeWhisperer first prevents the working
* q.* endpoint from ever being tried. Keep q.* first only for api_key accounts.
*
* The Kiro IDE gateway (runtime.*.kiro.dev) expects Kiro OIDC/social tokens * The Kiro IDE gateway (runtime.*.kiro.dev) expects Kiro OIDC/social tokens
* and rejects an `tokentype: API_KEY` token with 401/403 — which * and rejects TokenType=API_KEY. External IdP enterprise tokens instead
* BaseExecutor.execute() returns immediately (only 429 / network errors fall
* through to the next host). So for api-key auth we must try the *.amazonaws.com
* CodeWhisperer hosts FIRST, mirroring the Kiro-Go reference fork which never
* routes api-key traffic through kiro.dev. External IdP enterprise tokens also
* use the CodeWhisperer surface, with the `TokenType: EXTERNAL_IDP` header. * use the CodeWhisperer surface, with the `TokenType: EXTERNAL_IDP` header.
* Other OAuth methods keep the default order (kiro.dev first) since their * Other OAuth methods keep the default order (kiro.dev first) since their
* tokens are what that gateway accepts. * tokens are what that gateway accepts.
@@ -282,6 +291,14 @@ export class KiroExecutor extends BaseExecutor {
const amazon = baseUrls.filter((u) => u.includes("amazonaws.com")).map(regionalize); const amazon = baseUrls.filter((u) => u.includes("amazonaws.com")).map(regionalize);
const others = baseUrls.filter((u) => !u.includes("amazonaws.com")); const others = baseUrls.filter((u) => !u.includes("amazonaws.com"));
if (authMethod === "api_key") {
const q = amazon.filter((u) => u.includes("://q."));
const remaining = amazon.filter((u) => !u.includes("://q."));
return q.length > 0
? [...q, ...remaining, ...others]
: [...amazon, ...others];
}
return amazon.length > 0 ? [...amazon, ...others] : baseUrls; return amazon.length > 0 ? [...amazon, ...others] : baseUrls;
} }
@@ -290,6 +307,14 @@ export class KiroExecutor extends BaseExecutor {
return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl; return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl;
} }
// Retry only endpoint/auth-surface failures. Payload-invalid HTTP 400 must be
// terminal: sending the same malformed body to every surface cannot repair it.
shouldRetry(status, urlIndex) {
const hasFallback = urlIndex + 1 < this.getFallbackCount();
return super.shouldRetry(status, urlIndex)
|| (hasFallback && KIRO_ENDPOINT_FALLBACK_STATUSES.has(status));
}
transformRequest(model, body, stream, credentials) { transformRequest(model, body, stream, credentials) {
return body; return body;
} }
+175 -38
View File
@@ -32,7 +32,11 @@ import { SSE_DONE } from "../utils/sseConstants.js";
import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js"; import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js";
import { import {
QODER_CHAT_URL_ENCODED, QODER_CHAT_URL_ENCODED,
QODER_JOB_TOKEN_EXCHANGE_URL,
QODER_USERINFO_URL,
QODER_MODEL_MAP, QODER_MODEL_MAP,
QODER_IDE_VERSION,
QODER_CLIENT_TYPE,
} from "../shared/qoder/constants.js"; } from "../shared/qoder/constants.js";
import { getQoderModelConfig, resolveQoderModels } from "../services/qoderModels.js"; import { getQoderModelConfig, resolveQoderModels } from "../services/qoderModels.js";
@@ -220,8 +224,13 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio
* Each upstream line looks like: * Each upstream line looks like:
* data: {"statusCodeValue":200,"body":"{\"choices\":[{\"delta\":{...}}]}"} * data: {"statusCodeValue":200,"body":"{\"choices\":[{\"delta\":{...}}]}"}
* The inner body is an OpenAI streaming chunk (or "[DONE]"). We unwrap it * The inner body is an OpenAI streaming chunk (or "[DONE]"). We unwrap it
* and re-emit as `data: <inner>\n\n`. Errors become `data: [DONE]\n\n` plus * and re-emit as `data: <inner>\n\n`. Errors become a synthetic OpenAI error
* a synthetic OpenAI error chunk. * chunk + [DONE].
*
* Critical: Qoder's SSE often keeps the socket open after the terminal
* [DONE]/error frame (agent keepalive). Non-streaming clients drain via
* response.text() which hangs until the socket closes — so on terminal
* events we cancel the upstream reader and close our stream immediately.
*/ */
function wrapQoderSSE(response, model) { function wrapQoderSSE(response, model) {
if (!response.ok || !response.body) return response; if (!response.ok || !response.body) return response;
@@ -230,15 +239,14 @@ function wrapQoderSSE(response, model) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
let buffer = ""; let buffer = "";
let doneEmitted = false; let doneEmitted = false;
const reader = response.body.getReader();
// Process one already-extracted SSE line (no trailing newline). Returns // Process one already-extracted SSE line (no trailing newline).
// false when the line indicated end-of-stream so the caller can stop
// forwarding any remaining chunks after [DONE].
const processLine = (line, controller) => { const processLine = (line, controller) => {
const trimmed = line.replace(/\r$/, "").trim(); const trimmed = line.replace(/\r$/, "").trim();
if (!trimmed) return; if (!trimmed) return;
if (!trimmed.startsWith("data:")) return; if (!trimmed.startsWith("data:")) return;
if (doneEmitted) return; // never forward chunks past stream end if (doneEmitted) return;
const data = trimmed.slice(5).trimStart(); const data = trimmed.slice(5).trimStart();
if (data === "[DONE]") { if (data === "[DONE]") {
@@ -271,47 +279,60 @@ function wrapQoderSSE(response, model) {
doneEmitted = true; doneEmitted = true;
return; return;
} }
// Inner is an OpenAI-shaped chunk. Strip any embedded newlines so the // Strip embedded newlines so the SSE frame stays a single event.
// SSE frame stays a single event (a literal "\n" inside `inner` would
// otherwise split the frame across multiple data: lines and downstream
// parsers would reassemble them as separate events).
const sanitized = inner.replace(/\r?\n/g, ""); const sanitized = inner.replace(/\r?\n/g, "");
controller.enqueue(encoder.encode(`data: ${sanitized}\n\n`)); controller.enqueue(encoder.encode(`data: ${sanitized}\n\n`));
}; };
const transform = new TransformStream({ const stream = new ReadableStream({
transform(chunk, controller) { // Use start()+loop (not pull): a pull that buffers a partial line without
buffer += decoder.decode(chunk, { stream: true }); // enqueueing would never be re-invoked, hanging consumers like .text().
let nl; async start(controller) {
while ((nl = buffer.indexOf("\n")) !== -1) { try {
const line = buffer.slice(0, nl); while (!doneEmitted) {
buffer = buffer.slice(nl + 1); const { done, value } = await reader.read();
processLine(line, controller); if (done) {
buffer += decoder.decode();
if (buffer.length > 0) {
processLine(buffer, controller);
buffer = "";
}
break;
}
buffer += decoder.decode(value, { stream: true });
let nl;
while ((nl = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nl);
buffer = buffer.slice(nl + 1);
processLine(line, controller);
if (doneEmitted) {
// Terminal frame received — drop upstream keepalive and end.
await reader.cancel().catch(() => {});
controller.close();
return;
}
}
}
} catch {
// fall through to terminal [DONE] + close
} finally {
if (!doneEmitted) {
try {
controller.enqueue(encoder.encode(SSE_DONE));
doneEmitted = true;
} catch { /* already closed */ }
}
try { controller.close(); } catch { /* already closed */ }
await reader.cancel().catch(() => {});
} }
}, },
flush(controller) { cancel() {
// Finalize the decoder so any pending multi-byte sequence is return reader.cancel().catch(() => {});
// released into `buffer` instead of being silently dropped.
buffer += decoder.decode();
// Drain any trailing line that arrived without a terminating newline
// (e.g. upstream closed the socket immediately after the last write,
// or a CDN stripped the final CRLF). Without this, the chunk that
// carries finish_reason is silently lost.
if (buffer.length > 0) {
processLine(buffer, controller);
buffer = "";
}
if (!doneEmitted) {
controller.enqueue(encoder.encode(SSE_DONE));
doneEmitted = true;
}
}, },
}); });
const transformed = response.body.pipeThrough(transform); return new Response(stream, {
// Build a Response with passable headers; the streaming handler reads
// `.body` as a ReadableStream regardless of Content-Type.
return new Response(transformed, {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
headers: { headers: {
@@ -321,6 +342,92 @@ function wrapQoderSSE(response, model) {
}); });
} }
// ── PAT (Personal Access Token) → job-token exchange ───────────────────────
// PATs (pt-...) cannot sign COSY requests directly. Exchange them for a
// short-lived job token (jt-...) via /api/v1/jobToken/exchange (plain JSON,
// not COSY-signed), then resolve the userId from userinfo. Mirrors the
// official qodercli flow. Cached per-PAT until near-expiry.
const PAT_PREFIX = "pt-";
const PAT_REFRESH_BUFFER_MS = 5 * 60 * 1000;
const patJobCache = new Map();
export function isQoderPat(token) {
return typeof token === "string" && token.startsWith(PAT_PREFIX);
}
async function exchangeJobToken(pat, proxyOptions = null, signal = null) {
const res = await proxyAwareFetch(
QODER_JOB_TOKEN_EXCHANGE_URL,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": "qodercli/1.0.0",
"Cosy-Version": QODER_IDE_VERSION,
"Cosy-ClientType": QODER_CLIENT_TYPE,
},
body: JSON.stringify({ personal_token: pat }),
signal,
},
proxyOptions,
);
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`qoder PAT exchange failed: ${res.status} ${text.slice(0, 200)}`);
}
const data = await res.json();
if (!data.token) throw new Error("qoder PAT exchange returned no job token");
let expiresAt = Date.now() + 24 * 60 * 60 * 1000;
if (data.expires_at) {
const parsed = Date.parse(data.expires_at);
if (!Number.isNaN(parsed)) expiresAt = parsed;
} else if (typeof data.expires_in === "number" && data.expires_in > 0) {
expiresAt = Date.now() + data.expires_in;
}
return { jobToken: data.token, jobRefreshToken: data.refresh_token || "", expiresAt };
}
async function fetchUserIdForJobToken(jobToken, proxyOptions = null, signal = null) {
try {
const res = await proxyAwareFetch(
QODER_USERINFO_URL,
{
method: "GET",
headers: {
Authorization: `Bearer ${jobToken}`,
Accept: "application/json",
"User-Agent": "qodercli/1.0.0",
},
signal,
},
proxyOptions,
);
if (!res.ok) return "";
const info = await res.json().catch(() => ({}));
return info.id || info.userId || info.user_id || "";
} catch {
return "";
}
}
/**
* Exchange a PAT for a job token + userId, caching until near-expiry so repeat
* chat requests don't re-exchange. Returns { accessToken, userId }.
*/
async function resolvePatCredential(pat, proxyOptions = null, signal = null) {
const cached = patJobCache.get(pat);
if (cached && cached.expiresAt - Date.now() > PAT_REFRESH_BUFFER_MS) {
return cached;
}
const { jobToken, expiresAt } = await exchangeJobToken(pat, proxyOptions, signal);
const userId = await fetchUserIdForJobToken(jobToken, proxyOptions, signal);
const entry = { accessToken: jobToken, userId, expiresAt };
patJobCache.set(pat, entry);
return entry;
}
export class QoderExecutor extends BaseExecutor { export class QoderExecutor extends BaseExecutor {
constructor() { constructor() {
super("qoder", PROVIDERS.qoder); super("qoder", PROVIDERS.qoder);
@@ -338,6 +445,34 @@ export class QoderExecutor extends BaseExecutor {
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
const url = this.buildUrl(); const url = this.buildUrl();
// PAT (pt-...) → exchange for short-lived job token + resolve userId so
// downstream COSY signing + catalog fetch work. Device tokens (dt-...) and
// job tokens (jt-...) skip this and are used directly.
const rawToken = credentials?.apiKey || credentials?.accessToken;
if (isQoderPat(rawToken)) {
try {
const resolved = await resolvePatCredential(rawToken, proxyOptions, signal);
credentials = {
...credentials,
accessToken: resolved.accessToken,
apiKey: undefined,
providerSpecificData: {
authMethod: "pat",
...(credentials?.providerSpecificData || {}),
userId: resolved.userId || credentials?.providerSpecificData?.userId || "",
machineId: credentials?.providerSpecificData?.machineId || "",
},
};
} catch (err) {
log?.error?.("QODER", `PAT exchange failed: ${err.message}`);
const fakeResp = new Response(
JSON.stringify({ error: { message: `qoder PAT exchange failed: ${err.message}` } }),
{ status: 401, headers: { "Content-Type": "application/json" } },
);
return { response: fakeResp, url, headers: {}, transformedBody: body };
}
}
const psd = credentials?.providerSpecificData || {}; const psd = credentials?.providerSpecificData || {};
if (!psd.userId) { if (!psd.userId) {
// No user id → no way to sign. Surface a 401 so the dashboard nudges // No user id → no way to sign. Surface a 401 so the dashboard nudges
@@ -455,4 +590,6 @@ export const __test__ = {
normalizeMessages, normalizeMessages,
wrapQoderSSE, wrapQoderSSE,
buildQoderRequestBody, buildQoderRequestBody,
isQoderPat,
resolvePatCredential,
}; };
+339
View File
@@ -0,0 +1,339 @@
import { BaseExecutor } from "./base.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { PROVIDERS } from "../config/providers.js";
// Trae executor — SOLO remote agent API.
//
// Flow:
// 1. POST {base}/chat_sessions → { code:0, data:{ chat_session_id, message_id } }
// 2. GET {base}/chat_sessions/{id}/events?reply_to_message_id={message_id}
// → text/event-stream. Assistant text streams in `plan_item` events under
// the `thought` field (cumulative per plan-item id). `token_usage` carries
// usage; `done` ends the turn; `error` carries upstream errors.
//
// Auth: header `Authorization: Cloud-IDE-JWT <jwt>` (RS256, ~14-day lifetime).
// Identity fields for common_params live in credentials.providerSpecificData.
const STREAM_TIMEOUT_MS = parseInt(process.env.TRAE_STREAM_TIMEOUT_MS || "300000", 10);
const TRAE_UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
function flattenQuery(messages) {
const parts = [];
for (const m of messages) {
let content = "";
if (typeof m.content === "string") content = m.content;
else if (Array.isArray(m.content)) {
content = m.content
.map((p) => {
if (typeof p === "string") return p;
if (p && typeof p === "object") return String(p.text ?? "");
return "";
})
.join("");
}
if (m.role === "system") parts.push(`[System]\n${content}`);
else if (m.role === "assistant") parts.push(`[Assistant]\n${content}`);
else parts.push(content);
}
// Trae expects query as a JSON-encoded string of typed content blocks.
return JSON.stringify([{ type: "text", data: { content: parts.join("\n\n") } }]);
}
export default class TraeExecutor extends BaseExecutor {
constructor() {
super("trae", PROVIDERS.trae);
}
base() {
return (this.config.baseUrl || "https://core-normal.trae.ai/api/remote/v1").replace(/\/$/, "");
}
buildHeaders(credentials, stream = true) {
const token = credentials?.accessToken || "";
const psd = credentials?.providerSpecificData || {};
return {
Authorization: `Cloud-IDE-JWT ${token}`,
"Content-Type": "application/json",
"X-Trae-Client-Type": "web",
"X-Preferenced-Language": psd.appLanguage || "en",
"x-user-region": psd.userRegion || "US",
Referer: "https://solo.trae.ai/",
"User-Agent": TRAE_UA,
Accept: stream ? "text/event-stream" : "application/json",
};
}
// SOLO session modes: "code" (model picker) vs "work" (fast auto lane).
resolveMode(model) {
const m = (model || "").trim().toLowerCase();
if (m === "work" || m === "auto-work" || m === "solo-work") {
return { mode: "work", strategy: "auto", modelName: "" };
}
const auto = !m || m === "auto";
return { mode: "code", strategy: auto ? "auto" : "manual", modelName: auto ? "" : model };
}
// common_params is a JSON-encoded string embedded inside initial_message.
commonParams(psd, mode, sessionId) {
const cp = {
language: "en-us",
app_language: psd.appLanguage || "en",
quality: "stable",
app_version: psd.appVersion || "1.0.0.1229",
web_id: psd.webId || "",
user_identity: psd.userIdentity || "Free",
is_freshman: "0",
biz_user_id: psd.bizUserId || "",
user_unique_id: psd.userUniqueId || "",
scope: psd.scope || "marscode-us",
tenant: psd.tenant || "marscode",
region: psd.region || "US-East",
aiRegion: psd.aiRegion || psd.region || "US-East",
is_privacy_mode: 0,
privacy_mode: "off",
solo_chat_mode: mode,
};
if (sessionId) cp.biz_session_id = sessionId;
return JSON.stringify(cp);
}
// POST /chat_sessions — creates a session and submits the first turn.
async createSession(headers, query, model, psd, signal) {
const { mode, strategy, modelName } = this.resolveMode(model);
const body = {
mode,
environment_id: "default",
initial_message: {
chat_session_id: "",
content: [],
query,
model_name: modelName,
agent_type: "solo_agent_remote",
model_selection_strategy: strategy,
common_params: this.commonParams(psd, mode),
},
env: "remote",
auto_create_project: false,
origin: "web",
};
const res = await proxyAwareFetch(`${this.base()}/chat_sessions`, {
method: "POST",
headers,
body: JSON.stringify(body),
signal,
}, null);
const text = await res.text();
if (!res.ok) throw new Error(`[${res.status}] ${text}`);
const json = JSON.parse(text);
if (json?.code !== 0) throw new Error(`Trae create_session: ${JSON.stringify(json)}`);
return { sessionId: json.data.chat_session_id, messageId: json.data.message_id };
}
// GET /events SSE → invoke onEvent(eventType, dataObj) per frame.
// Resolves when `done`/`error` arrives, the stream ends, or timeout fires.
async streamEvents(headers, sessionId, replyTo, onEvent, signal) {
const url = `${this.base()}/chat_sessions/${sessionId}/events?reply_to_message_id=${encodeURIComponent(replyTo)}`;
const ctrl = new AbortController();
if (signal?.aborted) ctrl.abort();
const timer = setTimeout(() => ctrl.abort(new Error("trae stream timeout")), STREAM_TIMEOUT_MS);
const onAbort = () => ctrl.abort();
if (signal) signal.addEventListener("abort", onAbort, { once: true });
try {
const res = await proxyAwareFetch(url, { method: "GET", headers, signal: ctrl.signal }, null);
if (!res.ok || !res.body) throw new Error(`[${res.status}] events stream failed`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let ev = null;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).replace(/\r$/, "");
buf = buf.slice(nl + 1);
if (line.startsWith("event:")) ev = line.slice(6).trim();
else if (line.startsWith("data:")) {
const payload = line.slice(5).trim();
let data;
try { data = JSON.parse(payload); } catch { data = { _raw: payload }; }
if (onEvent(ev, data)) {
await reader.cancel().catch(() => {});
return;
}
} else if (line === "") ev = null;
}
}
} finally {
clearTimeout(timer);
if (signal) signal.removeEventListener("abort", onAbort);
}
}
async execute({ model, body, stream, credentials, signal }) {
const headers = this.buildHeaders(credentials, stream !== false);
const psd = credentials?.providerSpecificData || {};
const query = flattenQuery(body?.messages || []);
const responseId = `chatcmpl-trae-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
const errResponse = (status, message) => new Response(
JSON.stringify({ error: { message, type: "api_error", code: "" } }),
{ status, headers: { "Content-Type": "application/json" } }
);
let session;
try {
session = await this.createSession(headers, query, model, psd, signal);
} catch (err) {
return { response: errResponse(502, err?.message ? String(err.message) : String(err)), url: this.base(), headers, transformedBody: body };
}
// Shared per-turn state: plan_item thoughts (cumulative, longest wins).
const order = [];
const thoughts = {};
let sent = 0;
let usage = null;
let errorEvent = null;
const renderNewText = (data) => {
const pid = data.id;
if (!pid) return "";
if (!(pid in thoughts)) order.push(pid);
const t = data.thought || "";
if (t.length >= (thoughts[pid] || "").length) thoughts[pid] = t;
const full = order.map((i) => thoughts[i]).join("");
const piece = full.slice(sent);
sent = full.length;
return piece;
};
if (stream !== false) {
const enc = new TextEncoder();
const sse = new ReadableStream({
start: async (controller) => {
const emit = (obj) => controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
emit({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
});
try {
await this.streamEvents(headers, session.sessionId, session.messageId, (ev, data) => {
if (ev === "error") { errorEvent = data; return true; }
if (ev === "token_usage") usage = data;
if (ev === "plan_item") {
const piece = renderNewText(data);
if (piece) {
emit({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: { content: piece }, finish_reason: null }],
});
}
}
return ev === "done";
}, signal);
if (errorEvent) {
emit({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [],
error: { message: `trae ${errorEvent.code || ""}: ${errorEvent.message || ""}`, type: "api_error" },
});
} else {
emit({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
});
if (usage) {
emit({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [],
usage: {
prompt_tokens: usage.prompt_tokens || 0,
completion_tokens: usage.completion_tokens || 0,
total_tokens: usage.total_tokens || 0,
},
});
}
}
controller.enqueue(enc.encode("data: [DONE]\n\n"));
controller.close();
} catch (err) {
controller.error(err);
}
},
});
return {
response: new Response(sse, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
}),
url: this.base(),
headers,
transformedBody: body,
};
}
// Non-streaming: drive to completion, return chat.completion JSON.
try {
await this.streamEvents(headers, session.sessionId, session.messageId, (ev, data) => {
if (ev === "error") { errorEvent = data; return true; }
if (ev === "token_usage") usage = data;
if (ev === "plan_item") renderNewText(data);
return ev === "done";
}, signal);
} catch (err) {
return { response: errResponse(502, err?.message ? String(err.message) : String(err)), url: this.base(), headers, transformedBody: body };
}
if (errorEvent) {
return { response: errResponse(502, `trae ${errorEvent.code || ""}: ${errorEvent.message || ""}`), url: this.base(), headers, transformedBody: body };
}
const content = order.map((i) => thoughts[i]).join("");
const out = {
id: responseId,
object: "chat.completion",
created,
model,
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
};
if (usage) {
out.usage = {
prompt_tokens: usage.prompt_tokens || 0,
completion_tokens: usage.completion_tokens || 0,
total_tokens: usage.total_tokens || 0,
};
}
return {
response: new Response(JSON.stringify(out), { status: 200, headers: { "Content-Type": "application/json" } }),
url: this.base(),
headers,
transformedBody: body,
};
}
// Refresh hook placeholder — Cloud-IDE-JWT is long-lived (~14d); refresh via
// ExchangeToken (refresh→access) is wired in services/tokenRefresh/providers.js.
async refreshCredentials() {
return null;
}
}
+588
View File
@@ -0,0 +1,588 @@
import { BaseExecutor } from "./base.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { PROVIDERS } from "../config/providers.js";
import { randomUUID } from "node:crypto";
// WindsurfExecutor — Codeium gRPC-web chat.
//
// Wire protocol: gRPC-web over HTTPS (Content-Type: application/grpc-web+proto).
// Service: exa.language_server_pb.LanguageServerService
// Method: GetChatMessage (unary request → streamed CompletionChunk frames)
//
// Auth: credentials.accessToken = Codeium apiKey (sk-ws-... or Firebase-derived)
// — placed in Metadata.api_key protobuf field of every request + Bearer header.
const WS_BASE_URL = "https://server.codeium.com";
const WS_SERVICE = "exa.language_server_pb.LanguageServerService";
const WS_METHOD_CHAT = "GetChatMessage";
const WS_CHAT_URL = `${WS_BASE_URL}/${WS_SERVICE}/${WS_METHOD_CHAT}`;
const WS_IDE_NAME = "windsurf";
const WS_IDE_VERSION = "3.14.0";
const WS_EXT_VERSION = "3.14.0";
const WS_LOCALE = "en-US";
// ─── Model alias map (catalog name → Windsurf wire name) ─────────────────────
const MODEL_ALIAS_MAP = {
// ── Cognition SWE ───────────────────────────────────────────────────────
"swe-1.6-fast": "swe-1-6-fast",
"swe-1.6": "swe-1-6",
"swe-1.5-fast": "swe-1-5-fast",
"swe-1.5": "swe-1-5",
// ── Claude Opus 4.7 — effort-tiered ─────────────────────────────────────
"claude-opus-4.7-max": "claude-opus-4-7-max",
"claude-opus-4.7-xhigh": "claude-opus-4-7-xhigh",
"claude-opus-4.7-high": "claude-opus-4-7-high",
"claude-opus-4.7-medium": "claude-opus-4-7-medium",
"claude-opus-4.7-low": "claude-opus-4-7-low",
"claude-opus-4.7-review": "opus-4-7-review",
// ── Claude Opus/Sonnet 4.6 ──────────────────────────────────────────────
"claude-sonnet-4.6-thinking-1m": "claude-sonnet-4-6-thinking-1m",
"claude-sonnet-4.6-1m": "claude-sonnet-4-6-1m",
"claude-sonnet-4.6-thinking": "claude-sonnet-4-6-thinking",
"claude-sonnet-4.6": "claude-sonnet-4-6",
"claude-opus-4.6-thinking": "claude-opus-4-6-thinking",
"claude-opus-4.6": "claude-opus-4-6",
// ── Claude 4.5 ──────────────────────────────────────────────────────────
"claude-opus-4.5-thinking": "MODEL_CLAUDE_4_5_OPUS_THINKING",
"claude-opus-4.5": "MODEL_CLAUDE_4_5_OPUS",
"claude-sonnet-4.5-thinking": "MODEL_PRIVATE_3",
"claude-sonnet-4.5": "MODEL_PRIVATE_2",
"claude-haiku-4.5": "MODEL_PRIVATE_11",
// ── GPT-5.5 ─────────────────────────────────────────────────────────────
"gpt-5.5-xhigh-fast": "gpt-5-5-xhigh-priority",
"gpt-5.5-high-fast": "gpt-5-5-high-priority",
"gpt-5.5-medium-fast": "gpt-5-5-medium-priority",
"gpt-5.5-low-fast": "gpt-5-5-low-priority",
"gpt-5.5-none-fast": "gpt-5-5-none-priority",
"gpt-5.5-xhigh": "gpt-5-5-xhigh",
"gpt-5.5-high": "gpt-5-5-high",
"gpt-5.5-medium": "gpt-5-5-medium",
"gpt-5.5-low": "gpt-5-5-low",
"gpt-5.5-none": "gpt-5-5-none",
"gpt-5.5-review": "gpt-5-5-review",
"gpt-5.5": "gpt-5-5-medium",
// ── GPT-5.4 ─────────────────────────────────────────────────────────────
"gpt-5.4-xhigh-fast": "gpt-5-4-xhigh-priority",
"gpt-5.4-high-fast": "gpt-5-4-high-priority",
"gpt-5.4-medium-fast": "gpt-5-4-medium-priority",
"gpt-5.4-low-fast": "gpt-5-4-low-priority",
"gpt-5.4-none-fast": "gpt-5-4-none-priority",
"gpt-5.4-xhigh": "gpt-5-4-xhigh",
"gpt-5.4-high": "gpt-5-4-high",
"gpt-5.4-medium": "gpt-5-4-medium",
"gpt-5.4-low": "gpt-5-4-low",
"gpt-5.4-none": "gpt-5-4-none",
"gpt-5.4-mini-xhigh": "gpt-5-4-mini-xhigh",
"gpt-5.4-mini-high": "gpt-5-4-mini-high",
"gpt-5.4-mini-medium": "gpt-5-4-mini-medium",
"gpt-5.4-mini-low": "gpt-5-4-mini-low",
"gpt-5.4": "gpt-5-4-medium",
// ── GPT-5.3-Codex ───────────────────────────────────────────────────────
"gpt-5.3-codex-xhigh-fast": "gpt-5-3-codex-xhigh-priority",
"gpt-5.3-codex-high-fast": "gpt-5-3-codex-high-priority",
"gpt-5.3-codex-medium-fast": "gpt-5-3-codex-medium-priority",
"gpt-5.3-codex-low-fast": "gpt-5-3-codex-low-priority",
"gpt-5.3-codex-xhigh": "gpt-5-3-codex-xhigh",
"gpt-5.3-codex-high": "gpt-5-3-codex-high",
"gpt-5.3-codex-medium": "gpt-5-3-codex-medium",
"gpt-5.3-codex-low": "gpt-5-3-codex-low",
"gpt-5.3-codex": "gpt-5-3-codex-medium",
// ── GPT-5.2 ─────────────────────────────────────────────────────────────
"gpt-5.2-xhigh": "MODEL_GPT_5_2_XHIGH",
"gpt-5.2-high": "MODEL_GPT_5_2_HIGH",
"gpt-5.2-medium": "MODEL_GPT_5_2_MEDIUM",
"gpt-5.2-low": "MODEL_GPT_5_2_LOW",
"gpt-5.2-none": "MODEL_GPT_5_2_NONE",
"gpt-5.2": "MODEL_GPT_5_2_MEDIUM",
// ── GPT-5 ───────────────────────────────────────────────────────────────
"gpt-5": "gpt-5",
// ── GPT-4.1 / 4o ────────────────────────────────────────────────────────
"gpt-4.1": "MODEL_CHAT_GPT_4_1_2025_04_14",
"gpt-4.1-mini": "gpt-4.1-mini",
"gpt-4o": "MODEL_CHAT_GPT_4O_2024_08_06",
// ── Gemini ──────────────────────────────────────────────────────────────
"gemini-3.1-pro-high": "gemini-3-1-pro-high",
"gemini-3.1-pro-low": "gemini-3-1-pro-low",
"gemini-3.1-pro": "gemini-3-1-pro-high",
"gemini-3.0-flash-high": "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH",
"gemini-3.0-flash-medium": "MODEL_GOOGLE_GEMINI_3_0_FLASH_MEDIUM",
"gemini-3.0-flash-low": "MODEL_GOOGLE_GEMINI_3_0_FLASH_LOW",
"gemini-3.0-flash-minimal": "MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL",
"gemini-3.0-flash": "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH",
"gemini-2.5-pro": "MODEL_GOOGLE_GEMINI_2_5_PRO",
// ── Others ──────────────────────────────────────────────────────────────
"deepseek-v4": "deepseek-v4",
"kimi-k2.6": "kimi-k2-6",
"kimi-k2.5": "kimi-k2-5",
"glm-5.1": "glm-5-1",
};
export function resolveWsModelId(model) {
return MODEL_ALIAS_MAP[model] ?? model;
}
// ─── Minimal protobuf encoder ────────────────────────────────────────────────
// Wire types: 0 = varint, 2 = length-delimited.
function encodeVarint(value) {
const bytes = [];
let v = value >>> 0;
while (v > 0x7f) {
bytes.push((v & 0x7f) | 0x80);
v >>>= 7;
}
bytes.push(v & 0x7f);
return new Uint8Array(bytes);
}
function concatBytes(arrays) {
const total = arrays.reduce((n, a) => n + a.length, 0);
const out = new Uint8Array(total);
let off = 0;
for (const a of arrays) {
out.set(a, off);
off += a.length;
}
return out;
}
const TEXT_ENC = new TextEncoder();
const TEXT_DEC = new TextDecoder();
function encodeField(fieldNum, payload) {
const tag = encodeVarint((fieldNum << 3) | 2);
const len = encodeVarint(payload.length);
return concatBytes([tag, len, payload]);
}
function encodeString(fieldNum, value) {
return encodeField(fieldNum, TEXT_ENC.encode(value));
}
function encodeMessage(fieldNum, msg) {
return encodeField(fieldNum, msg);
}
// ─── Protobuf message builders ───────────────────────────────────────────────
function buildMetadata(apiKey, sessionId) {
return concatBytes([
encodeString(1, apiKey),
encodeString(2, WS_IDE_NAME),
encodeString(3, WS_IDE_VERSION),
encodeString(4, WS_EXT_VERSION),
encodeString(5, sessionId),
encodeString(6, WS_LOCALE),
]);
}
function buildModelOrAlias(model) {
return encodeString(1, model);
}
function buildChatMessage(msg) {
const parts = [encodeString(1, msg.role), encodeString(2, msg.content)];
if (msg.toolCallId) parts.push(encodeString(3, msg.toolCallId));
return concatBytes(parts);
}
export function buildGetChatMessageRequest(apiKey, model, messages) {
const sessionId = randomUUID();
const cascadeId = randomUUID();
const parts = [
encodeMessage(1, buildMetadata(apiKey, sessionId)), // metadata
encodeString(2, cascadeId), // cascade_id
encodeMessage(3, buildModelOrAlias(model)), // model_or_alias
];
for (const msg of messages) {
parts.push(encodeMessage(4, buildChatMessage(msg))); // repeated messages
}
return concatBytes(parts);
}
// ─── gRPC-web framing ────────────────────────────────────────────────────────
export function grpcWebFrame(payload) {
const frame = new Uint8Array(5 + payload.length);
frame[0] = 0x00; // no compression
const view = new DataView(frame.buffer);
view.setUint32(1, payload.length, false); // big-endian length
frame.set(payload, 5);
return frame;
}
// ─── Protobuf response decoder ───────────────────────────────────────────────
// CompletionChunk (oneof):
// field 1 → ContentChunk { field 1: string text }
// field 2 → ToolCallChunk (skipped)
// field 3 → DoneChunk { field 1: UsageStats{ field1: prompt, field2: completion } }
// field 4 → ErrorChunk { field 1: string message }
function readVarint(buf, offset) {
let result = 0;
let shift = 0;
while (offset < buf.length) {
const b = buf[offset++];
result |= (b & 0x7f) << shift;
if ((b & 0x80) === 0) break;
shift += 7;
}
return [result >>> 0, offset];
}
function decodeStringField(buf, targetField) {
let offset = 0;
while (offset < buf.length) {
let tag;
[tag, offset] = readVarint(buf, offset);
const fieldNum = tag >>> 3;
const wireType = tag & 0x07;
if (wireType === 2) {
let len;
[len, offset] = readVarint(buf, offset);
const payload = buf.slice(offset, offset + len);
offset += len;
if (fieldNum === targetField) return TEXT_DEC.decode(payload);
} else if (wireType === 0) {
let v;
[v, offset] = readVarint(buf, offset);
} else if (wireType === 1) {
offset += 8;
} else if (wireType === 5) {
offset += 4;
} else {
break;
}
}
return null;
}
function decodeDoneChunk(buf) {
// DoneChunk: field 1 = UsageStats (nested)
// UsageStats: field 1 = prompt_tokens (varint), field 2 = completion_tokens (varint)
let offset = 0;
let usageBytes = null;
while (offset < buf.length) {
let tag;
[tag, offset] = readVarint(buf, offset);
const fieldNum = tag >>> 3;
const wireType = tag & 0x07;
if (wireType === 2) {
let len;
[len, offset] = readVarint(buf, offset);
if (fieldNum === 1) usageBytes = buf.slice(offset, offset + len);
offset += len;
} else if (wireType === 0) {
let v;
[v, offset] = readVarint(buf, offset);
} else {
break;
}
}
if (!usageBytes) return [0, 0];
let promptTokens = 0;
let completionTokens = 0;
offset = 0;
while (offset < usageBytes.length) {
let tag;
[tag, offset] = readVarint(usageBytes, offset);
const fieldNum = tag >>> 3;
const wireType = tag & 0x07;
if (wireType === 0) {
let v;
[v, offset] = readVarint(usageBytes, offset);
if (fieldNum === 1) promptTokens = v;
else if (fieldNum === 2) completionTokens = v;
} else if (wireType === 2) {
let len;
[len, offset] = readVarint(usageBytes, offset);
offset += len;
} else {
break;
}
}
return [promptTokens, completionTokens];
}
export function decodeCompletionChunk(buf) {
let offset = 0;
while (offset < buf.length) {
let tag;
[tag, offset] = readVarint(buf, offset);
const fieldNum = tag >>> 3;
const wireType = tag & 0x07;
if (wireType === 2) {
let len;
[len, offset] = readVarint(buf, offset);
const payload = buf.slice(offset, offset + len);
offset += len;
if (fieldNum === 1) {
const text = decodeStringField(payload, 1);
if (text !== null) return { kind: "content", text };
} else if (fieldNum === 3) {
const usage = decodeDoneChunk(payload);
return { kind: "done", promptTokens: usage[0], completionTokens: usage[1] };
} else if (fieldNum === 4) {
const msg = decodeStringField(payload, 1);
return { kind: "error", message: msg ?? "unknown windsurf error" };
}
// field 2 = ToolCallChunk — not yet handled; skip
} else if (wireType === 0) {
let v;
[v, offset] = readVarint(buf, offset);
} else if (wireType === 1) {
offset += 8;
} else if (wireType === 5) {
offset += 4;
} else {
break;
}
}
return { kind: "unknown" };
}
// ─── OpenAI messages → Windsurf wire ─────────────────────────────────────────
function openAIMessagesToWs(messages) {
const out = [];
for (const m of messages) {
const role = String(m.role || "user");
let content = "";
if (typeof m.content === "string") {
content = m.content;
} else if (Array.isArray(m.content)) {
for (const part of m.content) {
if (part && typeof part === "object" && part.type === "text") {
content += String(part.text || "");
}
}
}
out.push({ role, content, toolCallId: m.tool_call_id });
}
return out;
}
// ─── WindsurfExecutor ────────────────────────────────────────────────────────
export class WindsurfExecutor extends BaseExecutor {
constructor() {
super("windsurf", PROVIDERS.windsurf || { id: "windsurf", baseUrl: WS_CHAT_URL });
}
buildUrl() {
return WS_CHAT_URL;
}
buildHeaders(credentials, stream = true) {
const token = credentials?.accessToken || credentials?.apiKey || "";
return {
"Content-Type": "application/grpc-web+proto",
Accept: "application/grpc-web+proto",
// Codeium apiKey also goes in Metadata.api_key (protobuf field) — see request body.
...(token ? { Authorization: `Bearer ${token}` } : {}),
"User-Agent": `windsurf/${WS_IDE_VERSION}`,
"X-Grpc-Web": "1",
};
}
// Request body is built manually in execute() — requires model + messages.
transformRequest() {
return null;
}
async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders, proxyOptions = null }) {
const apiKey = credentials?.accessToken || credentials?.apiKey || "";
const wsModel = resolveWsModelId(model);
const b = body ?? {};
const rawMessages = Array.isArray(b.messages) ? b.messages : [];
let wsMessages = openAIMessagesToWs(rawMessages);
if (wsMessages.length === 0) {
wsMessages.push({ role: "user", content: "" });
}
const protoPayload = buildGetChatMessageRequest(apiKey, wsModel, wsMessages);
const framedPayload = grpcWebFrame(protoPayload);
const url = this.buildUrl();
const headers = this.buildHeaders(credentials);
if (upstreamExtraHeaders) Object.assign(headers, upstreamExtraHeaders);
log?.debug?.("WS", `Windsurf → ${wsModel} (${wsMessages.length} messages)`);
const upstream = await proxyAwareFetch(url, {
method: "POST",
headers,
body: framedPayload,
signal,
}, proxyOptions);
if (!upstream.ok && upstream.status !== 200) {
return { response: upstream, url, headers, transformedBody: protoPayload };
}
const sseResponse = this.transformToSSE(upstream, model);
return { response: sseResponse, url, headers, transformedBody: protoPayload };
}
// Convert a gRPC-web binary response into an OpenAI-compatible SSE stream.
transformToSSE(upstream, model) {
const responseId = `chatcmpl-ws-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
const executor = this;
const sseStream = new ReadableStream({
async start(controller) {
const enc = new TextEncoder();
let roleEmitted = false;
let totalText = "";
let promptTokens = 0;
let completionTokens = 0;
let hadError = null;
const emit = (data) => controller.enqueue(enc.encode(data));
try {
let pending = new Uint8Array(0);
const reader = upstream.body?.getReader();
const handleFrame = (flag, payload) => {
if (flag === 0x80) {
// Trailer frame — contains grpc-status, grpc-message
const trailer = TEXT_DEC.decode(payload);
const statusMatch = /grpc-status:\s*(\d+)/i.exec(trailer);
if (statusMatch && statusMatch[1] !== "0") {
const msgMatch = /grpc-message:\s*(.+)/i.exec(trailer);
hadError = msgMatch
? decodeURIComponent(msgMatch[1].trim())
: `gRPC status ${statusMatch[1]}`;
}
return;
}
if (flag !== 0x00) return; // skip unknown flags
const chunk = executor.constructor.decodeCompletionChunk
? executor.constructor.decodeCompletionChunk(payload)
: decodeCompletionChunk(payload);
if (chunk.kind === "content" && chunk.text) {
totalText += chunk.text;
if (!roleEmitted) {
emit(`data: ${JSON.stringify({
id: responseId, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }],
})}\n\n`);
roleEmitted = true;
}
emit(`data: ${JSON.stringify({
id: responseId, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { content: chunk.text }, finish_reason: null }],
})}\n\n`);
} else if (chunk.kind === "done") {
promptTokens = chunk.promptTokens;
completionTokens = chunk.completionTokens;
} else if (chunk.kind === "error") {
hadError = chunk.message;
}
};
const drainFrames = () => {
let offset = 0;
while (offset + 5 <= pending.length) {
const flag = pending[offset];
const len =
(pending[offset + 1] << 24) |
(pending[offset + 2] << 16) |
(pending[offset + 3] << 8) |
pending[offset + 4];
if (len < 0 || offset + 5 + len > pending.length) break;
handleFrame(flag, pending.slice(offset + 5, offset + 5 + len));
offset += 5 + len;
}
if (offset > 0) pending = pending.slice(offset);
};
if (reader) {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
pending = pending.length === 0 ? value : concatBytes([pending, value]);
drainFrames();
}
} finally {
reader.releaseLock();
}
}
drainFrames();
if (hadError) {
emit(`data: ${JSON.stringify({
error: { message: hadError, type: "windsurf_error", code: "upstream_error" },
})}\n\n`);
emit("data: [DONE]\n\n");
controller.close();
return;
}
// Unary fallback: nothing streamed but text decoded → emit as one chunk.
if (!roleEmitted && totalText) {
emit(`data: ${JSON.stringify({
id: responseId, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }],
})}\n\n`);
emit(`data: ${JSON.stringify({
id: responseId, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: { content: totalText }, finish_reason: null }],
})}\n\n`);
}
const finishPayload = {
id: responseId, object: "chat.completion.chunk", created, model,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
};
if (promptTokens > 0 || completionTokens > 0) {
finishPayload.usage = {
prompt_tokens: promptTokens,
completion_tokens: completionTokens,
total_tokens: promptTokens + completionTokens,
};
}
emit(`data: ${JSON.stringify(finishPayload)}\n\n`);
emit("data: [DONE]\n\n");
} catch (err) {
const msg = err?.message ? String(err.message) : String(err);
emit(`data: ${JSON.stringify({
error: { message: `Windsurf stream error: ${msg}`, type: "windsurf_error" },
})}\n\n`);
emit("data: [DONE]\n\n");
}
controller.close();
},
});
return new Response(sseStream, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
// apiKey is long-lived (Firebase-derived or Devin ide_token); refresh handled out-of-band.
async refreshCredentials() {
return null;
}
}
export default WindsurfExecutor;
+304
View File
@@ -0,0 +1,304 @@
// ZedHostedExecutor — routes requests to Zed's hosted LLM aggregator
// (cloud.zed.dev/completions), a multi-format proxy fronting
// Anthropic/OpenAI/Google/xAI depending on the requested model.
//
// Wire protocol: POST /completions with an NDJSON/SSE-ish body-per-line
// response stream (`{"event": <provider-shaped-chunk>}` / `{"status": ...}` /
// `[DONE]`), authenticated with a short-lived LLM bearer token exchanged from
// the RSA-decrypted access_token (see open-sse/shared/zedAuth.js). The
// provider-shaped chunk is Claude/Gemini/OpenAI-Responses/xAI(OpenAI-shaped)
// depending on which upstream Zed fronts for the model — translated back to
// OpenAI Chat Completions by reusing the existing translators.
//
// Overrides execute() entirely (does NOT use DefaultExecutor's pipeline) because the Zed wire
// shape (thread envelope, LLM-token exchange, NDJSON status frames) doesn't
// fit the generic transformRequest/buildUrl contract.
import { BaseExecutor } from "./base.js";
import { FORMATS } from "../translator/formats.js";
import { initState } from "../translator/index.js";
import { openaiToClaudeRequest } from "../translator/request/openai-to-claude.js";
import { openaiToGeminiRequest } from "../translator/request/openai-to-gemini.js";
import { openaiToOpenAIResponsesRequest } from "../translator/request/openai-responses.js";
import { claudeToOpenAIResponse } from "../translator/response/claude-to-openai.js";
import { geminiToOpenAIResponse } from "../translator/response/gemini-to-openai.js";
import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js";
import {
ZED_HEADERS,
resolveZedModels,
zedLlmFetch,
} from "../shared/zedAuth.js";
const ZED_PROVIDER = {
anthropic: "Anthropic",
openai: "OpenAi",
google: "Google",
xai: "XAi",
};
function normalizeZedProvider(value, model) {
const raw = String(value || "").toLowerCase();
if (raw === "anthropic") return ZED_PROVIDER.anthropic;
if (raw === "openai" || raw === "open_ai") return ZED_PROVIDER.openai;
if (raw === "google" || raw === "gemini") return ZED_PROVIDER.google;
if (raw === "xai" || raw === "x_ai" || raw === "x-ai") return ZED_PROVIDER.xai;
const m = String(model || "").toLowerCase();
if (m.includes("claude")) return ZED_PROVIDER.anthropic;
if (m.includes("gemini")) return ZED_PROVIDER.google;
if (m.includes("grok") || m.includes("xai")) return ZED_PROVIDER.xai;
return ZED_PROVIDER.openai;
}
function buildProviderRequest(provider, model, body, stream, credentials) {
if (provider === ZED_PROVIDER.anthropic) {
return openaiToClaudeRequest(model, body, true);
}
if (provider === ZED_PROVIDER.google) {
return openaiToGeminiRequest(model, body, true);
}
if (provider === ZED_PROVIDER.openai) {
return openaiToOpenAIResponsesRequest(model, body, true, credentials);
}
// xAI is OpenAI-shaped — forward as-is.
return { ...(body || {}), model, stream: stream !== false };
}
function initProviderState(provider, model) {
if (provider === ZED_PROVIDER.anthropic) return initState(FORMATS.CLAUDE);
if (provider === ZED_PROVIDER.google) return initState(FORMATS.GEMINI);
if (provider === ZED_PROVIDER.openai) return initState(FORMATS.OPENAI_RESPONSES);
const state = initState(FORMATS.OPENAI);
state.model = model;
return state;
}
function convertProviderEvent(provider, event, state) {
if (provider === ZED_PROVIDER.anthropic) return claudeToOpenAIResponse(event, state);
if (provider === ZED_PROVIDER.google) return geminiToOpenAIResponse(event, state);
if (provider === ZED_PROVIDER.openai) return openaiResponsesToOpenAIResponse(event, state);
return event;
}
function createErrorChunk(model, message) {
return {
id: `chatcmpl-zed-error-${Date.now()}`,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model,
choices: [
{ index: 0, delta: { content: `[Zed error] ${message}` }, finish_reason: "stop" },
],
};
}
function enqueueSseObject(controller, encoder, chunk) {
if (!chunk) return;
const items = Array.isArray(chunk) ? chunk : [chunk];
for (const item of items) {
if (!item) continue;
controller.enqueue(encoder.encode(`data: ${JSON.stringify(item)}\n\n`));
}
}
function unwrapZedLine(line) {
let text = line.replace(/\r$/, "").trim();
if (!text) return null;
if (text.startsWith("data:")) text = text.slice(5).trimStart();
if (text === "[DONE]") return { done: true };
try {
const parsed = JSON.parse(text);
if (parsed && Object.prototype.hasOwnProperty.call(parsed, "event")) {
return { event: parsed.event };
}
if (parsed && Object.prototype.hasOwnProperty.call(parsed, "status")) {
return { status: parsed.status };
}
return { event: parsed };
} catch {
return null;
}
}
function normalizeStatus(status) {
if (!status) return null;
if (typeof status === "string") return { type: status };
if (typeof status === "object") {
const key = Object.keys(status)[0];
if (key && typeof status[key] === "object") return { type: key, ...status[key] };
return status;
}
return null;
}
function wrapZedCompletionStream(response, provider, model) {
if (!response.ok || !response.body) return response;
const decoder = new TextDecoder();
const encoder = new TextEncoder();
const state = initProviderState(provider, model);
let buffer = "";
let done = false;
const finish = (controller) => {
if (done) return;
const finalChunk = convertProviderEvent(provider, null, state);
enqueueSseObject(controller, encoder, finalChunk);
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
done = true;
};
const processLine = (line, controller) => {
if (done) return;
const payload = unwrapZedLine(line);
if (!payload) return;
if (payload.done) {
finish(controller);
return;
}
if (payload.status) {
const status = normalizeStatus(payload.status);
if (status?.type === "failed" || status?.failed) {
const failed = status.failed || status;
const message = String(failed.message || failed.error || failed.code || "request failed");
enqueueSseObject(controller, encoder, createErrorChunk(model, message));
finish(controller);
} else if (status?.type === "stream_ended" || status === "stream_ended") {
finish(controller);
}
return;
}
const converted = convertProviderEvent(provider, payload.event, state);
enqueueSseObject(controller, encoder, converted);
};
const transformed = response.body.pipeThrough(
new TransformStream({
transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true });
let nl;
while ((nl = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nl);
buffer = buffer.slice(nl + 1);
processLine(line, controller);
}
},
flush(controller) {
buffer += decoder.decode();
if (buffer) {
processLine(buffer, controller);
buffer = "";
}
finish(controller);
},
}),
);
return new Response(transformed, {
status: response.status,
statusText: response.statusText,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
},
});
}
class ZedExecutor extends BaseExecutor {
constructor() {
super("zed");
}
async resolveModel(model, credentials, signal, log) {
try {
const catalog = await resolveZedModels(credentials, { config: this.config, signal });
let raw = catalog?.rawById?.get(model) ?? null;
if (!raw) {
const refreshed = await resolveZedModels(credentials, {
config: this.config,
signal,
forceRefresh: true,
});
raw = refreshed?.rawById?.get(model) ?? null;
}
return { raw, provider: normalizeZedProvider(raw?.provider, model) };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log?.warn?.("ZED", `model catalog unavailable, inferring provider for ${model}: ${message}`);
return { raw: null, provider: normalizeZedProvider(null, model) };
}
}
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
const { provider } = await this.resolveModel(model, credentials, signal, log);
const providerRequest = buildProviderRequest(provider, model, body, stream, credentials);
const bodyRecord = body || {};
const payload = {
thread_id: bodyRecord.thread_id || credentials?._clientSessionId,
prompt_id: bodyRecord.prompt_id,
provider,
model,
provider_request: providerRequest,
};
const response = await zedLlmFetch(credentials, "/completions", {
config: this.config,
signal,
fetchOptions: {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/x-ndjson, text/event-stream, */*",
"User-Agent": "9router/zed",
"x-zed-version": this.config?.appVersion?.toString() || "0.200.0",
[ZED_HEADERS.clientSupportsStatus]: "true",
[ZED_HEADERS.clientSupportsStreamEnded]: "true",
},
body: JSON.stringify(payload),
},
});
const wrapped = response.ok ? wrapZedCompletionStream(response, provider, model) : response;
return {
response: wrapped,
url: `${this.config?.llmBaseUrl || "https://cloud.zed.dev"}/completions`,
headers: { "Content-Type": "application/json", Authorization: "Bearer <zed-llm-token>" },
transformedBody: payload,
};
}
parseError(response, bodyText) {
let parsed = null;
try {
parsed = JSON.parse(bodyText || "{}");
} catch {
parsed = null;
}
const errorObj = parsed?.error || undefined;
const code = parsed?.code || errorObj?.code || "";
const rawMessage =
parsed?.message || errorObj?.message || bodyText || response.statusText;
if (code === "trial_blocked") {
return {
status: response.status,
message: `Zed trial access is blocked upstream. The account can list hosted models, but Zed is refusing completions until trial/billing access is enabled or unblocked. Zed says: ${rawMessage}`,
};
}
if (code) {
return { status: response.status, message: `Zed ${code}: ${rawMessage}` };
}
return { status: response.status, message: rawMessage || `Zed upstream error: ${response.status}` };
}
async refreshCredentials() {
// Zed uses a long-lived RSA-decrypted access_token — no OAuth refresh.
return null;
}
needsRefresh() {
return false;
}
}
export default ZedExecutor;
+12 -1
View File
@@ -330,7 +330,18 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
// Handle 401/403 - try token refresh (skip for noAuth providers) // Handle 401/403 - try token refresh (skip for noAuth providers)
if (!executor.noAuth && (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN)) { if (!executor.noAuth && (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN)) {
try { try {
const newCredentials = await refreshWithRetry(() => executor.refreshCredentials(credentials, log), 3, log); // Mutate credentials after each successful refresh: rotating refresh_token
// providers (xAI/grok-cli) issue a new RT on every refresh; without this,
// refreshWithRetry's 2nd/3rd attempt reuses the already-consumed RT →
// invalid_grant → auth_failed retryable=false.
const newCredentials = await refreshWithRetry(async () => {
const result = await executor.refreshCredentials(credentials, log);
if (result?.refreshToken && result.refreshToken !== credentials.refreshToken) {
if (result.accessToken) credentials.accessToken = result.accessToken;
credentials.refreshToken = result.refreshToken;
}
return result;
}, 3, log);
if (newCredentials?.accessToken || newCredentials?.copilotToken) { if (newCredentials?.accessToken || newCredentials?.copilotToken) {
if (log?.line) log.line(reqTag, "🔑", `TOKEN REFRESHED · ${provider}/${model}`); if (log?.line) log.line(reqTag, "🔑", `TOKEN REFRESHED · ${provider}/${model}`);
Object.assign(credentials, newCredentials); Object.assign(credentials, newCredentials);
+1
View File
@@ -116,6 +116,7 @@ export async function handleEmbeddingsCore({
return { return {
success: true, success: true,
usage: normalized.usage || null,
response: new Response(JSON.stringify(normalized), { response: new Response(JSON.stringify(normalized), {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
+11 -5
View File
@@ -49,7 +49,10 @@ function truncate(text, max) {
} }
function parseJinaTitle(text) { function parseJinaTitle(text) {
const m = String(text || "").match(/^\s*#\s+(.+)$/m); const source = String(text || "");
const metadataTitle = source.match(/^\s*Title:\s*(.+)$/mi);
if (metadataTitle) return metadataTitle[1].trim();
const m = source.match(/^\s*#\s+(.+)$/m);
return m ? m[1].trim() : null; return m ? m[1].trim() : null;
} }
@@ -151,11 +154,14 @@ async function runFirecrawl({ url, fmt, timeoutMs, apiKey, maxCharacters, costPe
} }
async function runJina({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }) { async function runJina({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }) {
const target = `https://r.jina.ai/${encodeURIComponent(url)}`;
const upstreamStart = Date.now(); const upstreamStart = Date.now();
const r = await tryFetch(target, { const r = await tryFetch("https://r.jina.ai/", {
method: "GET", method: "POST",
headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {} headers: {
"content-type": "application/json",
...(apiKey ? { authorization: `Bearer ${apiKey}` } : {})
},
body: JSON.stringify({ url })
}, timeoutMs); }, timeoutMs);
if (!r.ok) { if (!r.ok) {
-1
View File
@@ -1,7 +1,6 @@
/** /**
* Search Provider Request Builders * Search Provider Request Builders
* *
* Ported from OmniRoute open-sse/handlers/search.ts (lines 223-610).
* Builds HTTP request `{ url, init }` for 10 search providers. * Builds HTTP request `{ url, init }` for 10 search providers.
* *
* @typedef {Object} SearchProviderConfig * @typedef {Object} SearchProviderConfig
-1
View File
@@ -1,7 +1,6 @@
/** /**
* Search Response Normalizers * Search Response Normalizers
* *
* Ported from OmniRoute open-sse/handlers/search.ts.
* Each normalizer maps a provider-specific response into the unified SearchResult shape. * Each normalizer maps a provider-specific response into the unified SearchResult shape.
*/ */
+18 -1
View File
@@ -71,7 +71,11 @@ export function capabilitiesFromServiceKind(kind) {
* otherwise mis-match. Only declare deltas vs DEFAULT. * otherwise mis-match. Only declare deltas vs DEFAULT.
*/ */
export const MODEL_CAPABILITIES = { export const MODEL_CAPABILITIES = {
// Claude 4.6/4.7/4.8 and Kiro Sonnet 5 have 1M context + adaptive thinking (override generic claude pattern) // Claude Opus 5, 4.6/4.7/4.8, and Kiro Sonnet 5 have 1M context + adaptive thinking (override generic claude pattern)
"claude-opus-5": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-5-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-5-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-5-thinking-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-opus-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-4.7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-opus-4.7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-4-7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-opus-4-7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
@@ -170,6 +174,11 @@ export const PROVIDER_CAPABILITIES = {
"deepseek-v4-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 50000 }, "deepseek-v4-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 50000 },
"deepseek-v3-2-volc": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 96000, maxOutput: 32000 }, "deepseek-v3-2-volc": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 96000, maxOutput: 32000 },
}, },
// Poolside Laguna — OpenAI-compatible, all reasoning-capable (32K max output).
"poolside": {
"laguna-s-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 },
"laguna-xs-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 },
},
}; };
/** /**
@@ -180,6 +189,7 @@ export const PROVIDER_CAPABILITIES = {
*/ */
export const PATTERN_CAPABILITIES = [ export const PATTERN_CAPABILITIES = [
// ── Claude (4.6+ = adaptive thinking; older/haiku = budget) ────── // ── Claude (4.6+ = adaptive thinking; older/haiku = budget) ──────
{ pattern: "*claude*opus-5*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 } },
{ pattern: "*claude*opus-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, { pattern: "*claude*opus-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
{ pattern: "*claude*opus-4.7*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, { pattern: "*claude*opus-4.7*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
{ pattern: "*claude*opus-4.8*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, { pattern: "*claude*opus-4.8*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
@@ -291,6 +301,13 @@ export const PATTERN_CAPABILITIES = [
{ pattern: "*pplx*", caps: { search: true, contextWindow: 128000 } }, { pattern: "*pplx*", caps: { search: true, contextWindow: 128000 } },
{ pattern: "*perplexity*", caps: { search: true, contextWindow: 128000 } }, { pattern: "*perplexity*", caps: { search: true, contextWindow: 128000 } },
// ── Poolside Laguna (resellers: openrouter/nvidia/kilocode/vercel/...) ──
// Free tiers cap S 2.1 well below the paid 1M window → match the free suffix
// (":free" or "-free", depending on reseller) before the plain id.
{ pattern: "*laguna-s-2.1*free*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } },
{ pattern: "*laguna-s-2.1*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 } },
{ pattern: "*laguna*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } },
// ── Others ─────────────────────────────────────────────────────── // ── Others ───────────────────────────────────────────────────────
{ pattern: "*hunyuan*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } }, { pattern: "*hunyuan*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } },
{ pattern: "hy3*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } }, { pattern: "hy3*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } },
+7 -1
View File
@@ -57,7 +57,13 @@ export const MODEL_PRICING = {
"o1-mini": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 }, "o1-mini": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 },
// === Gemini === // === Gemini ===
"gemini-3-flash-preview": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 }, "gemini-3.6-flash": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.6-flash-high": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.6-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.6-flash-low": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
"gemini-3.5-flash-lite": { input: 0.30, output: 2.50, cached: 0.03, reasoning: 3.75, cache_creation: 0.375 },
"gemini-3.5-flash-high": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 },
"gemini-3-flash-preview": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 },
"gemini-3-pro-preview": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 }, "gemini-3-pro-preview": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 },
"gemini-3.1-pro-low": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 }, "gemini-3.1-pro-low": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 },
"gemini-3.1-pro-high": { input: 4.00, output: 18.00, cached: 0.50, reasoning: 27.00, cache_creation: 4.00 }, "gemini-3.1-pro-high": { input: 4.00, output: 18.00, cached: 0.50, reasoning: 27.00, cache_creation: 4.00 },
+6 -1
View File
@@ -36,6 +36,7 @@ export default {
}, },
}, },
usage: { usage: {
// Discovery (quota/project) on PROD; daily host rejects these.
quotaApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", quotaApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels",
loadProjectApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", loadProjectApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
tokenUrl: "https://oauth2.googleapis.com/token", tokenUrl: "https://oauth2.googleapis.com/token",
@@ -44,6 +45,10 @@ export default {
clientSecret: "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", clientSecret: "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",
}, },
models: [ models: [
{ id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", upstreamModelId: "gemini-3.6-flash-tiered(high)" },
{ id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", upstreamModelId: "gemini-3.6-flash-tiered(medium)" },
{ id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)", upstreamModelId: "gemini-3.6-flash-tiered(low)" },
{ id: "gemini-3.5-flash-high", name: "Gemini 3.5 Flash (High)" },
{ id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)" }, { id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)" },
{ id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium)" }, { id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium)" },
{ id: "gemini-3.5-flash-extra-low", name: "Gemini 3.5 Flash (Low)" }, { id: "gemini-3.5-flash-extra-low", name: "Gemini 3.5 Flash (Low)" },
@@ -67,7 +72,7 @@ export default {
"https://www.googleapis.com/auth/cclog", "https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs", "https://www.googleapis.com/auth/experimentsandconfigs",
], ],
apiEndpoint: "https://cloudcode-pa.googleapis.com", apiEndpoint: "https://daily-cloudcode-pa.googleapis.com",
apiVersion: "v1internal", apiVersion: "v1internal",
loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
@@ -0,0 +1,36 @@
export default {
id: "api-airforce",
alias: "af",
aliases: [
"airforce",
],
uiAlias: "af",
display: {
name: "API.airforce",
icon: "flight",
color: "#0EA5E9",
textIcon: "AF",
website: "https://api.airforce",
notice: {
apiKeyUrl: "https://api.airforce",
},
},
category: "freeTier",
authType: "apikey",
authModes: [
"apikey",
],
transport: {
baseUrl: "https://api.airforce/v1/chat/completions",
validateUrl: "https://api.airforce/v1/models",
headers: {
"HTTP-Referer": "https://endpoint-proxy.local",
"X-Title": "Endpoint Proxy",
},
},
models: [
{ id: "anthropic/claude-3.7-sonnet", name: "Claude 3.7 Sonnet (Free)", contextLength: 200000 },
{ id: "moonshot/kimi-k2.6", name: "Kimi K2.6 (Free)", contextLength: 262144 },
{ id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash (Free)", contextLength: 1048576 },
],
};
+33
View File
@@ -0,0 +1,33 @@
export default {
id: "baidu",
alias: "qianfan",
aliases: ["qianfan", "ernie", "baidu-qianfan"],
uiAlias: "qianfan",
category: "apikey",
authType: "apikey",
authModes: ["apikey"],
display: {
name: "Baidu Qianfan",
icon: "search",
color: "#2932E1",
textIcon: "BD",
website: "https://cloud.baidu.com/product/qianfan.html",
notice: {
apiKeyUrl:
"https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application",
},
},
transport: {
baseUrl: "https://qianfan.baidubce.com/v2/chat/completions",
validateUrl: "https://qianfan.baidubce.com/v2/models",
},
models: [
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", contextLength: 1048576 },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", contextLength: 1048576 },
{ id: "glm-5.2", name: "GLM 5.2", contextLength: 512000 },
{ id: "glm-5.1", name: "GLM 5.1", contextLength: 198000 },
{ id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 262144 },
{ id: "qwen3.5-397b-a17b", name: "Qwen 3.5 397B A17B", contextLength: 262144 },
{ id: "qwen3.5-27b", name: "Qwen 3.5 27B", contextLength: 262144 },
],
};
+47
View File
@@ -0,0 +1,47 @@
export default {
id: "bazaarlink",
alias: "bzl",
aliases: ["bazaar-link"],
uiAlias: "bzl",
category: "freeTier",
authType: "apikey",
authModes: ["apikey"],
display: {
name: "Bazaarlink",
icon: "storefront",
color: "#DC2626",
textIcon: "BZ",
website: "https://bazaarlink.ai",
notice: { apiKeyUrl: "https://bazaarlink.ai" },
},
transport: {
baseUrl: "https://bazaarlink.ai/api/v1/chat/completions",
validateUrl: "https://bazaarlink.ai/api/v1/models",
},
models: [
{ id: "auto:free", name: "Auto Free (Zero Cost)" },
{ id: "claude-opus-4.7", name: "Claude Opus 4.7", contextLength: 1000000 },
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", contextLength: 1000000 },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5", contextLength: 200000 },
{ id: "gpt-5.5", name: "GPT-5.5", contextLength: 1050000 },
{ id: "gpt-5.4", name: "GPT-5.4", contextLength: 1050000 },
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini", contextLength: 400000 },
{ id: "gpt-5.4-nano", name: "GPT-5.4 Nano", contextLength: 400000 },
{ id: "grok-4.3", name: "Grok 4.3", contextLength: 1000000 },
{ id: "grok-4.20", name: "Grok 4.20", contextLength: 2000000 },
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro", contextLength: 1048576 },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash", contextLength: 1048576 },
{ id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite", contextLength: 1048576 },
{ id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 262144 },
{ id: "kimi-k2.5", name: "Kimi K2.5", contextLength: 262144 },
{ id: "glm-5.1", name: "GLM 5.1", contextLength: 204800 },
{ id: "glm-5", name: "GLM 5", contextLength: 204800 },
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", contextLength: 1050000 },
{ id: "mimo-v2.5", name: "MiMo-V2.5", contextLength: 1050000 },
{ id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576 },
{ id: "minimax-m2.7", name: "MiniMax M2.7", contextLength: 204800 },
{ id: "minimax-m2.5", name: "MiniMax M2.5", contextLength: 204800 },
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", contextLength: 1000000 },
{ id: "nemotron-3-super-120b-a12b", name: "Nemotron 3 Super", contextLength: 1000000 },
],
};
+38
View File
@@ -0,0 +1,38 @@
export default {
id: "bluesminds",
alias: "bm",
aliases: ["blue-sminds"],
uiAlias: "bm",
hidden: true,
display: {
name: "BluesMinds",
icon: "psychology",
color: "#2563EB",
textIcon: "BM",
website: "https://bluesminds.com",
notice: { apiKeyUrl: "https://bluesminds.com" },
},
category: "apikey",
authType: "apikey",
authModes: ["apikey"],
transport: {
baseUrl: "https://api.bluesminds.com/v1/chat/completions",
validateUrl: "https://api.bluesminds.com/v1/models",
},
models: [
{ id: "gpt-4.1", name: "GPT-4.1", contextLength: 1048576 },
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", contextLength: 1048576 },
{ id: "gpt-4.1-nano", name: "GPT-4.1 Nano", contextLength: 1048576 },
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", contextLength: 200000 },
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5", contextLength: 200000 },
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", contextLength: 1048576 },
{ id: "gemini-2.0-flash-exp", name: "Gemini 2.0 Flash (Exp)", contextLength: 1048576 },
{ id: "qwen-turbo", name: "Qwen Turbo", contextLength: 1000000 },
{ id: "kimi-k2", name: "Kimi K2", contextLength: 262144 },
{ id: "kimi-k2-thinking", name: "Kimi K2 Thinking", contextLength: 262144 },
{ id: "glm-4.7", name: "GLM 4.7", contextLength: 204800 },
{ id: "minimax-m2.5", name: "MiniMax M2.5", contextLength: 204800 },
{ id: "claude-opus-4-5", name: "Claude Opus 4.5 (VIP)", contextLength: 200000 },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (VIP)", contextLength: 1048576 },
],
};
+1 -2
View File
@@ -60,10 +60,9 @@ export default {
}, },
}, },
models: [ models: [
{ id: "claude-opus-5", name: "Claude Opus 5" },
{ id: "claude-fable-5", name: "Claude Fable 5" }, { id: "claude-fable-5", name: "Claude Fable 5" },
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" }, { id: "claude-sonnet-5", name: "Claude Sonnet 5" },
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
{ id: "claude-opus-4-7", name: "Claude Opus 4.7" },
{ id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" }, { id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" },
], ],
oauth: { oauth: {
@@ -0,0 +1,73 @@
// CodeBuddy international (codebuddy.ai) — mirrors codebuddy-cn registry shape,
// swapping the Tencent CN domain for the .ai endpoint set. All OAuth/plugin URLs
// use the /v2/plugin prefix with platform=ide (CN uses platform=CLI).
export default {
id: "codebuddy-intl",
alias: "cbai",
uiAlias: "cbai",
hidden: false,
priority: 90,
display: {
name: "CodeBuddy",
icon: "smart_toy",
color: "#006EFF",
website: "https://www.codebuddy.ai",
notice: {
signupUrl: "https://www.codebuddy.ai",
},
},
category: "oauth",
authModes: ["oauth", "apikey"],
hasOAuth: true,
transport: {
// Chat gateway is OpenAI-compatible SSE (same /v2/chat/completions path as CN).
baseUrl: "https://www.codebuddy.ai/v2/chat/completions",
forceStream: true,
// CodeBuddy intl speaks the same unified OpenAI reasoning_effort shape as CN.
thinkingFormat: "openai",
headers: {
"User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1",
"X-Product": "SaaS",
"X-IDE-Type": "IDE",
"X-IDE-Name": "IDE",
"x-requested-with": "XMLHttpRequest",
"x-codebuddy-request": "1",
},
auth: {
combined: true,
header: "Authorization",
scheme: "bearer",
},
},
// Same model lineup exposed by the CN gateway — intl backend is the same catalog.
models: [
{ id: "glm-5.2", name: "GLM-5.2" },
{ id: "glm-5.1", name: "GLM-5.1" },
{ id: "glm-5.0", name: "GLM-5.0" },
{ id: "glm-5.0-turbo", name: "GLM-5.0-Turbo" },
{ id: "glm-5v-turbo", name: "GLM-5v-Turbo" },
{ id: "glm-4.7", name: "GLM-4.7" },
{ id: "minimax-m3", name: "MiniMax-M3" },
{ id: "minimax-m2.7", name: "MiniMax-M2.7" },
{ id: "kimi-k2.7", name: "Kimi-K2.7-Code" },
{ id: "kimi-k2.6", name: "Kimi-K2.6" },
{ id: "kimi-k2.5", name: "Kimi-K2.5" },
{ id: "hy3-preview", name: "Hy3 Preview" },
{ id: "deepseek-v4-pro", name: "DeepSeek-V4-Pro" },
{ id: "deepseek-v4-flash", name: "DeepSeek-V4-Flash" },
{ id: "deepseek-v3-2-volc", name: "DeepSeek-V3.2" },
],
oauth: {
baseUrl: "https://www.codebuddy.ai",
stateUrl: "https://www.codebuddy.ai/v2/plugin/auth/state",
tokenUrl: "https://www.codebuddy.ai/v2/plugin/auth/token",
refreshUrl: "https://www.codebuddy.ai/v2/plugin/auth/token/refresh",
userAgent: "IDE/2.63.2 CodeBuddy/2.63.2",
platform: "ide",
pollInterval: 5000,
},
features: {
usage: true,
usageApikey: true,
},
};
+4
View File
@@ -48,4 +48,8 @@ export default {
{ id: "deepseek-chat", name: "DeepSeek V3.2 Chat" }, { id: "deepseek-chat", name: "DeepSeek V3.2 Chat" },
{ id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" }, { id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" },
], ],
features: {
usage: true,
usageApikey: true,
},
}; };
+63
View File
@@ -0,0 +1,63 @@
export default {
id: "devin-cli",
alias: "dv",
aliases: ["devin"],
uiAlias: "dv",
hidden: true,
display: {
name: "Devin CLI",
icon: "smart_toy",
color: "#6366F1",
textIcon: "DV",
website: "https://devin.ai",
notice: {
signupUrl: "https://cli.devin.ai",
text: "Install: `curl -fsSL https://cli.devin.ai/install.sh | bash` (macOS: `brew install --cask devin-cli`, Windows PowerShell: `irm https://static.devin.ai/cli/setup.ps1 | iex`). Then run `devin auth login`. No API key needed.",
},
},
category: "free",
authType: "none",
noAuth: true,
authModes: ["none"],
transport: {
baseUrl: "devin://acp/stdio",
format: "openai",
},
models: [
{ id: "swe-1.6-fast", name: "SWE-1.6 Fast" },
{ id: "swe-1.6", name: "SWE-1.6" },
{ id: "swe-1.5-fast", name: "SWE-1.5 Fast" },
{ id: "swe-1.5", name: "SWE-1.5" },
{ id: "claude-opus-4.7-max", name: "Claude Opus 4.7 Max", contextLength: 200000 },
{ id: "claude-opus-4.7-high", name: "Claude Opus 4.7 High", contextLength: 200000 },
{ id: "claude-opus-4.7-medium", name: "Claude Opus 4.7 Medium", contextLength: 200000 },
{ id: "claude-opus-4.7-low", name: "Claude Opus 4.7 Low", contextLength: 200000 },
{ id: "claude-sonnet-4.6-thinking-1m", name: "Claude Sonnet 4.6 Thinking 1M", contextLength: 1000000 },
{ id: "claude-sonnet-4.6-thinking", name: "Claude Sonnet 4.6 Thinking", contextLength: 200000 },
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", contextLength: 200000 },
{ id: "claude-opus-4.6-thinking", name: "Claude Opus 4.6 Thinking", contextLength: 200000 },
{ id: "claude-opus-4.6", name: "Claude Opus 4.6", contextLength: 200000 },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", contextLength: 200000 },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5", contextLength: 200000 },
{ id: "gpt-5.5-xhigh", name: "GPT-5.5 XHigh", contextLength: 200000 },
{ id: "gpt-5.5-high", name: "GPT-5.5 High", contextLength: 200000 },
{ id: "gpt-5.5-medium", name: "GPT-5.5 Medium", contextLength: 200000 },
{ id: "gpt-5.5-low", name: "GPT-5.5 Low", contextLength: 200000 },
{ id: "gpt-5.4-high", name: "GPT-5.4 High", contextLength: 200000 },
{ id: "gpt-5.4-medium", name: "GPT-5.4 Medium", contextLength: 200000 },
{ id: "gpt-5.4-low", name: "GPT-5.4 Low", contextLength: 200000 },
{ id: "gpt-5.3-codex-high", name: "GPT-5.3 Codex High", contextLength: 200000 },
{ id: "gpt-5.3-codex-medium", name: "GPT-5.3 Codex Medium", contextLength: 200000 },
{ id: "gpt-5.3-codex-low", name: "GPT-5.3 Codex Low", contextLength: 200000 },
{ id: "gpt-5.2-high", name: "GPT-5.2 High", contextLength: 200000 },
{ id: "gpt-5.2-medium", name: "GPT-5.2 Medium", contextLength: 200000 },
{ id: "gpt-5.2-low", name: "GPT-5.2 Low", contextLength: 200000 },
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High", contextLength: 1000000 },
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low", contextLength: 1000000 },
{ id: "gemini-3.0-flash-high", name: "Gemini 3 Flash High", contextLength: 1000000 },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", contextLength: 1000000 },
{ id: "deepseek-v4", name: "DeepSeek V4", contextLength: 1048576 },
{ id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 262144 },
{ id: "glm-5.1", name: "GLM-5.1", contextLength: 204800 },
],
};
+4
View File
@@ -16,6 +16,8 @@ export default {
}, },
}, },
category: "freeTier", category: "freeTier",
authType: "apikey",
authModes: ["apikey"],
mediaPriority: 1, mediaPriority: 1,
transport: { transport: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta/models", baseUrl: "https://generativelanguage.googleapis.com/v1beta/models",
@@ -34,6 +36,8 @@ export default {
}, },
}, },
models: [ models: [
{ id: "gemini-3.6-flash", name: "Gemini 3.6 Flash" },
{ id: "gemini-3.5-flash-lite", name: "Gemini 3.5 Flash Lite" },
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
{ id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" }, { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" }, { id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
+129 -102
View File
@@ -1,106 +1,121 @@
// Auto-generated: static imports for all registry entries // Auto-generated by scripts/generate-provider-registry.mjs. Do not edit manually.
import p0 from "./alicode-intl.js"; import p0 from "./alicode.js";
import p1 from "./alicode.js"; import p1 from "./alicode-intl.js";
import p2 from "./anthropic.js"; import p2 from "./alims-intl.js";
import p3 from "./antigravity.js"; import p3 from "./anthropic.js";
import p4 from "./assemblyai.js"; import p4 from "./antigravity.js";
import p5 from "./aws-polly.js"; import p5 from "./api-airforce.js";
import p6 from "./azure.js"; import p6 from "./assemblyai.js";
import p7 from "./black-forest-labs.js"; import p7 from "./aws-polly.js";
import p8 from "./blackbox.js"; import p8 from "./azure.js";
import p9 from "./brave-search.js"; import p9 from "./baidu.js";
import p10 from "./byteplus.js"; import p10 from "./bazaarlink.js";
import p11 from "./cartesia.js"; import p11 from "./black-forest-labs.js";
import p12 from "./cerebras.js"; import p12 from "./blackbox.js";
import p13 from "./chutes.js"; import p13 from "./bluesminds.js";
import p14 from "./claude.js"; import p14 from "./brave-search.js";
import p15 from "./cline.js"; import p15 from "./byteplus.js";
import p16 from "./clinepass.js"; import p16 from "./cartesia.js";
import p17 from "./cloudflare-ai.js"; import p17 from "./cerebras.js";
import p18 from "./codebuddy-cn.js"; import p18 from "./chutes.js";
import p19 from "./codex.js"; import p19 from "./claude.js";
import p20 from "./cohere.js"; import p20 from "./cline.js";
import p21 from "./comfyui.js"; import p21 from "./clinepass.js";
import p22 from "./commandcode.js"; import p22 from "./cloudflare-ai.js";
import p23 from "./coqui.js"; import p23 from "./codebuddy-cn.js";
import p24 from "./cursor.js"; import p24 from "./codebuddy-intl.js";
import p25 from "./deepgram.js"; import p25 from "./codex.js";
import p26 from "./deepseek.js"; import p26 from "./cohere.js";
import p27 from "./edge-tts.js"; import p27 from "./comfyui.js";
import p28 from "./elevenlabs.js"; import p28 from "./commandcode.js";
import p29 from "./exa.js"; import p29 from "./coqui.js";
import p30 from "./fal-ai.js"; import p30 from "./cursor.js";
import p31 from "./featherless.js"; import p31 from "./deepgram.js";
import p32 from "./firecrawl.js"; import p32 from "./deepseek.js";
import p33 from "./fireworks.js"; import p33 from "./edge-tts.js";
import p34 from "./gemini-cli.js"; import p34 from "./elevenlabs.js";
import p35 from "./gemini.js"; import p35 from "./exa.js";
import p36 from "./github.js"; import p36 from "./fal-ai.js";
import p37 from "./gitlab.js"; import p37 from "./featherless.js";
import p38 from "./glm-cn.js"; import p38 from "./firecrawl.js";
import p39 from "./glm.js"; import p39 from "./fireworks.js";
import p40 from "./google-pse.js"; import p40 from "./gemini.js";
import p41 from "./google-tts.js"; import p41 from "./gemini-cli.js";
import p42 from "./grok-cli.js"; import p42 from "./github.js";
import p43 from "./grok-web.js"; import p43 from "./gitlab.js";
import p44 from "./groq.js"; import p44 from "./glm.js";
import p45 from "./huggingface.js"; import p45 from "./glm-cn.js";
import p46 from "./hyperbolic.js"; import p46 from "./google-pse.js";
import p47 from "./iflow.js"; import p47 from "./google-tts.js";
import p48 from "./inworld.js"; import p48 from "./grok-cli.js";
import p49 from "./jina-ai.js"; import p49 from "./grok-web.js";
import p50 from "./jina-reader.js"; import p50 from "./groq.js";
import p51 from "./kilocode.js"; import p51 from "./huggingface.js";
import p52 from "./kimchi.js"; import p52 from "./hyperbolic.js";
import p53 from "./kimi.js"; import p53 from "./iflow.js";
import p54 from "./kiro.js"; import p54 from "./inworld.js";
import p55 from "./linkup.js"; import p55 from "./jina-ai.js";
import p56 from "./local-device.js"; import p56 from "./jina-reader.js";
import p57 from "./mimo-free.js"; import p57 from "./kilo-gateway.js";
import p58 from "./minimax-cn.js"; import p58 from "./kilocode.js";
import p59 from "./minimax.js"; import p59 from "./kimchi.js";
import p60 from "./mistral.js"; import p60 from "./kimi.js";
import p61 from "./mmf.js"; import p61 from "./kiro.js";
import p62 from "./nanobanana.js"; import p62 from "./linkup.js";
import p63 from "./nebius.js"; import p63 from "./llm7.js";
import p64 from "./nvidia.js"; import p64 from "./local-device.js";
import p65 from "./ollama-local.js"; import p65 from "./mimo-free.js";
import p66 from "./ollama.js"; import p66 from "./minimax.js";
import p67 from "./openai.js"; import p67 from "./minimax-cn.js";
import p68 from "./opencode-go.js"; import p68 from "./mistral.js";
import p69 from "./opencode.js"; import p69 from "./mmf.js";
import p70 from "./openrouter.js"; import p70 from "./morph.js";
import p71 from "./perplexity-web.js"; import p71 from "./nanobanana.js";
import p72 from "./perplexity.js"; import p72 from "./nebius.js";
import p73 from "./perplexity-agent.js"; import p73 from "./nvidia.js";
import p74 from "./playht.js"; import p74 from "./ollama.js";
import p75 from "./qoder.js"; import p75 from "./ollama-local.js";
import p76 from "./qwen.js"; import p76 from "./openai.js";
import p77 from "./recraft.js"; import p77 from "./opencode.js";
import p78 from "./runwayml.js"; import p78 from "./opencode-go.js";
import p79 from "./sdwebui.js"; import p79 from "./openrouter.js";
import p80 from "./searchapi.js"; import p80 from "./orbit-provider.js";
import p81 from "./searxng.js"; import p81 from "./perplexity.js";
import p82 from "./serper.js"; import p82 from "./perplexity-agent.js";
import p83 from "./siliconflow.js"; import p83 from "./perplexity-web.js";
import p84 from "./stability-ai.js"; import p84 from "./playht.js";
import p85 from "./tavily.js"; import p85 from "./poolside.js";
import p86 from "./together.js"; import p86 from "./qoder.js";
import p87 from "./topaz.js"; import p87 from "./qwen.js";
import p88 from "./tortoise.js"; import p88 from "./recraft.js";
import p89 from "./venice.js"; import p89 from "./runwayml.js";
import p90 from "./vercel-ai-gateway.js"; import p90 from "./sambanova.js";
import p91 from "./vertex-partner.js"; import p91 from "./sdwebui.js";
import p92 from "./vertex.js"; import p92 from "./searchapi.js";
import p93 from "./volcengine-ark.js"; import p93 from "./searxng.js";
import p94 from "./voyage-ai.js"; import p94 from "./serper.js";
import p95 from "./xai.js"; import p95 from "./siliconflow.js";
import p96 from "./xiaomi-mimo.js"; import p96 from "./stability-ai.js";
import p97 from "./xiaomi-tokenplan.js"; import p97 from "./tavily.js";
import p98 from "./youcom.js"; import p98 from "./tencent.js";
import p99 from "./alims-intl.js"; import p99 from "./together.js";
import p100 from "./orbit-provider.js"; import p100 from "./topaz.js";
import p101 from "./tortoise.js";
import p102 from "./venice.js";
import p103 from "./vercel-ai-gateway.js";
import p104 from "./vertex.js";
import p105 from "./vertex-partner.js";
import p106 from "./volcengine-ark.js";
import p107 from "./voyage-ai.js";
import p108 from "./xai.js";
import p109 from "./xiaomi-mimo.js";
import p110 from "./xiaomi-tokenplan.js";
import p111 from "./youcom.js";
import p112 from "./zed.js";
// Hidden: devin-cli — spawns a local agent with shell/filesystem access.
// Hidden: trae — SOLO agent currently skips tool-call chunks.
// Hidden: windsurf — gRPC integration currently skips tool-call chunks.
export default [ export default [
p0, p0,
p1, p1,
@@ -203,4 +218,16 @@ export default [
p98, p98,
p99, p99,
p100, p100,
p101,
p102,
p103,
p104,
p105,
p106,
p107,
p108,
p109,
p110,
p111,
p112,
]; ];
@@ -0,0 +1,34 @@
export default {
id: "kilo-gateway",
alias: "kgw",
aliases: [
"kilo-gateway",
"kilogateway",
],
uiAlias: "kgw",
category: "freeTier",
display: {
name: "Kilo Gateway",
icon: "login",
color: "#8B5CF6",
textIcon: "KG",
website: "https://kilo.ai",
notice: {
apiKeyUrl: "https://kilo.ai/dashboard?tab=apiKeys",
},
},
authType: "apikey",
authModes: ["apikey"],
transport: {
baseUrl: "https://api.kilo.ai/api/gateway/chat/completions",
validateUrl: "https://api.kilo.ai/api/gateway/models",
},
models: [
{ id: "kilo-auto/free", name: "Kilo Auto Free", contextLength: 256000 },
{ id: "nvidia/nemotron-3-super-120b-a12b:free", name: "Nemotron 3 Super 120B (Free)", contextLength: 262144 },
{ id: "nvidia/nemotron-3-ultra-550b-a55b:free", name: "Nemotron 3 Ultra 550B (Free)", contextLength: 1000000 },
{ id: "kwaipilot/kat-coder-pro-v2.5:free", name: "Kat Coder Pro v2.5 (Free)", contextLength: 256000 },
{ id: "kilo-auto/frontier", name: "Kilo Auto Frontier", contextLength: 1000000 },
{ id: "kilo-auto/balanced", name: "Kilo Auto Balanced", contextLength: 1000000 },
],
};
+1 -1
View File
@@ -13,7 +13,7 @@ export default {
signupUrl: "https://app.kimchi.dev", signupUrl: "https://app.kimchi.dev",
}, },
}, },
category: "oauth", category: "freeTier",
authModes: ["oauth"], authModes: ["oauth"],
hasOAuth: true, hasOAuth: true,
transport: { transport: {
+3
View File
@@ -85,5 +85,8 @@ export default {
}, },
features: { features: {
usage: true, usage: true,
// API-key connections also hit /v1/usages (x-api-key) — need usageApikey
// so isUsageEligible + /api/usage allow non-oauth authType.
usageApikey: true,
}, },
}; };
+4 -1
View File
@@ -29,7 +29,6 @@ export default {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Accept: "application/vnd.amazon.eventstream", Accept: "application/vnd.amazon.eventstream",
"X-Amz-Target": "AmazonCodeWhispererStreamingService.GenerateAssistantResponse",
"User-Agent": "AWS-SDK-JS/3.0.0 kiro-ide/1.0.0", "User-Agent": "AWS-SDK-JS/3.0.0 kiro-ide/1.0.0",
"X-Amz-User-Agent": "aws-sdk-js/3.0.0 kiro-ide/1.0.0", "X-Amz-User-Agent": "aws-sdk-js/3.0.0 kiro-ide/1.0.0",
}, },
@@ -43,6 +42,10 @@ export default {
}, },
models: [ models: [
// Opus (added per kiro.dev/changelog/models and kiro.dev/docs/models) // Opus (added per kiro.dev/changelog/models and kiro.dev/docs/models)
{ id: "claude-opus-5", name: "Claude Opus 5" },
{ id: "claude-opus-5-thinking", name: "Claude Opus 5 (Thinking)" },
{ id: "claude-opus-5-agentic", name: "Claude Opus 5 (Agentic)" },
{ id: "claude-opus-5-thinking-agentic", name: "Claude Opus 5 (Thinking + Agentic)" },
{ id: "claude-opus-4.8", name: "Claude Opus 4.8" }, { id: "claude-opus-4.8", name: "Claude Opus 4.8" },
{ id: "claude-opus-4.8-thinking", name: "Claude Opus 4.8 (Thinking)" }, { id: "claude-opus-4.8-thinking", name: "Claude Opus 4.8 (Thinking)" },
{ id: "claude-opus-4.8-agentic", name: "Claude Opus 4.8 (Agentic)" }, { id: "claude-opus-4.8-agentic", name: "Claude Opus 4.8 (Agentic)" },
+35
View File
@@ -0,0 +1,35 @@
export default {
id: "llm7",
alias: "llm7",
aliases: [
"llm-7",
],
uiAlias: "llm7",
display: {
name: "LLM7",
icon: "pool",
color: "#7C3AED",
textIcon: "L7",
website: "https://llm7.io",
notice: {
apiKeyUrl: "https://llm7.io",
},
},
category: "apikey",
authType: "apikey",
authModes: [
"apikey",
],
transport: {
baseUrl: "https://api.llm7.io/v1/chat/completions",
validateUrl: "https://api.llm7.io/v1/models",
},
models: [
{ id: "gpt-5.5", name: "GPT-5.5 (LLM7)", contextLength: 1050000 },
{ id: "claude-opus-5", name: "Claude Opus 5 (LLM7)", contextLength: 1000000 },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash (LLM7)", contextLength: 1000000 },
{ id: "grok-4.5", name: "Grok 4.5 (LLM7)", contextLength: 500000 },
{ id: "kimi-k3", name: "Kimi K3 (LLM7)", contextLength: 1000000 },
],
passthroughModels: true,
};
+3
View File
@@ -1,5 +1,8 @@
// Xiaomi ended the free MiMo channel ("MiMo free API service has ended").
// Hidden until/unless a replacement (OAuth MiMo Platform) is wired.
export default { export default {
id: "mimo-free", id: "mimo-free",
hidden: true,
priority: 50, priority: 50,
hasFree: true, hasFree: true,
alias: "mmf", alias: "mmf",
+29
View File
@@ -0,0 +1,29 @@
export default {
id: "morph",
alias: "morph",
aliases: ["morphllm"],
uiAlias: "morph",
display: {
name: "Morph",
icon: "change_history",
color: "#14B8A6",
textIcon: "MP",
website: "https://morphllm.com",
notice: { apiKeyUrl: "https://morphllm.com" },
},
category: "apikey",
authType: "apikey",
authModes: ["apikey"],
transport: {
baseUrl: "https://api.morphllm.com/v1/chat/completions",
validateUrl: "https://api.morphllm.com/v1/models",
},
models: [
{ id: "morph-v3-large", name: "Morph v3 Large" },
{ id: "morph-v3-fast", name: "Morph v3 Fast" },
{ id: "morph-qwen35-397b", name: "Qwen 3.5 397B (Morph)", contextLength: 262144 },
{ id: "morph-minimax27-230b", name: "MiniMax M2.7 (Morph)", contextLength: 200704 },
{ id: "morph-qwen36-27b", name: "Qwen 3.6 27B (Morph)", contextLength: 262144 },
{ id: "morph-dsv4flash", name: "DeepSeek V4 Flash (Morph)", contextLength: 1048576 },
],
};
+2
View File
@@ -15,6 +15,8 @@ export default {
}, },
}, },
category: "freeTier", category: "freeTier",
authType: "apikey",
authModes: ["apikey"],
transport: { transport: {
baseUrl: "https://integrate.api.nvidia.com/v1/chat/completions", baseUrl: "https://integrate.api.nvidia.com/v1/chat/completions",
validateUrl: "https://integrate.api.nvidia.com/v1/models", validateUrl: "https://integrate.api.nvidia.com/v1/models",
@@ -15,6 +15,8 @@ export default {
}, },
}, },
category: "freeTier", category: "freeTier",
authType: "apikey",
authModes: ["apikey"],
transport: { transport: {
baseUrl: "https://openrouter.ai/api/v1/chat/completions", baseUrl: "https://openrouter.ai/api/v1/chat/completions",
thinkingFormat: "openai", thinkingFormat: "openai",
+30
View File
@@ -0,0 +1,30 @@
export default {
id: "poolside",
priority: 60,
alias: "poolside",
aliases: [
"ps",
],
uiAlias: "ps",
display: {
name: "Poolside",
icon: "water_drop",
color: "#0EA5E9",
textIcon: "PS",
website: "https://poolside.ai",
notice: {
apiKeyUrl: "https://platform.poolside.ai/api-keys",
},
},
category: "freeTier",
authType: "apikey",
authModes: ["apikey"],
transport: {
baseUrl: "https://inference.poolside.ai/v1/chat/completions",
validateUrl: "https://inference.poolside.ai/v1/models",
},
models: [
{ id: "poolside/laguna-s-2.1", name: "Laguna S 2.1" },
{ id: "poolside/laguna-xs-2.1", name: "Laguna XS 2.1" },
],
};
+17 -15
View File
@@ -11,10 +11,11 @@ export default {
notice: { notice: {
signupUrl: "https://qoder.com", signupUrl: "https://qoder.com",
}, },
deprecated: true,
deprecationNotice: "RISK_NOTICE",
}, },
category: "free", category: "oauth",
authModes: ["oauth", "apikey"],
hasOAuth: true,
authHint: "Personal Access Token (pt-...) từ https://qoder.com/account/integrations",
transport: { transport: {
baseUrl: "https://api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation", baseUrl: "https://api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation",
headers: {}, headers: {},
@@ -25,18 +26,19 @@ export default {
}, },
}, },
models: [ models: [
// { id: "auto", name: "Qoder Auto" }, { id: "ultimate", name: "Ultimate" },
// { id: "ultimate", name: "Qoder Ultimate" }, { id: "auto", name: "Auto" },
// { id: "performance", name: "Qoder Performance" }, { id: "performance", name: "Performance" },
// { id: "efficient", name: "Qoder Efficient" }, { id: "efficient", name: "Efficient" },
// { id: "lite", name: "Qoder Lite" }, { id: "qmodel_preview", name: "Qwen3.8-Max-Preview" },
// { id: "qmodel", name: "Qwen 3.6 Plus (Qoder)" }, { id: "qmodel_latest", name: "Qwen3.7-Max" },
{ id: "qmodel_latest", name: "Qoder Qwen 3.7 Max" }, { id: "qmodel", name: "Qwen3.7-Plus" },
// { id: "dmodel", name: "DeepSeek V4 Pro (Qoder)" }, { id: "kmodel_latest", name: "Kimi-K3" },
// { id: "dfmodel", name: "DeepSeek V4 Flash (Qoder)" }, { id: "kmodel", name: "Kimi-K2.7-Code" },
// { id: "gm51model", name: "GLM 5.1 (Qoder)" }, { id: "gm51model", name: "GLM-5.2" },
// { id: "kmodel", name: "Kimi K2.6 (Qoder)" }, { id: "dmodel", name: "DeepSeek-V4-Pro" },
// { id: "mmodel", name: "MiniMax M2.7 (Qoder)" }, { id: "dfmodel", name: "DeepSeek-V4-Flash" },
{ id: "mmodel", name: "MiniMax-M3" },
], ],
oauth: { oauth: {
openApiBaseUrl: "https://openapi.qoder.sh", openApiBaseUrl: "https://openapi.qoder.sh",
+27
View File
@@ -0,0 +1,27 @@
export default {
id: "sambanova",
alias: "samba",
aliases: ["sambanova-ai"],
uiAlias: "samba",
hidden: true,
display: {
name: "SambaNova",
icon: "memory",
color: "#F97316",
textIcon: "SN",
website: "https://sambanova.ai",
notice: {
apiKeyUrl: "https://cloud.sambanova.ai/apis",
},
},
category: "apikey",
authType: "apikey",
authModes: ["apikey"],
transport: {
baseUrl: "https://api.sambanova.ai/v1/chat/completions",
validateUrl: "https://api.sambanova.ai/v1/models",
},
models: [
{ id: "MiniMax-M2.7", name: "MiniMax M2.7", contextLength: 196608 },
],
};
+27
View File
@@ -0,0 +1,27 @@
export default {
id: "tencent",
alias: "hunyuan",
aliases: ["hunyuan", "tencent-hunyuan"],
uiAlias: "hunyuan",
display: {
name: "Tencent Hunyuan",
icon: "cloud",
color: "#0052D9",
textIcon: "HY",
website: "https://cloud.tencent.com/product/hunyuan",
notice: {
apiKeyUrl: "https://console.cloud.tencent.com/hunyuan/api-key",
},
},
category: "apikey",
authType: "apikey",
authModes: ["apikey"],
transport: {
baseUrl: "https://api.hunyuan.cloud.tencent.com/v1/chat/completions",
validateUrl: "https://api.hunyuan.cloud.tencent.com/v1/models",
},
models: [
{ id: "hunyuan-turbos-latest", name: "Hunyuan TurboS Latest", contextLength: 200000 },
{ id: "hunyuan-t1-latest", name: "Hunyuan T1 Latest", contextLength: 256000 },
],
};
+76
View File
@@ -0,0 +1,76 @@
// Trae (ByteDance marscode) provider registry entry.
// Chat = SOLO remote agent API:
// POST {base}/chat_sessions → {data:{chat_session_id, message_id}}
// GET {base}/chat_sessions/{id}/events?reply_to_message_id=... → SSE
// Auth: Authorization: Cloud-IDE-JWT <jwt>
export default {
id: "trae",
alias: "tr",
uiAlias: "tr",
aliases: ["marscode"],
category: "oauth",
authType: "oauth",
hasOAuth: true,
authModes: ["oauth"],
display: {
name: "Trae",
icon: "bolt",
color: "#FF6A00",
textIcon: "TR",
website: "https://www.trae.ai",
notice: { signupUrl: "https://www.trae.ai" },
},
transport: {
// SOLO remote agent base — verified working chat endpoint.
baseUrl: "https://core-normal.trae.ai/api/remote/v1",
format: "openai",
headers: {
"X-Trae-Client-Type": "web",
"X-Preferenced-Language": "en",
"Referer": "https://solo.trae.ai/",
},
// Auth: Cloud-IDE-JWT scheme on Authorization — injected by executor buildHeaders.
auth: {
combined: true,
header: "Authorization",
scheme: "Cloud-IDE-JWT",
},
usage: {
url: "https://api.marscode.com/cloudide/api/v3/trae/GetUserInfo",
},
regions: {
cn: "https://api.marscode.com",
sg: "https://api.trae.ai",
us: "https://www.trae.ai",
},
defaultRegion: "cn",
},
oauth: {
clientId: "ono9krqynydwx5",
clientSecret: "-",
platform: "trae",
pollInterval: 1500,
// Login guidance returns LoginHost for browser open.
loginGuidanceUrl: "https://api.marscode.com/cloudide/api/v3/trae/GetLoginGuidance",
// ExchangeToken: refresh -> access (POST JSON, body below).
tokenUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken",
exchangeTokenUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken",
refreshUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken",
userInfoUrl: "https://api.marscode.com/cloudide/api/v3/trae/GetUserInfo",
// Trae refresh uses custom JSON body, not OAuth form — handled by refresh.js, not config-driven.
refresh: { encoding: "json" },
},
// Model catalog (IDE flow, core-normal.trae.ai).
models: [
{ id: "auto", name: "Auto (Server Picks)" },
{ id: "work", name: "Work (Fast)" },
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
{ id: "gemini-3-flash-solo", name: "Gemini 3 Flash" },
{ id: "minimax-m3", name: "MiniMax M3" },
{ id: "minimax-m2.7", name: "MiniMax M2.7" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "gpt-5.4", name: "GPT 5.4" },
{ id: "gpt-5.2", name: "GPT 5.2" },
],
features: { usage: true },
};
+143
View File
@@ -0,0 +1,143 @@
// Windsurf provider registry — Firebase+Codeium+Devin auth chain.
// Chat = Codeium gRPC-web protobuf:
// POST {base} Content-Type: application/grpc-web+proto
// Service: exa.language_server_pb.LanguageServerService / GetChatMessage
export default {
id: "windsurf",
alias: "ws",
uiAlias: "ws",
display: {
name: "Windsurf",
icon: "surfing",
color: "#14B8A6",
website: "https://windsurf.com",
notice: { signupUrl: "https://windsurf.com" },
},
category: "oauth",
authType: "oauth",
hasOAuth: true,
authModes: ["oauth", "apikey"],
transport: {
baseUrl: "https://server.codeium.com/exa.language_server_pb.LanguageServerService/GetChatMessage",
format: "openai",
headers: {
"Content-Type": "application/grpc-web+proto",
"Accept": "application/grpc-web+proto",
"X-Grpc-Web": "1",
},
// apiKey (sk-ws-... or Firebase-derived) as Bearer + in protobuf Metadata.api_key.
auth: { combined: true, header: "Authorization", scheme: "Bearer" },
},
// Auth chain (4 terminal paths, all yield apiKey):
// 1) OAuth web → Firebase JWT → POST register.windsurf.com/.../RegisterUser {firebase_id_token} → {apiKey, apiServerUrl, name}
// 2) sk-ws-... direct API key (apiKey used as metadata.apiKey on GetUserStatus)
// 3) Firebase JWT (eyJ...) → same RegisterUser exchange as #1
// 4) Devin auth1_... → self-serve chain → ide_token used as apiKey on server.self-serve.windsurf.com
oauth: {
clientId: "3GUryQ7ldAeKEuD2obYnppsnmj58eP5u",
firebaseApiKey: "AIzaSyDsOl-1XpT5err0Tcn0TFFod1H8gVGIycY",
firebaseSignInUrl: "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword",
registerUrl: "https://register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser",
apiServerUrl: "https://server.codeium.com",
auth1ApiServerUrl: "https://server.self-serve.windsurf.com",
platform: "windsurf",
// Quota (Connect RPC, protobuf): POST windsurf.com/_backend/.../GetPlanStatus,
// headers Content-Type:application/proto + Connect-Protocol-Version:1 + X-Auth-Token:<session>,
// body = field1:session_token, field2:varint 1.
quotaUrl: "https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/GetPlanStatus",
},
// Catalog verified against model_configs_v2.bin from Devin CLI (2026.5.x).
// Dot-notation ids; the executor MODEL_ALIAS_MAP maps these to Windsurf modelUid.
// contextLength dropped — 9router schema uses id+name only.
models: [
// Cognition / SWE
{ id: "swe-1.6-fast", name: "SWE-1.6 Fast" },
{ id: "swe-1.6", name: "SWE-1.6" },
{ id: "swe-1.5-fast", name: "SWE-1.5 Fast" },
{ id: "swe-1.5", name: "SWE-1.5" },
// Claude Opus 4.7 — effort-tiered
{ id: "claude-opus-4.7-max", name: "Claude Opus 4.7 Max" },
{ id: "claude-opus-4.7-xhigh", name: "Claude Opus 4.7 XHigh" },
{ id: "claude-opus-4.7-high", name: "Claude Opus 4.7 High" },
{ id: "claude-opus-4.7-medium", name: "Claude Opus 4.7 Medium" },
{ id: "claude-opus-4.7-low", name: "Claude Opus 4.7 Low" },
{ id: "claude-opus-4.7-review", name: "Claude Opus 4.7 Review" },
// Claude Sonnet/Opus 4.6
{ id: "claude-sonnet-4.6-thinking-1m", name: "Claude Sonnet 4.6 Thinking 1M" },
{ id: "claude-sonnet-4.6-1m", name: "Claude Sonnet 4.6 1M" },
{ id: "claude-sonnet-4.6-thinking", name: "Claude Sonnet 4.6 Thinking" },
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" },
{ id: "claude-opus-4.6-thinking", name: "Claude Opus 4.6 Thinking" },
{ id: "claude-opus-4.6", name: "Claude Opus 4.6" },
// Claude 4.5
{ id: "claude-opus-4.5-thinking", name: "Claude Opus 4.5 Thinking" },
{ id: "claude-opus-4.5", name: "Claude Opus 4.5" },
{ id: "claude-sonnet-4.5-thinking", name: "Claude Sonnet 4.5 Thinking" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
// GPT-5.5 — effort-tiered
{ id: "gpt-5.5-xhigh-fast", name: "GPT-5.5 XHigh Fast" },
{ id: "gpt-5.5-xhigh", name: "GPT-5.5 XHigh" },
{ id: "gpt-5.5-high-fast", name: "GPT-5.5 High Fast" },
{ id: "gpt-5.5-high", name: "GPT-5.5 High" },
{ id: "gpt-5.5-medium-fast", name: "GPT-5.5 Medium Fast" },
{ id: "gpt-5.5-medium", name: "GPT-5.5 Medium" },
{ id: "gpt-5.5-low-fast", name: "GPT-5.5 Low Fast" },
{ id: "gpt-5.5-low", name: "GPT-5.5 Low" },
{ id: "gpt-5.5-none-fast", name: "GPT-5.5 None Fast" },
{ id: "gpt-5.5-none", name: "GPT-5.5 None" },
// GPT-5.4 — effort-tiered
{ id: "gpt-5.4-xhigh-fast", name: "GPT-5.4 XHigh Fast" },
{ id: "gpt-5.4-xhigh", name: "GPT-5.4 XHigh" },
{ id: "gpt-5.4-high-fast", name: "GPT-5.4 High Fast" },
{ id: "gpt-5.4-high", name: "GPT-5.4 High" },
{ id: "gpt-5.4-medium-fast", name: "GPT-5.4 Medium Fast" },
{ id: "gpt-5.4-medium", name: "GPT-5.4 Medium" },
{ id: "gpt-5.4-low-fast", name: "GPT-5.4 Low Fast" },
{ id: "gpt-5.4-low", name: "GPT-5.4 Low" },
{ id: "gpt-5.4-none-fast", name: "GPT-5.4 None Fast" },
{ id: "gpt-5.4-none", name: "GPT-5.4 None" },
{ id: "gpt-5.4-mini-xhigh", name: "GPT-5.4 Mini XHigh" },
{ id: "gpt-5.4-mini-high", name: "GPT-5.4 Mini High" },
{ id: "gpt-5.4-mini-medium", name: "GPT-5.4 Mini Medium" },
{ id: "gpt-5.4-mini-low", name: "GPT-5.4 Mini Low" },
// GPT-5.3 Codex
{ id: "gpt-5.3-codex-xhigh-fast", name: "GPT-5.3 Codex XHigh Fast" },
{ id: "gpt-5.3-codex-xhigh", name: "GPT-5.3 Codex XHigh" },
{ id: "gpt-5.3-codex-high-fast", name: "GPT-5.3 Codex High Fast" },
{ id: "gpt-5.3-codex-high", name: "GPT-5.3 Codex High" },
{ id: "gpt-5.3-codex-medium-fast", name: "GPT-5.3 Codex Medium Fast" },
{ id: "gpt-5.3-codex-medium", name: "GPT-5.3 Codex Medium" },
{ id: "gpt-5.3-codex-low-fast", name: "GPT-5.3 Codex Low Fast" },
{ id: "gpt-5.3-codex-low", name: "GPT-5.3 Codex Low" },
// GPT-5.2 / 5
{ id: "gpt-5.2-xhigh", name: "GPT-5.2 XHigh" },
{ id: "gpt-5.2-high", name: "GPT-5.2 High" },
{ id: "gpt-5.2-medium", name: "GPT-5.2 Medium" },
{ id: "gpt-5.2-low", name: "GPT-5.2 Low" },
{ id: "gpt-5.2-none", name: "GPT-5.2 None" },
{ id: "gpt-5", name: "GPT-5" },
// GPT-4.1 / 4o
{ id: "gpt-4.1", name: "GPT-4.1" },
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini" },
{ id: "gpt-4.1-nano", name: "GPT-4.1 Nano" },
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
// Gemini
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High" },
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low" },
{ id: "gemini-3.0-flash-high", name: "Gemini 3 Flash High" },
{ id: "gemini-3.0-flash-medium", name: "Gemini 3 Flash Medium" },
{ id: "gemini-3.0-flash-low", name: "Gemini 3 Flash Low" },
{ id: "gemini-3.0-flash-minimal", name: "Gemini 3 Flash Minimal" },
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
// Others
{ id: "deepseek-v4", name: "DeepSeek V4" },
{ id: "kimi-k2.6", name: "Kimi K2.6" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "glm-5.1", name: "GLM-5.1" },
],
};
+71
View File
@@ -0,0 +1,71 @@
// Zed provider — RSA keypair callback auth (NOT standard OAuth).
export default {
id: "zed",
priority: 10,
alias: "zd",
uiAlias: "zd",
hidden: true,
display: {
name: "Zed",
icon: "code",
color: "#A855F7",
website: "https://zed.dev",
notice: {
signupUrl: "https://zed.dev/native_app_signin",
},
},
category: "oauth",
authType: "oauth",
hasOAuth: true,
transport: {
// Zed hosted LLM aggregator: cloud.zed.dev/completions is a
// multi-format proxy fronting Anthropic/OpenAI/Google/xAI depending on the model.
// Wire protocol = NDJSON/SSE-ish stream authenticated with a short-lived LLM bearer
// token exchanged from the RSA-decrypted access_token (see open-sse/shared/zedAuth).
baseUrl: "https://cloud.zed.dev/completions",
format: "openai",
forceStream: true,
headers: {
"content-type": "application/json",
},
// Auth scheme is non-standard: "Authorization: <user_id> <access_token>" plus a duplicate
// x-zed-cloud-token header (verified in zed_account.rs build_authorization_header +
// cloud fetch). Executor builds both; scheme here is a marker for config-driven tooling.
auth: {
combined: true,
header: "Authorization",
scheme: "<user_id> <access_token>", // placeholder — real value built in executor
},
usage: {
url: "https://cloud.zed.dev/client/users/me", // verified in zed_account.rs
},
// Live catalog discovery — Zed's hosted model list changes frequently and is fetched
// per-connection rather than hardcoded.
modelsUrl: "https://cloud.zed.dev/models",
},
// Empty static catalog + passthrough: Zed fronts a rotating set of upstream models
// (Claude/GPT/Gemini/Grok). Resolved live via modelsUrl; any client-sent model id is
// forwarded as-is rather than validated against a frozen list.
models: [],
passthroughModels: true,
oauth: {
// Zed auth flow is RSA-based, NOT OAuth2/PKCE:
// 1. App generates RSA-2048 keypair locally (PKCS#1 DER, URL-safe base64).
// 2. Bind random TCP port on 127.0.0.1.
// 3. Open https://zed.dev/native_app_signin?native_app_port={port}&native_app_public_key={pub}.
// 4. After login, browser redirects http://127.0.0.1:{port}/?user_id=...&access_token=...
// where access_token = base64(RSA-encrypted plaintext token).
// 5. Decrypt with private key (OAEP-SHA256, fallback PKCS1v15). Store user_id + plaintext token.
// No clientId/clientSecret/tokenUrl/refreshUrl — long-lived access_token, no refresh.
authorizeUrl: "https://zed.dev/native_app_signin",
platform: "zed",
rsaKeyExchange: true, // new flag: signals frontend/router this flow needs local RSA + TCP listener.
},
features: {
usage: true,
},
};
+1 -1
View File
@@ -58,7 +58,7 @@ export const ANTHROPIC_COMPAT_BASE = "https://api.anthropic.com/v1";
// Keep this static even when 9router runs on Linux: the provider profile is // Keep this static even when 9router runs on Linux: the provider profile is
// intentionally matching the IDE client, not the server host. // intentionally matching the IDE client, not the server host.
export const ANTIGRAVITY_IDE_VERSION = "2.1.1"; export const ANTIGRAVITY_IDE_VERSION = "2.1.1";
export const ANTIGRAVITY_IDE_BASE_URL = "https://cloudcode-pa.googleapis.com"; export const ANTIGRAVITY_IDE_BASE_URL = "https://daily-cloudcode-pa.googleapis.com";
export const ANTIGRAVITY_IDE_USER_AGENT = `antigravity/ide/${ANTIGRAVITY_IDE_VERSION} darwin/arm64`; export const ANTIGRAVITY_IDE_USER_AGENT = `antigravity/ide/${ANTIGRAVITY_IDE_VERSION} darwin/arm64`;
// Antigravity OAuth client credentials (public CLI client — duplicated in usage.js + src/lib/oauth) // Antigravity OAuth client credentials (public CLI client — duplicated in usage.js + src/lib/oauth)
+2
View File
@@ -2,6 +2,7 @@
// Reuses capabilities.js (thinkingFormat/canDisable) so this file only maps format→levels (DRY). // Reuses capabilities.js (thinkingFormat/canDisable) so this file only maps format→levels (DRY).
import { getCapabilitiesForModel } from "./capabilities.js"; import { getCapabilitiesForModel } from "./capabilities.js";
import { matchPattern } from "./pricing.js"; import { matchPattern } from "./pricing.js";
import { resolveKiroEffortPath } from "../config/kiroConstants.js";
// Shared level sets (deduped) — verified against provider docs + wire in thinkingUnified.applyFormat. // Shared level sets (deduped) — verified against provider docs + wire in thinkingUnified.applyFormat.
const L = { const L = {
@@ -39,6 +40,7 @@ const PATTERN_THINKING = [
// Returns valid thinking levels for a model, or null when the model has no reasoning. // Returns valid thinking levels for a model, or null when the model has no reasoning.
export function getThinkingLevels(provider, model) { export function getThinkingLevels(provider, model) {
if (provider === "kiro" && resolveKiroEffortPath(model) === null) return null;
const caps = getCapabilitiesForModel(provider, model); const caps = getCapabilitiesForModel(provider, model);
if (!caps.reasoning) return null; if (!caps.reasoning) return null;
const hit = PATTERN_THINKING.find((p) => matchPattern(p.pattern, model)); const hit = PATTERN_THINKING.find((p) => matchPattern(p.pattern, model));
+8 -7
View File
@@ -83,7 +83,7 @@ startCacheCleanup();
* @param {string} accessToken - Valid OAuth access token * @param {string} accessToken - Valid OAuth access token
* @returns {Promise<string|null>} Real project ID or null * @returns {Promise<string|null>} Real project ID or null
*/ */
export async function getProjectIdForConnection(connectionId, accessToken) { export async function getProjectIdForConnection(connectionId, accessToken, provider = "gemini-cli") {
if (!connectionId || !accessToken) return null; if (!connectionId || !accessToken) return null;
// Return cached value if still fresh // Return cached value if still fresh
@@ -102,7 +102,7 @@ export async function getProjectIdForConnection(connectionId, accessToken) {
const promise = (async () => { const promise = (async () => {
try { try {
const projectId = await fetchProjectId(accessToken, controller.signal); const projectId = await fetchProjectId(accessToken, controller.signal, provider);
if (projectId) { if (projectId) {
projectIdCache.set(connectionId, {projectId, fetchedAt: Date.now()}); projectIdCache.set(connectionId, {projectId, fetchedAt: Date.now()});
return projectId; return projectId;
@@ -155,8 +155,9 @@ export function removeConnection(connectionId) {
* @param {AbortSignal} signal * @param {AbortSignal} signal
* @returns {Promise<string|null>} * @returns {Promise<string|null>}
*/ */
async function fetchProjectId(accessToken, signal) { async function fetchProjectId(accessToken, signal, provider) {
const response = await fetch(CLOUD_CODE_API.loadCodeAssist, { const endpoints = CLOUD_CODE_API[provider] || CLOUD_CODE_API["gemini-cli"];
const response = await fetch(endpoints.loadCodeAssist, {
method: "POST", method: "POST",
headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` }, headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` },
body: JSON.stringify({ metadata: LOAD_CODE_ASSIST_METADATA }), body: JSON.stringify({ metadata: LOAD_CODE_ASSIST_METADATA }),
@@ -185,7 +186,7 @@ async function fetchProjectId(accessToken, signal) {
} }
} }
return onboardUser(accessToken, tierID, signal); return onboardUser(accessToken, tierID, signal, endpoints);
} }
/** /**
@@ -196,7 +197,7 @@ async function fetchProjectId(accessToken, signal) {
* @param {AbortSignal} externalSignal propagated from the connection's AbortController * @param {AbortSignal} externalSignal propagated from the connection's AbortController
* @returns {Promise<string|null>} * @returns {Promise<string|null>}
*/ */
async function onboardUser(accessToken, tierID, externalSignal) { async function onboardUser(accessToken, tierID, externalSignal, endpoints) {
console.log(`[ProjectId] Onboarding user with tier: ${tierID}`); console.log(`[ProjectId] Onboarding user with tier: ${tierID}`);
const reqBody = { tierId: tierID, metadata: LOAD_CODE_ASSIST_METADATA }; const reqBody = { tierId: tierID, metadata: LOAD_CODE_ASSIST_METADATA };
@@ -213,7 +214,7 @@ async function onboardUser(accessToken, tierID, externalSignal) {
externalSignal?.addEventListener("abort", forwardAbort); externalSignal?.addEventListener("abort", forwardAbort);
try { try {
const response = await fetch(CLOUD_CODE_API.onboardUser, { const response = await fetch(endpoints.onboardUser, {
method: "POST", method: "POST",
headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` }, headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` },
body: JSON.stringify(reqBody), body: JSON.stringify(reqBody),
+12
View File
@@ -13,6 +13,10 @@ import {
refreshGitHubToken, refreshGitHubToken,
refreshCopilotToken, refreshCopilotToken,
refreshCodebuddyToken, refreshCodebuddyToken,
refreshCodebuddyIntlToken,
refreshTraeToken,
refreshZedToken,
refreshWindsurfToken,
classifyOAuthRefreshError, classifyOAuthRefreshError,
} from "./tokenRefresh/providers.js"; } from "./tokenRefresh/providers.js";
@@ -29,6 +33,10 @@ export {
refreshGitHubToken, refreshGitHubToken,
refreshCopilotToken, refreshCopilotToken,
refreshCodebuddyToken, refreshCodebuddyToken,
refreshCodebuddyIntlToken,
refreshTraeToken,
refreshZedToken,
refreshWindsurfToken,
classifyOAuthRefreshError, classifyOAuthRefreshError,
}; };
@@ -138,6 +146,10 @@ const REFRESH_HANDLERS = {
"grok-cli": (c, log) => refreshXaiToken(c.refreshToken, log), "grok-cli": (c, log) => refreshXaiToken(c.refreshToken, log),
gcli: (c, log) => refreshXaiToken(c.refreshToken, log), gcli: (c, log) => refreshXaiToken(c.refreshToken, log),
"codebuddy-cn": (c, log) => refreshCodebuddyToken(c.refreshToken, log), "codebuddy-cn": (c, log) => refreshCodebuddyToken(c.refreshToken, log),
"codebuddy-intl": (c, log) => refreshCodebuddyIntlToken(c.refreshToken, log),
trae: (c, log) => refreshTraeToken(c.refreshToken, c, log),
zed: () => refreshZedToken(),
windsurf: (c, log) => refreshWindsurfToken(c, log),
// Kimi Code OAuth (merged into id `kimi`); legacy id still routes here // Kimi Code OAuth (merged into id `kimi`); legacy id still routes here
kimi: (c, log) => refreshKimiToken(c.refreshToken, c, log), kimi: (c, log) => refreshKimiToken(c.refreshToken, c, log),
"kimi-coding": (c, log) => refreshKimiToken(c.refreshToken, c, log), "kimi-coding": (c, log) => refreshKimiToken(c.refreshToken, c, log),
+223 -223
View File
@@ -31,10 +31,68 @@ export async function refreshXaiToken(refreshToken, log) {
}, log); }, log);
} }
// Per-provider refresh variants for the generic path. Keys not listed fall back
// to the default form-encoded OAuth2 refresh with client_id + client_secret.
const REFRESH_PROFILES = {
claude: {
bodyFormat: "json",
includeClientSecret: false,
url: () => OAUTH_ENDPOINTS.anthropic.token,
dedupKey: "claude",
},
qwen: {
url: () => OAUTH_ENDPOINTS.qwen.token,
dedupKey: "qwen",
parse: (tokens) => tokens.resource_url ? { providerSpecificData: { resourceUrl: tokens.resource_url } } : {},
},
iflow: {
url: () => OAUTH_ENDPOINTS.iflow.token,
dedupKey: "iflow",
extraHeaders: (creds, cfg) => ({
Authorization: `Basic ${btoa(`${cfg.clientId}:${cfg.clientSecret}`)}`,
}),
},
github: {
url: () => OAUTH_ENDPOINTS.github.token,
dedupKey: "github",
includeClientSecret: (cfg) => !!cfg?.clientSecret,
},
kimi: {
dedupKey: "kimi",
extraHeaders: (creds) => buildKimiHeaders(creds?.providerSpecificData?.deviceId),
},
};
function resolveRefreshUrl(provider, config, profile) {
if (profile?.url) {
try { return profile.url(); } catch { /* fall through */ }
}
return config?.refreshUrl || PROVIDER_OAUTH[provider]?.tokenUrl || null;
}
function buildRefreshBody(profile, config, refreshToken) {
const fmt = profile?.bodyFormat === "json" ? "json" : "form";
const includeSecret = profile?.includeClientSecret === undefined
? true
: typeof profile.includeClientSecret === "function"
? profile.includeClientSecret(config)
: profile.includeClientSecret;
const payload = {
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: config.clientId,
};
if (includeSecret && config.clientSecret) payload.client_secret = config.clientSecret;
if (fmt === "json") return { format: "json", body: JSON.stringify(payload) };
return { format: "form", body: new URLSearchParams(payload) };
}
export async function refreshAccessToken(provider, refreshToken, credentials, log) { export async function refreshAccessToken(provider, refreshToken, credentials, log) {
const config = PROVIDERS[provider]; const config = PROVIDERS[provider];
const profile = REFRESH_PROFILES[provider] || {};
const url = resolveRefreshUrl(provider, config, profile);
if (!config || !config.refreshUrl) { if (!config || !url) {
log?.warn?.("TOKEN_REFRESH", `No refresh URL configured for provider: ${provider}`); log?.warn?.("TOKEN_REFRESH", `No refresh URL configured for provider: ${provider}`);
return null; return null;
} }
@@ -44,21 +102,17 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo
return null; return null;
} }
return dedupRefresh(provider, refreshToken, async () => { const dedupKey = profile.dedupKey || provider;
return dedupRefresh(dedupKey, refreshToken, async () => {
try { try {
const response = await fetch(config.refreshUrl, { const { format: bodyFormat, body } = buildRefreshBody(profile, config, refreshToken);
method: "POST", const headers = {
headers: { "Content-Type": bodyFormat === "json" ? "application/json" : "application/x-www-form-urlencoded",
"Content-Type": "application/x-www-form-urlencoded", Accept: "application/json",
Accept: "application/json", ...(profile.extraHeaders ? (profile.extraHeaders(credentials, config) || {}) : {}),
}, };
body: new URLSearchParams({ const response = await fetch(url, { method: "POST", headers, body });
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: config.clientId,
client_secret: config.clientSecret,
}),
});
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
@@ -81,6 +135,7 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo
accessToken: tokens.access_token, accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken, refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in, expiresIn: tokens.expires_in,
...(profile.parse ? (profile.parse(tokens) || {}) : {}),
}; };
} catch (error) { } catch (error) {
log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, { log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, {
@@ -92,82 +147,14 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo
} }
// CLIProxyAPI DeviceFlowClient.RefreshToken: form body (no client_secret) + X-Msh-* headers // CLIProxyAPI DeviceFlowClient.RefreshToken: form body (no client_secret) + X-Msh-* headers
// Delegate to refreshAccessToken("kimi", ...) — profile carries the X-Msh headers.
export async function refreshKimiToken(refreshToken, credentials, log) { export async function refreshKimiToken(refreshToken, credentials, log) {
const config = PROVIDERS.kimi; return refreshAccessToken("kimi", refreshToken, credentials, log);
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);
} }
// Claude OAuth: JSON body, client_id only. Delegate to refreshAccessToken("claude", ...).
export async function refreshClaudeOAuthToken(refreshToken, log) { export async function refreshClaudeOAuthToken(refreshToken, log) {
if (!refreshToken) return null; return refreshAccessToken("claude", refreshToken, {}, log);
return dedupRefresh("claude", refreshToken, async () => {
try {
const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.claude.clientId,
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", { status: response.status, error: errorText });
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in });
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in };
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`);
return null;
}
}, log);
} }
export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) { export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) {
@@ -204,58 +191,9 @@ export async function refreshGoogleToken(refreshToken, clientId, clientSecret, l
}, log); }, log);
} }
// Qwen: form body + clientId, surfaces resource_url. Delegate to refreshAccessToken("qwen", ...).
export async function refreshQwenToken(refreshToken, log) { export async function refreshQwenToken(refreshToken, log) {
if (!refreshToken) return null; return refreshAccessToken("qwen", refreshToken, {}, log);
return dedupRefresh("qwen", refreshToken, async () => {
const endpoint = OAUTH_ENDPOINTS.qwen.token;
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.qwen.clientId,
}),
});
if (response.status === 200) {
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qwen token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
providerSpecificData: tokens.resource_url
? { resourceUrl: tokens.resource_url }
: undefined,
};
} else {
const errorText = await response.text().catch(() => "");
log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, {
status: response.status,
error: errorText,
});
}
} catch (error) {
log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, {
error: error.message,
});
}
log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token");
return null;
}, log);
} }
export function classifyOAuthRefreshError(errorText = "", status = 0) { export function classifyOAuthRefreshError(errorText = "", status = 0) {
@@ -480,95 +418,14 @@ export async function refreshKiroToken(refreshToken, providerSpecificData, log,
}, log); }, log);
} }
// iFlow: Basic Auth + client_id+client_secret in body. Delegate to refreshAccessToken("iflow", ...).
export async function refreshIflowToken(refreshToken, log) { export async function refreshIflowToken(refreshToken, log) {
if (!refreshToken) return null; return refreshAccessToken("iflow", refreshToken, {}, log);
return dedupRefresh("iflow", refreshToken, async () => {
const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`);
const response = await fetch(OAUTH_ENDPOINTS.iflow.token, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
Authorization: `Basic ${basicAuth}`,
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.iflow.clientId,
client_secret: PROVIDERS.iflow.clientSecret,
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh iFlow token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed iFlow token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
}, log);
} }
// GitHub: optional client_secret. Delegate to refreshAccessToken("github", ...).
export async function refreshGitHubToken(refreshToken, log) { export async function refreshGitHubToken(refreshToken, log) {
if (!refreshToken) return null; return refreshAccessToken("github", refreshToken, {}, log);
return dedupRefresh("github", refreshToken, async () => {
const params = {
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.github.clientId,
};
if (PROVIDERS.github.clientSecret) {
params.client_secret = PROVIDERS.github.clientSecret;
}
const response = await fetch(OAUTH_ENDPOINTS.github.token, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams(params),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh GitHub token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitHub token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
}, log);
} }
export async function refreshCopilotToken(githubAccessToken, log) { export async function refreshCopilotToken(githubAccessToken, log) {
@@ -668,3 +525,146 @@ export async function refreshCodebuddyToken(refreshToken, log) {
}; };
}, log); }, log);
} }
export async function refreshCodebuddyIntlToken(refreshToken, log) {
if (!refreshToken) return null;
return dedupRefresh("codebuddy-intl", refreshToken, async () => {
const oauth = PROVIDER_OAUTH["codebuddy-intl"] || {};
const response = await fetch(oauth.refreshUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": oauth.userAgent,
"X-Requested-With": "XMLHttpRequest",
"X-Domain": "www.codebuddy.ai",
"X-Refresh-Token": refreshToken,
"X-Auth-Refresh-Source": "plugin",
"X-Product": "SaaS",
},
body: "{}",
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh CodeBuddy intl token", {
status: response.status,
error: errorText,
});
return null;
}
const data = await response.json();
if (data.code !== 0 || !data.data?.accessToken) {
log?.error?.("TOKEN_REFRESH", "CodeBuddy intl token refresh returned no token", {
code: data.code,
msg: data.msg,
});
return null;
}
log?.info?.("TOKEN_REFRESH", "Successfully refreshed CodeBuddy intl token", {
hasNewAccessToken: !!data.data.accessToken,
hasNewRefreshToken: !!data.data.refreshToken,
expiresIn: data.data.expiresIn,
});
return {
accessToken: data.data.accessToken,
refreshToken: data.data.refreshToken || refreshToken,
expiresIn: data.data.expiresIn,
};
}, log);
}
// Trae refresh — POST ExchangeToken with JSON body {ClientID, RefreshToken, ClientSecret, UserID}.
// Response: {Result: {AccessToken, RefreshToken, TokenType, ExpiresAt}}.
export async function refreshTraeToken(refreshToken, credentials, log) {
if (!refreshToken) return null;
const oauth = PROVIDER_OAUTH.trae || {};
const url = oauth.exchangeTokenUrl || oauth.tokenUrl;
if (!url) {
log?.warn?.("TOKEN_REFRESH", "No Trae exchangeTokenUrl configured");
return null;
}
return dedupRefresh("trae", refreshToken, async () => {
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": "Trae/1.0.0 antigravity-cockpit-tools",
},
body: JSON.stringify({
ClientID: oauth.clientId || "ono9krqynydwx5",
RefreshToken: refreshToken,
ClientSecret: oauth.clientSecret || "-",
UserID: "",
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Trae token", {
status: response.status,
error: errorText,
});
return null;
}
const payload = await response.json();
const result = payload?.Result || payload?.result || payload;
const accessToken = result?.AccessToken || result?.accessToken;
if (!accessToken) {
log?.error?.("TOKEN_REFRESH", "Trae refresh returned no AccessToken", { payload });
return null;
}
const newRefresh = result?.RefreshToken || result?.refreshToken || refreshToken;
const expiresAt = result?.ExpiresAt || result?.expiresAt;
let expiresIn;
if (typeof expiresAt === "number") {
expiresIn = Math.max(1, expiresAt - Math.floor(Date.now() / 1000));
} else if (typeof expiresAt === "string") {
const ms = new Date(expiresAt).getTime() - Date.now();
expiresIn = ms > 0 ? Math.floor(ms / 1000) : undefined;
}
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Trae token", {
hasNewAccessToken: !!accessToken,
hasNewRefreshToken: newRefresh !== refreshToken,
expiresIn,
});
return {
accessToken,
refreshToken: newRefresh,
expiresIn,
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Error refreshing Trae token: ${error.message}`);
return null;
}
}, log);
}
// Zed access_token is long-lived; auth flow returns no refresh_token.
// No refresh possible — re-login required when token expires/revoked.
// Mirrors cursor/kilocode null-refresh pattern.
export function refreshZedToken() {
return null;
}
// Windsurf apiKey is the long-lived terminal credential (no OAuth2 refresh_token
// grant yields a fresh apiKey). Refresh handled out-of-band by the caller.
// TODO(firebase): if short-lived Firebase JWT credentials must be refreshed,
// re-run RegisterUser with the refreshed Firebase JWT (separate code path).
export async function refreshWindsurfToken(credentials, log) {
log?.info?.(
"TOKEN_REFRESH",
"windsurf: apiKey is long-lived (no refresh_token flow) — skipping"
);
return null;
}
+4
View File
@@ -13,6 +13,8 @@ import { getMiniMaxUsage } from "./usage/minimax.js";
import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.js"; import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.js";
import { getGrokCliUsage } from "./usage/grok-cli.js"; import { getGrokCliUsage } from "./usage/grok-cli.js";
import { getOrbitUsage } from "./usage/orbit.js"; import { getOrbitUsage } from "./usage/orbit.js";
import { getKimiUsage } from "./usage/kimi.js";
import { getDeepseekUsage } from "./usage/deepseek.js";
import { import {
getQwenUsage, getQwenUsage,
getIflowUsage, getIflowUsage,
@@ -47,6 +49,8 @@ const USAGE_HANDLERS = {
"codebuddy-cn": (c) => getCodeBuddyCnUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions), "codebuddy-cn": (c) => getCodeBuddyCnUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions),
"grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), "grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
"orbit-provider": (c) => getOrbitUsage(c.apiKey, c.proxyOptions), "orbit-provider": (c) => getOrbitUsage(c.apiKey, c.proxyOptions),
kimi: (c) => getKimiUsage(c.accessToken, c.apiKey, c.proxyOptions, c.providerSpecificData),
deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions),
}; };
export async function getUsageForProvider(connection, proxyOptions = null) { export async function getUsageForProvider(connection, proxyOptions = null) {
+112
View File
@@ -0,0 +1,112 @@
/**
* DeepSeek usage GET https://api.deepseek.com/user/balance
* Auth: Bearer <apiKey>
*/
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
import { toFiniteNumber } from "./shared.js";
const BALANCE_URL = "https://api.deepseek.com/user/balance";
function parseBalanceInfos(data) {
const list = Array.isArray(data?.balance_infos) ? data.balance_infos : [];
const results = [];
for (const item of list) {
if (!item || typeof item !== "object") continue;
const currency =
typeof item.currency === "string" ? item.currency.toUpperCase() : "";
if (!currency) continue;
const totalBalance = toFiniteNumber(
item.total_balance ?? item.totalBalance,
0,
);
results.push({
currency,
totalBalance,
grantedBalance: toFiniteNumber(
item.granted_balance ?? item.grantedBalance,
0,
),
toppedUpBalance: toFiniteNumber(
item.topped_up_balance ?? item.toppedUpBalance,
0,
),
});
}
return results;
}
/**
* @param {string|null|undefined} apiKey
* @param {object|null} proxyOptions
*/
export async function getDeepseekUsage(apiKey = null, proxyOptions = null) {
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
return { message: "DeepSeek API key not available. Add a key to view usage." };
}
try {
const response = await proxyAwareFetch(
BALANCE_URL,
{
method: "GET",
headers: {
Authorization: `Bearer ${apiKey.trim()}`,
"Content-Type": "application/json",
Accept: "application/json",
},
},
proxyOptions,
);
if (response.status === 401 || response.status === 403) {
return {
plan: "DeepSeek",
message: "DeepSeek authentication failed. Check the API key.",
};
}
if (!response.ok) {
const errText = await response.text().catch(() => "");
return {
plan: "DeepSeek",
message: `DeepSeek balance API error (${response.status})${errText ? `: ${errText.slice(0, 120)}` : ""}`,
};
}
const data = await response.json().catch(() => null);
if (!data || typeof data !== "object") {
return { message: "DeepSeek balance response was not JSON." };
}
const balances = parseBalanceInfos(data);
if (balances.length === 0) {
return {
plan: "DeepSeek",
message: "DeepSeek connected. No balance data returned.",
};
}
const isAvailable = data.is_available === true || data.isAvailable === true;
const quotas = {};
for (const b of balances) {
const total = Math.max(0, b.totalBalance);
// Credit pot: show full remaining against current balance; never set absolute
// `remaining` — QuotaTable treats it as a 0100 percentage.
quotas[`Balance (${b.currency})`] = {
used: 0,
total,
remainingPercentage: total > 0 ? 100 : 0,
resetAt: null,
unlimited: total > 0,
};
}
return {
plan: isAvailable ? "DeepSeek" : "DeepSeek (Insufficient Balance)",
quotas,
};
} catch (error) {
return { message: `DeepSeek error: ${error.message}` };
}
}
+77
View File
@@ -29,11 +29,19 @@ import {
GROK_CLI_USER_AGENT, GROK_CLI_USER_AGENT,
GROK_CLI_VERSION, GROK_CLI_VERSION,
} from "../../config/grokCli.js"; } from "../../config/grokCli.js";
import { decodeGrokCreditsFrame } from "./grokCliQuotaFrame.js";
const USAGE = U("grok-cli"); const USAGE = U("grok-cli");
const BILLING_URL = USAGE.url || "https://cli-chat-proxy.grok.com/v1/billing?format=credits"; const BILLING_URL = USAGE.url || "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
const USER_URL = USAGE.userUrl || "https://cli-chat-proxy.grok.com/v1/user?include=subscription"; const USER_URL = USAGE.userUrl || "https://cli-chat-proxy.grok.com/v1/user?include=subscription";
// SuperGrok weekly pool.
const GRPC_CREDITS_URL =
"https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig";
// Empty gRPC-web request frame (flag 0 + length 0). Without it upstream returns
// grpc-status 13 "Missing request message." with a 0-byte body.
const GRPC_WEB_EMPTY_REQUEST_FRAME = Buffer.from([0, 0, 0, 0, 0]);
/** Unwrap protobuf-json `{ val: n }` or plain numbers/strings. */ /** Unwrap protobuf-json `{ val: n }` or plain numbers/strings. */
function unwrapVal(value, fallback = 0) { function unwrapVal(value, fallback = 0) {
if (value == null) return fallback; if (value == null) return fallback;
@@ -198,6 +206,21 @@ export function parseGrokCliBilling(billing, user = null) {
}; };
} }
// SuperGrok weekly shared-pool usage (subscription tier). creditUsagePercent is
// the single total used %; productUsage is a breakdown legend, NOT independent
// quotas — never split it into separate bars.
const usedPct = unwrapVal(
config.creditUsagePercent ?? config.credit_usage_percent ?? root.creditUsagePercent,
NaN,
);
if (Number.isFinite(usedPct) && usedPct >= 0) {
quotas["Weekly SuperGrok"] = makeQuota({
used: Math.max(0, Math.min(100, usedPct)),
total: 100,
resetAt: periodEnd,
});
}
// Opportunistic richer credit envelopes (future / other account types) // Opportunistic richer credit envelopes (future / other account types)
const creditBags = [ const creditBags = [
root.credits, root.credits,
@@ -256,6 +279,50 @@ export function parseGrokCliBilling(billing, user = null) {
}; };
} }
/**
* Live SuperGrok weekly pool via gRPC-web GetGrokCreditsConfig.
* Fail-open: any network/auth/parse failure returns null.
* @returns {{ percentUsed: number, resetAt: string|null } | null}
*/
export async function fetchGrokCliCreditsConfig(accessToken, proxyOptions = null) {
if (!accessToken) return null;
try {
const res = await proxyAwareFetch(
GRPC_CREDITS_URL,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/grpc-web+proto",
"X-Grpc-Web": "1",
Accept: "application/grpc-web+proto",
},
body: GRPC_WEB_EMPTY_REQUEST_FRAME,
},
proxyOptions,
);
if (!res?.ok) return null;
const arrayBuffer = await res.arrayBuffer().catch(() => null);
if (!arrayBuffer) return null;
return decodeGrokCreditsFrame(Buffer.from(arrayBuffer));
} catch {
return null;
}
}
function quotasFromGrpcCredits(decoded) {
if (!decoded || !Number.isFinite(decoded.percentUsed)) return null;
// Round for bar display (fixed32 ratio * 100 can be 34.999… for 0.35)
const used = Math.round(Math.max(0, Math.min(100, decoded.percentUsed)));
return {
"Weekly SuperGrok": makeQuota({
used,
total: 100,
resetAt: decoded.resetAt || null,
}),
};
}
/** /**
* @param {string} accessToken * @param {string} accessToken
* @param {object|null} providerSpecificData * @param {object|null} providerSpecificData
@@ -306,6 +373,16 @@ export async function getGrokCliUsage(accessToken, providerSpecificData = null,
const parsed = parseGrokCliBilling(billing, user); const parsed = parseGrokCliBilling(billing, user);
if (!parsed.quotas || Object.keys(parsed.quotas).length === 0) { if (!parsed.quotas || Object.keys(parsed.quotas).length === 0) {
// Paid SuperGrok often returns cap=0 over REST but exposes the shared
// weekly pool on GetGrokCreditsConfig — try that before giving up.
const grpc = await fetchGrokCliCreditsConfig(accessToken, proxyOptions);
const grpcQuotas = quotasFromGrpcCredits(grpc);
if (grpcQuotas) {
return {
plan: parsed.plan,
quotas: grpcQuotas,
};
}
return { return {
plan: parsed.plan, plan: parsed.plan,
message: parsed.subscriptionAccess message: parsed.subscriptionAccess
@@ -0,0 +1,191 @@
/**
* gRPC-web frame decoder for xAI GetGrokCreditsConfig
* (grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig).
*
* Real response shape (live capture 2026-07-20):
* top-level field 1 (length-delimited) nested credits info
* subfield 1 (fixed32 float) usage ratio 0..1
* subfield 5 (Timestamp{seconds,nanos}) credit-pool reset time
*
* Fail-open: any malformed buffer returns null, never throws.
*/
const FIELD_CREDITS_INFO = 1;
const CREDITS_FIELD_USAGE_RATIO = 1;
const CREDITS_FIELD_RESET_TIMESTAMP = 5;
const TIMESTAMP_FIELD_SECONDS = 1;
const TIMESTAMP_FIELD_NANOS = 2;
const WIRE_TYPE_VARINT = 0;
const WIRE_TYPE_FIXED64 = 1;
const WIRE_TYPE_LENGTH_DELIMITED = 2;
const WIRE_TYPE_FIXED32 = 5;
const GRPC_WEB_TRAILER_FLAG_BIT = 0x80;
const MAX_VARINT_SHIFT_BITS = 70n;
/**
* Validate a gRPC-web frame header at `offset`.
* @returns {{ flag: number, payloadStart: number, payloadLength: number } | null}
*/
export function probeFrameHeader(buffer, offset = 0) {
if (!Buffer.isBuffer(buffer) || offset < 0 || buffer.length - offset < 5) return null;
const flag = buffer[offset];
if (flag !== 0x00 && flag !== 0x01 && flag !== 0x80 && flag !== 0x81) return null;
const payloadStart = offset + 5;
const payloadLength = buffer.readUInt32BE(offset + 1);
if (payloadLength > buffer.length - payloadStart) return null;
return { flag, payloadStart, payloadLength };
}
function readVarint(buffer, offset) {
let result = 0n;
let shift = 0n;
let pos = offset;
for (;;) {
if (pos >= buffer.length) return null;
const byte = buffer[pos];
result |= BigInt(byte & 0x7f) << shift;
pos += 1;
if ((byte & 0x80) === 0) break;
shift += 7n;
if (shift > MAX_VARINT_SHIFT_BITS) return null;
}
return { value: Number(result), next: pos };
}
function readLengthDelimitedField(buffer, offset) {
const lengthResult = readVarint(buffer, offset);
if (!lengthResult) return null;
const { value: length, next: bodyStart } = lengthResult;
if (length < 0 || bodyStart + length > buffer.length) return null;
return {
field: { wireType: WIRE_TYPE_LENGTH_DELIMITED, bytes: buffer.subarray(bodyStart, bodyStart + length) },
next: bodyStart + length,
};
}
function readFixedWidthField(buffer, offset, width, wireType) {
if (offset + width > buffer.length) return null;
return {
field: { wireType, bytes: buffer.subarray(offset, offset + width) },
next: offset + width,
};
}
function readField(buffer, offset) {
const tagResult = readVarint(buffer, offset);
if (!tagResult) return null;
const fieldNumber = tagResult.value >>> 3;
const wireType = tagResult.value & 0x7;
if (fieldNumber === 0) return null;
if (wireType === WIRE_TYPE_VARINT) {
const valueResult = readVarint(buffer, tagResult.next);
if (!valueResult) return null;
return {
fieldNumber,
field: { wireType: WIRE_TYPE_VARINT, value: valueResult.value },
next: valueResult.next,
};
}
if (wireType === WIRE_TYPE_LENGTH_DELIMITED) {
const result = readLengthDelimitedField(buffer, tagResult.next);
return result ? { fieldNumber, field: result.field, next: result.next } : null;
}
if (wireType === WIRE_TYPE_FIXED64) {
const result = readFixedWidthField(buffer, tagResult.next, 8, WIRE_TYPE_FIXED64);
return result ? { fieldNumber, field: result.field, next: result.next } : null;
}
if (wireType === WIRE_TYPE_FIXED32) {
const result = readFixedWidthField(buffer, tagResult.next, 4, WIRE_TYPE_FIXED32);
return result ? { fieldNumber, field: result.field, next: result.next } : null;
}
return null;
}
function decodeFields(buffer) {
const fields = new Map();
let offset = 0;
while (offset < buffer.length) {
const result = readField(buffer, offset);
if (!result) return null;
fields.set(result.fieldNumber, result.field);
offset = result.next;
}
return fields;
}
function findDataFramePayload(buffer) {
let offset = 0;
while (offset < buffer.length) {
const frame = probeFrameHeader(buffer, offset);
if (!frame) return null;
const frameEnd = frame.payloadStart + frame.payloadLength;
const isTrailer = (frame.flag & GRPC_WEB_TRAILER_FLAG_BIT) !== 0;
if (!isTrailer) {
return buffer.subarray(frame.payloadStart, frameEnd);
}
offset = frameEnd;
}
return null;
}
function extractNestedMessage(field) {
if (!field || field.wireType !== WIRE_TYPE_LENGTH_DELIMITED) return null;
return decodeFields(field.bytes);
}
function extractUsageRatio(field) {
if (!field) return 0; // proto3 omission = 0% used
if (field.wireType === WIRE_TYPE_FIXED32) return field.bytes.readFloatLE(0);
if (field.wireType === WIRE_TYPE_FIXED64) return field.bytes.readDoubleLE(0);
return null;
}
function extractResetAt(field) {
if (!field || field.wireType !== WIRE_TYPE_LENGTH_DELIMITED) return null;
const timestampFields = decodeFields(field.bytes);
if (!timestampFields) return null;
const secondsField = timestampFields.get(TIMESTAMP_FIELD_SECONDS);
const nanosField = timestampFields.get(TIMESTAMP_FIELD_NANOS);
const seconds = secondsField?.wireType === WIRE_TYPE_VARINT ? secondsField.value : 0;
const nanos = nanosField?.wireType === WIRE_TYPE_VARINT ? nanosField.value : 0;
const millis = seconds * 1000 + Math.round(nanos / 1_000_000);
const parsed = new Date(millis);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}
/**
* Decode GetGrokCreditsConfig response `{ percentUsed: 0-100, resetAt }` or null.
* @param {Buffer} buffer
* @returns {{ percentUsed: number, resetAt: string|null } | null}
*/
export function decodeGrokCreditsFrame(buffer) {
if (!buffer || !Buffer.isBuffer(buffer) || buffer.length === 0) return null;
try {
const framed = probeFrameHeader(buffer, 0) !== null;
const payload = framed ? findDataFramePayload(buffer) : buffer;
if (!payload) return null;
const topLevelFields = decodeFields(payload);
if (!topLevelFields) return null;
const creditsInfo = extractNestedMessage(topLevelFields.get(FIELD_CREDITS_INFO));
if (!creditsInfo) return null;
const usageRatio = extractUsageRatio(creditsInfo.get(CREDITS_FIELD_USAGE_RATIO));
if (usageRatio === null || !Number.isFinite(usageRatio) || usageRatio < 0) return null;
return {
percentUsed: Math.min(100, usageRatio * 100),
resetAt: extractResetAt(creditsInfo.get(CREDITS_FIELD_RESET_TIMESTAMP)),
};
} catch {
return null;
}
}
+211
View File
@@ -0,0 +1,211 @@
/**
* Kimi Coding usage GET /v1/usages
*
* Dual auth (single provider id `kimi`):
* - apiKey present x-api-key only (platform / coding API key)
* - else accessToken Bearer + X-Msh-* (device-code OAuth)
*
* Note: chat messages use combined x-api-key; /usages OAuth is Bearer.
* 403 permission_denied is NOT auth-expired account lacks usage feature / sub.
*/
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
import { parseResetTime, toFiniteNumber } from "./shared.js";
import { buildKimiHeaders } from "../../config/appConstants.js";
const USAGE_URL = "https://api.kimi.com/coding/v1/usages";
const PLAN_LEVELS = {
LEVEL_BASIC: "Moderato",
LEVEL_INTERMEDIATE: "Allegretto",
LEVEL_ADVANCED: "Allegro",
LEVEL_STANDARD: "Vivace",
};
function getKimiPlanName(level) {
if (!level) return "";
const key = String(level);
if (PLAN_LEVELS[key]) return PLAN_LEVELS[key];
return key.replace(/^LEVEL_/, "").toLowerCase();
}
/** Best-effort extract human message from Kimi error JSON (403 body is Connect-RPC-ish). */
export function formatKimiUsageError(status, responseText) {
let parsed = null;
try {
parsed = JSON.parse(responseText || "");
} catch {
/* plain text */
}
const detail0 = Array.isArray(parsed?.details) ? parsed.details[0] : null;
const debug = detail0?.debug || parsed?.debug || null;
const reason = debug?.reason || parsed?.reason || "";
const localized =
debug?.localizedMessage?.message ||
detail0?.localizedMessage?.message ||
parsed?.message ||
"";
if (status === 401) {
return "Kimi authentication expired. Please re-authorize.";
}
// Live OAuth token without Kimi Code usage entitlement returns 403
// REASON_FEATURE_NO_PERMISSION — not an expired session.
if (
status === 403 &&
(reason === "REASON_FEATURE_NO_PERMISSION" ||
/permission_denied|do not have permission|subscribe/i.test(
`${parsed?.code || ""} ${localized} ${responseText || ""}`,
))
) {
return (
localized ||
"Kimi connected, but this account has no permission to view usage. Subscribe to Kimi Code to access quota."
);
}
const snippet = (localized || responseText || "").slice(0, 100);
return snippet
? `Kimi Coding connected. API Error ${status}: ${snippet}`
: `Kimi Coding connected. API Error ${status}`;
}
function makeQuota({ used, total, remaining, resetAt }) {
const safeTotal = Math.max(0, toFiniteNumber(total, 0));
const safeUsed = Math.max(0, toFiniteNumber(used, 0));
// Prefer provider remaining when present; never set absolute `remaining`
// on the quota object — QuotaTable treats it as a 0100 percentage.
let remainingPct;
if (safeTotal > 0 && remaining != null && Number.isFinite(Number(remaining))) {
remainingPct = (Math.max(0, Number(remaining)) / safeTotal) * 100;
} else if (safeTotal > 0) {
remainingPct = (Math.max(0, safeTotal - safeUsed) / safeTotal) * 100;
} else {
remainingPct = 0;
}
return {
used: safeUsed,
total: safeTotal,
remainingPercentage: remainingPct,
resetAt: resetAt || null,
unlimited: false,
};
}
/**
* @param {string|null|undefined} accessToken
* @param {string|null|undefined} apiKey
* @param {object|null} proxyOptions
* @param {object|null} providerSpecificData
*/
export async function getKimiUsage(
accessToken = null,
apiKey = null,
proxyOptions = null,
providerSpecificData = null,
) {
const useApiKey = typeof apiKey === "string" && apiKey.length > 0;
const useOAuth = !useApiKey && typeof accessToken === "string" && accessToken.length > 0;
if (!useApiKey && !useOAuth) {
return { message: "Kimi access token or API key not available." };
}
const authHeaders = useApiKey
? { "x-api-key": apiKey }
: {
Authorization: `Bearer ${accessToken}`,
...buildKimiHeaders(providerSpecificData?.deviceId),
};
try {
const response = await proxyAwareFetch(
USAGE_URL,
{
method: "GET",
headers: {
...authHeaders,
"Content-Type": "application/json",
Accept: "application/json",
},
},
proxyOptions,
);
const responseText = await response.text().catch(() => "");
if (!response.ok) {
return {
plan: "Kimi Coding",
message: formatKimiUsageError(response.status, responseText),
};
}
let data;
try {
data = JSON.parse(responseText || "{}");
} catch {
return {
plan: "Kimi Coding",
message: "Kimi Coding connected. Invalid JSON response from API.",
};
}
const quotas = {};
const usageObj = data?.usage && typeof data.usage === "object" ? data.usage : {};
const usageLimit = toFiniteNumber(usageObj.limit ?? usageObj.Limit, 0);
const usageUsed = toFiniteNumber(usageObj.used ?? usageObj.Used, 0);
const usageRemainingRaw = usageObj.remaining ?? usageObj.Remaining;
const usageRemaining =
usageRemainingRaw != null && usageRemainingRaw !== ""
? toFiniteNumber(usageRemainingRaw, NaN)
: NaN;
const usageResetTime =
usageObj.resetTime || usageObj.ResetTime || usageObj.reset_at || usageObj.resetAt;
if (usageLimit > 0) {
quotas.Weekly = makeQuota({
used: usageUsed,
total: usageLimit,
remaining: Number.isFinite(usageRemaining) ? usageRemaining : null,
resetAt: parseResetTime(usageResetTime),
});
}
const limitsArray = Array.isArray(data?.limits) ? data.limits : [];
for (const item of limitsArray) {
if (!item || typeof item !== "object") continue;
const detail = item.detail && typeof item.detail === "object" ? item.detail : {};
const limit = toFiniteNumber(detail.limit ?? detail.Limit, 0);
const remaining = toFiniteNumber(detail.remaining ?? detail.Remaining, NaN);
const resetTime = detail.resetTime || detail.reset_at || detail.resetAt;
if (limit > 0) {
const rem = Number.isFinite(remaining) ? remaining : Math.max(0, limit);
quotas.Ratelimit = makeQuota({
used: Math.max(0, limit - rem),
total: limit,
remaining: rem,
resetAt: parseResetTime(resetTime),
});
}
}
const membershipLevel = data?.user?.membership?.level;
const planName = getKimiPlanName(membershipLevel) || "Kimi Coding";
if (Object.keys(quotas).length > 0) {
return { plan: planName, quotas };
}
return {
plan: planName,
message: "Kimi Coding connected. Usage tracked per request.",
};
} catch (error) {
return {
message: `Kimi Coding connected. Unable to fetch usage: ${error.message}`,
};
}
}
+5
View File
@@ -20,6 +20,11 @@ export const QODER_USERINFO_URL = `${QODER_OPENAPI_BASE}/api/v1/userinfo`;
export const QODER_QUOTA_USAGE_URL = `${QODER_OPENAPI_BASE}/api/v2/quota/usage`; export const QODER_QUOTA_USAGE_URL = `${QODER_OPENAPI_BASE}/api/v2/quota/usage`;
export const QODER_REFRESH_TOKEN_URL = `${QODER_CENTER_BASE}/algo/api/v3/user/refresh_token`; export const QODER_REFRESH_TOKEN_URL = `${QODER_CENTER_BASE}/algo/api/v3/user/refresh_token`;
// PAT (Personal Access Token, pt-...) → short-lived job token (jt-...) exchange.
// PATs cannot sign COSY requests directly — they must be exchanged first.
// This endpoint is NOT COSY-signed (plain JSON POST).
export const QODER_JOB_TOKEN_EXCHANGE_URL = `${QODER_OPENAPI_BASE}/api/v1/jobToken/exchange`;
// Inference endpoints (under /algo on api3.qoder.sh, all COSY-signed) // Inference endpoints (under /algo on api3.qoder.sh, all COSY-signed)
export const QODER_CHAT_SIG_PATH = "/api/v2/service/pro/sse/agent_chat_generation"; export const QODER_CHAT_SIG_PATH = "/api/v2/service/pro/sse/agent_chat_generation";
export const QODER_CHAT_URL = `${QODER_CHAT_BASE}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common`; export const QODER_CHAT_URL = `${QODER_CHAT_BASE}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common`;
+415
View File
@@ -0,0 +1,415 @@
// Zed hosted LLM aggregator — auth + model-catalog helpers.
//
// Zed's cloud (cloud.zed.dev) authenticates native apps with a self-generated RSA
// keypair instead of a registered OAuth client_id/secret:
// 1. Client generates an ephemeral RSA keypair.
// 2. Sends the public key to zed.dev/native_app_signin.
// 3. User signs in via browser; Zed redirects to a local callback with the
// access token RSA-encrypted against the public key.
// 4. Client decrypts locally with the private key that never left the host.
// No embedded client_id/secret — the credential is a per-login keypair.
import crypto from "node:crypto";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
export const ZED_WEB_BASE_URL = "https://zed.dev";
export const ZED_CLOUD_BASE_URL = "https://cloud.zed.dev";
export const ZED_LLM_BASE_URL = "https://cloud.zed.dev";
export const ZED_HEADERS = {
expiredToken: "x-zed-expired-token",
outdatedToken: "x-zed-outdated-token",
clientSupportsStatus: "x-zed-client-supports-status-messages",
clientSupportsStreamEnded:
"x-zed-client-supports-stream-ended-request-completion-status",
serverSupportsStatus: "x-zed-server-supports-status-messages",
clientSupportsXai: "x-zed-client-supports-x-ai",
systemId: "x-zed-system-id",
};
const PRIVATE_KEY_PREFIX = "zed-rsa-pkcs1:";
const LLM_TOKEN_TTL_MS = 50 * 60 * 1000;
const MODEL_CACHE_TTL_MS = 60 * 60 * 1000;
const llmTokenCache = new Map();
const modelCache = new Map();
const modelInflight = new Map();
function b64url(value) {
return Buffer.from(value).toString("base64url");
}
function b64urlPadded(buf) {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
}
function fromB64url(value) {
return Buffer.from(String(value || ""), "base64url").toString("utf8");
}
function normalizeBaseUrl(baseUrl, fallback) {
return String(baseUrl || fallback).replace(/\/+$/, "");
}
function zedUrl(config, key, path, fallbackBase) {
const base = normalizeBaseUrl(config?.[key], fallbackBase);
return `${base}${path}`;
}
/** Encode a PEM private key as an opaque verifier (flows through the OAuth codeVerifier slot). */
export function encodeZedPrivateKeyVerifier(privateKeyPem) {
return `${PRIVATE_KEY_PREFIX}${b64url(privateKeyPem)}`;
}
export function decodeZedPrivateKeyVerifier(verifier) {
const value = String(verifier || "");
if (!value.startsWith(PRIVATE_KEY_PREFIX)) {
throw new Error("Missing Zed private key verifier; restart the login flow");
}
return fromB64url(value.slice(PRIVATE_KEY_PREFIX.length));
}
/** Generate a fresh RSA keypair + the zed.dev native_app_signin URL for it. */
export function createZedNativeAuthData(config = {}, options = {}) {
const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "pkcs1", format: "der" },
privateKeyEncoding: { type: "pkcs1", format: "pem" },
});
const nativeAppPort = Number(
options.nativeAppPort || config.defaultNativeAppPort || 58443,
);
const systemId = options.systemId || crypto.randomUUID();
const publicKeyString = b64urlPadded(publicKey);
const signInUrl = new URL(
`${normalizeBaseUrl(config.webBaseUrl, ZED_WEB_BASE_URL)}/native_app_signin`,
);
signInUrl.searchParams.set("native_app_port", String(nativeAppPort));
signInUrl.searchParams.set("native_app_public_key", publicKeyString);
if (systemId) signInUrl.searchParams.set("system_id", systemId);
return {
authUrl: signInUrl.toString(),
privateKeyVerifier: encodeZedPrivateKeyVerifier(privateKey),
nativeAppPort,
systemId,
publicKey: publicKeyString,
};
}
/** Parse the pasted native-app callback URL/JSON/query into userId + encrypted token. */
export function parseZedCallbackPayload(input) {
const raw = String(input || "").trim();
if (!raw) throw new Error("Missing Zed callback URL");
let data = {};
try {
data = JSON.parse(raw);
} catch {
let url;
try {
url = new URL(raw);
} catch {
try {
url = new URL(`http://127.0.0.1/?${raw.replace(/^\?/, "")}`);
} catch {
throw new Error("Invalid Zed callback URL");
}
}
url.searchParams.forEach((value, key) => {
data[key] = value;
});
}
const userId = data.user_id || data.userId;
const encryptedAccessToken = data.access_token || data.accessToken || data.token;
if (!userId || !encryptedAccessToken) {
throw new Error("Zed callback must include user_id and access_token");
}
return { userId: String(userId), encryptedAccessToken: String(encryptedAccessToken) };
}
/** Decrypt the RSA-encrypted access token using the stored private key. */
export function decryptZedAccessToken(encryptedAccessToken, privateKeyVerifier) {
const privateKey = decodeZedPrivateKeyVerifier(privateKeyVerifier);
const encrypted = Buffer.from(String(encryptedAccessToken), "base64url");
try {
return crypto
.privateDecrypt(
{ key: privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" },
encrypted,
)
.toString("utf8");
} catch (oaepError) {
try {
return crypto
.privateDecrypt(
{ key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING },
encrypted,
)
.toString("utf8");
} catch {
const message = oaepError instanceof Error ? oaepError.message : String(oaepError);
throw new Error(`Failed to decrypt Zed access token: ${message}`);
}
}
}
export function buildZedUserAuthHeader(credentials) {
const psd = credentials?.providerSpecificData || {};
const userId = psd.userId || credentials?.userId;
const accessToken = credentials?.accessToken || credentials?.apiKey;
if (!userId || !accessToken) {
throw new Error("Zed credential is missing userId or accessToken");
}
return `${userId} ${accessToken}`;
}
function getSystemId(credentials) {
return String(
credentials?.providerSpecificData?.systemId || credentials?.systemId || "",
);
}
async function fetchJson(url, options) {
const res = await proxyAwareFetch(url, options);
const text = await res.text();
let data = null;
if (text) {
try {
data = JSON.parse(text);
} catch {
data = { raw: text };
}
}
if (!res.ok) {
const message =
data?.message || data?.error?.message || data?.error || text || `HTTP ${res.status}`;
const err = new Error(String(message));
err.status = res.status;
err.body = data;
throw err;
}
return data;
}
export async function fetchZedAuthenticatedUser(credentials, options = {}) {
const config = options.config || {};
const headers = {
Accept: "application/json",
Authorization: buildZedUserAuthHeader(credentials),
};
const systemId = getSystemId(credentials);
if (systemId) headers[ZED_HEADERS.systemId] = systemId;
return fetchJson(zedUrl(config, "cloudBaseUrl", "/client/users/me", ZED_CLOUD_BASE_URL), {
method: "GET",
headers,
signal: options.signal ?? undefined,
});
}
function normalizeOrganizationId(value) {
if (!value) return "";
if (typeof value === "string") return value;
if (typeof value === "object" && value !== null) {
if (typeof value[0] === "string") return value[0];
if (typeof value.id === "string") return value.id;
}
return String(value);
}
export function resolveZedOrganizationId(credentials, userInfo = null) {
const psd = credentials?.providerSpecificData || {};
const explicit = normalizeOrganizationId(psd.organizationId || psd.defaultOrganizationId);
if (explicit) return explicit;
const fromUser = normalizeOrganizationId(
userInfo?.default_organization_id || userInfo?.defaultOrganizationId,
);
if (fromUser) return fromUser;
const orgs = userInfo?.organizations || [];
const org = orgs.find((item) => item?.is_personal) || orgs[0];
return normalizeOrganizationId(org?.id);
}
function zedUserCacheKey(credentials, organizationId) {
const psd = credentials?.providerSpecificData || {};
const userId = psd.userId || credentials?.userId || "unknown";
const token = credentials?.accessToken || credentials?.apiKey || "";
return `${userId}:${organizationId || "default"}:${token.slice(-16)}`;
}
function zedModelCacheKey(credentials) {
const psd = credentials?.providerSpecificData || {};
const org = psd.organizationId || psd.defaultOrganizationId || "default";
const token = credentials?.accessToken || credentials?.apiKey || "";
return `${psd.userId || "unknown"}:${org}:${token.slice(-16)}`;
}
export async function fetchZedLlmToken(credentials, options = {}) {
const config = options.config || {};
let organizationId = options.organizationId || resolveZedOrganizationId(credentials);
if (!organizationId) {
const userInfo = await fetchZedAuthenticatedUser(credentials, options);
organizationId = resolveZedOrganizationId(credentials, userInfo);
}
if (!organizationId) throw new Error("No Zed organization selected");
const cacheKey = zedUserCacheKey(credentials, organizationId);
const cached = llmTokenCache.get(cacheKey);
if (!options.forceRefresh && cached && cached.expiresAt > Date.now()) return cached.token;
const headers = {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: buildZedUserAuthHeader(credentials),
};
const systemId = getSystemId(credentials);
if (systemId) headers[ZED_HEADERS.systemId] = systemId;
const data = await fetchJson(
zedUrl(config, "cloudBaseUrl", "/client/llm_tokens", ZED_CLOUD_BASE_URL),
{
method: "POST",
headers,
body: JSON.stringify({ organization_id: organizationId }),
signal: options.signal ?? undefined,
},
);
const token =
typeof data?.token === "string" ? data.token : data?.token?.[0] || data?.token?.value;
if (!token) throw new Error("Zed did not return an LLM token");
llmTokenCache.set(cacheKey, { token, expiresAt: Date.now() + LLM_TOKEN_TTL_MS });
return token;
}
export function shouldRefreshZedLlmToken(response) {
return (
response?.status === 401 ||
!!response?.headers?.has?.(ZED_HEADERS.expiredToken) ||
!!response?.headers?.has?.(ZED_HEADERS.outdatedToken)
);
}
export async function zedLlmFetch(credentials, path, options = {}) {
const config = options.config || {};
const url = zedUrl(config, "llmBaseUrl", path, ZED_LLM_BASE_URL);
const buildRequest = async (forceRefresh) => {
const token = await fetchZedLlmToken(credentials, { ...options, forceRefresh });
return proxyAwareFetch(url, {
...options.fetchOptions,
headers: {
...(options.fetchOptions?.headers || {}),
Authorization: `Bearer ${token}`,
},
signal: options.signal ?? undefined,
});
};
let response = await buildRequest(false);
if (shouldRefreshZedLlmToken(response)) {
response = await buildRequest(true);
}
return response;
}
function normalizeZedModelId(id) {
if (!id) return "";
if (typeof id === "string") return id;
if (typeof id === "object" && id !== null) {
if (typeof id[0] === "string") return id[0];
if (typeof id.id === "string") return id.id;
}
return String(id);
}
export function mapZedModel(model) {
const id = normalizeZedModelId(model?.id);
if (!id) return null;
return {
id,
name: model.display_name || model.displayName || id,
provider: model.provider,
isLatest: !!model.is_latest,
contextLength: model.max_token_count ?? model.maxTokenCount,
contextLengthInMaxMode: model.max_token_count_in_max_mode ?? model.maxTokenCountInMaxMode,
maxOutputTokens: model.max_output_tokens ?? model.maxOutputTokens,
supportsTools: !!model.supports_tools,
supportsImages: !!model.supports_images,
supportsThinking: !!model.supports_thinking,
supportsDisablingThinking: !!model.supports_disabling_thinking,
supportsFastMode: !!model.supports_fast_mode,
supportsServerSideCompaction: !!model.supports_server_side_compaction,
supportedEffortLevels: model.supported_effort_levels ?? model.supportedEffortLevels ?? [],
supportsStreamingTools: !!model.supports_streaming_tools,
supportsParallelToolCalls: !!model.supports_parallel_tool_calls,
isDisabled: !!model.is_disabled,
disabledReason: model.disabled_reason ?? null,
};
}
/** Resolve (and cache) the live Zed model catalog. Never hardcoded — always a live fetch. */
export async function resolveZedModels(credentials, options = {}) {
if (!credentials?.accessToken) return null;
const key = zedModelCacheKey(credentials);
const cached = modelCache.get(key);
if (!options.forceRefresh && cached && cached.expiresAt > Date.now()) return cached;
const existing = modelInflight.get(key);
if (existing && !options.forceRefresh) return existing;
const promise = (async () => {
const response = await zedLlmFetch(credentials, "/models", {
...options,
fetchOptions: {
method: "GET",
headers: {
Accept: "application/json",
[ZED_HEADERS.clientSupportsXai]: "true",
},
},
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`Zed models failed: ${response.status} ${text}`);
}
const data = await response.json();
const rawModels = Array.isArray(data?.models) ? data.models : [];
const models = rawModels
.map(mapZedModel)
.filter(Boolean)
.filter((model) => !model.isDisabled);
const rawById = new Map();
for (const raw of rawModels) {
const id = normalizeZedModelId(raw?.id);
if (id) rawById.set(id, raw);
}
const entry = {
expiresAt: Date.now() + MODEL_CACHE_TTL_MS,
models,
rawModels,
rawById,
defaultModel: normalizeZedModelId(data?.default_model ?? data?.defaultModel),
defaultFastModel: normalizeZedModelId(data?.default_fast_model ?? data?.defaultFastModel),
recommendedModels: (data?.recommended_models || data?.recommendedModels || [])
.map(normalizeZedModelId)
.filter(Boolean),
};
modelCache.set(key, entry);
return entry;
})();
modelInflight.set(key, promise);
try {
return await promise;
} finally {
if (modelInflight.get(key) === promise) modelInflight.delete(key);
}
}
export function clearZedCaches() {
llmTokenCache.clear();
modelCache.clear();
modelInflight.clear();
}
@@ -0,0 +1,435 @@
import {
KIRO_TOOL_DESCRIPTION_MAX_LENGTH,
KIRO_TOOL_ID_MAX_LENGTH,
KIRO_TOOL_NAME_MAX_LENGTH,
} from "../../config/kiroConstants.js";
const TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
const TOOL_NAME_PATTERN = /[^a-zA-Z0-9_-]/g;
function clone(value) {
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function text(value) {
if (typeof value === "string") return value;
if (value == null) return "";
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function appendText(target, extra) {
if (!extra) return;
target.content = target.content ? `${target.content}\n\n${extra}` : extra;
}
function trimCodePoints(value, limit) {
return [...String(value || "")].slice(0, limit).join("");
}
function uniqueName(rawName, index, usedNames) {
const cleaned = String(rawName || "")
.trim()
.replace(TOOL_NAME_PATTERN, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
const base = trimCodePoints(cleaned || `tool_${index + 1}`, KIRO_TOOL_NAME_MAX_LENGTH);
let candidate = base;
let suffix = 2;
while (usedNames.has(candidate)) {
const tail = `_${suffix++}`;
candidate = `${base.slice(0, KIRO_TOOL_NAME_MAX_LENGTH - tail.length)}${tail}`;
}
usedNames.add(candidate);
return candidate;
}
function cleanSchemaValue(value) {
if (Array.isArray(value)) return value.map(cleanSchemaValue);
if (!value || typeof value !== "object") return value;
const cleaned = {};
for (const [key, child] of Object.entries(value)) {
if (key === "additionalProperties") continue;
if (key === "required" && Array.isArray(child) && child.length === 0) continue;
cleaned[key] = cleanSchemaValue(child);
}
return cleaned;
}
function normalizeRootSchema(schema) {
const cleaned = cleanSchemaValue(schema && typeof schema === "object" ? clone(schema) : {});
cleaned.type = "object";
if (!cleaned.properties || typeof cleaned.properties !== "object" || Array.isArray(cleaned.properties)) {
cleaned.properties = {};
}
if (Array.isArray(cleaned.required)) {
cleaned.required = [...new Set(cleaned.required.filter(
(name) => typeof name === "string" && Object.hasOwn(cleaned.properties, name)
))];
if (cleaned.required.length === 0) delete cleaned.required;
}
return cleaned;
}
/** Normalize OpenAI- or Claude-shaped tool definitions into Kiro tool specs. */
export function normalizeKiroToolSpecs(tools) {
const specs = [];
const nameMap = new Map();
const usedNames = new Set();
for (const [index, tool] of (Array.isArray(tools) ? tools : []).entries()) {
if (!tool || typeof tool !== "object") continue;
const rawName = tool.function?.name ?? tool.name;
if (typeof rawName !== "string" || !rawName.trim()) continue;
// A repeated definition with the same source name describes the same tool.
if (nameMap.has(rawName)) continue;
const name = uniqueName(rawName, index, usedNames);
nameMap.set(rawName, name);
const rawDescription = tool.function?.description ?? tool.description ?? `Tool: ${rawName}`;
const description = trimCodePoints(
String(rawDescription || `Tool: ${rawName}`),
KIRO_TOOL_DESCRIPTION_MAX_LENGTH
);
const schema = tool.function?.parameters ?? tool.parameters ?? tool.input_schema ?? {};
specs.push({
toolSpecification: {
name,
description,
inputSchema: { json: normalizeRootSchema(schema) },
},
});
}
return { specs, nameMap };
}
function toolCallText(toolUse) {
return `[Tool call: ${toolUse?.name || "unknown"}(${text(toolUse?.input || {})})]`;
}
function toolResultText(toolResult) {
const content = Array.isArray(toolResult?.content)
? toolResult.content.map((part) => text(part?.text ?? part)).filter(Boolean).join("\n")
: text(toolResult?.content);
return `[Tool result${toolResult?.status === "error" ? " (error)" : ""}: ${content}]`;
}
function mergeUser(target, source) {
appendText(target, source.content);
if (Array.isArray(source.images) && source.images.length > 0) {
target.images = [...(target.images || []), ...source.images];
}
const results = source.userInputMessageContext?.toolResults;
if (Array.isArray(results) && results.length > 0) {
target.userInputMessageContext ||= {};
target.userInputMessageContext.toolResults = [
...(target.userInputMessageContext.toolResults || []),
...results,
];
}
}
function mergeAssistant(target, source) {
appendText(target, source.content);
if (Array.isArray(source.toolUses) && source.toolUses.length > 0) {
target.toolUses = [...(target.toolUses || []), ...source.toolUses];
}
}
function normalizeTurns(history, currentMessage, modelId) {
const rawTurns = [...(Array.isArray(history) ? history : [])];
if (currentMessage) rawTurns.push(currentMessage);
const turns = [];
for (const raw of rawTurns) {
const isUser = !!raw?.userInputMessage;
const isAssistant = !!raw?.assistantResponseMessage;
if (isUser === isAssistant) continue;
const turn = isUser
? { userInputMessage: clone(raw.userInputMessage) }
: { assistantResponseMessage: clone(raw.assistantResponseMessage) };
const previous = turns[turns.length - 1];
if (turn.userInputMessage && previous?.userInputMessage) {
mergeUser(previous.userInputMessage, turn.userInputMessage);
} else if (turn.assistantResponseMessage && previous?.assistantResponseMessage) {
mergeAssistant(previous.assistantResponseMessage, turn.assistantResponseMessage);
} else {
turns.push(turn);
}
}
if (turns[0]?.assistantResponseMessage) {
turns.unshift({ userInputMessage: { content: "continue", modelId } });
}
if (turns.length === 0 || turns[turns.length - 1]?.assistantResponseMessage) {
turns.push({ userInputMessage: { content: "continue", modelId } });
}
for (const turn of turns) {
if (turn.userInputMessage) {
turn.userInputMessage.content = text(turn.userInputMessage.content).trim() || "continue";
turn.userInputMessage.modelId ||= modelId;
if (turn.userInputMessage.userInputMessageContext?.tools) {
delete turn.userInputMessage.userInputMessageContext.tools;
}
} else {
turn.assistantResponseMessage.content =
text(turn.assistantResponseMessage.content).trim() || "...";
}
}
return turns;
}
function rawId(value) {
return typeof value === "string" ? value : "";
}
function reserveToolId(value, turnIndex, callIndex, name, usedIds) {
const sanitized = rawId(value).replace(/[^a-zA-Z0-9_-]/g, "");
const generated = `call_msg${turnIndex}_tc${callIndex}_${name || "tool"}`;
const base = trimCodePoints(
TOOL_ID_PATTERN.test(sanitized) && sanitized ? sanitized : generated,
KIRO_TOOL_ID_MAX_LENGTH
);
let candidate = base;
let suffix = 2;
while (usedIds.has(candidate)) {
const tail = `_${suffix++}`;
candidate = `${base.slice(0, KIRO_TOOL_ID_MAX_LENGTH - tail.length)}${tail}`;
}
usedIds.add(candidate);
return candidate;
}
function normalizeToolInput(input) {
if (input && typeof input === "object" && !Array.isArray(input)) return clone(input);
if (typeof input === "string") {
try {
const parsed = JSON.parse(input);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
} catch {
return null;
}
}
return input == null ? {} : null;
}
function normalizeToolResult(result) {
const content = Array.isArray(result?.content)
? result.content.map((part) => ({ text: text(part?.text ?? part) }))
: [{ text: text(result?.content) }];
return {
toolUseId: rawId(result?.toolUseId),
status: result?.status === "error" ? "error" : "success",
content: content.length > 0 ? content : [{ text: "" }],
};
}
function flattenResults(userMessage, results) {
for (const result of results) appendText(userMessage, toolResultText(result));
}
function cleanUserContext(userMessage) {
const context = userMessage.userInputMessageContext;
if (!context) return;
if (!context.toolResults?.length) delete context.toolResults;
if (!context.tools?.length) delete context.tools;
if (Object.keys(context).length === 0) delete userMessage.userInputMessageContext;
}
function reconcileToolPair(assistant, user, turnIndex, nameMap, specNames, usedIds, repairs) {
const calls = Array.isArray(assistant.toolUses) ? assistant.toolUses : [];
const results = Array.isArray(user.userInputMessageContext?.toolResults)
? user.userInputMessageContext.toolResults.map(normalizeToolResult)
: [];
if (calls.length === 0) {
if (results.length > 0) {
flattenResults(user, results);
repairs.orphanResults += results.length;
}
if (user.userInputMessageContext) delete user.userInputMessageContext.toolResults;
cleanUserContext(user);
return;
}
const callQueues = new Map();
const callRecords = calls.map((call, callIndex) => {
const key = rawId(call?.toolUseId);
const mappedName = nameMap.get(call?.name) || call?.name;
const input = normalizeToolInput(call?.input);
const record = { call, callIndex, key, mappedName, input, result: null };
const queue = callQueues.get(key) || [];
queue.push(record);
callQueues.set(key, queue);
return record;
});
const orphanResults = [];
for (const result of results) {
const queue = callQueues.get(rawId(result.toolUseId));
const record = queue?.find((candidate) => !candidate.result);
if (record) record.result = result;
else orphanResults.push(result);
}
const keptCalls = [];
const keptResults = [];
for (const record of callRecords) {
const hasSpec = typeof record.mappedName === "string" && specNames.has(record.mappedName);
const valid = !!record.result && hasSpec && record.input !== null;
if (!valid) {
appendText(assistant, toolCallText({ name: record.mappedName, input: record.call?.input }));
repairs.missingResults += record.result ? 0 : 1;
repairs.invalidToolUses += hasSpec && record.input !== null ? 0 : 1;
if (record.result) {
flattenResults(user, [record.result]);
repairs.orphanResults++;
}
continue;
}
const toolUseId = reserveToolId(
record.key,
turnIndex,
record.callIndex,
record.mappedName,
usedIds
);
keptCalls.push({
toolUseId,
name: record.mappedName,
input: record.input,
});
keptResults.push({ ...record.result, toolUseId });
}
if (orphanResults.length > 0) {
flattenResults(user, orphanResults);
repairs.orphanResults += orphanResults.length;
}
if (keptCalls.length > 0) assistant.toolUses = keptCalls;
else delete assistant.toolUses;
user.userInputMessageContext ||= {};
if (keptResults.length > 0) user.userInputMessageContext.toolResults = keptResults;
else delete user.userInputMessageContext.toolResults;
cleanUserContext(user);
}
/** Validate the final Kiro wire conversation without mutating it. */
export function validateKiroConversation(history, currentMessage, toolSpecs = []) {
const errors = [];
const turns = [...(history || []), currentMessage].filter(Boolean);
const specNames = new Set(toolSpecs.map((spec) => spec?.toolSpecification?.name).filter(Boolean));
const usedIds = new Set();
for (let index = 0; index < turns.length; index++) {
const expectedUser = index % 2 === 0;
const isUser = !!turns[index]?.userInputMessage;
if (isUser !== expectedUser) errors.push(`role:${index}`);
if (!isUser) {
const calls = turns[index].assistantResponseMessage?.toolUses || [];
const results = turns[index + 1]?.userInputMessage?.userInputMessageContext?.toolResults || [];
const callIds = calls.map((call) => call.toolUseId);
const resultIds = results.map((result) => result.toolUseId);
if (calls.length !== results.length || callIds.some((id) => !resultIds.includes(id))) {
errors.push(`pair:${index}`);
}
for (const call of calls) {
if (!call.toolUseId || usedIds.has(call.toolUseId)) errors.push(`id:${index}`);
usedIds.add(call.toolUseId);
if (!specNames.has(call.name)) errors.push(`spec:${index}`);
}
} else if (index === 0) {
const results = turns[index].userInputMessage?.userInputMessageContext?.toolResults;
if (results?.length) errors.push("orphan:0");
}
}
if (!currentMessage?.userInputMessage?.content) errors.push("current");
return { valid: errors.length === 0, errors };
}
function flattenAllStructuredTools(turns, repairs) {
for (const turn of turns) {
if (turn.assistantResponseMessage?.toolUses?.length) {
for (const call of turn.assistantResponseMessage.toolUses) {
appendText(turn.assistantResponseMessage, toolCallText(call));
}
repairs.invalidToolUses += turn.assistantResponseMessage.toolUses.length;
delete turn.assistantResponseMessage.toolUses;
}
const user = turn.userInputMessage;
const results = user?.userInputMessageContext?.toolResults;
if (results?.length) {
flattenResults(user, results);
repairs.orphanResults += results.length;
delete user.userInputMessageContext.toolResults;
cleanUserContext(user);
}
}
}
/**
* Produce a strict Kiro conversation: alternating turns, current user message,
* adjacent one-to-one tool use/result pairs, and tool specs only on currentMessage.
*/
export function canonicalizeKiroConversation({
history,
currentMessage,
modelId,
toolSpecs = [],
nameMap = new Map(),
} = {}) {
const turns = normalizeTurns(history, currentMessage, modelId);
const repairs = { missingResults: 0, orphanResults: 0, invalidToolUses: 0 };
const specNames = new Set(toolSpecs.map((spec) => spec?.toolSpecification?.name).filter(Boolean));
const usedIds = new Set();
for (let index = 0; index < turns.length; index += 2) {
const user = turns[index].userInputMessage;
if (index === 0) {
const leadingResults = user.userInputMessageContext?.toolResults || [];
if (leadingResults.length > 0) {
flattenResults(user, leadingResults);
repairs.orphanResults += leadingResults.length;
delete user.userInputMessageContext.toolResults;
cleanUserContext(user);
}
}
const assistant = turns[index + 1]?.assistantResponseMessage;
const nextUser = turns[index + 2]?.userInputMessage;
if (assistant && nextUser) {
reconcileToolPair(assistant, nextUser, index + 1, nameMap, specNames, usedIds, repairs);
}
}
const finalCurrent = turns[turns.length - 1];
finalCurrent.userInputMessage.userInputMessageContext ||= {};
if (toolSpecs.length > 0) {
finalCurrent.userInputMessage.userInputMessageContext.tools = clone(toolSpecs);
}
cleanUserContext(finalCurrent.userInputMessage);
let finalHistory = turns.slice(0, -1);
let validation = validateKiroConversation(finalHistory, finalCurrent, toolSpecs);
if (!validation.valid) {
flattenAllStructuredTools(turns, repairs);
finalHistory = turns.slice(0, -1);
validation = validateKiroConversation(finalHistory, finalCurrent, toolSpecs);
}
return {
history: finalHistory,
currentMessage: finalCurrent,
repairs,
valid: validation.valid,
errors: validation.errors,
};
}
+13
View File
@@ -353,6 +353,19 @@ export function cleanJSONSchemaForAntigravity(schema) {
function addPlaceholders(obj) { function addPlaceholders(obj) {
if (!obj || typeof obj !== "object") return; if (!obj || typeof obj !== "object") return;
// Empty schema {} (no type, no properties) after $ref removal — treat as object with placeholder
if (Object.keys(obj).length === 0) {
obj.type = "object";
obj.properties = {
reason: {
type: "string",
description: "Brief explanation of why you are calling this tool"
}
};
obj.required = ["reason"];
return;
}
if (obj.type === "object") { if (obj.type === "object") {
if (!obj.properties || Object.keys(obj.properties).length === 0) { if (!obj.properties || Object.keys(obj.properties).length === 0) {
obj.properties = { obj.properties = {
+7 -2
View File
@@ -62,8 +62,13 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
// Always ensure tool_calls have id (some providers require it) // Always ensure tool_calls have id (some providers require it)
ensureToolCallIds(result); ensureToolCallIds(result);
// Fix missing tool responses (insert empty tool_result if needed) // Kiro performs stricter source-aware reconciliation after session replay.
fixMissingToolResponses(result); // The generic helper inserts OpenAI `role: tool` messages, which a direct
// Claude→Kiro translator cannot consume and which cannot repair partial
// parallel tool results.
if (targetFormat !== FORMATS.KIRO) {
fixMissingToolResponses(result);
}
// Capture thinking intent from the original (pre-translation) body, before any // Capture thinking intent from the original (pre-translation) body, before any
// format conversion strips/renames the fields. Applied after translation. // format conversion strips/renames the fields. Applied after translation.
+30 -200
View File
@@ -6,17 +6,10 @@
* direct `claude:kiro` route in ../index.js uses; it is NOT reached through the * direct `claude:kiro` route in ../index.js uses; it is NOT reached through the
* claudeopenaikiro pivot. * claudeopenaikiro pivot.
* *
* It reproduces the two 400-guards that live in openai-to-kiro.js so that a * After session replay it delegates to the shared Kiro conversation
* Claude client which omits the `tools` array on a follow-up turn (typical * canonicalizer. That layer enforces adjacent one-to-one tool use/results,
* after client-side compaction) does not trip Kiro's schema validator and get * repairs partial parallel calls, and flattens compacted structured references
* "Improperly formed request" (HTTP 400): * that can no longer be represented safely.
*
* 1. flattenClaudeToolInteractions when the client sent NO tools, collapse
* every tool_use / tool_result block to plain text so no structured tool
* reference survives to trigger the "tools required" rule.
* 2. reconcileOrphanedToolResults when tools ARE present, fold any
* tool_result whose tool_use_id has no matching tool_use back into the
* user text instead of leaving a dangling structured reference.
* *
* It also handles the 9router-synthetic `-agentic` / `-thinking` suffixes and * It also handles the 9router-synthetic `-agentic` / `-thinking` suffixes and
* the `<thinking_mode>enabled</thinking_mode>` reasoning trigger, matching * the `<thinking_mode>enabled</thinking_mode>` reasoning trigger, matching
@@ -27,7 +20,8 @@ import { FORMATS } from "../formats.js";
import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js"; import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js";
import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js"; import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js";
import { import {
resolveKiroModel, resolveKiroModelIntent,
applyKiroThinkingOverride,
resolveKiroThinkingBudget, resolveKiroThinkingBudget,
buildThinkingSystemPrefix, buildThinkingSystemPrefix,
KIRO_AGENTIC_SYSTEM_PROMPT, KIRO_AGENTIC_SYSTEM_PROMPT,
@@ -37,82 +31,17 @@ import {
} from "../../config/kiroConstants.js"; } from "../../config/kiroConstants.js";
import { DEFAULT_IMAGE_MIME } from "../schema/index.js"; import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
import { ROLE, CLAUDE_BLOCK } from "../schema/index.js"; import { ROLE, CLAUDE_BLOCK } from "../schema/index.js";
import {
/** Stringify a tool_use input as a readable line. */ canonicalizeKiroConversation,
function toolUseToText(name, input) { normalizeKiroToolSpecs,
let argStr; } from "../concerns/kiroConversation.js";
try {
argStr = typeof input === "string" ? input : JSON.stringify(input ?? {});
} catch {
argStr = "{}";
}
return `[Tool call: ${name || "unknown"}(${argStr})]`;
}
/** Render a Claude tool_result block's content as a readable line. */
function toolResultBlockToText(content) {
let text = "";
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
text = content
.map((c) => (typeof c === "string" ? c : c?.text || ""))
.filter(Boolean)
.join("\n");
} else if (content) {
try {
text = JSON.stringify(content);
} catch {
text = "";
}
}
return `[Tool result: ${text}]`;
}
/**
* When the client sent no tools, rewrite every tool_use (assistant) and
* tool_result (user) content block into plain text. Keeps text + images.
* Returns a new messages array; never mutates the input.
*/
function flattenClaudeToolInteractions(messages) {
const out = [];
for (const msg of messages) {
if (!msg) continue;
if (msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) {
const parts = [];
for (const block of msg.content) {
if (block.type === CLAUDE_BLOCK.TEXT && block.text) {
parts.push(block.text);
} else if (block.type === CLAUDE_BLOCK.TOOL_USE) {
parts.push(toolUseToText(block.name, block.input));
}
}
out.push({ ...msg, content: parts.join("\n") });
continue;
}
if (msg.role === ROLE.USER && Array.isArray(msg.content)) {
const newContent = msg.content.map((block) =>
block.type === CLAUDE_BLOCK.TOOL_RESULT
? { type: CLAUDE_BLOCK.TEXT, text: toolResultBlockToText(block.content) }
: block
);
out.push({ ...msg, content: newContent });
continue;
}
out.push(msg);
}
return out;
}
/** /**
* Convert Claude messages to Kiro history + currentMessage. * Convert Claude messages to Kiro history + currentMessage.
* Kiro requires alternating user/assistant turns; consecutive same-role * Kiro requires alternating user/assistant turns; consecutive same-role
* messages are merged. * messages are merged.
*/ */
function convertClaudeMessagesToKiro(messages, tools, model) { function convertClaudeMessagesToKiro(messages, model) {
const history = []; const history = [];
let currentMessage = null; let currentMessage = null;
@@ -121,27 +50,6 @@ function convertClaudeMessagesToKiro(messages, tools, model) {
let pendingToolResults = []; let pendingToolResults = [];
let pendingImages = []; let pendingImages = [];
let currentRole = null; let currentRole = null;
let toolsInjected = false;
const clientProvidedTools = Array.isArray(tools) && tools.length > 0;
const buildToolSpecs = () =>
tools.map((t) => {
const name = t.name;
const description = t.description || `Tool: ${name}`;
const schema = t.input_schema || {};
const normalizedSchema =
Object.keys(schema).length === 0
? { type: "object", properties: {}, required: [] }
: { ...schema, required: schema.required ?? [] };
return {
toolSpecification: {
name,
description,
inputSchema: { json: normalizedSchema },
},
};
});
const flushPending = () => { const flushPending = () => {
if (currentRole === ROLE.USER) { if (currentRole === ROLE.USER) {
@@ -156,15 +64,6 @@ function convertClaudeMessagesToKiro(messages, tools, model) {
toolResults: pendingToolResults, toolResults: pendingToolResults,
}; };
} }
// Attach tools to the first user turn only.
if (clientProvidedTools && !toolsInjected) {
if (!userMsg.userInputMessage.userInputMessageContext) {
userMsg.userInputMessage.userInputMessageContext = {};
}
userMsg.userInputMessage.userInputMessageContext.tools = buildToolSpecs();
toolsInjected = true;
}
history.push(userMsg); history.push(userMsg);
currentMessage = userMsg; currentMessage = userMsg;
pendingUserContent = []; pendingUserContent = [];
@@ -208,7 +107,7 @@ function convertClaudeMessagesToKiro(messages, tools, model) {
} }
pendingToolResults.push({ pendingToolResults.push({
toolUseId: block.tool_use_id, toolUseId: block.tool_use_id,
status: "success", status: block.is_error ? "error" : "success",
content: [{ text: resultContent }], content: [{ text: resultContent }],
}); });
} }
@@ -255,14 +154,7 @@ function convertClaudeMessagesToKiro(messages, tools, model) {
} }
} }
// Grab tools from the first history user turn before cleanup strips them.
const firstHistoryTools =
history[0]?.userInputMessage?.userInputMessageContext?.tools;
history.forEach((item) => { history.forEach((item) => {
if (item.userInputMessage?.userInputMessageContext?.tools) {
delete item.userInputMessage.userInputMessageContext.tools;
}
if ( if (
item.userInputMessage?.userInputMessageContext && item.userInputMessage?.userInputMessageContext &&
Object.keys(item.userInputMessage.userInputMessageContext).length === 0 Object.keys(item.userInputMessage.userInputMessageContext).length === 0
@@ -306,66 +198,9 @@ function convertClaudeMessagesToKiro(messages, tools, model) {
currentMessage = { userInputMessage: { content: "", modelId: model } }; currentMessage = { userInputMessage: { content: "", modelId: model } };
} }
// Inject tools into currentMessage after cleanup if not already present.
if (
firstHistoryTools?.length > 0 &&
!currentMessage.userInputMessage.userInputMessageContext?.tools
) {
if (!currentMessage.userInputMessage.userInputMessageContext) {
currentMessage.userInputMessage.userInputMessageContext = {};
}
currentMessage.userInputMessage.userInputMessageContext.tools =
firstHistoryTools;
}
return { history: mergedHistory, currentMessage }; return { history: mergedHistory, currentMessage };
} }
/**
* Fold orphaned toolResults (those whose toolUseId has no matching toolUse in
* any assistant turn) back into the user text, removing the dangling
* structured reference that makes Kiro 400.
*/
function reconcileOrphanedToolResults(history, currentMessage) {
const validIds = new Set();
for (const h of history) {
const arm = h.assistantResponseMessage;
if (!arm) continue;
for (const tu of arm.toolUses || []) {
if (tu.toolUseId) validIds.add(tu.toolUseId);
}
}
const carriers = currentMessage ? [...history, currentMessage] : history;
for (const item of carriers) {
const uim = item.userInputMessage;
const ctx = uim?.userInputMessageContext;
if (!ctx?.toolResults?.length) continue;
const kept = [];
const salvaged = [];
for (const tr of ctx.toolResults) {
if (validIds.has(tr.toolUseId)) {
kept.push(tr);
} else {
const text = Array.isArray(tr.content)
? tr.content.map((c) => c?.text || "").join("\n")
: "";
salvaged.push(`[Tool result: ${text}]`);
}
}
if (salvaged.length === 0) continue;
const extra = salvaged.join("\n");
uim.content = uim.content ? `${uim.content}\n\n${extra}` : extra;
ctx.toolResults = kept;
if (kept.length === 0 && !ctx.tools?.length) {
delete uim.userInputMessageContext;
}
}
}
function extractClaudeSystemText(system) { function extractClaudeSystemText(system) {
if (!system) return ""; if (!system) return "";
if (typeof system === "string") return system; if (typeof system === "string") return system;
@@ -382,33 +217,21 @@ function extractClaudeSystemText(system) {
* Build a Kiro payload directly from a Claude Messages API request body. * Build a Kiro payload directly from a Claude Messages API request body.
*/ */
export function claudeToKiroRequest(model, body, stream, credentials) { export function claudeToKiroRequest(model, body, stream, credentials) {
let messages = Array.isArray(body.messages) ? body.messages : []; const messages = Array.isArray(body.messages) ? body.messages : [];
const tools = Array.isArray(body.tools) ? body.tools : []; const tools = Array.isArray(body.tools) ? body.tools : [];
const clientProvidedTools = tools.length > 0;
const maxTokens = body.max_tokens || 32000; const maxTokens = body.max_tokens || 32000;
const temperature = body.temperature; const temperature = body.temperature;
const topP = body.top_p; const topP = body.top_p;
const { upstream: upstreamModel, agentic } = resolveKiroModel(model); const modelIntent = resolveKiroModelIntent(model);
const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model); const { upstream: upstreamModel, agentic } = modelIntent;
const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel); const thinkingBody = applyKiroThinkingOverride(body, modelIntent.thinkingOverride);
const usesNativeGptEffort = usesKiroNativeGptEffort(body, upstreamModel); const thinkingBudget = resolveKiroThinkingBudget(thinkingBody, credentials?.rawHeaders, modelIntent.model);
const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(thinkingBody, upstreamModel);
const usesNativeGptEffort = usesKiroNativeGptEffort(thinkingBody, upstreamModel);
// Guard 1: no client tools → flatten all tool interactions to text. const { specs: toolSpecs, nameMap } = normalizeKiroToolSpecs(tools);
if (!clientProvidedTools) { const { history, currentMessage } = convertClaudeMessagesToKiro(messages, upstreamModel);
messages = flattenClaudeToolInteractions(messages);
}
const { history, currentMessage } = convertClaudeMessagesToKiro(
messages,
tools,
upstreamModel
);
// Guard 2: tools present → reconcile dangling tool_results.
if (clientProvidedTools) {
reconcileOrphanedToolResults(history, currentMessage);
}
// api_key / idc / external_idp must never use the shared default ARN (belongs // api_key / idc / external_idp must never use the shared default ARN (belongs
// to another account → 403 "bearer token invalid"); OAuth/social fall back to it. // to another account → 403 "bearer token invalid"); OAuth/social fall back to it.
@@ -457,7 +280,14 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
history, history,
currentMessage, currentMessage,
}); });
const replayCurrent = replay.currentMessage?.userInputMessage || {}; const canonical = canonicalizeKiroConversation({
history: replay.history,
currentMessage: replay.currentMessage,
modelId: upstreamModel,
toolSpecs,
nameMap,
});
const replayCurrent = canonical.currentMessage.userInputMessage;
const userInputMessage = { const userInputMessage = {
content: replayCurrent.content || "", content: replayCurrent.content || "",
modelId: upstreamModel, modelId: upstreamModel,
@@ -479,7 +309,7 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
currentMessage: { currentMessage: {
userInputMessage, userInputMessage,
}, },
history: replay.history, history: canonical.history,
}, },
agentMode: "vibe", agentMode: "vibe",
}; };
@@ -253,6 +253,10 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map()) {
} }
} }
} else if (msg.role === ROLE.ASSISTANT) { } else if (msg.role === ROLE.ASSISTANT) {
if (typeof msg.reasoning_content === "string" && msg.reasoning_content) {
blocks.push({ type: CLAUDE_BLOCK.THINKING, thinking: msg.reasoning_content });
}
if (Array.isArray(msg.content)) { if (Array.isArray(msg.content)) {
for (const part of msg.content) { for (const part of msg.content) {
if (part.type === OPENAI_BLOCK.TEXT && part.text) { if (part.type === OPENAI_BLOCK.TEXT && part.text) {
+26 -230
View File
@@ -8,7 +8,8 @@ import { v4 as uuidv4 } from "uuid";
import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js"; import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js";
import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js"; import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js";
import { import {
resolveKiroModel, resolveKiroModelIntent,
applyKiroThinkingOverride,
resolveKiroThinkingBudget, resolveKiroThinkingBudget,
buildThinkingSystemPrefix, buildThinkingSystemPrefix,
KIRO_AGENTIC_SYSTEM_PROMPT, KIRO_AGENTIC_SYSTEM_PROMPT,
@@ -19,148 +20,10 @@ import {
import { parseDataUri } from "../concerns/image.js"; import { parseDataUri } from "../concerns/image.js";
import { DEFAULT_IMAGE_MIME } from "../schema/index.js"; import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js"; import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js";
import {
/** Render a single tool call as a readable text line. */ canonicalizeKiroConversation,
function toolCallToText(name, input) { normalizeKiroToolSpecs,
let argStr; } from "../concerns/kiroConversation.js";
try {
argStr = typeof input === "string" ? input : JSON.stringify(input ?? {});
} catch {
argStr = "{}";
}
return `[Tool call: ${name || "unknown"}(${argStr})]`;
}
/** Render a tool result (string or content-block array) as a text line. */
function toolResultToText(content) {
const text = Array.isArray(content)
? content.map(c => (typeof c === "string" ? c : c.text || "")).join("\n")
: (typeof content === "string" ? content : "");
return `[Tool result: ${text}]`;
}
/**
* Flatten all tool calls/results in a conversation into plain text.
*
* Kiro's schema validator requires a non-empty
* currentMessage.userInputMessageContext.tools array whenever the history
* references any tool use; otherwise it returns "Improperly formed request"
* (HTTP 400). A client can hit this by omitting the `tools` array on a
* follow-up request typically after client-side compaction (e.g. OpenCode).
*
* Rather than fabricate stub tool specs which would advertise tool-calling
* capability the client never requested and may not handle, risking a phantom
* tool call on an otherwise plain turn we collapse the tool interaction into
* text. The request stays honest, and since no structured tool content
* remains, the validator's "tools required" rule never fires.
*
* Only invoked when the client did NOT send tools; when tools are present the
* structured form is preserved.
*/
function flattenToolInteractions(messages) {
const out = [];
for (const msg of messages) {
// OpenAI tool-result message → user text line
if (msg.role === ROLE.TOOL) {
out.push({ role: ROLE.USER, content: toolResultToText(msg.content) });
continue;
}
if (msg.role === ROLE.ASSISTANT) {
const parts = [];
if (Array.isArray(msg.content)) {
for (const c of msg.content) {
if (c.type === CLAUDE_BLOCK.TOOL_USE) {
parts.push(toolCallToText(c.name, c.input));
} else if (c.type === OPENAI_BLOCK.TEXT || c.text) {
parts.push(c.text || "");
}
}
} else if (typeof msg.content === "string") {
parts.push(msg.content);
}
for (const tc of msg.tool_calls || []) {
parts.push(toolCallToText(tc.function?.name, tc.function?.arguments));
}
out.push({ role: ROLE.ASSISTANT, content: parts.filter(Boolean).join("\n") });
continue;
}
// User messages: replace tool_result blocks with text, keep text + images.
if (msg.role === ROLE.USER && Array.isArray(msg.content)) {
const newContent = msg.content.map(c =>
c.type === CLAUDE_BLOCK.TOOL_RESULT
? { type: OPENAI_BLOCK.TEXT, text: toolResultToText(c.content) }
: c
);
out.push({ ...msg, content: newContent });
continue;
}
out.push(msg);
}
return out;
}
/**
* Reconcile orphaned toolResults those whose toolUseId has no matching
* toolUse in any assistant message. This happens when client-side compaction
* truncates the conversation and removes the assistant message containing the
* tool_use, but keeps the user message with the corresponding tool_result.
*
* A dangling structured reference makes Kiro return 400, so it must be removed.
* But the client deliberately kept the result content through compaction, so
* rather than discard it we fold it back into the user message as text the
* same shape flattenToolInteractions() produces. The 400 trigger (the
* structured reference) is gone; the content survives.
*
* `messages` is every carrier that can hold toolResults both history items
* and the popped-out currentMessage (orphans can land on either).
*/
function reconcileOrphanedToolResults(history, currentMessage) {
// Phase 1: collect all valid toolUseIds from assistant messages in history.
// (currentMessage is always a user turn, so it carries no toolUses.)
const validIds = new Set();
for (const h of history) {
const arm = h.assistantResponseMessage;
if (!arm) continue;
for (const tu of arm.toolUses || []) {
if (tu.toolUseId) validIds.add(tu.toolUseId);
}
}
// Phase 2: across history + currentMessage, keep results with a matching
// toolUse and salvage the rest as text.
const carriers = currentMessage ? [...history, currentMessage] : history;
for (const item of carriers) {
const uim = item.userInputMessage;
const ctx = uim?.userInputMessageContext;
if (!ctx?.toolResults?.length) continue;
const kept = [];
const salvaged = [];
for (const tr of ctx.toolResults) {
if (validIds.has(tr.toolUseId)) {
kept.push(tr);
} else {
salvaged.push(toolResultToText(tr.content));
}
}
if (salvaged.length === 0) continue; // no orphans — leave untouched
// Fold orphaned result content into the user text so it is not lost
const extra = salvaged.join("\n");
uim.content = uim.content ? `${uim.content}\n\n${extra}` : extra;
ctx.toolResults = kept;
if (kept.length === 0 && !ctx.tools?.length) {
delete uim.userInputMessageContext;
}
}
}
/** /**
* Safely parse JSON string, returning fallback on failure. * Safely parse JSON string, returning fallback on failure.
@@ -176,26 +39,15 @@ function safeJSONParse(str, fallback) {
* *
* Returns { history, currentMessage }. * Returns { history, currentMessage }.
*/ */
function convertMessages(messages, tools, model) { function convertMessages(messages, model) {
let history = []; let history = [];
let currentMessage = null; let currentMessage = null;
const clientProvidedTools = tools && tools.length > 0;
// When the client did not send tools, flatten any tool calls/results in the
// history into plain text (see flattenToolInteractions). This keeps the
// request honest and sidesteps Kiro's "tools required" 400, since no
// structured tool content survives to trigger it.
if (!clientProvidedTools) {
messages = flattenToolInteractions(messages);
}
let pendingUserContent = []; let pendingUserContent = [];
let pendingAssistantContent = []; let pendingAssistantContent = [];
let pendingToolResults = []; let pendingToolResults = [];
let pendingImages = []; let pendingImages = [];
let currentRole = null; let currentRole = null;
let toolsInjectedToFirstUserMsg = false;
const flushPending = () => { const flushPending = () => {
if (currentRole === "user") { if (currentRole === "user") {
@@ -218,39 +70,6 @@ function convertMessages(messages, tools, model) {
}; };
} }
// Add tools to the user message that has no preceding assistant messages,
// OR the first user message (whichever comes first after any opening
// assistant messages). We track whether any user message has already
// received tools via a flag on the history array.
if (clientProvidedTools && !toolsInjectedToFirstUserMsg) {
if (!userMsg.userInputMessage.userInputMessageContext) {
userMsg.userInputMessage.userInputMessageContext = {};
}
userMsg.userInputMessage.userInputMessageContext.tools = tools.map(t => {
const name = t.function?.name || t.name;
let description = t.function?.description || t.description || "";
if (!description.trim()) {
description = `Tool: ${name}`;
}
const schema = t.function?.parameters || t.parameters || t.input_schema || {};
// Normalize schema: Kiro requires required[] and proper type/properties
const normalizedSchema = Object.keys(schema).length === 0
? { type: "object", properties: {}, required: [] }
: { ...schema, required: schema.required ?? [] };
return {
toolSpecification: {
name,
description,
inputSchema: { json: normalizedSchema }
}
};
});
toolsInjectedToFirstUserMsg = true;
}
history.push(userMsg); history.push(userMsg);
currentMessage = userMsg; currentMessage = userMsg;
pendingUserContent = []; pendingUserContent = [];
@@ -326,7 +145,7 @@ function convertMessages(messages, tools, model) {
pendingToolResults.push({ pendingToolResults.push({
toolUseId: block.tool_use_id, toolUseId: block.tool_use_id,
status: "success", status: block.is_error ? "error" : "success",
content: [{ text: text }] content: [{ text: text }]
}); });
}); });
@@ -338,7 +157,7 @@ function convertMessages(messages, tools, model) {
const toolContent = typeof msg.content === "string" ? msg.content : ""; const toolContent = typeof msg.content === "string" ? msg.content : "";
pendingToolResults.push({ pendingToolResults.push({
toolUseId: msg.tool_call_id, toolUseId: msg.tool_call_id,
status: "success", status: msg.is_error || msg.status === "error" ? "error" : "success",
content: [{ text: toolContent }] content: [{ text: toolContent }]
}); });
} else if (content) { } else if (content) {
@@ -412,14 +231,8 @@ function convertMessages(messages, tools, model) {
} }
} }
// Grab tools from first history item BEFORE cleanup removes them
const firstHistoryTools = history[0]?.userInputMessage?.userInputMessageContext?.tools;
// Clean up history for Kiro API compatibility // Clean up history for Kiro API compatibility
history.forEach(item => { history.forEach(item => {
if (item.userInputMessage?.userInputMessageContext?.tools) {
delete item.userInputMessage.userInputMessageContext.tools;
}
if (item.userInputMessage?.userInputMessageContext && if (item.userInputMessage?.userInputMessageContext &&
Object.keys(item.userInputMessage.userInputMessageContext).length === 0) { Object.keys(item.userInputMessage.userInputMessageContext).length === 0) {
delete item.userInputMessage.userInputMessageContext; delete item.userInputMessage.userInputMessageContext;
@@ -472,33 +285,6 @@ function convertMessages(messages, tools, model) {
}; };
} }
// Reconcile orphaned toolResults across history AND currentMessage — when
// client-side compaction removes assistant messages containing tool_use but
// keeps the tool_result, the dangling reference triggers a Kiro 400. Fold the
// content back into the user text instead of discarding it. Run after
// currentMessage is finalized (an orphan can be merged into it) and before
// tool injection (which may re-add userInputMessageContext).
//
// Only needed on the tools-present path: when the client sent no tools,
// flattenToolInteractions already collapsed every toolResult to text, so
// there is nothing structured left to orphan.
if (clientProvidedTools) {
reconcileOrphanedToolResults(mergedHistory, currentMessage);
}
// Inject tools into currentMessage AFTER cleanup. Tools only exist here when
// the client explicitly sent them (otherwise flattenToolInteractions already
// collapsed all tool content to text upstream, so there is nothing to carry).
const resolvedTools = firstHistoryTools;
if (resolvedTools?.length > 0 &&
!currentMessage.userInputMessage.userInputMessageContext?.tools) {
if (!currentMessage.userInputMessage.userInputMessageContext) {
currentMessage.userInputMessage.userInputMessageContext = {};
}
currentMessage.userInputMessage.userInputMessageContext.tools = resolvedTools;
}
return { history: mergedHistory, currentMessage }; return { history: mergedHistory, currentMessage };
} }
@@ -524,12 +310,15 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
const temperature = body.temperature; const temperature = body.temperature;
const topP = body.top_p; const topP = body.top_p;
const { upstream: upstreamModel, agentic } = resolveKiroModel(model); const modelIntent = resolveKiroModelIntent(model);
const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model); const { upstream: upstreamModel, agentic } = modelIntent;
const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel); const thinkingBody = applyKiroThinkingOverride(body, modelIntent.thinkingOverride);
const usesNativeGptEffort = usesKiroNativeGptEffort(body, upstreamModel); const thinkingBudget = resolveKiroThinkingBudget(thinkingBody, credentials?.rawHeaders, modelIntent.model);
const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(thinkingBody, upstreamModel);
const usesNativeGptEffort = usesKiroNativeGptEffort(thinkingBody, upstreamModel);
const { history, currentMessage } = convertMessages(messages, tools, upstreamModel); const { specs: toolSpecs, nameMap } = normalizeKiroToolSpecs(tools);
const { history, currentMessage } = convertMessages(messages, upstreamModel);
// API-key (headless) auth uses a raw CodeWhisperer credential whose profile is // API-key (headless) auth uses a raw CodeWhisperer credential whose profile is
// account-specific. Injecting the shared builder-id/social *default* placeholder // account-specific. Injecting the shared builder-id/social *default* placeholder
@@ -583,7 +372,14 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
history, history,
currentMessage, currentMessage,
}); });
const replayCurrent = replay.currentMessage?.userInputMessage || {}; const canonical = canonicalizeKiroConversation({
history: replay.history,
currentMessage: replay.currentMessage,
modelId: upstreamModel,
toolSpecs,
nameMap,
});
const replayCurrent = canonical.currentMessage.userInputMessage;
const payload = { const payload = {
conversationState: { conversationState: {
@@ -604,7 +400,7 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
}) })
} }
}, },
history: replay.history history: canonical.history
}, },
agentMode: "vibe", agentMode: "vibe",
}; };
+21 -2
View File
@@ -42,6 +42,14 @@ function findFirstUserIndex(history) {
return history.findIndex((item) => item?.userInputMessage); return history.findIndex((item) => item?.userInputMessage);
} }
function hasToolResults(message) {
return !!message?.userInputMessage?.userInputMessageContext?.toolResults?.length;
}
function canReplaceSessionStart(history, firstUserIndex) {
return firstUserIndex === 0 && !hasToolResults(history[firstUserIndex]);
}
function rememberSessionStart(key, entry) { function rememberSessionStart(key, entry) {
if (sessionStartStore.size >= MAX_SESSION_STARTS) { if (sessionStartStore.size >= MAX_SESSION_STARTS) {
sessionStartStore.delete(sessionStartStore.keys().next().value); sessionStartStore.delete(sessionStartStore.keys().next().value);
@@ -73,10 +81,13 @@ export function applyKiroSessionReplay({
existing.lastUsed = Date.now(); existing.lastUsed = Date.now();
const firstUserIndex = findFirstUserIndex(baseHistory); const firstUserIndex = findFirstUserIndex(baseHistory);
const sessionStart = ensureUserMessageModelId(clone(existing.sessionStart), modelId); const sessionStart = ensureUserMessageModelId(clone(existing.sessionStart), modelId);
if (firstUserIndex >= 0) { if (canReplaceSessionStart(baseHistory, firstUserIndex)) {
baseHistory[firstUserIndex] = sessionStart; baseHistory[firstUserIndex] = sessionStart;
} else { } else {
baseHistory.unshift(sessionStart); baseHistory.unshift(sessionStart);
if (baseHistory.length === 1) {
baseHistory.push({ assistantResponseMessage: { content: "..." } });
}
} }
return { return {
history: ensureHistoryModelIds(baseHistory, modelId), history: ensureHistoryModelIds(baseHistory, modelId),
@@ -88,10 +99,18 @@ export function applyKiroSessionReplay({
const firstUserIndex = findFirstUserIndex(baseHistory); const firstUserIndex = findFirstUserIndex(baseHistory);
let sessionStart; let sessionStart;
let nextCurrent = ensureUserMessageModelId(baseCurrent, modelId); let nextCurrent = ensureUserMessageModelId(baseCurrent, modelId);
if (firstUserIndex >= 0) { if (canReplaceSessionStart(baseHistory, firstUserIndex)) {
sessionStart = prefixUserMessage(baseHistory[firstUserIndex], contentPrefix, modelId); sessionStart = prefixUserMessage(baseHistory[firstUserIndex], contentPrefix, modelId);
baseHistory[firstUserIndex] = clone(sessionStart); baseHistory[firstUserIndex] = clone(sessionStart);
nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId); nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId);
} else if (firstUserIndex >= 0) {
sessionStart = prefixUserMessage(
{ userInputMessage: { content: "", modelId } },
contentPrefix,
modelId
);
baseHistory.unshift(clone(sessionStart));
nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId);
} else { } else {
sessionStart = prefixUserMessage(baseCurrent, contentPrefix, modelId); sessionStart = prefixUserMessage(baseCurrent, contentPrefix, modelId);
nextCurrent = clone(sessionStart); nextCurrent = clone(sessionStart);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "9router-app", "name": "9router-app",
"version": "0.5.40", "version": "0.5.45",
"description": "9Router web dashboard", "description": "9Router web dashboard",
"private": true, "private": true,
"scripts": { "scripts": {
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+56 -31
View File
@@ -276,6 +276,16 @@ export default function ProvidersPage() {
})) }))
.filter((p) => matchSearch(p.name)); .filter((p) => matchSearch(p.name));
// Dual-auth providers (oauth + apikey) store API keys as authType "apikey"
// (and sometimes "api_key"). Card stats must count both so totals match detail.
// kiro has no authModes in registry but accepts both (headless uses "api_key").
const dualAuthTypes = (info, key) => {
if (key === "kiro") return ["oauth", "apikey", "api_key"];
const modes = info?.authModes;
if (!Array.isArray(modes) || !modes.includes("apikey")) return "oauth";
return ["oauth", "apikey", "api_key"];
};
const oauthEntries = sortByPriority( const oauthEntries = sortByPriority(
Object.entries(OAUTH_PROVIDERS).filter(([, info]) => !info.hidden && matchSearch(info.name)), Object.entries(OAUTH_PROVIDERS).filter(([, info]) => !info.hidden && matchSearch(info.name)),
"oauth", "oauth",
@@ -287,15 +297,27 @@ export default function ProvidersPage() {
matchSearch(info.name), matchSearch(info.name),
) )
.sort(([, a], [, b]) => (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0)); .sort(([, a], [, b]) => (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0));
const freeTierEntries = sortByPriority( // Free Tier cards may be oauth-only (e.g. kimchi) or dual-auth, so count via
Object.entries(FREE_TIER_PROVIDERS).filter( // dualAuthTypes per provider instead of a fixed "apikey" — otherwise oauth
// connections are invisible here (mismatch with the detail page).
const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS)
.filter(
([, info]) => ([, info]) =>
!info.hidden && !info.hidden &&
matchSearch(info.name) && matchSearch(info.name) &&
(info.serviceKinds ?? ["llm"]).includes("llm"), (info.serviceKinds ?? ["llm"]).includes("llm"),
), )
"freeTier", .sort(([ka, a], [kb, b]) => {
).sort(([, a], [, b]) => (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0)); const pa = a.priority ?? 999;
const pb = b.priority ?? 999;
if (pa !== pb) return pa - pb;
const noAuthDiff = (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0);
if (noAuthDiff !== 0) return noAuthDiff;
const ca = getProviderStats(ka, dualAuthTypes(a, ka)).connected > 0 ? 0 : 1;
const cb = getProviderStats(kb, dualAuthTypes(b, kb)).connected > 0 ? 0 : 1;
if (ca !== cb) return ca - cb;
return (a.name || "").localeCompare(b.name || "");
});
// API Key: connected providers first, then alphabetical by name // API Key: connected providers first, then alphabetical by name
const apikeyEntries = Object.entries(APIKEY_PROVIDERS) const apikeyEntries = Object.entries(APIKEY_PROVIDERS)
.filter( .filter(
@@ -429,16 +451,19 @@ export default function ProvidersPage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
{oauthEntries.map(([key, info]) => ( {oauthEntries.map(([key, info]) => {
<ProviderCard const authTypes = dualAuthTypes(info, key);
key={key} return (
providerId={key} <ProviderCard
provider={info} key={key}
stats={getProviderStats(key, "oauth")} providerId={key}
authType="oauth" provider={info}
onToggle={(active) => handleToggleProvider(key, "oauth", active)} stats={getProviderStats(key, authTypes)}
/> authType="oauth"
))} onToggle={(active) => handleToggleProvider(key, authTypes, active)}
/>
);
})}
</div> </div>
</div> </div>
)} )}
@@ -471,12 +496,9 @@ export default function ProvidersPage() {
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
{freeEntries.map(([key, info]) => { {freeEntries.map(([key, info]) => {
// Kiro accepts both OAuth and api-key connections; count/toggle both // Dual-auth (e.g. kiro): count/toggle oauth + apikey/api_key so the
// so the card total matches the provider detail page (#kiro-apikey). // card total matches the provider detail page.
// Kiro's headless api-key flow persists authType "api_key" (underscore), const freeAuthTypes = dualAuthTypes(info, key);
// while generic apikey providers use "apikey" — include both spellings.
const freeAuthTypes =
key === "kiro" ? ["oauth", "apikey", "api_key"] : "oauth";
return ( return (
<ProviderCard <ProviderCard
key={key} key={key}
@@ -490,16 +512,19 @@ export default function ProvidersPage() {
/> />
); );
})} })}
{freeTierEntries.map(([key, info]) => ( {freeTierEntries.map(([key, info]) => {
<ApiKeyProviderCard const freeAuthTypes = dualAuthTypes(info, key);
key={key} return (
providerId={key} <ApiKeyProviderCard
provider={info} key={key}
stats={getProviderStats(key, "apikey")} providerId={key}
authType="apikey" provider={info}
onToggle={(active) => handleToggleProvider(key, "apikey", active)} stats={getProviderStats(key, freeAuthTypes)}
/> authType={Array.isArray(freeAuthTypes) ? (freeAuthTypes[0] ?? "apikey") : freeAuthTypes}
))} onToggle={(active) => handleToggleProvider(key, freeAuthTypes, active)}
/>
);
})}
</div> </div>
</div> </div>
)} )}
@@ -146,103 +146,100 @@ export default function QuotaTable({
)} )}
</div> </div>
<div className="overflow-x-auto"> <div className="space-y-px">
<table className="w-full table-fixed text-left"> {currentPageRows.map((quota) => {
<tbody> const colors = getColorClasses(quota.remaining);
{currentPageRows.map((quota) => { const countdown = formatResetTime(quota.resetAt);
const colors = getColorClasses(quota.remaining); const resetDisplay = formatResetTimeDisplay(quota.resetAt);
const countdown = formatResetTime(quota.resetAt); // recurring defaults true: a missing flag means the quota
const resetDisplay = formatResetTimeDisplay(quota.resetAt); // refreshes at resetAt. Bonus/one-shot packs set recurring:false
// recurring defaults true: a missing flag means the quota // and their resetAt is a hard expiry, so word it as "expires".
// refreshes at resetAt. Bonus/one-shot packs set recurring:false const recurring = quota.recurring !== false;
// and their resetAt is a hard expiry, so word it as "expires". const countdownLabel = recurring ? `in ${countdown}` : `expires in ${countdown}`;
const recurring = quota.recurring !== false;
const countdownLabel = recurring ? `in ${countdown}` : `expires in ${countdown}`;
return ( return (
<tr <div
key={`${quota.name}-${quota.index}`} key={`${quota.name}-${quota.index}`}
className="border-b border-black/5 dark:border-white/5 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors" className={`flex items-center gap-2 border-b border-black/5 dark:border-white/5 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors ${cellPad}`}
>
{/* Name */}
<div className="flex w-36 min-w-0 items-center gap-1.5">
<span className="text-[10px] shrink-0">{colors.emoji}</span>
<span className={`${nameText} font-medium text-text-primary truncate`}>
{quota.name}
</span>
</div>
{/* Progress + used/total */}
<div className={`min-w-0 flex-1 ${compact ? "space-y-1" : "space-y-1.5"}`}>
<div className={`${compact ? "h-1" : "h-1.5"} rounded-full overflow-hidden border ${colors.bgLight} ${
quota.remaining === 0 ? "border-black/10 dark:border-white/10" : "border-transparent"
}`}>
<div
className={`h-full transition-all duration-300 ${colors.bg}`}
style={{ width: `${Math.min(quota.remaining, 100)}%` }}
/>
</div>
<div className={`flex items-center justify-between gap-1 min-w-0 ${compact ? "text-[10px]" : "text-xs"}`}>
<span
className="text-text-muted truncate"
title={`${quota.used.toLocaleString()} / ${quota.total > 0 ? quota.total.toLocaleString() : "∞"}`}
>
{quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"}
</span>
<span className={`font-medium ${colors.text} shrink-0`}>
{quota.remaining}%
</span>
</div>
</div>
{/* Reset time */}
<div className="min-w-0 shrink">
{countdown !== "-" || resetDisplay ? (
compact ? (
<div
className={`${resetPrimary} text-text-primary font-medium truncate`}
title={resetDisplay || ""}
>
{countdown !== "-" ? countdownLabel : resetDisplay}
</div>
) : (
<div className="min-w-0 space-y-0.5">
{countdown !== "-" && (
<div className={`${resetPrimary} text-text-primary font-medium truncate`}>
{countdownLabel}
</div>
)}
{resetDisplay && (
<div className={`${resetSecondary} text-text-muted truncate`}>
{resetDisplay}
</div>
)}
</div>
)
) : (
<div className={`${resetPrimary} text-text-muted italic`}>N/A</div>
)}
</div>
{/* Hide action */}
{hasHideAction && (
<button
type="button"
onClick={() => onHideQuota(quota)}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-text-muted transition-colors hover:bg-black/5 hover:text-text-primary dark:hover:bg-white/5"
title="Hide this quota row"
aria-label={`Hide quota ${quota.name}`}
> >
<td className={`${cellPad} w-[30%]`}> <span className="material-symbols-outlined text-[15px]">
<div className="flex items-center gap-1.5 min-w-0"> visibility_off
<span className="text-[10px] shrink-0">{colors.emoji}</span> </span>
<span className={`${nameText} font-medium text-text-primary truncate`}> </button>
{quota.name} )}
</span> </div>
</div> );
</td> })}
<td className={`${cellPad} w-[45%]`}>
<div className={compact ? "space-y-1" : "space-y-1.5"}>
<div className={`${compact ? "h-1" : "h-1.5"} rounded-full overflow-hidden border ${colors.bgLight} ${
quota.remaining === 0 ? "border-black/10 dark:border-white/10" : "border-transparent"
}`}>
<div
className={`h-full transition-all duration-300 ${colors.bg}`}
style={{ width: `${Math.min(quota.remaining, 100)}%` }}
/>
</div>
<div className={`flex items-center justify-between ${compact ? "text-[10px]" : "text-xs"}`}>
<span className="text-text-muted">
{quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"}
</span>
<span className={`font-medium ${colors.text}`}>
{quota.remaining}%
</span>
</div>
</div>
</td>
<td className={`${cellPad} ${hasHideAction ? "w-[20%]" : "w-[25%]"}`}>
{countdown !== "-" || resetDisplay ? (
compact ? (
<div
className={`${resetPrimary} text-text-primary font-medium truncate`}
title={resetDisplay || ""}
>
{countdown !== "-" ? countdownLabel : resetDisplay}
</div>
) : (
<div className="space-y-0.5">
{countdown !== "-" && (
<div className={`${resetPrimary} text-text-primary font-medium`}>
{countdownLabel}
</div>
)}
{resetDisplay && (
<div className={`${resetSecondary} text-text-muted`}>
{resetDisplay}
</div>
)}
</div>
)
) : (
<div className={`${resetPrimary} text-text-muted italic`}>N/A</div>
)}
</td>
{hasHideAction && (
<td className={`${cellPad} w-[5%] text-right`}>
<button
type="button"
onClick={() => onHideQuota(quota)}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-text-muted transition-colors hover:bg-black/5 hover:text-text-primary dark:hover:bg-white/5"
title="Hide this quota row"
aria-label={`Hide quota ${quota.name}`}
>
<span className="material-symbols-outlined text-[15px]">
visibility_off
</span>
</button>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div> </div>
{totalPages > 1 && ( {totalPages > 1 && (
@@ -1241,7 +1241,7 @@ export default function ProviderLimits() {
visibility_off visibility_off
</span> </span>
<span className="shrink-0">Hidden:</span> <span className="shrink-0">Hidden:</span>
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto whitespace-nowrap"> <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto whitespace-nowrap pb-2">
{hiddenQuotaRows.map((quotaRow) => ( {hiddenQuotaRows.map((quotaRow) => (
<button <button
key={getQuotaVisibilityKey(quotaRow)} key={getQuotaVisibilityKey(quotaRow)}
@@ -512,6 +512,36 @@ export function parseQuotaData(provider, data) {
} }
break; break;
case "kimi":
// Weekly / Ratelimit from /v1/usages. Prefer remainingPercentage only.
if (data.quotas) {
Object.entries(data.quotas).forEach(([name, quota]) => {
normalizedQuotas.push({
name,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: quota.remainingPercentage,
});
});
}
break;
case "deepseek":
// Credit balance — remainingPercentage only (no absolute remaining).
if (data.quotas) {
Object.entries(data.quotas).forEach(([name, quota]) => {
normalizedQuotas.push({
name,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: quota.remainingPercentage,
});
});
}
break;
default: default:
// Generic fallback for unknown providers // Generic fallback for unknown providers
if (data.quotas) { if (data.quotas) {
@@ -0,0 +1,88 @@
"use server";
import { NextResponse } from "next/server";
import { exec } from "child_process";
import { promisify } from "util";
import fs from "fs/promises";
import path from "path";
import os from "os";
const execAsync = promisify(exec);
// Mirror the executor's resolveDevinBin discovery so the dashboard's status
// matches what the runtime actually spawns.
const candidateDevinPaths = () => {
const home = os.homedir();
const isWin = os.platform() === "win32";
const localAppData = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
// Mirror resolveDevinBin in the executor — cover installer + common
// package-manager locations so detection matches runtime resolution.
return isWin
? [
path.join(localAppData, "devin", "cli", "bin", "devin.exe"),
path.join(home, ".local", "bin", "devin.exe"),
path.join(home, "scoop", "shims", "devin.exe"),
path.join(localAppData, "Programs", "devin", "devin.exe"),
]
: [
path.join(home, ".local", "share", "devin", "bin", "devin"),
path.join(home, ".devin", "bin", "devin"),
path.join(home, ".local", "bin", "devin"),
"/opt/homebrew/bin/devin",
"/usr/local/bin/devin",
"/usr/bin/devin",
];
};
const checkDevinInstalled = async () => {
// 1. PATH lookup
try {
const isWindows = os.platform() === "win32";
const command = isWindows ? "where devin" : "which devin";
await execAsync(command, { windowsHide: true });
return { installed: true, source: "path" };
} catch {
// fall through to filesystem probes
}
// 2. Known installer paths
for (const candidate of candidateDevinPaths()) {
try {
await fs.access(candidate);
return { installed: true, source: candidate };
} catch { /* keep probing */ }
}
return { installed: false, source: null };
};
const readDevinVersion = async () => {
try {
const { stdout } = await execAsync("devin --version", { windowsHide: true });
return stdout.trim().split("\n")[0] || null;
} catch {
return null;
}
};
// GET — install detection only. No config to write: the binary handles its own auth.
export async function GET() {
try {
const { installed, source } = await checkDevinInstalled();
if (!installed) {
return NextResponse.json({
installed: false,
message: "Devin CLI is not installed. Install it from https://cli.devin.ai and run `devin auth login`.",
installUrl: "https://cli.devin.ai",
});
}
const version = await readDevinVersion();
return NextResponse.json({
installed: true,
source,
version,
message: "Devin CLI detected. Make sure `devin auth login` has been run.",
});
} catch (error) {
console.log("Error checking devin settings:", error);
return NextResponse.json({ error: "Failed to check devin settings" }, { status: 500 });
}
}
+113 -12
View File
@@ -19,7 +19,24 @@ import {
registerXaiSession, registerXaiSession,
getXaiSessionStatus, getXaiSessionStatus,
clearXaiSession, clearXaiSession,
startTraeProxy,
stopTraeProxy,
registerTraeSession,
getTraeSessionStatus,
clearTraeSession,
startWindsurfProxy,
stopWindsurfProxy,
registerWindsurfSession,
getWindsurfSessionStatus,
clearWindsurfSession,
startZedProxy,
stopZedProxy,
registerZedSession,
getZedSessionStatus,
clearZedSession,
} from "@/lib/oauth/utils/server"; } from "@/lib/oauth/utils/server";
import { detectIdeInstalled } from "@/lib/oauth/utils/ideDetect";
import { ZED_HOSTED_CONFIG } from "@/lib/oauth/constants/oauth";
async function completeXaiManualCode(code, state, ownerId) { async function completeXaiManualCode(code, state, ownerId) {
const session = state ? getXaiSessionStatus(state) : null; const session = state ? getXaiSessionStatus(state) : null;
@@ -80,13 +97,34 @@ export async function GET(request, { params }) {
const reservedParams = new Set(["redirect_uri"]); const reservedParams = new Set(["redirect_uri"]);
const meta = {}; const meta = {};
searchParams.forEach((value, key) => { if (!reservedParams.has(key)) meta[key] = value; }); searchParams.forEach((value, key) => { if (!reservedParams.has(key)) meta[key] = value; });
// Zed: derive native_app_port from the local callback URL so the RSA keypair
// is bound to the port the proxy is actually listening on.
if (provider === "zed") {
try { const p = new URL(redirectUri).port; if (p) meta.nativeAppPort = p; } catch { /* ignore */ }
}
const authData = await generateAuthData(provider, redirectUri, Object.keys(meta).length ? meta : undefined); const authData = await generateAuthData(provider, redirectUri, Object.keys(meta).length ? meta : undefined);
return NextResponse.json(authData); return NextResponse.json(authData);
} }
if (action === "start-proxy") { if (action === "start-proxy") {
// Trae/Windsurf/Zed use a dynamic-port local callback server (singleton session,
// state is registered separately via /register-session after /authorize).
if (provider === "trae") {
const result = await startTraeProxy();
return NextResponse.json(result);
}
if (provider === "windsurf") {
const result = await startWindsurfProxy();
return NextResponse.json(result);
}
if (provider === "zed") {
// Prefer ZED_HOSTED_CONFIG.defaultNativeAppPort (58443) so the browser redirect
// matches what Zed expects; falls back to a random port if it's busy.
const result = await startZedProxy(searchParams.get("native_app_port") || ZED_HOSTED_CONFIG.defaultNativeAppPort);
return NextResponse.json(result);
}
if (!["codex", "xai"].includes(provider)) { if (!["codex", "xai"].includes(provider)) {
return NextResponse.json({ error: "Proxy only supported for codex/xai" }, { status: 400 }); return NextResponse.json({ error: "Proxy only supported for codex/xai/trae/windsurf/zed" }, { status: 400 });
} }
const appPort = searchParams.get("app_port"); const appPort = searchParams.get("app_port");
if (!appPort) { if (!appPort) {
@@ -108,18 +146,24 @@ export async function GET(request, { params }) {
} }
if (action === "poll-status") { if (action === "poll-status") {
if (!["codex", "xai"].includes(provider)) {
return NextResponse.json({ error: "Poll only supported for codex/xai" }, { status: 400 });
}
const state = searchParams.get("state"); const state = searchParams.get("state");
if (!state) { if (!state) {
return NextResponse.json({ error: "Missing state" }, { status: 400 }); return NextResponse.json({ error: "Missing state" }, { status: 400 });
} }
const session = provider === "xai" ? getXaiSessionStatus(state) : getCodexSessionStatus(state); let session;
if (provider === "trae") session = getTraeSessionStatus(state, user.id);
else if (provider === "windsurf") session = getWindsurfSessionStatus(state, user.id);
else if (provider === "zed") session = getZedSessionStatus(state, user.id);
else if (provider === "xai") session = getXaiSessionStatus(state);
else if (provider === "codex") session = getCodexSessionStatus(state);
else return NextResponse.json({ error: "Poll only supported for codex/xai/trae/windsurf/zed" }, { status: 400 });
if (!session) return NextResponse.json({ status: "unknown" }); if (!session) return NextResponse.json({ status: "unknown" });
if (session.status === "done" || session.status === "error") { if (session.status === "done" || session.status === "error") {
const payload = { ...session }; const payload = { ...session };
if (provider === "xai") clearXaiSession(state); if (provider === "trae") clearTraeSession(state);
else if (provider === "windsurf") clearWindsurfSession(state);
else if (provider === "zed") clearZedSession(state);
else if (provider === "xai") clearXaiSession(state);
else clearCodexSession(state); else clearCodexSession(state);
return NextResponse.json(payload); return NextResponse.json(payload);
} }
@@ -127,14 +171,24 @@ export async function GET(request, { params }) {
} }
if (action === "stop-proxy") { if (action === "stop-proxy") {
if (!["codex", "xai"].includes(provider)) { if (provider === "trae") stopTraeProxy();
return NextResponse.json({ error: "Proxy only supported for codex/xai" }, { status: 400 }); else if (provider === "windsurf") stopWindsurfProxy();
} else if (provider === "zed") stopZedProxy();
if (provider === "xai") stopXaiProxy(); else if (provider === "xai") stopXaiProxy();
else stopCodexProxy(); else if (provider === "codex") stopCodexProxy();
else return NextResponse.json({ error: "Proxy only supported for codex/xai/trae/windsurf/zed" }, { status: 400 });
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }
if (action === "ide-status") {
// Detect whether the IDE is installed locally (used by import-token UX).
if (provider !== "trae" && provider !== "windsurf") {
return NextResponse.json({ error: "ide-status only supported for trae/windsurf" }, { status: 400 });
}
const status = await detectIdeInstalled(provider);
return NextResponse.json(status);
}
if (action === "device-code") { if (action === "device-code") {
const providerData = getProvider(provider); const providerData = getProvider(provider);
if (providerData.flowType !== "device_code") { if (providerData.flowType !== "device_code") {
@@ -161,6 +215,7 @@ export async function GET(request, { params }) {
"kimi-coding", "kimi-coding",
"kilocode", "kilocode",
"codebuddy-cn", "codebuddy-cn",
"codebuddy-intl",
"qoder", "qoder",
"grok-cli", "grok-cli",
]; ];
@@ -206,9 +261,55 @@ export async function POST(request, { params }) {
return NextResponse.json({ error: "Invalid or empty request body" }, { status: 400 }); return NextResponse.json({ error: "Invalid or empty request body" }, { status: 400 });
} }
if (action === "register-session") {
// Register proxy session out of URL query (state) + body (codeVerifier).
// Zed's codeVerifier encodes the RSA private key — must stay out of URL/logs.
const state = body?.state;
if (!state) return NextResponse.json({ error: "Missing state" }, { status: 400 });
let ok = false;
if (provider === "trae") ok = registerTraeSession({ state, ownerId: user.id });
else if (provider === "windsurf") ok = registerWindsurfSession({ state, ownerId: user.id });
else if (provider === "zed") ok = registerZedSession({ state, codeVerifier: body?.codeVerifier, ownerId: user.id });
else return NextResponse.json({ error: "register-session only supported for trae/windsurf/zed" }, { status: 400 });
return NextResponse.json({ success: ok });
}
if (action === "exchange") { if (action === "exchange") {
const { code, redirectUri, codeVerifier, state, meta } = body; const { code, redirectUri, codeVerifier, state, meta } = body;
// Trae/Windsurf: code is either a raw callback URL or a pasted token.
// exchangeTokens() handles both paths; no PKCE, skip codex JWT extraction.
if (provider === "trae" || provider === "windsurf") {
const token = typeof code === "string" ? code.trim() : "";
if (!token) {
return NextResponse.json({ error: "Missing token or callback URL" }, { status: 400 });
}
try {
const tokenData = await exchangeTokens(provider, token, null, null, state);
const connection = await createProviderConnection({
provider,
authType: provider === "windsurf" ? "api_key" : "oauth",
ownerId: user.id,
...tokenData,
expiresAt: tokenData.expiresIn
? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString()
: null,
testStatus: "active",
});
return NextResponse.json({
success: true,
connection: {
id: connection.id,
provider: connection.provider,
email: connection.email,
displayName: connection.displayName,
}
});
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
// Detect if "code" is actually a raw JWT access token (starts with eyJ) // Detect if "code" is actually a raw JWT access token (starts with eyJ)
if (code && code.startsWith("eyJ") && code.includes(".")) { if (code && code.startsWith("eyJ") && code.includes(".")) {
const { extractCodexAccountInfo } = await import("@/lib/oauth/providers"); const { extractCodexAccountInfo } = await import("@/lib/oauth/providers");
@@ -292,7 +393,7 @@ export async function POST(request, { params }) {
} }
// Providers that don't use PKCE for device code // Providers that don't use PKCE for device code
const noPkceProviders = ["github", "kimi", "kimi-coding", "kilocode", "codebuddy-cn"]; const noPkceProviders = ["github", "kimi", "kimi-coding", "kilocode", "codebuddy-cn", "codebuddy-intl"];
let result; let result;
if (noPkceProviders.includes(provider)) { if (noPkceProviders.includes(provider)) {
// kimi needs extraData._kimiDeviceId for stable X-Msh-Device-Id (CLIProxyAPI parity) // kimi needs extraData._kimiDeviceId for stable X-Msh-Device-Id (CLIProxyAPI parity)
+4 -4
View File
@@ -6,8 +6,8 @@ import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
/** /**
* POST /api/oauth/kiro/api-key * POST /api/oauth/kiro/api-key
* Import a Kiro API key (headless auth). The key is a long-lived bearer * Import a Kiro API key (headless auth). The key is a long-lived bearer
* credential there is no refresh token. It is validated by listing * credential there is no refresh token. It is validated against the Amazon
* CodeWhisperer profiles, then stored with authMethod="api_key". * Q model catalog, then stored with authMethod="api_key".
*/ */
export async function POST(request) { export async function POST(request) {
try { try {
@@ -23,7 +23,7 @@ export async function POST(request) {
const kiroService = new KiroService(); const kiroService = new KiroService();
// Validate the key and resolve its profileArn via ListAvailableProfiles // Validate the key against the same Amazon Q surface used for inference.
const credential = await kiroService.validateApiKey( const credential = await kiroService.validateApiKey(
apiKey, apiKey,
region || "us-east-1" region || "us-east-1"
@@ -43,7 +43,7 @@ export async function POST(request) {
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
email: email || null, email: email || null,
providerSpecificData: { providerSpecificData: {
profileArn: credential.profileArn, ...(credential.profileArn ? { profileArn: credential.profileArn } : {}),
region: credential.region, region: credential.region,
authMethod: "api_key", authMethod: "api_key",
provider: "API Key", provider: "API Key",
@@ -793,6 +793,26 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
}, effectiveProxy); }, effectiveProxy);
return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
} }
case "qoder": {
// PAT (pt-...) exchange → job token. A successful exchange proves the PAT.
const raw = connection.apiKey || "";
const pat = raw.startsWith("pt-") ? raw : `pt-${raw}`;
const exRes = await fetchWithConnectionProxy(
"https://openapi.qoder.sh/api/v1/jobToken/exchange",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"Cosy-Version": "1.0.1",
"Cosy-ClientType": "5",
},
body: JSON.stringify({ personal_token: pat }),
},
effectiveProxy,
);
return { valid: exRes.ok, error: exRes.ok ? null : "Invalid Personal Access Token" };
}
default: default:
return { valid: false, error: "Provider test not supported" }; return { valid: false, error: "Provider test not supported" };
} }
@@ -83,8 +83,9 @@ export async function GET(request) {
return new Response(stream, { return new Response(stream, {
headers: { headers: {
"Content-Type": "text/event-stream", "Content-Type": "text/event-stream",
"Cache-Control": "no-cache", "Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive", "Connection": "keep-alive",
"X-Accel-Buffering": "no",
}, },
}); });
} }
+18 -1
View File
@@ -14,6 +14,7 @@ import { resolveCopilotModels } from "open-sse/services/copilotModels.js";
import { resolveClinepassModels } from "open-sse/services/clinepassModels.js"; import { resolveClinepassModels } from "open-sse/services/clinepassModels.js";
import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js"; import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js";
import { resolveCursorModels } from "open-sse/services/cursorModels.js"; import { resolveCursorModels } from "open-sse/services/cursorModels.js";
import { resolveZedModels } from "open-sse/shared/zedAuth.js";
import { updateProviderCredentials } from "@/sse/services/tokenRefresh"; import { updateProviderCredentials } from "@/sse/services/tokenRefresh";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js"; import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
@@ -104,7 +105,23 @@ const LIVE_MODEL_RESOLVERS = {
providerSpecificData: conn.providerSpecificData || {}, providerSpecificData: conn.providerSpecificData || {},
}, { log: console }); }, { log: console });
return result?.models?.length ? { models: result.models } : null; return result?.models?.length ? { models: result.models } : null;
} },
zed: async (conn) => {
const result = await resolveZedModels({
accessToken: conn.accessToken,
providerSpecificData: conn.providerSpecificData || {},
});
if (!result?.models?.length) return null;
return {
models: result.models
.filter((m) => !m.isDisabled)
.map((m) => ({
id: m.id,
name: m.name,
capabilities: m.supportsTools ? { tools: true } : undefined,
})),
};
},
}; };
const parseOpenAIStyleModels = (data) => { const parseOpenAIStyleModels = (data) => {

Some files were not shown because too many files have changed in this diff Show More