diff --git a/CHANGELOG.md b/CHANGELOG.md index ba02368a..fda0857b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ - **Dokploy**: deploy production Compose from GitHub Actions with serialized rollout tracking, terminal-status handling, health verification, and deployment summaries - **GitHub Actions**: remove unrelated Docker image publishing and GitBook Pages deployment workflows +# v0.5.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) ## Features diff --git a/README.md b/README.md index 916298bf..4458ba78 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ [🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com) -[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) +[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) • [🇮🇩 Indonesia](./i18n/README.id-ID.md) diff --git a/cli/package.json b/cli/package.json index cb24c4f0..efb786e4 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.5.40", + "version": "0.5.45", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/cli/src/cli/menus/providers.js b/cli/src/cli/menus/providers.js index 291ae674..7e28ec64 100644 --- a/cli/src/cli/menus/providers.js +++ b/cli/src/cli/menus/providers.js @@ -53,6 +53,9 @@ const PROVIDER_MODELS = { { id: "glm-4.7" }, ], ag: [ + { id: "gemini-3.6-flash-high" }, + { id: "gemini-3.6-flash-medium" }, + { id: "gemini-3.6-flash-low" }, { id: "gemini-3-flash-agent" }, { id: "gemini-3.5-flash-low" }, { id: "gemini-3.5-flash-extra-low" }, @@ -95,6 +98,8 @@ const PROVIDER_MODELS = { { id: "claude-3-5-sonnet-20241022" }, ], gemini: [ + { id: "gemini-3.6-flash" }, + { id: "gemini-3.5-flash-lite" }, { id: "gemini-3-pro-preview" }, { id: "gemini-2.5-pro" }, { id: "gemini-2.5-flash" }, diff --git a/i18n/README.id-ID.md b/i18n/README.id-ID.md new file mode 100644 index 00000000..dbfbd8f5 --- /dev/null +++ b/i18n/README.id-ID.md @@ -0,0 +1,951 @@ +
+ 9Router Dashboard + + # 9Router - Router AI Gratis + + **Jangan berhenti ngoding. Otomatis dialihkan ke model AI gratis & murah dengan smart fallback.** + + **Hubungkan semua tool AI coding (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) ke 40+ provider AI dan 100+ model.** + + [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) + [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) + [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + + [🚀 Mulai Cepat](#-mulai-cepat) • [💡 Fitur](#-fitur-utama) • [📖 Setup](#-panduan-setup) • [🌐 Website](https://9router.com) + + [🇻🇳 Tiếng Việt](./README.vi.md) • [🇨🇳 中文](./README.zh-CN.md) • [🇯🇵 日本語](./README.ja-JP.md) • [🇮🇩 Bahasa Indonesia](./README.id-ID.md) +
+ +--- + +## 🤔 Kenapa 9Router? + +**Berhenti buang-buang uang dan terhambat limit:** + +- ❌ Kuota langganan hangus tiap bulan tanpa terpakai +- ❌ Rate limit bikin ngoding berhenti di tengah jalan +- ❌ API mahal ($20–50/bulan per provider) +- ❌ Harus gonta-ganti provider secara manual + +**9Router menyelesaikan itu semua:** + +- ✅ **Maksimalkan langganan** - lacak kuota dan habiskan sebelum reset +- ✅ **Fallback otomatis** - langganan → murah → gratis, tanpa downtime +- ✅ **Multi-akun** - round-robin antar akun untuk tiap provider +- ✅ **Universal** - mendukung Claude Code, Codex, Gemini CLI, Cursor, Cline, dan tool CLI apa pun + +--- + +## 🔄 Cara Kerja + +``` +┌─────────────┐ +│ Tool CLI │ (Claude Code, Codex, Gemini CLI, OpenClaw, Cursor, Cline...) +│ kamu │ +└──────┬──────┘ + │ http://localhost:20128/v1 + ↓ +┌─────────────────────────────────────────┐ +│ 9Router (Smart Router) │ +│ • Konversi format (OpenAI ↔ Claude) │ +│ • Pelacakan kuota │ +│ • Refresh token otomatis │ +└──────┬──────────────────────────────────┘ + │ + ├─→ [Tier 1: Langganan] Claude Code, Codex, Gemini CLI + │ ↓ kuota habis + ├─→ [Tier 2: Murah] GLM ($0.6/1M), MiniMax ($0.2/1M) + │ ↓ batas budget tercapai + └─→ [Tier 3: Gratis] iFlow, Qwen, Kiro (unlimited) + +Hasil: ngoding tanpa berhenti, biaya minimum +``` + +--- + +## ⚡ Mulai Cepat + +**1. Install secara global:** + +```bash +npm install -g 9router +9router +``` + +🎉 Dashboard terbuka di `http://localhost:20128` + +**2. Hubungkan provider gratis (tanpa perlu daftar):** + +Dashboard → Providers → hubungkan **Claude Code** atau **Antigravity** → login OAuth → selesai! + +**3. Pakai di tool CLI kamu:** + +``` +Konfigurasi Claude Code/Codex/Gemini CLI/OpenClaw/Cursor/Cline: + Endpoint: http://localhost:20128/v1 + API Key: [salin dari dashboard] + Model: if/kimi-k2-thinking +``` + +**Cuma itu!** Mulai ngoding dengan model AI gratis. + +**Alternatif: jalankan dari source (repo ini):** + +Paket repo ini bersifat privat (`9router-app`), jadi menjalankan dari source/Docker adalah jalur yang diharapkan untuk pengembangan lokal. + +```bash +cp .env.example .env +npm install +PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev +``` + +Mode produksi: + +```bash +npm run build +PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start +``` + +URL default: +- Dashboard: `http://localhost:20128/dashboard` +- API kompatibel OpenAI: `http://localhost:20128/v1` + +--- + +## 🎥 Video Tutorial + +
+ +### 📺 Panduan Setup Lengkap - 9Router + Claude Code Gratis + +[![9Router + Claude Code Setup](https://img.youtube.com/vi/raEyZPg5xE0/maxresdefault.jpg)](https://www.youtube.com/watch?v=raEyZPg5xE0) + +**🎬 Tonton tutorial langkah demi langkah:** +- ✅ Install dan setup 9Router +- ✅ Konfigurasi Claude Sonnet 4.5 gratis +- ✅ Integrasi dengan Claude Code +- ✅ Demo live coding + +**⏱️ Durasi:** 20 menit | **👥 Dibuat oleh:** Developer Community + +[▶️ Tonton di YouTube](https://www.youtube.com/watch?v=o3qYCyjrFYg) + +
+ +--- + +## 🛠️ Tool CLI yang Didukung + +9Router bekerja mulus dengan semua tool AI coding utama: + +
+ + + + + + + + + + + + + + + + + +
+ Claude Code
+ Claude-Code +
+ OpenClaw
+ OpenClaw +
+ Codex
+ Codex +
+ OpenCode
+ OpenCode +
+ Cursor
+ Cursor +
+ Antigravity
+ Antigravity +
+ Cline
+ Cline +
+ Continue
+ Continue +
+ Droid
+ Droid +
+ Roo
+ Roo +
+ Copilot
+ Copilot +
+ Kilo Code
+ Kilo Code +
+
+ +--- + +## 🌐 Provider yang Didukung + +### 🔐 Provider OAuth + +
+ + + + + + + + +
+ Claude Code
+ Claude-Code +
+ Antigravity
+ Antigravity +
+ Codex
+ Codex +
+ GitHub
+ GitHub +
+ Cursor
+ Cursor +
+
+ +### 🆓 Provider Gratis + +
+ + + + + + + +
+ iFlow
+ iFlow AI
+ 8+ model • unlimited +
+ Qwen
+ Qwen Code
+ 3+ model • unlimited +
+ Gemini CLI
+ Gemini CLI
+ 180 ribu request/bulan gratis +
+ Kiro
+ Kiro AI
+ Claude • unlimited +
+
+ +### 🔑 Provider API Key (40+) + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ OpenRouter
+ OpenRouter +
+ GLM
+ GLM +
+ Kimi
+ Kimi +
+ MiniMax
+ MiniMax +
+ OpenAI
+ OpenAI +
+ Anthropic
+ Anthropic +
+ Gemini
+ Gemini +
+ DeepSeek
+ DeepSeek +
+ Groq
+ Groq +
+ xAI
+ xAI +
+ Mistral
+ Mistral +
+ Perplexity
+ Perplexity +
+ Together
+ Together AI +
+ Fireworks
+ Fireworks +
+ Cerebras
+ Cerebras +
+ Cohere
+ Cohere +
+ NVIDIA
+ NVIDIA +
+ SiliconFlow
+ SiliconFlow +
+

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

+
+ +--- + +## 💡 Fitur Utama + +| Fitur | Ringkasan | Manfaat | +|-------|-----------|---------| +| 🎯 **Smart Fallback 3 Tingkat** | Routing otomatis: langganan → murah → gratis | Ngoding tanpa berhenti, zero downtime | +| 📊 **Pelacakan Kuota Real-time** | Hitungan token live + hitung mundur reset | Nilai langganan termanfaatkan maksimal | +| 🔄 **Konversi Format** | OpenAI ↔ Claude ↔ Gemini mulus | Bekerja dengan tool CLI apa pun | +| 👥 **Dukungan Multi-akun** | Beberapa akun per provider | Load balancing + redundansi | +| 🔄 **Auto Refresh Token** | Token OAuth diperbarui otomatis | Tidak perlu login ulang manual | +| 🎨 **Combo Kustom** | Buat kombinasi model tanpa batas | Fallback sesuai kebutuhanmu | +| 📝 **Log Request** | Log lengkap request/response | Troubleshooting jadi mudah | +| 💾 **Cloud Sync** | Sinkronkan pengaturan antar perangkat | Setup sama di mana pun | +| 📊 **Analitik Penggunaan** | Lacak token, biaya, dan tren | Optimalkan pengeluaran | +| 🌐 **Deploy di Mana Saja** | Localhost, VPS, Docker, Cloudflare Workers | Opsi deployment fleksibel | + +
+📖 Detail Fitur + +### 🎯 Smart Fallback 3 Tingkat + +Buat combo dengan fallback otomatis: + +``` +Combo: "my-coding-stack" + 1. cc/claude-opus-4-6 (langganan) + 2. glm/glm-4.7 (backup murah, $0.6/1M) + 3. if/kimi-k2-thinking (fallback gratis) + +→ Otomatis beralih saat kuota habis atau terjadi error +``` + +### 📊 Pelacakan Kuota Real-time + +- Konsumsi token per provider +- Hitung mundur reset (5 jam, harian, mingguan) +- Estimasi biaya untuk tier berbayar +- Laporan pengeluaran bulanan + +### 🔄 Konversi Format + +Konversi mulus antar format: +- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **OpenAI Responses** +- Tool CLI mengirim dalam format OpenAI → 9Router mengonversi → provider menerima dalam format nativenya +- Bekerja dengan semua tool yang mendukung custom OpenAI endpoint + +### 👥 Dukungan Multi-akun + +- Tambahkan beberapa akun per provider +- Round-robin otomatis atau routing berbasis prioritas +- Saat satu akun mencapai kuota, fallback ke akun berikutnya + +### 🔄 Auto Refresh Token + +- Token OAuth di-refresh otomatis sebelum kedaluwarsa +- Tidak perlu autentikasi ulang manual +- Pengalaman mulus di semua provider + +### 🎨 Combo Kustom + +- Buat kombinasi model tanpa batas +- Campur tier langganan, murah, dan gratis +- Beri nama combo agar mudah diakses +- Bagikan combo antar perangkat lewat cloud sync + +### 📝 Log Request + +- Log lengkap request/response dalam mode debug +- Lacak API call, header, dan payload +- Troubleshoot masalah integrasi +- Ekspor log untuk dianalisis + +### 💾 Cloud Sync + +- Sinkronkan provider, combo, dan pengaturan antar perangkat +- Sinkronisasi latar belakang otomatis +- Penyimpanan terenkripsi yang aman +- Akses setup dari mana saja + +#### Catatan tentang cloud runtime + +- Untuk produksi, disarankan memakai variabel cloud sisi server: + - `BASE_URL` (URL callback internal yang dipakai scheduler sinkronisasi) + - `CLOUD_URL` (base URL endpoint cloud sync) +- `NEXT_PUBLIC_BASE_URL` dan `NEXT_PUBLIC_CLOUD_URL` masih didukung untuk kompatibilitas/UI, tetapi runtime server memprioritaskan `BASE_URL`/`CLOUD_URL`. +- Request cloud sync memakai timeout + perilaku fail-fast untuk menghindari UI menggantung saat DNS/jaringan cloud tidak tersedia. + +### 📊 Analitik Penggunaan + +- Lacak pemakaian token per provider dan per model +- Estimasi biaya dan tren pengeluaran +- Laporan dan insight bulanan +- Optimalkan pengeluaran AI + +> **💡 PENTING - tentang biaya di dashboard:** +> +> "Biaya" yang ditampilkan pada analitik penggunaan **hanya untuk pelacakan dan perbandingan**. +> 9Router sendiri **tidak menagih apa pun**. Kamu hanya membayar langsung ke provider jika memakai layanan berbayar. +> +> **Contoh:** jika dashboard menampilkan "Total biaya $290" untuk pemakaian model iFlow, +> itu adalah jumlah yang seharusnya kamu bayar bila memakai API berbayar secara langsung. Biaya sebenarnya = **$0** (iFlow gratis tanpa batas). +> +> Anggap saja ini "pelacak penghematan" yang menunjukkan berapa banyak yang kamu hemat lewat model gratis dan routing 9Router! + +### 🌐 Deploy di Mana Saja + +- 💻 **Localhost** - default, jalan offline +- ☁️ **VPS/Cloud** - berbagi antar perangkat +- 🐳 **Docker** - deploy satu perintah +- 🚀 **Cloudflare Workers** - jaringan edge global + +
+ +--- + +## 💰 Ringkasan Harga + +| Tier | Provider | Biaya | Reset Kuota | Cocok Untuk | +|------|----------|-------|-------------|-------------| +| **💳 Langganan** | Claude Code (Pro) | $20/bulan | 5 jam + mingguan | Yang sudah punya langganan | +| | Codex (Plus/Pro) | $20-200/bulan | 5 jam + mingguan | Pengguna OpenAI | +| | Gemini CLI | **Gratis** | 180rb/bulan + 1rb/hari | Semua orang! | +| | GitHub Copilot | $10-19/bulan | Bulanan | Pengguna GitHub | +| **💰 Murah** | GLM-4.7 | $0.6/1M | Setiap hari jam 10.00 | Backup hemat | +| | MiniMax M2.1 | $0.2/1M | Rolling 5 jam | Opsi paling murah | +| | Kimi K2 | $9/bulan flat | 10 juta token/bulan | Biaya yang bisa diprediksi | +| **🆓 Gratis** | iFlow | $0 | Unlimited | 8 model gratis | +| | Qwen | $0 | Unlimited | 3 model gratis | +| | Kiro | $0 | Unlimited | Claude gratis | + +**💡 Tips pro:** combo Gemini CLI (180rb request/bulan gratis) + iFlow (gratis unlimited) = biaya $0! + +--- + +### 📊 Tentang Biaya dan Penagihan 9Router + +**Fakta soal penagihan 9Router:** + +✅ **Software 9Router = gratis selamanya** (open source, tanpa tagihan) +✅ **"Biaya" di dashboard = tampilan/pelacakan saja** (bukan tagihan sungguhan) +✅ **Pembayaran langsung ke provider** (langganan atau biaya API) +✅ **Provider gratis tetap gratis** (iFlow, Kiro, Qwen = $0 unlimited) +❌ **9Router tidak mengirim invoice** atau menagih kartumu + +**Cara kerja tampilan biaya:** + +Dashboard menampilkan **estimasi biaya** seandainya kamu memakai API berbayar secara langsung. Ini **bukan tagihan**, melainkan alat pembanding yang menunjukkan penghematanmu. + +**Contoh skenario:** +``` +Tampilan dashboard: +• Total request: 1.662 +• Total token: 47 juta +• Biaya tertampil: $290 + +Kenyataannya: +• Provider: iFlow (gratis unlimited) +• Yang benar-benar dibayar: $0.00 +• Arti $290: jumlah yang kamu hemat dengan memakai model gratis! +``` + +**Aturan pembayaran:** +- **Provider langganan** (Claude Code, Codex): bayar langsung di website masing-masing +- **Provider murah** (GLM, MiniMax): bayar langsung, 9Router hanya melakukan routing +- **Provider gratis** (iFlow, Kiro, Qwen): benar-benar gratis selamanya, tanpa biaya tersembunyi +- **9Router**: tidak menagih apa pun + +--- + +## 🎯 Studi Kasus + +### Kasus 1: "Saya punya langganan Claude Pro" + +**Masalah:** kuota hangus tanpa terpakai, kena rate limit saat ngoding berat + +**Solusi:** +``` +Combo: "maximize-claude" + 1. cc/claude-opus-4-6 (manfaatkan langganan semaksimal mungkin) + 2. glm/glm-4.7 (backup murah saat kuota habis) + 3. if/kimi-k2-thinking (fallback darurat gratis) + +Biaya bulanan: $20 (langganan) + ~$5 (backup) = total $25 +vs. $20 + kena limit = frustrasi +``` + +### Kasus 2: "Saya mau biaya nol" + +**Masalah:** tidak mampu bayar langganan, tapi butuh AI coding yang andal + +**Solusi:** +``` +Combo: "free-forever" + 1. gc/gemini-3-flash (180rb request/bulan gratis) + 2. if/kimi-k2-thinking (gratis unlimited) + 3. qw/qwen3-coder-plus (gratis unlimited) + +Biaya bulanan: $0 +Kualitas: model siap produksi +``` + +### Kasus 3: "Ngoding 24/7 tanpa terputus" + +**Masalah:** deadline mepet, downtime tidak dapat ditoleransi + +**Solusi:** +``` +Combo: "always-on" + 1. cc/claude-opus-4-6 (kualitas terbaik) + 2. cx/gpt-5.2-codex (langganan kedua) + 3. glm/glm-4.7 (murah, reset harian) + 4. minimax/MiniMax-M2.1 (paling murah, reset 5 jam) + 5. if/kimi-k2-thinking (gratis unlimited) + +Hasil: 5 lapis fallback = zero downtime +Biaya bulanan: $20-200 (langganan) + $10-20 (backup) +``` + +### Kasus 4: "Saya mau pakai AI gratis di OpenClaw" + +**Masalah:** butuh asisten AI di aplikasi pesan (WhatsApp, Telegram, Slack...), sepenuhnya gratis + +**Solusi:** +``` +Combo: "openclaw-free" + 1. if/glm-4.7 (gratis unlimited) + 2. if/minimax-m2.1 (gratis unlimited) + 3. if/kimi-k2-thinking (gratis unlimited) + +Biaya bulanan: $0 +Cara akses: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... +``` + +--- + +## ❓ FAQ + +
+📊 Kenapa dashboard menampilkan biaya yang besar? + +Dashboard melacak pemakaian token dan menampilkan **estimasi biaya** seandainya kamu memakai API berbayar secara langsung. Ini **bukan tagihan nyata**, melainkan acuan untuk melihat berapa banyak yang kamu hemat dengan memakai model gratis atau langganan yang sudah ada lewat 9Router. + +**Contoh:** +- **Tampilan dashboard:** "Total biaya $290" +- **Kenyataan:** sedang memakai iFlow (gratis unlimited) +- **Biaya sebenarnya:** **$0.00** +- **Arti $290:** jumlah yang **dihemat** karena memakai model gratis alih-alih API berbayar! + +Tampilan biaya adalah "pelacak penghematan" untuk memahami pola pemakaian dan peluang optimasi. + +
+ +
+💳 Apakah 9Router menagih saya? + +**Tidak.** 9Router adalah software open source gratis yang berjalan di komputermu sendiri. Tidak ada penagihan sama sekali. + +**Kamu membayar ke:** +- ✅ **Provider langganan** (Claude Code $20/bulan, Codex $20-200/bulan) → bayar langsung di website masing-masing +- ✅ **Provider murah** (GLM, MiniMax) → bayar langsung, 9Router hanya me-routing request +- ❌ **9Router sendiri** → **tidak menagih apa pun** + +9Router adalah proxy/router lokal. Ia tidak menyimpan informasi kartu kredit, tidak bisa mengirim invoice, dan tidak punya sistem penagihan. Sepenuhnya software gratis. + +
+ +
+🆓 Apakah provider gratis benar-benar unlimited? + +**Ya!** Provider yang ditandai gratis (iFlow, Kiro, Qwen) benar-benar unlimited dan **tanpa biaya tersembunyi**. + +Ini adalah layanan gratis yang disediakan masing-masing perusahaan: +- **iFlow**: akses gratis unlimited ke 8+ model via OAuth +- **Kiro**: model Claude gratis unlimited via AWS Builder ID +- **Qwen**: akses gratis unlimited ke model Qwen via device authentication + +9Router hanya me-routing request — tidak ada "jebakan" atau tagihan di kemudian hari. Layanannya memang gratis, dan 9Router membuatnya lebih mudah dipakai dengan dukungan fallback. + +**Catatan:** beberapa provider langganan (Antigravity, GitHub Copilot) punya masa preview gratis dan bisa jadi berbayar nanti, tetapi hal itu diumumkan secara jelas oleh provider tersebut, bukan oleh 9Router. + +
+ +
+💰 Bagaimana cara menekan biaya AI seminimal mungkin? + +**Strategi free-first:** + +1. **Mulai dari combo 100% gratis:** + ``` + 1. gc/gemini-3-flash (180rb/bulan gratis dari Google) + 2. if/kimi-k2-thinking (gratis unlimited dari iFlow) + 3. qw/qwen3-coder-plus (gratis unlimited dari Qwen) + ``` + **Biaya: $0/bulan** + +2. **Tambahkan backup murah hanya bila perlu:** + ``` + 4. glm/glm-4.7 ($0.6 per 1 juta token) + ``` + **Tambahan biaya: bayar sesuai pemakaian saja** + +3. **Gunakan provider langganan paling akhir:** + - Hanya jika kamu memang sudah punya + - 9Router memaksimalkan nilainya lewat pelacakan kuota + +**Hasil:** sebagian besar pengguna bisa jalan dengan $0/bulan hanya dengan tier gratis! + +
+ +
+📈 Bagaimana kalau pemakaian tiba-tiba melonjak? + +Smart fallback 9Router mencegah tagihan tak terduga: + +**Skenario:** kuota habis di tengah sprint coding + +**Tanpa 9Router:** +- ❌ Kena rate limit → kerja berhenti → frustrasi +- ❌ Atau: tagihan API mahal tanpa disengaja + +**Dengan 9Router:** +- ✅ Langganan mencapai batas → otomatis fallback ke tier murah +- ✅ Tier murah jadi mahal → otomatis fallback ke tier gratis +- ✅ Ngoding tidak berhenti → biaya tetap terprediksi + +**Kamu yang pegang kendali:** atur batas pengeluaran per provider di dashboard, dan 9Router akan mematuhinya. + +
+ +--- + +## 📖 Panduan Setup + +
+🔐 Provider Langganan (maksimalkan nilainya) + +### Claude Code (Pro/Max) + +```bash +Dashboard → Providers → hubungkan Claude Code +→ login OAuth → refresh token otomatis +→ pelacakan kuota 5 jam + mingguan + +Model: + cc/claude-opus-4-6 + cc/claude-sonnet-4-5-20250929 + cc/claude-haiku-4-5-20251001 +``` + +**Tips pro:** pakai Opus untuk tugas kompleks, Sonnet kalau mengutamakan kecepatan. 9Router melacak kuota per model! + +### OpenAI Codex (Plus/Pro) + +```bash +Dashboard → Providers → hubungkan Codex +→ login OAuth (port 1455) +→ reset 5 jam + mingguan + +Model: + cx/gpt-5.2-codex + cx/gpt-5.1-codex-max +``` + +### Gemini CLI (180rb request/bulan gratis!) + +```bash +Dashboard → Providers → hubungkan Gemini CLI +→ Google OAuth +→ 180rb/bulan + 1rb/hari + +Model: + gc/gemini-3-flash-preview + gc/gemini-2.5-pro +``` + +**Value terbaik:** free tier-nya besar sekali! Pakai ini sebelum tier berbayar. + +### GitHub Copilot + +```bash +Dashboard → Providers → hubungkan GitHub +→ OAuth via GitHub +→ reset bulanan (tanggal 1 tiap bulan) + +Model: + gh/gpt-5 + gh/claude-4.5-sonnet + gh/gemini-3-pro +``` + +
+ +
+💰 Provider Murah (backup) + +### GLM-4.7 (reset harian, $0.6/1M) + +1. Daftar: [Zhipu AI](https://open.bigmodel.cn/) +2. Ambil API key dari Coding Plan +3. Dashboard → tambahkan API key: + - Provider: `glm` + - API Key: `your-key` + +**Pemakaian:** `glm/glm-4.7` + +**Tips pro:** Coding Plan memberi kuota 3x lipat dengan biaya 1/7! Reset setiap hari jam 10.00. + +### MiniMax M2.1 (reset 5 jam, $0.20/1M) + +1. Daftar: [MiniMax](https://www.minimax.io/) +2. Ambil API key +3. Dashboard → tambahkan API key + +**Pemakaian:** `minimax/MiniMax-M2.1` + +**Tips pro:** opsi termurah dengan konteks panjang (1 juta token)! + +### Kimi K2 ($9/bulan flat) + +1. Berlangganan: [Moonshot AI](https://platform.moonshot.ai/) +2. Ambil API key +3. Dashboard → tambahkan API key + +**Pemakaian:** `kimi/kimi-latest` + +**Tips pro:** $9/bulan flat untuk 10 juta token = biaya efektif $0.90/1M! + +
+ +
+🆓 Provider Gratis (backup darurat) + +### iFlow (8 model gratis) + +```bash +Dashboard → hubungkan iFlow +→ login OAuth iFlow +→ pemakaian unlimited + +Model: + if/kimi-k2-thinking + if/qwen3-coder-plus + if/glm-4.7 + if/minimax-m2 + if/deepseek-r1 +``` + +### Qwen (3 model gratis) + +```bash +Dashboard → hubungkan Qwen +→ autentikasi device code +→ pemakaian unlimited + +Model: + qw/qwen3-coder-plus + qw/qwen3-coder-flash +``` + +### Kiro (Claude gratis) + +```bash +Dashboard → hubungkan Kiro +→ AWS Builder ID atau Google/GitHub +→ pemakaian unlimited + +Model: + kr/claude-sonnet-4.5 + kr/claude-haiku-4.5 +``` + +
+ +
+🎨 Membuat Combo + +### Contoh 1: maksimalkan langganan → backup murah + +``` +Dashboard → Combos → buat baru + +Nama: premium-coding +Model: + 1. cc/claude-opus-4-6 (langganan, utama) + 2. glm/glm-4.7 (backup murah, $0.6/1M) + 3. minimax/MiniMax-M2.1 (fallback termurah, $0.20/1M) + +Pemakaian di CLI: premium-coding + +Contoh biaya bulanan (100 juta token): + 80 juta lewat Claude (langganan): tambahan $0 + 15 juta lewat GLM: $9 + 5 juta lewat MiniMax: $1 + Total: $10 +``` + +### Contoh 2: combo 100% gratis + +``` +Nama: free-forever +Model: + 1. gc/gemini-3-flash (180rb request/bulan gratis) + 2. if/kimi-k2-thinking (gratis unlimited) + 3. qw/qwen3-coder-plus (gratis unlimited) + 4. kr/claude-sonnet-4.5 (gratis unlimited) + +Biaya bulanan: $0 +``` + +### Tips membuat combo + +- Urutkan dari kualitas/prioritas tertinggi ke fallback paling murah +- Selalu taruh minimal satu provider gratis di posisi terakhir +- Pakai nama combo yang deskriptif agar mudah dipilih dari CLI +- Aktifkan cloud sync agar combo ikut tersedia di perangkat lain + +
+ +--- + +## 🐳 Deployment + +
+Docker + +```bash +docker run -d \ + --name 9router \ + -p 20128:20128 \ + -v 9router-data:/app/data \ + -e PORT=20128 \ + -e BASE_URL=http://localhost:20128 \ + ghcr.io/decolua/9router:latest +``` + +Dashboard: `http://localhost:20128/dashboard` + +
+ +
+VPS / Cloud + +```bash +npm install -g 9router +PORT=20128 HOSTNAME=0.0.0.0 BASE_URL=https://your-domain.com 9router +``` + +Disarankan menaruhnya di belakang reverse proxy (Nginx/Caddy) dengan HTTPS, dan membatasi akses hanya untuk dirimu sendiri. + +
+ +
+Cloudflare Workers + +```bash +npm run build +npx wrangler deploy +``` + +Atur `BASE_URL` dan `CLOUD_URL` sebagai environment variable di dashboard Cloudflare. + +
+ +--- + +## 🧪 Troubleshooting + +| Masalah | Kemungkinan Penyebab | Solusi | +|---------|----------------------|--------| +| Tool CLI tidak bisa konek | Endpoint salah | Pastikan `http://localhost:20128/v1` | +| 401 / Unauthorized | API key salah | Salin ulang key dari dashboard | +| Model tidak ditemukan | Prefix provider salah | Pakai format `provider/model`, mis. `if/kimi-k2-thinking` | +| Selalu fallback ke gratis | Kuota langganan habis | Cek hitung mundur reset di dashboard | +| OAuth gagal | Port callback terpakai | Tutup proses lain (mis. port 1455 untuk Codex) | +| UI menggantung saat sync | DNS/jaringan cloud bermasalah | Cek `CLOUD_URL`; sync memakai timeout fail-fast | + +Aktifkan mode debug di dashboard untuk melihat log lengkap request/response. + +--- + +## 🤝 Kontribusi + +Kontribusi sangat diterima! + +1. Fork repo ini +2. Buat branch fitur (`git checkout -b feature/nama-fitur`) +3. Commit perubahanmu (`git commit -m 'feat: tambah fitur X'`) +4. Push ke branch (`git push origin feature/nama-fitur`) +5. Buka Pull Request + +--- + +## 📄 Lisensi + +MIT License — lihat [LICENSE](https://github.com/decolua/9router/blob/main/LICENSE) untuk detailnya. + +--- + +
+ +**Kalau 9Router membantumu, kasih ⭐ di [GitHub](https://github.com/decolua/9router)!** + +[🌐 Website](https://9router.com) • [📦 npm](https://www.npmjs.com/package/9router) • [🐛 Laporkan Bug](https://github.com/decolua/9router/issues) + +
diff --git a/open-sse/config/appConstants.js b/open-sse/config/appConstants.js index 2934fc88..5e3ec4be 100644 --- a/open-sse/config/appConstants.js +++ b/open-sse/config/appConstants.js @@ -134,10 +134,19 @@ export const ANTIGRAVITY_HEADERS = { "User-Agent": ANTIGRAVITY_IDE_USER_AGENT }; -// Cloud Code Assist API +// Cloud Code Assist API endpoints differ by client ecosystem. export const CLOUD_CODE_API = { - loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", - onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", + "gemini-cli": { + loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", + }, + // Project discovery (loadCodeAssist/onboardUser) stays on PROD — the daily host + // rejects these auth/onboarding calls. Only chat traffic uses the daily host + // (see transport.apiEndpoint in registry/antigravity.js, set to bypass prod 429). + antigravity: { + loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", + }, }; export const LOAD_CODE_ASSIST_HEADERS = { diff --git a/open-sse/config/kiroConstants.js b/open-sse/config/kiroConstants.js index 511ae919..e6408da1 100644 --- a/open-sse/config/kiroConstants.js +++ b/open-sse/config/kiroConstants.js @@ -15,11 +15,17 @@ * fiction. The suffix is stripped before the request leaves this process. */ -import { extractThinking } from "../translator/concerns/thinkingUnified.js"; +import { extractThinking, parseSuffix } from "../translator/concerns/thinkingUnified.js"; import { effortToBudget } from "../translator/concerns/thinking.js"; export const KIRO_AGENTIC_SUFFIX = "-agentic"; export const KIRO_THINKING_SUFFIX = "-thinking"; +export const KIRO_TOOL_NAME_MAX_LENGTH = 64; +export const KIRO_TOOL_DESCRIPTION_MAX_LENGTH = 10237; +export const KIRO_TOOL_ID_MAX_LENGTH = 64; +export const KIRO_CODEWHISPERER_TARGET = + "AmazonCodeWhispererStreamingService.GenerateAssistantResponse"; +export const KIRO_ENDPOINT_FALLBACK_STATUSES = new Set([401, 403, 404]); // Public default CodeWhisperer profile ARNs (us-east-1), keyed by auth method. // Used when an account cannot resolve its own profileArn. Builder ID and social @@ -40,6 +46,39 @@ export function resolveDefaultProfileArn(authMethod) { export const KIRO_THINKING_BUDGET_DEFAULT = 16000; +/** + * Resolve a Kiro model after consuming the generic model(level) suffix. + * The suffix is a 9router request override, not part of Kiro's upstream model id. + */ +export function resolveKiroModelIntent(model) { + const { cleanModel, override } = parseSuffix(model); + return { + model: cleanModel, + ...resolveKiroModel(cleanModel), + thinkingOverride: override, + }; +} + +/** Apply a parsed model(level) override without mutating the caller's body. */ +export function applyKiroThinkingOverride(body, override) { + if (!override) return body; + + const next = { ...body }; + if (override.mode === "budget") { + delete next.output_config; + delete next.reasoning_effort; + delete next.reasoning; + next.thinking = { type: "enabled", budget_tokens: override.budget }; + return next; + } + + next.output_config = { + ...(body.output_config || {}), + effort: override.mode === "level" ? override.level : override.mode, + }; + return next; +} + export const KIRO_AGENTIC_SYSTEM_PROMPT = ` # CRITICAL: CHUNKED WRITE PROTOCOL (MANDATORY) diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 108806e8..860f7097 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -4,7 +4,6 @@ import REGISTRY from "../providers/registry/index.js"; import { PROVIDER_MODELS } from "../providers/index.js"; import { modelQuotaFamily, modelStrip, modelTargetFormat, normalizeModelId } from "../providers/models/schema.js"; import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js"; - export { PROVIDER_MODELS }; @@ -70,8 +69,13 @@ export function getModelUpstreamId(aliasOrId, modelId) { const baseId = suffix ? modelId.slice(0, sufMatch.index).trim() : modelId; const models = PROVIDER_MODELS[aliasOrId]; const found = findModel(models, baseId, aliasOrId); - if (found?.upstreamModelId) return found.upstreamModelId + suffix; - if (found?.id) return found.id + suffix; + const resolvedId = found?.upstreamModelId || found?.id; + if (resolvedId) { + const presetMatch = resolvedId.match(/\([^()]+\)\s*$/); + const presetSuffix = presetMatch?.[0] || ""; + const resolvedBase = presetSuffix ? resolvedId.slice(0, presetMatch.index).trim() : resolvedId; + return resolvedBase + (suffix || presetSuffix); + } if (aliasOrId === "cx" && typeof baseId === "string" && baseId.endsWith(CODEX_REVIEW_SUFFIX)) { return baseId.slice(0, -CODEX_REVIEW_SUFFIX.length) + suffix; } diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index f669e3a7..7f24fd85 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -136,6 +136,10 @@ export class AntigravityExecutor extends BaseExecutor { transformRequest(model, body, stream, credentials) { const projectId = credentials?.projectId || this.generateProjectId(); + // OpenAI clients may include stream_options even for non-streaming calls. + // Google generateContent rejects that combination before processing the request. + if (stream !== true) delete body.stream_options; + // ─── Image generation: completely different request structure ─── if (isImageModel(model)) { const imageConfig = parseImageConfig(model); @@ -264,7 +268,7 @@ export class AntigravityExecutor extends BaseExecutor { return { ...body, project: projectId, - model: model, + model: body.model || model, userAgent: "antigravity", requestType: "agent", requestId: buildIdeRequestId({ body, request: transformedRequest, credentials, model, requestType: "agent" }), diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index 71418deb..5a3c4467 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -126,7 +126,7 @@ export class BaseExecutor { for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const url = this.buildUrl(model, stream, urlIndex, credentials); const transformedBody = this.transformRequest(model, body, stream, credentials); - const headers = this.buildHeaders(credentials, stream); + const headers = this.buildHeaders(credentials, stream, url); if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0; diff --git a/open-sse/executors/codebuddy-intl.js b/open-sse/executors/codebuddy-intl.js new file mode 100644 index 00000000..06fe4326 --- /dev/null +++ b/open-sse/executors/codebuddy-intl.js @@ -0,0 +1,30 @@ +import { DefaultExecutor } from "./default.js"; + +/** + * CodeBuddyIntlExecutor — talks to https://www.codebuddy.ai/v2/chat/completions + * + * Same OpenAI-compatible-but-stream-only gateway behavior as codebuddy-cn: + * non-stream requests are rejected, and reasoning is surfaced only when the + * request carries the IDE's OpenAI-style reasoning params. Force stream and + * mirror reasoning_summary exactly like CodeBuddyExecutor. + */ +export class CodeBuddyIntlExecutor extends DefaultExecutor { + constructor() { + super("codebuddy-intl"); + } + + transformRequest(model, body, stream, credentials) { + const transformed = super.transformRequest(model, body, stream, credentials); + transformed.stream = true; + + const eff = transformed.reasoning_effort; + if (eff === "none" || eff === "off") { + delete transformed.reasoning_effort; + } else if (eff) { + transformed.reasoning_summary = "auto"; + } + return transformed; + } +} + +export default CodeBuddyIntlExecutor; diff --git a/open-sse/executors/cursor.js b/open-sse/executors/cursor.js index 5023aa45..0aefc623 100644 --- a/open-sse/executors/cursor.js +++ b/open-sse/executors/cursor.js @@ -12,7 +12,7 @@ import { import { buildCursorHeaders } from "../utils/cursorChecksum.js"; import { estimateUsage } from "../utils/usageTracking.js"; import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js"; -import { chatChunkSse } from "../utils/sse.js"; +import { chatChunkSse, sseChunk } from "../utils/sse.js"; import { FORMATS } from "../translator/formats.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import zlib from "zlib"; @@ -543,6 +543,9 @@ export class CursorExecutor extends BaseExecutor { if (done) break; pending = Buffer.concat([pending, Buffer.from(value)]); pending = decodeAgentFrames(pending, (payload) => { + // A single read can carry several frames; once the turn is over the + // rest of the batch must not reach the already-closed controller. + if (finished) return; const serverMessage = decodeMessage(payload); // agent.v1.AgentServerMessage.interaction_update @@ -570,9 +573,12 @@ export class CursorExecutor extends BaseExecutor { if (execRequest.has(10)) { session.write(createRequestContextResponse()); } else { + // Every other ExecServerMessage variant is an editor-backed tool + // (shell, read, write, …) that 9router cannot service. Fail the + // turn rather than narrating protocol state as assistant text. + debugLog(`[CURSOR AGENT] Unsupported exec request fields: ${[...execRequest.keys()].join(",")}`); finished = true; onEvent({ type: "error", value: "Cursor AgentService requested an unsupported IDE tool" }); - onEvent({ type: "done" }); } } }); @@ -630,7 +636,12 @@ export class CursorExecutor extends BaseExecutor { } else if (event.type === "thinking") { controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { reasoning_content: event.value } }))); } else if (event.type === "error") { - controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { content: `\n[${event.value}]` } }))); + // An SSE error frame, not a content delta: a protocol failure must not + // be rendered to the user as the assistant's reply, and downstream + // usage tracking must not record the turn as a success. + controller.enqueue(encoder.encode(sseChunk({ error: { message: event.value, type: "api_error" } }))); + controller.enqueue(encoder.encode(SSE_DONE)); + controller.close(); } else if (event.type === "done") { controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: {}, finishReason: "stop" }))); controller.enqueue(encoder.encode(SSE_DONE)); diff --git a/open-sse/executors/devin-cli.js b/open-sse/executors/devin-cli.js new file mode 100644 index 00000000..7cb35ff1 --- /dev/null +++ b/open-sse/executors/devin-cli.js @@ -0,0 +1,847 @@ +/** + * DevinCliExecutor — routes completions through the official Devin CLI binary + * via the Agent Client Protocol (ACP) JSON-RPC 2.0 over stdio. + * + * Protocol flow: + * 1. Spawn `devin acp` (default agent = full built-in tools: fs/shell/search). + * Set CLI_DEVIN_AGENT_TYPE=summarizer for a tool-less, text-only mode. + * 2. Send: initialize → session/new (with model + cwd + mcpServers) → session/prompt. + * 3. Receive: session/update notifications (agent_message_chunk = reply text, + * tool_call/tool_call_update = built-in tool invocations, surfaced as text). + * When devin calls a client-tool from the exposed MCP ("Calling mcp_X from + * clientTools"), it is bridged to an OpenAI tool_use and the turn ends. + * 4. Emit deltas as OpenAI-compatible SSE chunks. + * 5. Kill subprocess on _cognition.ai/agent_stopped or error. + * + * Auth: noAuth — the subprocess inherits the parent env and uses credentials + * stored by `devin auth login` (~/.local/share/devin/credentials.toml). + * + * Binary discovery: CLI_DEVIN_BIN env → PATH lookup → platform installer paths. + */ + +import { spawn } from "node:child_process"; +import path from "node:path"; +import os from "node:os"; +import fs from "node:fs"; +import { BaseExecutor } from "./base.js"; + +// ─── Binary discovery ──────────────────────────────────────────────────────── + +function resolveDevinBin() { + // 1. Explicit override + const envBin = process.env.CLI_DEVIN_BIN?.trim(); + if (envBin) return envBin; + + const isWin = process.platform === "win32"; + const home = os.homedir(); + + // 2. Known installer / package-manager locations. spawn uses shell:false on + // macOS/Linux, so process.env.PATH alone may miss ~/.local/bin, Homebrew, + // Scoop, etc. when the server runs detached (tray/daemon/launchd) without + // a login shell — probe these explicitly before falling back to PATH. + const candidates = isWin + ? [ + // Official installer: %LOCALAPPDATA%\devin\cli\bin\devin.exe + path.join(process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "devin", "cli", "bin", "devin.exe"), + path.join(home, ".local", "bin", "devin.exe"), + path.join(home, "scoop", "shims", "devin.exe"), + path.join(process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Programs", "devin", "devin.exe"), + ] + : [ + path.join(home, ".local", "share", "devin", "bin", "devin"), + path.join(home, ".devin", "bin", "devin"), + path.join(home, ".local", "bin", "devin"), // pipx / user install + "/opt/homebrew/bin/devin", // Homebrew (Apple Silicon) + "/usr/local/bin/devin", // Homebrew (Intel) / manual + "/usr/bin/devin", + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + + // 3. Fallback — rely on process.env.PATH + return isWin ? "devin.exe" : "devin"; +} + +// ─── ACP JSON-RPC helper ──────────────────────────────────────────────────── + +function rpc(method, params, id) { + const msg = { jsonrpc: "2.0", method, params }; + if (id !== undefined) msg.id = id; + return JSON.stringify(msg) + "\n"; +} + +// ─── Client-tools → MCP bridge ─────────────────────────────────────────────── +// devin only invokes built-in + MCP tools, not OpenAI function-calling schemas. +// body.tools are exposed as a stdio MCP server "clientTools" so devin can call +// them. When devin calls one, we emit OpenAI tool_use and end the turn; the +// client executes and returns tool_result on the next request. That next request +// re-spawns with the full history (including tool_calls + tool results) and +// seeds the MCP server with those results so a re-call gets the real data. +// Tool schemas via DEVIN_MCP_TOOLS; prior results via DEVIN_MCP_RESULTS. + +const CLIENT_TOOLS_MCP_SCRIPT = ` +import readline from "node:readline"; +const TOOLS = JSON.parse(process.env.DEVIN_MCP_TOOLS || "[]"); +const RESULTS = JSON.parse(process.env.DEVIN_MCP_RESULTS || "{}"); +const rl = readline.createInterface({ input: process.stdin }); +function send(o){ process.stdout.write(JSON.stringify(o) + "\\n"); } +rl.on("line", (line) => { + let m; try { m = JSON.parse(line); } catch { return; } + if (m.method === "initialize") { + send({ jsonrpc: "2.0", id: m.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "clientTools", version: "1.0" } } }); + } else if (m.method === "tools/list") { + send({ jsonrpc: "2.0", id: m.id, result: { tools: TOOLS } }); + } else if (m.method === "tools/call") { + const name = m.params?.name || ""; + const seeded = RESULTS[name]; + const text = seeded !== undefined + ? String(seeded) + : "(awaiting client tool_result)"; + process.stderr.write("[client-tools] tool_call name=" + name + " seeded=" + (seeded !== undefined) + "\\n"); + send({ jsonrpc: "2.0", id: m.id, result: { content: [{ type: "text", text }] } }); + } +}); +`.trimStart(); + +function ensureClientToolsScript() { + const scriptPath = path.join(os.tmpdir(), "9router-devin-client-tools.mjs"); + // Always rewrite so script upgrades land without a process restart. + fs.writeFileSync(scriptPath, CLIENT_TOOLS_MCP_SCRIPT); + return scriptPath; +} + +// Map OpenAI tools ([{type:"function",function:{name,description,parameters}}]) +// to MCP tool declarations ([{name,description,inputSchema}]). +// devin only discovers MCP tools whose name carries the `mcp_` prefix, so we +// add it here and strip it back when bridging the call to the client. +const MCP_TOOL_PREFIX = "mcp_"; +function toMcpToolName(name) { + return name.startsWith(MCP_TOOL_PREFIX) ? name : MCP_TOOL_PREFIX + name; +} +function fromMcpToolName(name) { + return name.startsWith(MCP_TOOL_PREFIX) ? name.slice(MCP_TOOL_PREFIX.length) : name; +} + +function buildClientToolsMcp(tools, resultMap) { + const mcpTools = []; + for (const t of tools) { + if (!t) continue; + const f = t.function || t; + if (!f?.name) continue; + mcpTools.push({ + name: toMcpToolName(f.name), + description: f.description || "", + inputSchema: f.parameters || f.input_schema || { type: "object", properties: {} }, + }); + } + if (!mcpTools.length) return null; + const env = { DEVIN_MCP_TOOLS: JSON.stringify(mcpTools) }; + if (resultMap && Object.keys(resultMap).length) { + env.DEVIN_MCP_RESULTS = JSON.stringify(resultMap); + } + return { + command: process.execPath, + args: [ensureClientToolsScript()], + env, + }; +} + +// Extract tool_result content keyed by MCP tool name (mcp_). +// Walks messages: assistant.tool_calls id→name, role=tool tool_call_id→content. +function extractClientToolResults(messages) { + const idToMcpName = new Map(); + const results = {}; + for (const m of messages) { + if (m?.role === "assistant" && Array.isArray(m.tool_calls)) { + for (const tc of m.tool_calls) { + const name = tc?.function?.name || tc?.name; + if (tc?.id && name) idToMcpName.set(tc.id, toMcpToolName(name)); + } + } + // Claude-style tool_use blocks in content + if (m?.role === "assistant" && Array.isArray(m.content)) { + for (const b of m.content) { + if (b?.type === "tool_use" && b.id && b.name) { + idToMcpName.set(b.id, toMcpToolName(b.name)); + } + } + } + if (m?.role === "tool" && m.tool_call_id) { + const mcpName = idToMcpName.get(m.tool_call_id); + if (mcpName) { + results[mcpName] = + typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""); + } + } + // Claude-style tool_result blocks in user content + if (m?.role === "user" && Array.isArray(m.content)) { + for (const b of m.content) { + if (b?.type === "tool_result" && b.tool_use_id) { + const mcpName = idToMcpName.get(b.tool_use_id); + if (mcpName) { + const c = b.content; + results[mcpName] = + typeof c === "string" ? c : JSON.stringify(c ?? ""); + } + } + } + } + } + return results; +} + +// Resolve workspace cwd from client request (Codex/CLI env context, body fields). +// Prefer an absolute existing path so agent file tools hit the user's project +// instead of os.tmpdir() (which made relative create/delete inconsistent). +function resolveWorkspaceCwd(body) { + const candidates = []; + const push = (v) => { + if (typeof v === "string" && v.trim()) candidates.push(v.trim()); + }; + push(body?.cwd); + push(body?.working_directory); + push(body?.workdir); + push(body?.workspace); + push(body?.metadata?.cwd); + push(body?.metadata?.working_directory); + + const scanText = (text) => { + if (typeof text !== "string") return; + for (const m of text.matchAll(/\s*([^<]+?)\s*<\/cwd>/gi)) push(m[1]); + }; + const scanMessages = (msgs) => { + if (!Array.isArray(msgs)) return; + for (const msg of msgs) { + if (!msg) continue; + if (typeof msg.content === "string") scanText(msg.content); + else if (Array.isArray(msg.content)) { + for (const p of msg.content) { + if (typeof p === "string") scanText(p); + else if (p && typeof p === "object") { + scanText(p.text); + scanText(p.input_text); + scanText(p.content); + } + } + } + // Responses API input items + if (typeof msg === "string") scanText(msg); + if (msg.type === "message" && Array.isArray(msg.content)) { + for (const p of msg.content) scanText(p?.text || p?.input_text); + } + } + }; + scanMessages(body?.messages); + scanMessages(body?.input); + + for (const c of candidates) { + try { + if (path.isAbsolute(c) && fs.existsSync(c) && fs.statSync(c).isDirectory()) { + return c; + } + } catch { + /* ignore */ + } + } + return os.tmpdir(); +} + +// ─── Multi-turn message → single prompt builder ───────────────────────────── + +function buildPromptText(messages) { + // Inline the whole conversation so the model has full context, including + // prior tool_calls / tool_results so it can continue after a client round-trip. + const lines = []; + for (const m of messages) { + const role = String(m.role || "user"); + let text = ""; + if (typeof m.content === "string") { + text = m.content; + } else if (Array.isArray(m.content)) { + for (const p of m.content) { + if (!p || typeof p !== "object") continue; + if (p.type === "text") text += String(p.text || ""); + else if (p.type === "tool_use") { + text += `\n[Tool call ${p.name} id=${p.id}]\n${JSON.stringify(p.input ?? {})}\n`; + } else if (p.type === "tool_result") { + const c = + typeof p.content === "string" ? p.content : JSON.stringify(p.content ?? ""); + text += `\n[Tool result id=${p.tool_use_id}]\n${c}\n`; + } + } + } + // OpenAI tool_calls on assistant messages + if (role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length) { + const parts = m.tool_calls.map((tc) => { + const name = tc.function?.name || tc.name || "tool"; + const args = tc.function?.arguments ?? tc.arguments ?? {}; + const argStr = typeof args === "string" ? args : JSON.stringify(args); + return `[Tool call ${name} id=${tc.id}]\n${argStr}`; + }); + text = [text, ...parts].filter(Boolean).join("\n\n"); + } + // OpenAI role=tool messages + if (role === "tool") { + const c = typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""); + text = `[Tool result id=${m.tool_call_id || ""}]\n${c}`; + } + if (!text.trim()) continue; + if (role === "system") { + lines.push(`[System]\n${text}`); + } else if (role === "assistant") { + lines.push(`[Assistant]\n${text}`); + } else if (role === "tool") { + lines.push(`[Tool]\n${text}`); + } else { + lines.push(`[User]\n${text}`); + } + } + return lines.join("\n\n") || "(empty)"; +} + +// ─── DevinCliExecutor ───────────────────────────────────────────────────────── + +export class DevinCliExecutor extends BaseExecutor { + constructor() { + super("devin-cli", { id: "devin-cli", baseUrl: "devin://acp/stdio" }); + } + + buildUrl() { + return "devin://acp/stdio"; + } + + buildHeaders() { + return {}; + } + + transformRequest() { + return null; + } + + async execute({ model, body, credentials, signal, log }) { + const b = body ?? {}; + const messages = Array.isArray(b.messages) + ? b.messages + : Array.isArray(b.input) + ? b.input + : []; + const promptText = buildPromptText(messages); + const workspaceCwd = resolveWorkspaceCwd(b); + const devinBin = resolveDevinBin(); + + log?.info?.( + "DEVIN", + `devin acp → model=${model}, bin=${devinBin}, cwd=${workspaceCwd}` + ); + + // Optional MCP servers via DEVIN_MCP_SERVERS (JSON object, devin config format): + // {"echo":{"command":"/abs/node","args":["/srv/echo.js"],"env":{"K":"V"}}} + // Plus body.tools (OpenAI schema) → exposed as a "clientTools" MCP + // server so devin can invoke client-defined tools (bridged back in Phase 2). + // When any are present, a throwaway XDG_CONFIG_HOME holds devin/config.json so + // the agent auto-connects them (session/new mcpServers alone doesn't spawn + // them — see ACP mcp/connect, still unstable). Cleaned up on finish. + // NOTE: this replaces the user's global devin MCP config for the subprocess. + let mcpConfigDir = null; + const mcpServers = {}; + const mcpJson = process.env.DEVIN_MCP_SERVERS?.trim(); + if (mcpJson) { + try { + Object.assign(mcpServers, JSON.parse(mcpJson)); + } catch (e) { + log?.info?.("DEVIN", `DEVIN_MCP_SERVERS parse failed: ${e.message}`); + } + } + const clientTools = Array.isArray(b.tools) ? b.tools.filter(Boolean) : []; + const clientToolResults = extractClientToolResults(messages); + const clientToolsMcp = buildClientToolsMcp(clientTools, clientToolResults); + const hasClientTools = !!clientToolsMcp; + if (clientToolsMcp) { + mcpServers["clientTools"] = clientToolsMcp; + const seeded = Object.keys(clientToolResults).length; + log?.info?.( + "DEVIN", + `exposing ${clientTools.length} client tool(s) as MCP` + + (seeded ? ` (seeded ${seeded} result(s))` : "") + ); + } + if (Object.keys(mcpServers).length) { + try { + mcpConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "devin-mcp-")); + const cfgDev = path.join(mcpConfigDir, "devin"); + fs.mkdirSync(cfgDev, { recursive: true }); + fs.writeFileSync( + path.join(cfgDev, "config.json"), + JSON.stringify({ mcpServers }) + ); + log?.info?.("DEVIN", `mcp config written → ${mcpConfigDir}`); + } catch (e) { + log?.info?.("DEVIN", `mcp config write failed: ${e.message}`); + mcpConfigDir = null; + } + } + const cleanupMcp = () => { + if (!mcpConfigDir) return; + try { + fs.rmSync(mcpConfigDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + mcpConfigDir = null; + }; + + const sseStream = new ReadableStream({ + start(controller) { + const enc = new TextEncoder(); + const emit = (data) => controller.enqueue(enc.encode(data)); + + // Inherit the parent environment so devin resolves stored CLI credentials + // (~/.local/share/devin/credentials.toml from `devin auth login`). Do NOT + // inject WINDSURF_API_KEY: this provider is noAuth, and a bogus/leaked key + // overrides stored creds and makes devin return -32000 "invalid api key". + const env = { ...process.env }; + // Auto-approve tool execution so the agent doesn't block waiting for a + // session/request_permission response we never send (default mode would + // hang the stream on the first shell/exec tool call). Override via env. + // WARNING: bypass lets the agent run shell/modify FS unattended — local only. + env.DEVIN_PERMISSION_MODE = process.env.DEVIN_PERMISSION_MODE || "bypass"; + if (mcpConfigDir) env.XDG_CONFIG_HOME = mcpConfigDir; + + // Agent type: default (omitted) = full agent with built-in tools + // (fs/shell/search) so the model can actually perform tasks. Override to + // `summarizer` (no tools, text-only) via CLI_DEVIN_AGENT_TYPE for a safer, + // tool-less mode. WARNING: the default agent can run shell commands and + // modify the filesystem on the host running 9router — only expose locally. + const agentType = process.env.CLI_DEVIN_AGENT_TYPE?.trim(); + const acpArgs = ["acp"]; + if (agentType) acpArgs.push("--agent-type", agentType); + + // Spawn in the client workspace cwd (from env context) so built-in + // file tools create/delete relative paths in the user's project. + // MCP config still comes from XDG_CONFIG_HOME (throwaway), not project .devin/. + const child = spawn(devinBin, acpArgs, { + env, + cwd: workspaceCwd, + stdio: ["pipe", "pipe", "pipe"], + // On Windows, devin.exe may need shell resolution + shell: process.platform === "win32", + }); + + let spawnError = null; + let stdinClosed = false; + + child.on("error", (err) => { + spawnError = err; + const msg = + err.message.includes("ENOENT") || err.message.includes("not found") + ? `Devin CLI not found: ${devinBin}. Install via https://cli.devin.ai or set CLI_DEVIN_BIN env var.` + : `Devin CLI spawn error: ${err.message}`; + emit( + `data: ${JSON.stringify({ error: { message: msg, type: "devin_cli_error", code: "spawn_failed" } })}\n\n` + ); + emit("data: [DONE]\n\n"); + controller.close(); + }); + + if (signal) { + signal.addEventListener("abort", () => { + if (!child.killed) child.kill("SIGTERM"); + }); + } + + // ── JSON-RPC state machine ────────────────────────────────────────── + let idCounter = 1; + let sessionId = null; + let initDone = false; + let sessionCreated = false; + let promptSent = false; + const responseId = `chatcmpl-devin-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + let roleEmitted = false; + let totalText = ""; + let finished = false; + + const sendRpc = (method, params) => { + if (stdinClosed || child.stdin.destroyed) return; + const id = idCounter++; + try { + child.stdin.write(rpc(method, params, id)); + } catch { + /* ignore write errors after close */ + } + return id; + }; + + // Emit a content delta as an OpenAI-compatible SSE chunk (handles the + // leading role chunk once). + const emitDelta = (delta) => { + if (!roleEmitted) { + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }], + })}\n\n` + ); + roleEmitted = true; + } + totalText += delta; + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content: delta }, finish_reason: null }], + })}\n\n` + ); + }; + + // Emit an OpenAI tool_call delta (function calling). Ends the turn with + // finish_reason "tool_calls" so the client executes and returns tool_result. + let toolUseEmitted = false; + // ACP tool_call is upsert-by-id: the first event has title, a later update + // may only carry rawInput (title omitted). Track pending client-tool calls. + const pendingClientTools = new Map(); // toolCallId → original tool name + const emitToolUse = (toolName, args, toolCallId) => { + const argsStr = typeof args === "string" ? args : JSON.stringify(args ?? {}); + if (!roleEmitted) { + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant", content: null }, finish_reason: null }], + })}\n\n` + ); + roleEmitted = true; + } + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: toolCallId, + type: "function", + function: { name: toolName, arguments: argsStr }, + }, + ], + }, + finish_reason: null, + }, + ], + })}\n\n` + ); + }; + + const finish = (error, finishReason = "stop") => { + if (finished) return; + finished = true; + + if (error) { + emit( + `data: ${JSON.stringify({ error: { message: error, type: "devin_cli_error" } })}\n\n` + ); + } else { + // Emit finish chunk + emit( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: finishReason }], + usage: { + prompt_tokens: Math.ceil(promptText.length / 4), + completion_tokens: Math.ceil(totalText.length / 4), + total_tokens: Math.ceil((promptText.length + totalText.length) / 4), + estimated: true, + }, + })}\n\n` + ); + } + emit("data: [DONE]\n\n"); + + // Gracefully close stdin → devin will exit + try { + if (!stdinClosed) { + stdinClosed = true; + child.stdin.end(); + } + } catch { + /* ignore */ + } + + // Give it 2s to exit cleanly, then SIGKILL + const killTimer = setTimeout(() => { + if (!child.killed) child.kill("SIGKILL"); + }, 2000); + killTimer.unref?.(); + + controller.close(); + cleanupMcp(); + }; + + // ── stdout reader (NDJSON) ────────────────────────────────────────── + let buffer = ""; + + child.stdout.on("data", (chunk) => { + buffer += chunk.toString("utf8"); + let nl; + // Each ACP message is a newline-terminated JSON line + while ((nl = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (!line) continue; + + let msg; + try { + msg = JSON.parse(line); + } catch { + continue; // ignore non-JSON lines (banner text, etc.) + } + + // ── Initialize response ─────────────────────────────────────── + if (!initDone && msg.result !== undefined && !msg.method) { + initDone = true; + // Create session with the client workspace cwd so agent file tools + // resolve relative paths against the project (not /tmp). + // `mcpServers` is required by devin 3000.2.x (must be a sequence); + // omitting it returns -32602 "Invalid params: missing field mcpServers". + sendRpc("session/new", { + cwd: workspaceCwd, + mcpServers: [], + model: model || undefined, + }); + continue; + } + + // ── session/new response → get sessionId ────────────────────── + if (initDone && !sessionCreated && msg.result !== undefined && !msg.method) { + const res = msg.result || {}; + sessionId = res.sessionId || null; + if (!sessionId) { + finish("Devin ACP: session/new returned no sessionId"); + return; + } + sessionCreated = true; + // Send the prompt. devin 3000.2.x expects `prompt` (a sequence), + // not `content` — using `content` returns -32602 "missing field prompt". + promptSent = true; + sendRpc("session/prompt", { + sessionId, + prompt: [{ type: "text", text: promptText }], + }); + continue; + } + + // ── session/prompt response (ack / final result) ──────────── + if (sessionCreated && promptSent && msg.result !== undefined && !msg.method) { + // Devin 3000.2.x only resolves session/prompt with the final result + // (stopReason) after streaming completes. Streaming notifications are + // handled below; nothing to do here unless we never streamed. + if (!roleEmitted) { + const res = msg.result || undefined; + const content = extractResultText(res); + if (content) { + totalText = content; + emitDelta(content); + } + const stopReason = (res && res.stopReason) || ""; + if (stopReason && stopReason !== "cancelled") { + finish(); + return; + } + } + continue; + } + + // ── Permission requests → auto-approve the first allow option ── + // Devi asks before running shell/exec tools; as a headless proxy we + // grant once. (DEVIN_PERMISSION_MODE=bypass usually prevents these, + // but some tool kinds still prompt, so handle them here too.) + if (msg.method === "session/request_permission" && msg.id !== undefined) { + const options = msg.params?.options || []; + const allow = + options.find((o) => /allow/i.test(String(o.kind || ""))) || options[0]; + if (allow) { + child.stdin.write( + JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result: { outcome: { outcome: "selected", optionId: allow.optionId } }, + }) + "\n" + ); + } + continue; + } + + // ── Agent stopped notification (devin 3000.2.x stop signal) ─── + if (msg.method === "_cognition.ai/agent_stopped" || msg.method === "$/agent_stopped") { + const cause = msg.params?.cause; + if (cause === "error") { + // devin uses errorMessage on this notification (not message/error). + const errText = + msg.params?.errorMessage || + msg.params?.message || + msg.params?.error || + "Devin agent error"; + finish(String(errText)); + } else { + finish(); + } + return; + } + + // ── Streaming notifications (session/update) ────────────────── + if (msg.method === "session/update" || msg.method === "$/update") { + const params = msg.params; + if (!params) continue; + + // devin 3000.2.x nests the payload under params.update.sessionUpdate; + // older devin used a flat params.type. + const update = params.update || {}; + const type = update.sessionUpdate || params.type; + const contentField = update.content !== undefined ? update.content : params.content; + const deltaText = + typeof contentField === "string" + ? contentField + : contentField?.text ?? params.delta ?? params.text ?? ""; + + // ── Client-tool bridge: devin calling a tool from our exposed MCP ── + // ACP title shape: "Calling mcp_ from clientTools". + // tool_call is upsert-by-id: title may only appear on the first event, + // rawInput on a later tool_call_update. Track pending ids so we don't + // require both fields on the same notification. + if ( + hasClientTools && + !toolUseEmitted && + (type === "tool_call" || type === "tool_call_update") + ) { + const tcId = update.toolCallId; + if (typeof update.title === "string" && update.title.startsWith("Calling mcp_") && /from clientTools\b/.test(update.title)) { + const nameMatch = update.title.match(/^Calling (mcp_\S+)\b/); + const mcpName = nameMatch ? nameMatch[1] : ""; + const origName = fromMcpToolName(mcpName); + if (tcId && origName) pendingClientTools.set(tcId, origName); + } + const origName = tcId ? pendingClientTools.get(tcId) : null; + if (origName && update.rawInput) { + toolUseEmitted = true; + pendingClientTools.delete(tcId); + emitToolUse(origName, update.rawInput, tcId || `call_${Date.now()}`); + finish(null, "tool_calls"); + return; + } + continue; + } + + if (type === "agent_message_chunk" || type === "message_delta" || type === "text_delta" || type === "content_delta") { + if (deltaText) emitDelta(deltaText); + } else if (type === "agent_thought_chunk") { + // Internal reasoning — not surfaced to the client. + } else if (type === "message_stop" || type === "stop" || type === "done") { + finish(); + return; + } else if (type === "error") { + finish(String(params.message || params.error || "Devin ACP error")); + return; + } + continue; + } + + // ── Error responses ─────────────────────────────────────────── + if (msg.error) { + finish(`Devin ACP error ${msg.error.code}: ${msg.error.message}`); + return; + } + } + }); + + child.stderr.on("data", (chunk) => { + log?.debug?.("DEVIN", `stderr: ${chunk.toString("utf8").slice(0, 200)}`); + }); + + child.on("close", (code) => { + if (!finished) { + if (code !== 0 && !spawnError) { + finish(roleEmitted ? undefined : `Devin CLI exited with code ${code}`); + } else { + finish(); + } + } else { + cleanupMcp(); + } + }); + + // ── Send initialize ─────────────────────────────────────────────── + sendRpc("initialize", { + protocolVersion: "0.3", + clientInfo: { name: "9router", version: "1.0" }, + capabilities: {}, + }); + }, + }); + + return { + response: new Response(sseStream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }), + url: "devin://acp/stdio", + headers: {}, + transformedBody: { + model, + cwd: workspaceCwd, + clientTools: clientTools.map((t) => t?.function?.name || t?.name).filter(Boolean), + clientToolResults: Object.keys(clientToolResults), + mcpServers: Object.keys(mcpServers), + promptLength: Array.isArray(body?.messages) + ? body.messages.length + : Array.isArray(body?.input) + ? body.input.length + : 0, + }, + }; + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// Extract text from a final ACP session/prompt result object across common shapes. +function extractResultText(result) { + // { message: { content: "..." } } + // { messages: [{ content: "..." }] } + // { content: "..." } + // { text: "..." } + if (typeof result.content === "string") return result.content; + if (typeof result.text === "string") return result.text; + const msg = result.message; + if (msg && typeof msg.content === "string") return msg.content; + const msgs = result.messages; + if (Array.isArray(msgs)) { + return msgs + .filter((m) => m.role === "assistant") + .map((m) => String(m.content || "")) + .join("\n"); + } + return ""; +} + +export default DevinCliExecutor; diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index b6091b9d..92e10464 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -20,7 +20,12 @@ import { CommandCodeExecutor } from "./commandcode.js"; import { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; import { MimoFreeExecutor } from "./mimo-free.js"; import { CodeBuddyExecutor } from "./codebuddy-cn.js"; +import { CodeBuddyIntlExecutor } from "./codebuddy-intl.js"; +import TraeExecutor from "./trae.js"; +import ZedExecutor from "./zed.js"; +import WindsurfExecutor from "./windsurf.js"; import { DefaultExecutor } from "./default.js"; +import { DevinCliExecutor } from "./devin-cli.js"; const executors = { antigravity: new AntigravityExecutor(), @@ -50,6 +55,11 @@ const executors = { "mimo-free": new MimoFreeExecutor(), mmf: new MimoFreeExecutor(), // Alias for mimo-free "codebuddy-cn": new CodeBuddyExecutor(), + "codebuddy-intl": new CodeBuddyIntlExecutor(), + trae: new TraeExecutor(), + zed: new ZedExecutor(), + windsurf: new WindsurfExecutor(), + "devin-cli": new DevinCliExecutor(), }; const defaultCache = new Map(); @@ -88,3 +98,8 @@ export { CommandCodeExecutor } from "./commandcode.js"; export { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; export { MimoFreeExecutor } from "./mimo-free.js"; export { CodeBuddyExecutor } from "./codebuddy-cn.js"; +export { CodeBuddyIntlExecutor } from "./codebuddy-intl.js"; +export { default as TraeExecutor } from "./trae.js"; +export { default as ZedExecutor } from "./zed.js"; +export { default as WindsurfExecutor } from "./windsurf.js"; +export { DevinCliExecutor } from "./devin-cli.js"; diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index 90ec713e..1205522b 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -1,6 +1,10 @@ import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; -import { resolveKiroModel } from "../config/kiroConstants.js"; +import { + KIRO_CODEWHISPERER_TARGET, + KIRO_ENDPOINT_FALLBACK_STATUSES, + resolveKiroModel, +} from "../config/kiroConstants.js"; import { v4 as uuidv4 } from "uuid"; import { refreshKiroToken } from "../services/tokenRefresh.js"; import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js"; @@ -216,12 +220,17 @@ export class KiroExecutor extends BaseExecutor { super("kiro", PROVIDERS.kiro); } - buildHeaders(credentials, stream = true) { + buildHeaders(credentials, stream = true, url = "") { const headers = { ...this.config.headers, "Amz-Sdk-Request": "attempt=1; max=3", "Amz-Sdk-Invocation-Id": uuidv4() }; + if (url.includes("://codewhisperer.")) { + headers["X-Amz-Target"] = KIRO_CODEWHISPERER_TARGET; + } else { + delete headers["X-Amz-Target"]; + } // API-key auth: the key is stored as accessToken and sent as a bearer token // exactly like an OAuth access token, but with an extra `tokentype: API_KEY` @@ -236,8 +245,8 @@ export class KiroExecutor extends BaseExecutor { const apiKey = credentials?.apiKey || (isApiKey ? credentials?.accessToken : null); if (isApiKey && apiKey) { headers["Authorization"] = `Bearer ${apiKey}`; - headers["tokentype"] = "API_KEY"; - } else if (credentials.accessToken) { + headers["TokenType"] = "API_KEY"; + } else if (credentials?.accessToken) { headers["Authorization"] = `Bearer ${credentials.accessToken}`; if (isExternalIdp) { headers["TokenType"] = "EXTERNAL_IDP"; @@ -250,14 +259,14 @@ export class KiroExecutor extends BaseExecutor { /** * Auth-aware endpoint ordering. * - * API-key Kiro connections store a raw CodeWhisperer credential (validated - * against codewhisperer.us-east-1.amazonaws.com via ListAvailableProfiles). + * API-key Kiro connections use the Amazon Q surface. The legacy + * codewhisperer.* GenerateAssistantResponse endpoint can authenticate the key + * but rejects the same valid payload with REQUEST_BODY_INVALID. Since a 400 + * is terminal in BaseExecutor, putting CodeWhisperer first prevents the working + * q.* endpoint from ever being tried. Keep q.* first only for api_key accounts. + * * The Kiro IDE gateway (runtime.*.kiro.dev) expects Kiro OIDC/social tokens - * and rejects an `tokentype: API_KEY` token with 401/403 — which - * BaseExecutor.execute() returns immediately (only 429 / network errors fall - * through to the next host). So for api-key auth we must try the *.amazonaws.com - * CodeWhisperer hosts FIRST, mirroring the Kiro-Go reference fork which never - * routes api-key traffic through kiro.dev. External IdP enterprise tokens also + * and rejects TokenType=API_KEY. External IdP enterprise tokens instead * use the CodeWhisperer surface, with the `TokenType: EXTERNAL_IDP` header. * Other OAuth methods keep the default order (kiro.dev first) since their * tokens are what that gateway accepts. @@ -282,6 +291,14 @@ export class KiroExecutor extends BaseExecutor { const amazon = baseUrls.filter((u) => u.includes("amazonaws.com")).map(regionalize); const others = baseUrls.filter((u) => !u.includes("amazonaws.com")); + if (authMethod === "api_key") { + const q = amazon.filter((u) => u.includes("://q.")); + const remaining = amazon.filter((u) => !u.includes("://q.")); + return q.length > 0 + ? [...q, ...remaining, ...others] + : [...amazon, ...others]; + } + return amazon.length > 0 ? [...amazon, ...others] : baseUrls; } @@ -290,6 +307,14 @@ export class KiroExecutor extends BaseExecutor { return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl; } + // Retry only endpoint/auth-surface failures. Payload-invalid HTTP 400 must be + // terminal: sending the same malformed body to every surface cannot repair it. + shouldRetry(status, urlIndex) { + const hasFallback = urlIndex + 1 < this.getFallbackCount(); + return super.shouldRetry(status, urlIndex) + || (hasFallback && KIRO_ENDPOINT_FALLBACK_STATUSES.has(status)); + } + transformRequest(model, body, stream, credentials) { return body; } diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.js index 2a7714b3..d062b139 100644 --- a/open-sse/executors/qoder.js +++ b/open-sse/executors/qoder.js @@ -32,7 +32,11 @@ import { SSE_DONE } from "../utils/sseConstants.js"; import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js"; import { QODER_CHAT_URL_ENCODED, + QODER_JOB_TOKEN_EXCHANGE_URL, + QODER_USERINFO_URL, QODER_MODEL_MAP, + QODER_IDE_VERSION, + QODER_CLIENT_TYPE, } from "../shared/qoder/constants.js"; import { getQoderModelConfig, resolveQoderModels } from "../services/qoderModels.js"; @@ -220,8 +224,13 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio * Each upstream line looks like: * data: {"statusCodeValue":200,"body":"{\"choices\":[{\"delta\":{...}}]}"} * The inner body is an OpenAI streaming chunk (or "[DONE]"). We unwrap it - * and re-emit as `data: \n\n`. Errors become `data: [DONE]\n\n` plus - * a synthetic OpenAI error chunk. + * and re-emit as `data: \n\n`. Errors become a synthetic OpenAI error + * chunk + [DONE]. + * + * Critical: Qoder's SSE often keeps the socket open after the terminal + * [DONE]/error frame (agent keepalive). Non-streaming clients drain via + * response.text() which hangs until the socket closes — so on terminal + * events we cancel the upstream reader and close our stream immediately. */ function wrapQoderSSE(response, model) { if (!response.ok || !response.body) return response; @@ -230,15 +239,14 @@ function wrapQoderSSE(response, model) { const encoder = new TextEncoder(); let buffer = ""; let doneEmitted = false; + const reader = response.body.getReader(); - // Process one already-extracted SSE line (no trailing newline). Returns - // false when the line indicated end-of-stream so the caller can stop - // forwarding any remaining chunks after [DONE]. + // Process one already-extracted SSE line (no trailing newline). const processLine = (line, controller) => { const trimmed = line.replace(/\r$/, "").trim(); if (!trimmed) return; if (!trimmed.startsWith("data:")) return; - if (doneEmitted) return; // never forward chunks past stream end + if (doneEmitted) return; const data = trimmed.slice(5).trimStart(); if (data === "[DONE]") { @@ -271,47 +279,60 @@ function wrapQoderSSE(response, model) { doneEmitted = true; return; } - // Inner is an OpenAI-shaped chunk. Strip any embedded newlines so the - // SSE frame stays a single event (a literal "\n" inside `inner` would - // otherwise split the frame across multiple data: lines and downstream - // parsers would reassemble them as separate events). + // Strip embedded newlines so the SSE frame stays a single event. const sanitized = inner.replace(/\r?\n/g, ""); controller.enqueue(encoder.encode(`data: ${sanitized}\n\n`)); }; - const transform = new TransformStream({ - transform(chunk, controller) { - buffer += decoder.decode(chunk, { stream: true }); - let nl; - while ((nl = buffer.indexOf("\n")) !== -1) { - const line = buffer.slice(0, nl); - buffer = buffer.slice(nl + 1); - processLine(line, controller); + const stream = new ReadableStream({ + // Use start()+loop (not pull): a pull that buffers a partial line without + // enqueueing would never be re-invoked, hanging consumers like .text(). + async start(controller) { + try { + while (!doneEmitted) { + const { done, value } = await reader.read(); + if (done) { + buffer += decoder.decode(); + if (buffer.length > 0) { + processLine(buffer, controller); + buffer = ""; + } + break; + } + + buffer += decoder.decode(value, { stream: true }); + let nl; + while ((nl = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + processLine(line, controller); + if (doneEmitted) { + // Terminal frame received — drop upstream keepalive and end. + await reader.cancel().catch(() => {}); + controller.close(); + return; + } + } + } + } catch { + // fall through to terminal [DONE] + close + } finally { + if (!doneEmitted) { + try { + controller.enqueue(encoder.encode(SSE_DONE)); + doneEmitted = true; + } catch { /* already closed */ } + } + try { controller.close(); } catch { /* already closed */ } + await reader.cancel().catch(() => {}); } }, - flush(controller) { - // Finalize the decoder so any pending multi-byte sequence is - // released into `buffer` instead of being silently dropped. - buffer += decoder.decode(); - // Drain any trailing line that arrived without a terminating newline - // (e.g. upstream closed the socket immediately after the last write, - // or a CDN stripped the final CRLF). Without this, the chunk that - // carries finish_reason is silently lost. - if (buffer.length > 0) { - processLine(buffer, controller); - buffer = ""; - } - if (!doneEmitted) { - controller.enqueue(encoder.encode(SSE_DONE)); - doneEmitted = true; - } + cancel() { + return reader.cancel().catch(() => {}); }, }); - const transformed = response.body.pipeThrough(transform); - // Build a Response with passable headers; the streaming handler reads - // `.body` as a ReadableStream regardless of Content-Type. - return new Response(transformed, { + return new Response(stream, { status: response.status, statusText: response.statusText, headers: { @@ -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 { constructor() { super("qoder", PROVIDERS.qoder); @@ -338,6 +445,34 @@ export class QoderExecutor extends BaseExecutor { async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { const url = this.buildUrl(); + // PAT (pt-...) → exchange for short-lived job token + resolve userId so + // downstream COSY signing + catalog fetch work. Device tokens (dt-...) and + // job tokens (jt-...) skip this and are used directly. + const rawToken = credentials?.apiKey || credentials?.accessToken; + if (isQoderPat(rawToken)) { + try { + const resolved = await resolvePatCredential(rawToken, proxyOptions, signal); + credentials = { + ...credentials, + accessToken: resolved.accessToken, + apiKey: undefined, + providerSpecificData: { + authMethod: "pat", + ...(credentials?.providerSpecificData || {}), + userId: resolved.userId || credentials?.providerSpecificData?.userId || "", + machineId: credentials?.providerSpecificData?.machineId || "", + }, + }; + } catch (err) { + log?.error?.("QODER", `PAT exchange failed: ${err.message}`); + const fakeResp = new Response( + JSON.stringify({ error: { message: `qoder PAT exchange failed: ${err.message}` } }), + { status: 401, headers: { "Content-Type": "application/json" } }, + ); + return { response: fakeResp, url, headers: {}, transformedBody: body }; + } + } + const psd = credentials?.providerSpecificData || {}; if (!psd.userId) { // No user id → no way to sign. Surface a 401 so the dashboard nudges @@ -455,4 +590,6 @@ export const __test__ = { normalizeMessages, wrapQoderSSE, buildQoderRequestBody, + isQoderPat, + resolvePatCredential, }; diff --git a/open-sse/executors/trae.js b/open-sse/executors/trae.js new file mode 100644 index 00000000..59f29be3 --- /dev/null +++ b/open-sse/executors/trae.js @@ -0,0 +1,339 @@ +import { BaseExecutor } from "./base.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { PROVIDERS } from "../config/providers.js"; + +// Trae executor — SOLO remote agent API. +// +// Flow: +// 1. POST {base}/chat_sessions → { code:0, data:{ chat_session_id, message_id } } +// 2. GET {base}/chat_sessions/{id}/events?reply_to_message_id={message_id} +// → text/event-stream. Assistant text streams in `plan_item` events under +// the `thought` field (cumulative per plan-item id). `token_usage` carries +// usage; `done` ends the turn; `error` carries upstream errors. +// +// Auth: header `Authorization: Cloud-IDE-JWT ` (RS256, ~14-day lifetime). +// Identity fields for common_params live in credentials.providerSpecificData. + +const STREAM_TIMEOUT_MS = parseInt(process.env.TRAE_STREAM_TIMEOUT_MS || "300000", 10); +const TRAE_UA = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"; + +function flattenQuery(messages) { + const parts = []; + for (const m of messages) { + let content = ""; + if (typeof m.content === "string") content = m.content; + else if (Array.isArray(m.content)) { + content = m.content + .map((p) => { + if (typeof p === "string") return p; + if (p && typeof p === "object") return String(p.text ?? ""); + return ""; + }) + .join(""); + } + if (m.role === "system") parts.push(`[System]\n${content}`); + else if (m.role === "assistant") parts.push(`[Assistant]\n${content}`); + else parts.push(content); + } + // Trae expects query as a JSON-encoded string of typed content blocks. + return JSON.stringify([{ type: "text", data: { content: parts.join("\n\n") } }]); +} + +export default class TraeExecutor extends BaseExecutor { + constructor() { + super("trae", PROVIDERS.trae); + } + + base() { + return (this.config.baseUrl || "https://core-normal.trae.ai/api/remote/v1").replace(/\/$/, ""); + } + + buildHeaders(credentials, stream = true) { + const token = credentials?.accessToken || ""; + const psd = credentials?.providerSpecificData || {}; + return { + Authorization: `Cloud-IDE-JWT ${token}`, + "Content-Type": "application/json", + "X-Trae-Client-Type": "web", + "X-Preferenced-Language": psd.appLanguage || "en", + "x-user-region": psd.userRegion || "US", + Referer: "https://solo.trae.ai/", + "User-Agent": TRAE_UA, + Accept: stream ? "text/event-stream" : "application/json", + }; + } + + // SOLO session modes: "code" (model picker) vs "work" (fast auto lane). + resolveMode(model) { + const m = (model || "").trim().toLowerCase(); + if (m === "work" || m === "auto-work" || m === "solo-work") { + return { mode: "work", strategy: "auto", modelName: "" }; + } + const auto = !m || m === "auto"; + return { mode: "code", strategy: auto ? "auto" : "manual", modelName: auto ? "" : model }; + } + + // common_params is a JSON-encoded string embedded inside initial_message. + commonParams(psd, mode, sessionId) { + const cp = { + language: "en-us", + app_language: psd.appLanguage || "en", + quality: "stable", + app_version: psd.appVersion || "1.0.0.1229", + web_id: psd.webId || "", + user_identity: psd.userIdentity || "Free", + is_freshman: "0", + biz_user_id: psd.bizUserId || "", + user_unique_id: psd.userUniqueId || "", + scope: psd.scope || "marscode-us", + tenant: psd.tenant || "marscode", + region: psd.region || "US-East", + aiRegion: psd.aiRegion || psd.region || "US-East", + is_privacy_mode: 0, + privacy_mode: "off", + solo_chat_mode: mode, + }; + if (sessionId) cp.biz_session_id = sessionId; + return JSON.stringify(cp); + } + + // POST /chat_sessions — creates a session and submits the first turn. + async createSession(headers, query, model, psd, signal) { + const { mode, strategy, modelName } = this.resolveMode(model); + const body = { + mode, + environment_id: "default", + initial_message: { + chat_session_id: "", + content: [], + query, + model_name: modelName, + agent_type: "solo_agent_remote", + model_selection_strategy: strategy, + common_params: this.commonParams(psd, mode), + }, + env: "remote", + auto_create_project: false, + origin: "web", + }; + const res = await proxyAwareFetch(`${this.base()}/chat_sessions`, { + method: "POST", + headers, + body: JSON.stringify(body), + signal, + }, null); + const text = await res.text(); + if (!res.ok) throw new Error(`[${res.status}] ${text}`); + const json = JSON.parse(text); + if (json?.code !== 0) throw new Error(`Trae create_session: ${JSON.stringify(json)}`); + return { sessionId: json.data.chat_session_id, messageId: json.data.message_id }; + } + + // GET /events SSE → invoke onEvent(eventType, dataObj) per frame. + // Resolves when `done`/`error` arrives, the stream ends, or timeout fires. + async streamEvents(headers, sessionId, replyTo, onEvent, signal) { + const url = `${this.base()}/chat_sessions/${sessionId}/events?reply_to_message_id=${encodeURIComponent(replyTo)}`; + const ctrl = new AbortController(); + if (signal?.aborted) ctrl.abort(); + const timer = setTimeout(() => ctrl.abort(new Error("trae stream timeout")), STREAM_TIMEOUT_MS); + const onAbort = () => ctrl.abort(); + if (signal) signal.addEventListener("abort", onAbort, { once: true }); + try { + const res = await proxyAwareFetch(url, { method: "GET", headers, signal: ctrl.signal }, null); + if (!res.ok || !res.body) throw new Error(`[${res.status}] events stream failed`); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + let ev = null; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let nl; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl).replace(/\r$/, ""); + buf = buf.slice(nl + 1); + if (line.startsWith("event:")) ev = line.slice(6).trim(); + else if (line.startsWith("data:")) { + const payload = line.slice(5).trim(); + let data; + try { data = JSON.parse(payload); } catch { data = { _raw: payload }; } + if (onEvent(ev, data)) { + await reader.cancel().catch(() => {}); + return; + } + } else if (line === "") ev = null; + } + } + } finally { + clearTimeout(timer); + if (signal) signal.removeEventListener("abort", onAbort); + } + } + + async execute({ model, body, stream, credentials, signal }) { + const headers = this.buildHeaders(credentials, stream !== false); + const psd = credentials?.providerSpecificData || {}; + const query = flattenQuery(body?.messages || []); + const responseId = `chatcmpl-trae-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + + const errResponse = (status, message) => new Response( + JSON.stringify({ error: { message, type: "api_error", code: "" } }), + { status, headers: { "Content-Type": "application/json" } } + ); + + let session; + try { + session = await this.createSession(headers, query, model, psd, signal); + } catch (err) { + return { response: errResponse(502, err?.message ? String(err.message) : String(err)), url: this.base(), headers, transformedBody: body }; + } + + // Shared per-turn state: plan_item thoughts (cumulative, longest wins). + const order = []; + const thoughts = {}; + let sent = 0; + let usage = null; + let errorEvent = null; + const renderNewText = (data) => { + const pid = data.id; + if (!pid) return ""; + if (!(pid in thoughts)) order.push(pid); + const t = data.thought || ""; + if (t.length >= (thoughts[pid] || "").length) thoughts[pid] = t; + const full = order.map((i) => thoughts[i]).join(""); + const piece = full.slice(sent); + sent = full.length; + return piece; + }; + + if (stream !== false) { + const enc = new TextEncoder(); + const sse = new ReadableStream({ + start: async (controller) => { + const emit = (obj) => controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`)); + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }); + try { + await this.streamEvents(headers, session.sessionId, session.messageId, (ev, data) => { + if (ev === "error") { errorEvent = data; return true; } + if (ev === "token_usage") usage = data; + if (ev === "plan_item") { + const piece = renderNewText(data); + if (piece) { + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { content: piece }, finish_reason: null }], + }); + } + } + return ev === "done"; + }, signal); + if (errorEvent) { + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [], + error: { message: `trae ${errorEvent.code || ""}: ${errorEvent.message || ""}`, type: "api_error" }, + }); + } else { + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }); + if (usage) { + emit({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [], + usage: { + prompt_tokens: usage.prompt_tokens || 0, + completion_tokens: usage.completion_tokens || 0, + total_tokens: usage.total_tokens || 0, + }, + }); + } + } + controller.enqueue(enc.encode("data: [DONE]\n\n")); + controller.close(); + } catch (err) { + controller.error(err); + } + }, + }); + return { + response: new Response(sse, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + }), + url: this.base(), + headers, + transformedBody: body, + }; + } + + // Non-streaming: drive to completion, return chat.completion JSON. + try { + await this.streamEvents(headers, session.sessionId, session.messageId, (ev, data) => { + if (ev === "error") { errorEvent = data; return true; } + if (ev === "token_usage") usage = data; + if (ev === "plan_item") renderNewText(data); + return ev === "done"; + }, signal); + } catch (err) { + return { response: errResponse(502, err?.message ? String(err.message) : String(err)), url: this.base(), headers, transformedBody: body }; + } + if (errorEvent) { + return { response: errResponse(502, `trae ${errorEvent.code || ""}: ${errorEvent.message || ""}`), url: this.base(), headers, transformedBody: body }; + } + const content = order.map((i) => thoughts[i]).join(""); + const out = { + id: responseId, + object: "chat.completion", + created, + model, + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + }; + if (usage) { + out.usage = { + prompt_tokens: usage.prompt_tokens || 0, + completion_tokens: usage.completion_tokens || 0, + total_tokens: usage.total_tokens || 0, + }; + } + return { + response: new Response(JSON.stringify(out), { status: 200, headers: { "Content-Type": "application/json" } }), + url: this.base(), + headers, + transformedBody: body, + }; + } + + // Refresh hook placeholder — Cloud-IDE-JWT is long-lived (~14d); refresh via + // ExchangeToken (refresh→access) is wired in services/tokenRefresh/providers.js. + async refreshCredentials() { + return null; + } +} diff --git a/open-sse/executors/windsurf.js b/open-sse/executors/windsurf.js new file mode 100644 index 00000000..8526df17 --- /dev/null +++ b/open-sse/executors/windsurf.js @@ -0,0 +1,588 @@ +import { BaseExecutor } from "./base.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { PROVIDERS } from "../config/providers.js"; +import { randomUUID } from "node:crypto"; + +// WindsurfExecutor — Codeium gRPC-web chat. +// +// Wire protocol: gRPC-web over HTTPS (Content-Type: application/grpc-web+proto). +// Service: exa.language_server_pb.LanguageServerService +// Method: GetChatMessage (unary request → streamed CompletionChunk frames) +// +// Auth: credentials.accessToken = Codeium apiKey (sk-ws-... or Firebase-derived) +// — placed in Metadata.api_key protobuf field of every request + Bearer header. + +const WS_BASE_URL = "https://server.codeium.com"; +const WS_SERVICE = "exa.language_server_pb.LanguageServerService"; +const WS_METHOD_CHAT = "GetChatMessage"; +const WS_CHAT_URL = `${WS_BASE_URL}/${WS_SERVICE}/${WS_METHOD_CHAT}`; + +const WS_IDE_NAME = "windsurf"; +const WS_IDE_VERSION = "3.14.0"; +const WS_EXT_VERSION = "3.14.0"; +const WS_LOCALE = "en-US"; + +// ─── Model alias map (catalog name → Windsurf wire name) ───────────────────── +const MODEL_ALIAS_MAP = { + // ── Cognition SWE ─────────────────────────────────────────────────────── + "swe-1.6-fast": "swe-1-6-fast", + "swe-1.6": "swe-1-6", + "swe-1.5-fast": "swe-1-5-fast", + "swe-1.5": "swe-1-5", + // ── Claude Opus 4.7 — effort-tiered ───────────────────────────────────── + "claude-opus-4.7-max": "claude-opus-4-7-max", + "claude-opus-4.7-xhigh": "claude-opus-4-7-xhigh", + "claude-opus-4.7-high": "claude-opus-4-7-high", + "claude-opus-4.7-medium": "claude-opus-4-7-medium", + "claude-opus-4.7-low": "claude-opus-4-7-low", + "claude-opus-4.7-review": "opus-4-7-review", + // ── Claude Opus/Sonnet 4.6 ────────────────────────────────────────────── + "claude-sonnet-4.6-thinking-1m": "claude-sonnet-4-6-thinking-1m", + "claude-sonnet-4.6-1m": "claude-sonnet-4-6-1m", + "claude-sonnet-4.6-thinking": "claude-sonnet-4-6-thinking", + "claude-sonnet-4.6": "claude-sonnet-4-6", + "claude-opus-4.6-thinking": "claude-opus-4-6-thinking", + "claude-opus-4.6": "claude-opus-4-6", + // ── Claude 4.5 ────────────────────────────────────────────────────────── + "claude-opus-4.5-thinking": "MODEL_CLAUDE_4_5_OPUS_THINKING", + "claude-opus-4.5": "MODEL_CLAUDE_4_5_OPUS", + "claude-sonnet-4.5-thinking": "MODEL_PRIVATE_3", + "claude-sonnet-4.5": "MODEL_PRIVATE_2", + "claude-haiku-4.5": "MODEL_PRIVATE_11", + // ── GPT-5.5 ───────────────────────────────────────────────────────────── + "gpt-5.5-xhigh-fast": "gpt-5-5-xhigh-priority", + "gpt-5.5-high-fast": "gpt-5-5-high-priority", + "gpt-5.5-medium-fast": "gpt-5-5-medium-priority", + "gpt-5.5-low-fast": "gpt-5-5-low-priority", + "gpt-5.5-none-fast": "gpt-5-5-none-priority", + "gpt-5.5-xhigh": "gpt-5-5-xhigh", + "gpt-5.5-high": "gpt-5-5-high", + "gpt-5.5-medium": "gpt-5-5-medium", + "gpt-5.5-low": "gpt-5-5-low", + "gpt-5.5-none": "gpt-5-5-none", + "gpt-5.5-review": "gpt-5-5-review", + "gpt-5.5": "gpt-5-5-medium", + // ── GPT-5.4 ───────────────────────────────────────────────────────────── + "gpt-5.4-xhigh-fast": "gpt-5-4-xhigh-priority", + "gpt-5.4-high-fast": "gpt-5-4-high-priority", + "gpt-5.4-medium-fast": "gpt-5-4-medium-priority", + "gpt-5.4-low-fast": "gpt-5-4-low-priority", + "gpt-5.4-none-fast": "gpt-5-4-none-priority", + "gpt-5.4-xhigh": "gpt-5-4-xhigh", + "gpt-5.4-high": "gpt-5-4-high", + "gpt-5.4-medium": "gpt-5-4-medium", + "gpt-5.4-low": "gpt-5-4-low", + "gpt-5.4-none": "gpt-5-4-none", + "gpt-5.4-mini-xhigh": "gpt-5-4-mini-xhigh", + "gpt-5.4-mini-high": "gpt-5-4-mini-high", + "gpt-5.4-mini-medium": "gpt-5-4-mini-medium", + "gpt-5.4-mini-low": "gpt-5-4-mini-low", + "gpt-5.4": "gpt-5-4-medium", + // ── GPT-5.3-Codex ─────────────────────────────────────────────────────── + "gpt-5.3-codex-xhigh-fast": "gpt-5-3-codex-xhigh-priority", + "gpt-5.3-codex-high-fast": "gpt-5-3-codex-high-priority", + "gpt-5.3-codex-medium-fast": "gpt-5-3-codex-medium-priority", + "gpt-5.3-codex-low-fast": "gpt-5-3-codex-low-priority", + "gpt-5.3-codex-xhigh": "gpt-5-3-codex-xhigh", + "gpt-5.3-codex-high": "gpt-5-3-codex-high", + "gpt-5.3-codex-medium": "gpt-5-3-codex-medium", + "gpt-5.3-codex-low": "gpt-5-3-codex-low", + "gpt-5.3-codex": "gpt-5-3-codex-medium", + // ── GPT-5.2 ───────────────────────────────────────────────────────────── + "gpt-5.2-xhigh": "MODEL_GPT_5_2_XHIGH", + "gpt-5.2-high": "MODEL_GPT_5_2_HIGH", + "gpt-5.2-medium": "MODEL_GPT_5_2_MEDIUM", + "gpt-5.2-low": "MODEL_GPT_5_2_LOW", + "gpt-5.2-none": "MODEL_GPT_5_2_NONE", + "gpt-5.2": "MODEL_GPT_5_2_MEDIUM", + // ── GPT-5 ─────────────────────────────────────────────────────────────── + "gpt-5": "gpt-5", + // ── GPT-4.1 / 4o ──────────────────────────────────────────────────────── + "gpt-4.1": "MODEL_CHAT_GPT_4_1_2025_04_14", + "gpt-4.1-mini": "gpt-4.1-mini", + "gpt-4o": "MODEL_CHAT_GPT_4O_2024_08_06", + // ── Gemini ────────────────────────────────────────────────────────────── + "gemini-3.1-pro-high": "gemini-3-1-pro-high", + "gemini-3.1-pro-low": "gemini-3-1-pro-low", + "gemini-3.1-pro": "gemini-3-1-pro-high", + "gemini-3.0-flash-high": "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH", + "gemini-3.0-flash-medium": "MODEL_GOOGLE_GEMINI_3_0_FLASH_MEDIUM", + "gemini-3.0-flash-low": "MODEL_GOOGLE_GEMINI_3_0_FLASH_LOW", + "gemini-3.0-flash-minimal": "MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL", + "gemini-3.0-flash": "MODEL_GOOGLE_GEMINI_3_0_FLASH_HIGH", + "gemini-2.5-pro": "MODEL_GOOGLE_GEMINI_2_5_PRO", + // ── Others ────────────────────────────────────────────────────────────── + "deepseek-v4": "deepseek-v4", + "kimi-k2.6": "kimi-k2-6", + "kimi-k2.5": "kimi-k2-5", + "glm-5.1": "glm-5-1", +}; + +export function resolveWsModelId(model) { + return MODEL_ALIAS_MAP[model] ?? model; +} + +// ─── Minimal protobuf encoder ──────────────────────────────────────────────── +// Wire types: 0 = varint, 2 = length-delimited. + +function encodeVarint(value) { + const bytes = []; + let v = value >>> 0; + while (v > 0x7f) { + bytes.push((v & 0x7f) | 0x80); + v >>>= 7; + } + bytes.push(v & 0x7f); + return new Uint8Array(bytes); +} + +function concatBytes(arrays) { + const total = arrays.reduce((n, a) => n + a.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const a of arrays) { + out.set(a, off); + off += a.length; + } + return out; +} + +const TEXT_ENC = new TextEncoder(); +const TEXT_DEC = new TextDecoder(); + +function encodeField(fieldNum, payload) { + const tag = encodeVarint((fieldNum << 3) | 2); + const len = encodeVarint(payload.length); + return concatBytes([tag, len, payload]); +} + +function encodeString(fieldNum, value) { + return encodeField(fieldNum, TEXT_ENC.encode(value)); +} + +function encodeMessage(fieldNum, msg) { + return encodeField(fieldNum, msg); +} + +// ─── Protobuf message builders ─────────────────────────────────────────────── + +function buildMetadata(apiKey, sessionId) { + return concatBytes([ + encodeString(1, apiKey), + encodeString(2, WS_IDE_NAME), + encodeString(3, WS_IDE_VERSION), + encodeString(4, WS_EXT_VERSION), + encodeString(5, sessionId), + encodeString(6, WS_LOCALE), + ]); +} + +function buildModelOrAlias(model) { + return encodeString(1, model); +} + +function buildChatMessage(msg) { + const parts = [encodeString(1, msg.role), encodeString(2, msg.content)]; + if (msg.toolCallId) parts.push(encodeString(3, msg.toolCallId)); + return concatBytes(parts); +} + +export function buildGetChatMessageRequest(apiKey, model, messages) { + const sessionId = randomUUID(); + const cascadeId = randomUUID(); + + const parts = [ + encodeMessage(1, buildMetadata(apiKey, sessionId)), // metadata + encodeString(2, cascadeId), // cascade_id + encodeMessage(3, buildModelOrAlias(model)), // model_or_alias + ]; + + for (const msg of messages) { + parts.push(encodeMessage(4, buildChatMessage(msg))); // repeated messages + } + + return concatBytes(parts); +} + +// ─── gRPC-web framing ──────────────────────────────────────────────────────── + +export function grpcWebFrame(payload) { + const frame = new Uint8Array(5 + payload.length); + frame[0] = 0x00; // no compression + const view = new DataView(frame.buffer); + view.setUint32(1, payload.length, false); // big-endian length + frame.set(payload, 5); + return frame; +} + +// ─── Protobuf response decoder ─────────────────────────────────────────────── +// CompletionChunk (oneof): +// field 1 → ContentChunk { field 1: string text } +// field 2 → ToolCallChunk (skipped) +// field 3 → DoneChunk { field 1: UsageStats{ field1: prompt, field2: completion } } +// field 4 → ErrorChunk { field 1: string message } + +function readVarint(buf, offset) { + let result = 0; + let shift = 0; + while (offset < buf.length) { + const b = buf[offset++]; + result |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7; + } + return [result >>> 0, offset]; +} + +function decodeStringField(buf, targetField) { + let offset = 0; + while (offset < buf.length) { + let tag; + [tag, offset] = readVarint(buf, offset); + const fieldNum = tag >>> 3; + const wireType = tag & 0x07; + if (wireType === 2) { + let len; + [len, offset] = readVarint(buf, offset); + const payload = buf.slice(offset, offset + len); + offset += len; + if (fieldNum === targetField) return TEXT_DEC.decode(payload); + } else if (wireType === 0) { + let v; + [v, offset] = readVarint(buf, offset); + } else if (wireType === 1) { + offset += 8; + } else if (wireType === 5) { + offset += 4; + } else { + break; + } + } + return null; +} + +function decodeDoneChunk(buf) { + // DoneChunk: field 1 = UsageStats (nested) + // UsageStats: field 1 = prompt_tokens (varint), field 2 = completion_tokens (varint) + let offset = 0; + let usageBytes = null; + while (offset < buf.length) { + let tag; + [tag, offset] = readVarint(buf, offset); + const fieldNum = tag >>> 3; + const wireType = tag & 0x07; + if (wireType === 2) { + let len; + [len, offset] = readVarint(buf, offset); + if (fieldNum === 1) usageBytes = buf.slice(offset, offset + len); + offset += len; + } else if (wireType === 0) { + let v; + [v, offset] = readVarint(buf, offset); + } else { + break; + } + } + if (!usageBytes) return [0, 0]; + let promptTokens = 0; + let completionTokens = 0; + offset = 0; + while (offset < usageBytes.length) { + let tag; + [tag, offset] = readVarint(usageBytes, offset); + const fieldNum = tag >>> 3; + const wireType = tag & 0x07; + if (wireType === 0) { + let v; + [v, offset] = readVarint(usageBytes, offset); + if (fieldNum === 1) promptTokens = v; + else if (fieldNum === 2) completionTokens = v; + } else if (wireType === 2) { + let len; + [len, offset] = readVarint(usageBytes, offset); + offset += len; + } else { + break; + } + } + return [promptTokens, completionTokens]; +} + +export function decodeCompletionChunk(buf) { + let offset = 0; + while (offset < buf.length) { + let tag; + [tag, offset] = readVarint(buf, offset); + const fieldNum = tag >>> 3; + const wireType = tag & 0x07; + + if (wireType === 2) { + let len; + [len, offset] = readVarint(buf, offset); + const payload = buf.slice(offset, offset + len); + offset += len; + + if (fieldNum === 1) { + const text = decodeStringField(payload, 1); + if (text !== null) return { kind: "content", text }; + } else if (fieldNum === 3) { + const usage = decodeDoneChunk(payload); + return { kind: "done", promptTokens: usage[0], completionTokens: usage[1] }; + } else if (fieldNum === 4) { + const msg = decodeStringField(payload, 1); + return { kind: "error", message: msg ?? "unknown windsurf error" }; + } + // field 2 = ToolCallChunk — not yet handled; skip + } else if (wireType === 0) { + let v; + [v, offset] = readVarint(buf, offset); + } else if (wireType === 1) { + offset += 8; + } else if (wireType === 5) { + offset += 4; + } else { + break; + } + } + return { kind: "unknown" }; +} + +// ─── OpenAI messages → Windsurf wire ───────────────────────────────────────── + +function openAIMessagesToWs(messages) { + const out = []; + for (const m of messages) { + const role = String(m.role || "user"); + let content = ""; + if (typeof m.content === "string") { + content = m.content; + } else if (Array.isArray(m.content)) { + for (const part of m.content) { + if (part && typeof part === "object" && part.type === "text") { + content += String(part.text || ""); + } + } + } + out.push({ role, content, toolCallId: m.tool_call_id }); + } + return out; +} + +// ─── WindsurfExecutor ──────────────────────────────────────────────────────── + +export class WindsurfExecutor extends BaseExecutor { + constructor() { + super("windsurf", PROVIDERS.windsurf || { id: "windsurf", baseUrl: WS_CHAT_URL }); + } + + buildUrl() { + return WS_CHAT_URL; + } + + buildHeaders(credentials, stream = true) { + const token = credentials?.accessToken || credentials?.apiKey || ""; + return { + "Content-Type": "application/grpc-web+proto", + Accept: "application/grpc-web+proto", + // Codeium apiKey also goes in Metadata.api_key (protobuf field) — see request body. + ...(token ? { Authorization: `Bearer ${token}` } : {}), + "User-Agent": `windsurf/${WS_IDE_VERSION}`, + "X-Grpc-Web": "1", + }; + } + + // Request body is built manually in execute() — requires model + messages. + transformRequest() { + return null; + } + + async execute({ model, body, stream, credentials, signal, log, upstreamExtraHeaders, proxyOptions = null }) { + const apiKey = credentials?.accessToken || credentials?.apiKey || ""; + const wsModel = resolveWsModelId(model); + + const b = body ?? {}; + const rawMessages = Array.isArray(b.messages) ? b.messages : []; + let wsMessages = openAIMessagesToWs(rawMessages); + if (wsMessages.length === 0) { + wsMessages.push({ role: "user", content: "" }); + } + + const protoPayload = buildGetChatMessageRequest(apiKey, wsModel, wsMessages); + const framedPayload = grpcWebFrame(protoPayload); + + const url = this.buildUrl(); + const headers = this.buildHeaders(credentials); + if (upstreamExtraHeaders) Object.assign(headers, upstreamExtraHeaders); + + log?.debug?.("WS", `Windsurf → ${wsModel} (${wsMessages.length} messages)`); + + const upstream = await proxyAwareFetch(url, { + method: "POST", + headers, + body: framedPayload, + signal, + }, proxyOptions); + + if (!upstream.ok && upstream.status !== 200) { + return { response: upstream, url, headers, transformedBody: protoPayload }; + } + + const sseResponse = this.transformToSSE(upstream, model); + return { response: sseResponse, url, headers, transformedBody: protoPayload }; + } + + // Convert a gRPC-web binary response into an OpenAI-compatible SSE stream. + transformToSSE(upstream, model) { + const responseId = `chatcmpl-ws-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + const executor = this; + + const sseStream = new ReadableStream({ + async start(controller) { + const enc = new TextEncoder(); + let roleEmitted = false; + let totalText = ""; + let promptTokens = 0; + let completionTokens = 0; + let hadError = null; + + const emit = (data) => controller.enqueue(enc.encode(data)); + + try { + let pending = new Uint8Array(0); + const reader = upstream.body?.getReader(); + + const handleFrame = (flag, payload) => { + if (flag === 0x80) { + // Trailer frame — contains grpc-status, grpc-message + const trailer = TEXT_DEC.decode(payload); + const statusMatch = /grpc-status:\s*(\d+)/i.exec(trailer); + if (statusMatch && statusMatch[1] !== "0") { + const msgMatch = /grpc-message:\s*(.+)/i.exec(trailer); + hadError = msgMatch + ? decodeURIComponent(msgMatch[1].trim()) + : `gRPC status ${statusMatch[1]}`; + } + return; + } + if (flag !== 0x00) return; // skip unknown flags + + const chunk = executor.constructor.decodeCompletionChunk + ? executor.constructor.decodeCompletionChunk(payload) + : decodeCompletionChunk(payload); + + if (chunk.kind === "content" && chunk.text) { + totalText += chunk.text; + if (!roleEmitted) { + emit(`data: ${JSON.stringify({ + id: responseId, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }], + })}\n\n`); + roleEmitted = true; + } + emit(`data: ${JSON.stringify({ + id: responseId, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { content: chunk.text }, finish_reason: null }], + })}\n\n`); + } else if (chunk.kind === "done") { + promptTokens = chunk.promptTokens; + completionTokens = chunk.completionTokens; + } else if (chunk.kind === "error") { + hadError = chunk.message; + } + }; + + const drainFrames = () => { + let offset = 0; + while (offset + 5 <= pending.length) { + const flag = pending[offset]; + const len = + (pending[offset + 1] << 24) | + (pending[offset + 2] << 16) | + (pending[offset + 3] << 8) | + pending[offset + 4]; + if (len < 0 || offset + 5 + len > pending.length) break; + handleFrame(flag, pending.slice(offset + 5, offset + 5 + len)); + offset += 5 + len; + } + if (offset > 0) pending = pending.slice(offset); + }; + + if (reader) { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + pending = pending.length === 0 ? value : concatBytes([pending, value]); + drainFrames(); + } + } finally { + reader.releaseLock(); + } + } + drainFrames(); + + if (hadError) { + emit(`data: ${JSON.stringify({ + error: { message: hadError, type: "windsurf_error", code: "upstream_error" }, + })}\n\n`); + emit("data: [DONE]\n\n"); + controller.close(); + return; + } + + // Unary fallback: nothing streamed but text decoded → emit as one chunk. + if (!roleEmitted && totalText) { + emit(`data: ${JSON.stringify({ + id: responseId, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }], + })}\n\n`); + emit(`data: ${JSON.stringify({ + id: responseId, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { content: totalText }, finish_reason: null }], + })}\n\n`); + } + + const finishPayload = { + id: responseId, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }; + if (promptTokens > 0 || completionTokens > 0) { + finishPayload.usage = { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + }; + } + emit(`data: ${JSON.stringify(finishPayload)}\n\n`); + emit("data: [DONE]\n\n"); + } catch (err) { + const msg = err?.message ? String(err.message) : String(err); + emit(`data: ${JSON.stringify({ + error: { message: `Windsurf stream error: ${msg}`, type: "windsurf_error" }, + })}\n\n`); + emit("data: [DONE]\n\n"); + } + + controller.close(); + }, + }); + + return new Response(sseStream, { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } + + // apiKey is long-lived (Firebase-derived or Devin ide_token); refresh handled out-of-band. + async refreshCredentials() { + return null; + } +} + +export default WindsurfExecutor; diff --git a/open-sse/executors/zed.js b/open-sse/executors/zed.js new file mode 100644 index 00000000..e6233fcb --- /dev/null +++ b/open-sse/executors/zed.js @@ -0,0 +1,304 @@ +// ZedHostedExecutor — routes requests to Zed's hosted LLM aggregator +// (cloud.zed.dev/completions), a multi-format proxy fronting +// Anthropic/OpenAI/Google/xAI depending on the requested model. +// +// Wire protocol: POST /completions with an NDJSON/SSE-ish body-per-line +// response stream (`{"event": }` / `{"status": ...}` / +// `[DONE]`), authenticated with a short-lived LLM bearer token exchanged from +// the RSA-decrypted access_token (see open-sse/shared/zedAuth.js). The +// provider-shaped chunk is Claude/Gemini/OpenAI-Responses/xAI(OpenAI-shaped) +// depending on which upstream Zed fronts for the model — translated back to +// OpenAI Chat Completions by reusing the existing translators. +// +// Overrides execute() entirely (does NOT use DefaultExecutor's pipeline) because the Zed wire +// shape (thread envelope, LLM-token exchange, NDJSON status frames) doesn't +// fit the generic transformRequest/buildUrl contract. + +import { BaseExecutor } from "./base.js"; +import { FORMATS } from "../translator/formats.js"; +import { initState } from "../translator/index.js"; +import { openaiToClaudeRequest } from "../translator/request/openai-to-claude.js"; +import { openaiToGeminiRequest } from "../translator/request/openai-to-gemini.js"; +import { openaiToOpenAIResponsesRequest } from "../translator/request/openai-responses.js"; +import { claudeToOpenAIResponse } from "../translator/response/claude-to-openai.js"; +import { geminiToOpenAIResponse } from "../translator/response/gemini-to-openai.js"; +import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js"; +import { + ZED_HEADERS, + resolveZedModels, + zedLlmFetch, +} from "../shared/zedAuth.js"; + +const ZED_PROVIDER = { + anthropic: "Anthropic", + openai: "OpenAi", + google: "Google", + xai: "XAi", +}; + +function normalizeZedProvider(value, model) { + const raw = String(value || "").toLowerCase(); + if (raw === "anthropic") return ZED_PROVIDER.anthropic; + if (raw === "openai" || raw === "open_ai") return ZED_PROVIDER.openai; + if (raw === "google" || raw === "gemini") return ZED_PROVIDER.google; + if (raw === "xai" || raw === "x_ai" || raw === "x-ai") return ZED_PROVIDER.xai; + + const m = String(model || "").toLowerCase(); + if (m.includes("claude")) return ZED_PROVIDER.anthropic; + if (m.includes("gemini")) return ZED_PROVIDER.google; + if (m.includes("grok") || m.includes("xai")) return ZED_PROVIDER.xai; + return ZED_PROVIDER.openai; +} + +function buildProviderRequest(provider, model, body, stream, credentials) { + if (provider === ZED_PROVIDER.anthropic) { + return openaiToClaudeRequest(model, body, true); + } + if (provider === ZED_PROVIDER.google) { + return openaiToGeminiRequest(model, body, true); + } + if (provider === ZED_PROVIDER.openai) { + return openaiToOpenAIResponsesRequest(model, body, true, credentials); + } + // xAI is OpenAI-shaped — forward as-is. + return { ...(body || {}), model, stream: stream !== false }; +} + +function initProviderState(provider, model) { + if (provider === ZED_PROVIDER.anthropic) return initState(FORMATS.CLAUDE); + if (provider === ZED_PROVIDER.google) return initState(FORMATS.GEMINI); + if (provider === ZED_PROVIDER.openai) return initState(FORMATS.OPENAI_RESPONSES); + const state = initState(FORMATS.OPENAI); + state.model = model; + return state; +} + +function convertProviderEvent(provider, event, state) { + if (provider === ZED_PROVIDER.anthropic) return claudeToOpenAIResponse(event, state); + if (provider === ZED_PROVIDER.google) return geminiToOpenAIResponse(event, state); + if (provider === ZED_PROVIDER.openai) return openaiResponsesToOpenAIResponse(event, state); + return event; +} + +function createErrorChunk(model, message) { + return { + id: `chatcmpl-zed-error-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { index: 0, delta: { content: `[Zed error] ${message}` }, finish_reason: "stop" }, + ], + }; +} + +function enqueueSseObject(controller, encoder, chunk) { + if (!chunk) return; + const items = Array.isArray(chunk) ? chunk : [chunk]; + for (const item of items) { + if (!item) continue; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(item)}\n\n`)); + } +} + +function unwrapZedLine(line) { + let text = line.replace(/\r$/, "").trim(); + if (!text) return null; + if (text.startsWith("data:")) text = text.slice(5).trimStart(); + if (text === "[DONE]") return { done: true }; + try { + const parsed = JSON.parse(text); + if (parsed && Object.prototype.hasOwnProperty.call(parsed, "event")) { + return { event: parsed.event }; + } + if (parsed && Object.prototype.hasOwnProperty.call(parsed, "status")) { + return { status: parsed.status }; + } + return { event: parsed }; + } catch { + return null; + } +} + +function normalizeStatus(status) { + if (!status) return null; + if (typeof status === "string") return { type: status }; + if (typeof status === "object") { + const key = Object.keys(status)[0]; + if (key && typeof status[key] === "object") return { type: key, ...status[key] }; + return status; + } + return null; +} + +function wrapZedCompletionStream(response, provider, model) { + if (!response.ok || !response.body) return response; + + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + const state = initProviderState(provider, model); + let buffer = ""; + let done = false; + + const finish = (controller) => { + if (done) return; + const finalChunk = convertProviderEvent(provider, null, state); + enqueueSseObject(controller, encoder, finalChunk); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + done = true; + }; + + const processLine = (line, controller) => { + if (done) return; + const payload = unwrapZedLine(line); + if (!payload) return; + if (payload.done) { + finish(controller); + return; + } + if (payload.status) { + const status = normalizeStatus(payload.status); + if (status?.type === "failed" || status?.failed) { + const failed = status.failed || status; + const message = String(failed.message || failed.error || failed.code || "request failed"); + enqueueSseObject(controller, encoder, createErrorChunk(model, message)); + finish(controller); + } else if (status?.type === "stream_ended" || status === "stream_ended") { + finish(controller); + } + return; + } + const converted = convertProviderEvent(provider, payload.event, state); + enqueueSseObject(controller, encoder, converted); + }; + + const transformed = response.body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + let nl; + while ((nl = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + processLine(line, controller); + } + }, + flush(controller) { + buffer += decoder.decode(); + if (buffer) { + processLine(buffer, controller); + buffer = ""; + } + finish(controller); + }, + }), + ); + + return new Response(transformed, { + status: response.status, + statusText: response.statusText, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + }, + }); +} + +class ZedExecutor extends BaseExecutor { + constructor() { + super("zed"); + } + + async resolveModel(model, credentials, signal, log) { + try { + const catalog = await resolveZedModels(credentials, { config: this.config, signal }); + let raw = catalog?.rawById?.get(model) ?? null; + if (!raw) { + const refreshed = await resolveZedModels(credentials, { + config: this.config, + signal, + forceRefresh: true, + }); + raw = refreshed?.rawById?.get(model) ?? null; + } + return { raw, provider: normalizeZedProvider(raw?.provider, model) }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log?.warn?.("ZED", `model catalog unavailable, inferring provider for ${model}: ${message}`); + return { raw: null, provider: normalizeZedProvider(null, model) }; + } + } + + async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + const { provider } = await this.resolveModel(model, credentials, signal, log); + const providerRequest = buildProviderRequest(provider, model, body, stream, credentials); + const bodyRecord = body || {}; + const payload = { + thread_id: bodyRecord.thread_id || credentials?._clientSessionId, + prompt_id: bodyRecord.prompt_id, + provider, + model, + provider_request: providerRequest, + }; + + const response = await zedLlmFetch(credentials, "/completions", { + config: this.config, + signal, + fetchOptions: { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/x-ndjson, text/event-stream, */*", + "User-Agent": "9router/zed", + "x-zed-version": this.config?.appVersion?.toString() || "0.200.0", + [ZED_HEADERS.clientSupportsStatus]: "true", + [ZED_HEADERS.clientSupportsStreamEnded]: "true", + }, + body: JSON.stringify(payload), + }, + }); + + const wrapped = response.ok ? wrapZedCompletionStream(response, provider, model) : response; + return { + response: wrapped, + url: `${this.config?.llmBaseUrl || "https://cloud.zed.dev"}/completions`, + headers: { "Content-Type": "application/json", Authorization: "Bearer " }, + transformedBody: payload, + }; + } + + parseError(response, bodyText) { + let parsed = null; + try { + parsed = JSON.parse(bodyText || "{}"); + } catch { + parsed = null; + } + + const errorObj = parsed?.error || undefined; + const code = parsed?.code || errorObj?.code || ""; + const rawMessage = + parsed?.message || errorObj?.message || bodyText || response.statusText; + if (code === "trial_blocked") { + return { + status: response.status, + message: `Zed trial access is blocked upstream. The account can list hosted models, but Zed is refusing completions until trial/billing access is enabled or unblocked. Zed says: ${rawMessage}`, + }; + } + if (code) { + return { status: response.status, message: `Zed ${code}: ${rawMessage}` }; + } + return { status: response.status, message: rawMessage || `Zed upstream error: ${response.status}` }; + } + + async refreshCredentials() { + // Zed uses a long-lived RSA-decrypted access_token — no OAuth refresh. + return null; + } + + needsRefresh() { + return false; + } +} + +export default ZedExecutor; diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 4e3475f4..4f91e020 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -330,7 +330,18 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Handle 401/403 - try token refresh (skip for noAuth providers) if (!executor.noAuth && (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN)) { try { - const newCredentials = await refreshWithRetry(() => executor.refreshCredentials(credentials, log), 3, log); + // Mutate credentials after each successful refresh: rotating refresh_token + // providers (xAI/grok-cli) issue a new RT on every refresh; without this, + // refreshWithRetry's 2nd/3rd attempt reuses the already-consumed RT → + // invalid_grant → auth_failed retryable=false. + const newCredentials = await refreshWithRetry(async () => { + const result = await executor.refreshCredentials(credentials, log); + if (result?.refreshToken && result.refreshToken !== credentials.refreshToken) { + if (result.accessToken) credentials.accessToken = result.accessToken; + credentials.refreshToken = result.refreshToken; + } + return result; + }, 3, log); if (newCredentials?.accessToken || newCredentials?.copilotToken) { if (log?.line) log.line(reqTag, "🔑", `TOKEN REFRESHED · ${provider}/${model}`); Object.assign(credentials, newCredentials); diff --git a/open-sse/handlers/embeddingsCore.js b/open-sse/handlers/embeddingsCore.js index 5a4c92ba..aa81117c 100644 --- a/open-sse/handlers/embeddingsCore.js +++ b/open-sse/handlers/embeddingsCore.js @@ -116,6 +116,7 @@ export async function handleEmbeddingsCore({ return { success: true, + usage: normalized.usage || null, response: new Response(JSON.stringify(normalized), { headers: { "Content-Type": "application/json", diff --git a/open-sse/handlers/fetch/index.js b/open-sse/handlers/fetch/index.js index da1c2303..187bd60e 100644 --- a/open-sse/handlers/fetch/index.js +++ b/open-sse/handlers/fetch/index.js @@ -49,7 +49,10 @@ function truncate(text, max) { } function parseJinaTitle(text) { - const m = String(text || "").match(/^\s*#\s+(.+)$/m); + const source = String(text || ""); + const metadataTitle = source.match(/^\s*Title:\s*(.+)$/mi); + if (metadataTitle) return metadataTitle[1].trim(); + const m = source.match(/^\s*#\s+(.+)$/m); return m ? m[1].trim() : null; } @@ -151,11 +154,14 @@ async function runFirecrawl({ url, fmt, timeoutMs, apiKey, maxCharacters, costPe } async function runJina({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }) { - const target = `https://r.jina.ai/${encodeURIComponent(url)}`; const upstreamStart = Date.now(); - const r = await tryFetch(target, { - method: "GET", - headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {} + const r = await tryFetch("https://r.jina.ai/", { + method: "POST", + headers: { + "content-type": "application/json", + ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}) + }, + body: JSON.stringify({ url }) }, timeoutMs); if (!r.ok) { diff --git a/open-sse/handlers/search/callers.js b/open-sse/handlers/search/callers.js index 64f045c3..e32d93ed 100644 --- a/open-sse/handlers/search/callers.js +++ b/open-sse/handlers/search/callers.js @@ -1,7 +1,6 @@ /** * Search Provider Request Builders * - * Ported from OmniRoute open-sse/handlers/search.ts (lines 223-610). * Builds HTTP request `{ url, init }` for 10 search providers. * * @typedef {Object} SearchProviderConfig diff --git a/open-sse/handlers/search/normalizers.js b/open-sse/handlers/search/normalizers.js index da008bf3..898b271f 100644 --- a/open-sse/handlers/search/normalizers.js +++ b/open-sse/handlers/search/normalizers.js @@ -1,7 +1,6 @@ /** * Search Response Normalizers * - * Ported from OmniRoute open-sse/handlers/search.ts. * Each normalizer maps a provider-specific response into the unified SearchResult shape. */ diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 5bd7f6d8..526ceb9b 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -71,7 +71,11 @@ export function capabilitiesFromServiceKind(kind) { * otherwise mis-match. Only declare deltas vs DEFAULT. */ export const MODEL_CAPABILITIES = { - // Claude 4.6/4.7/4.8 and Kiro Sonnet 5 have 1M context + adaptive thinking (override generic claude pattern) + // Claude Opus 5, 4.6/4.7/4.8, and Kiro Sonnet 5 have 1M context + adaptive thinking (override generic claude pattern) + "claude-opus-5": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, + "claude-opus-5-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, + "claude-opus-5-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, + "claude-opus-5-thinking-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-opus-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-opus-4.7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, "claude-opus-4-7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 }, @@ -170,6 +174,11 @@ export const PROVIDER_CAPABILITIES = { "deepseek-v4-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 50000 }, "deepseek-v3-2-volc": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 96000, maxOutput: 32000 }, }, + // Poolside Laguna — OpenAI-compatible, all reasoning-capable (32K max output). + "poolside": { + "laguna-s-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 }, + "laguna-xs-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 }, + }, }; /** @@ -180,6 +189,7 @@ export const PROVIDER_CAPABILITIES = { */ export const PATTERN_CAPABILITIES = [ // ── Claude (4.6+ = adaptive thinking; older/haiku = budget) ────── + { pattern: "*claude*opus-5*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 } }, { pattern: "*claude*opus-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, { pattern: "*claude*opus-4.7*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, { pattern: "*claude*opus-4.8*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } }, @@ -291,6 +301,13 @@ export const PATTERN_CAPABILITIES = [ { pattern: "*pplx*", caps: { search: true, contextWindow: 128000 } }, { pattern: "*perplexity*", caps: { search: true, contextWindow: 128000 } }, + // ── Poolside Laguna (resellers: openrouter/nvidia/kilocode/vercel/...) ── + // Free tiers cap S 2.1 well below the paid 1M window → match the free suffix + // (":free" or "-free", depending on reseller) before the plain id. + { pattern: "*laguna-s-2.1*free*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } }, + { pattern: "*laguna-s-2.1*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 } }, + { pattern: "*laguna*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } }, + // ── Others ─────────────────────────────────────────────────────── { pattern: "*hunyuan*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } }, { pattern: "hy3*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } }, diff --git a/open-sse/providers/pricing.js b/open-sse/providers/pricing.js index c2831fdb..9a0768ef 100644 --- a/open-sse/providers/pricing.js +++ b/open-sse/providers/pricing.js @@ -57,7 +57,13 @@ export const MODEL_PRICING = { "o1-mini": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 }, // === Gemini === - "gemini-3-flash-preview": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 }, + "gemini-3.6-flash": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.6-flash-high": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.6-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.6-flash-low": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 }, + "gemini-3.5-flash-lite": { input: 0.30, output: 2.50, cached: 0.03, reasoning: 3.75, cache_creation: 0.375 }, + "gemini-3.5-flash-high": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 }, + "gemini-3-flash-preview": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 }, "gemini-3-pro-preview": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 }, "gemini-3.1-pro-low": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 }, "gemini-3.1-pro-high": { input: 4.00, output: 18.00, cached: 0.50, reasoning: 27.00, cache_creation: 4.00 }, diff --git a/open-sse/providers/registry/antigravity.js b/open-sse/providers/registry/antigravity.js index dd7fbc02..a4ca7346 100644 --- a/open-sse/providers/registry/antigravity.js +++ b/open-sse/providers/registry/antigravity.js @@ -36,6 +36,7 @@ export default { }, }, usage: { + // Discovery (quota/project) on PROD; daily host rejects these. quotaApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", loadProjectApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", tokenUrl: "https://oauth2.googleapis.com/token", @@ -44,6 +45,10 @@ export default { clientSecret: "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", }, models: [ + { id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", upstreamModelId: "gemini-3.6-flash-tiered(high)" }, + { id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", upstreamModelId: "gemini-3.6-flash-tiered(medium)" }, + { id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)", upstreamModelId: "gemini-3.6-flash-tiered(low)" }, + { id: "gemini-3.5-flash-high", name: "Gemini 3.5 Flash (High)" }, { id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)" }, { id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium)" }, { id: "gemini-3.5-flash-extra-low", name: "Gemini 3.5 Flash (Low)" }, @@ -67,7 +72,7 @@ export default { "https://www.googleapis.com/auth/cclog", "https://www.googleapis.com/auth/experimentsandconfigs", ], - apiEndpoint: "https://cloudcode-pa.googleapis.com", + apiEndpoint: "https://daily-cloudcode-pa.googleapis.com", apiVersion: "v1internal", loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", diff --git a/open-sse/providers/registry/api-airforce.js b/open-sse/providers/registry/api-airforce.js new file mode 100644 index 00000000..16ed8b3e --- /dev/null +++ b/open-sse/providers/registry/api-airforce.js @@ -0,0 +1,36 @@ +export default { + id: "api-airforce", + alias: "af", + aliases: [ + "airforce", + ], + uiAlias: "af", + display: { + name: "API.airforce", + icon: "flight", + color: "#0EA5E9", + textIcon: "AF", + website: "https://api.airforce", + notice: { + apiKeyUrl: "https://api.airforce", + }, + }, + category: "freeTier", + authType: "apikey", + authModes: [ + "apikey", + ], + transport: { + baseUrl: "https://api.airforce/v1/chat/completions", + validateUrl: "https://api.airforce/v1/models", + headers: { + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy", + }, + }, + models: [ + { id: "anthropic/claude-3.7-sonnet", name: "Claude 3.7 Sonnet (Free)", contextLength: 200000 }, + { id: "moonshot/kimi-k2.6", name: "Kimi K2.6 (Free)", contextLength: 262144 }, + { id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash (Free)", contextLength: 1048576 }, + ], +}; diff --git a/open-sse/providers/registry/baidu.js b/open-sse/providers/registry/baidu.js new file mode 100644 index 00000000..62541e7d --- /dev/null +++ b/open-sse/providers/registry/baidu.js @@ -0,0 +1,33 @@ +export default { + id: "baidu", + alias: "qianfan", + aliases: ["qianfan", "ernie", "baidu-qianfan"], + uiAlias: "qianfan", + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + display: { + name: "Baidu Qianfan", + icon: "search", + color: "#2932E1", + textIcon: "BD", + website: "https://cloud.baidu.com/product/qianfan.html", + notice: { + apiKeyUrl: + "https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application", + }, + }, + transport: { + baseUrl: "https://qianfan.baidubce.com/v2/chat/completions", + validateUrl: "https://qianfan.baidubce.com/v2/models", + }, + models: [ + { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", contextLength: 1048576 }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", contextLength: 1048576 }, + { id: "glm-5.2", name: "GLM 5.2", contextLength: 512000 }, + { id: "glm-5.1", name: "GLM 5.1", contextLength: 198000 }, + { id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 262144 }, + { id: "qwen3.5-397b-a17b", name: "Qwen 3.5 397B A17B", contextLength: 262144 }, + { id: "qwen3.5-27b", name: "Qwen 3.5 27B", contextLength: 262144 }, + ], +}; diff --git a/open-sse/providers/registry/bazaarlink.js b/open-sse/providers/registry/bazaarlink.js new file mode 100644 index 00000000..44d4dac8 --- /dev/null +++ b/open-sse/providers/registry/bazaarlink.js @@ -0,0 +1,47 @@ +export default { + id: "bazaarlink", + alias: "bzl", + aliases: ["bazaar-link"], + uiAlias: "bzl", + category: "freeTier", + authType: "apikey", + authModes: ["apikey"], + display: { + name: "Bazaarlink", + icon: "storefront", + color: "#DC2626", + textIcon: "BZ", + website: "https://bazaarlink.ai", + notice: { apiKeyUrl: "https://bazaarlink.ai" }, + }, + transport: { + baseUrl: "https://bazaarlink.ai/api/v1/chat/completions", + validateUrl: "https://bazaarlink.ai/api/v1/models", + }, + models: [ + { id: "auto:free", name: "Auto Free (Zero Cost)" }, + { id: "claude-opus-4.7", name: "Claude Opus 4.7", contextLength: 1000000 }, + { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", contextLength: 1000000 }, + { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", contextLength: 200000 }, + { id: "gpt-5.5", name: "GPT-5.5", contextLength: 1050000 }, + { id: "gpt-5.4", name: "GPT-5.4", contextLength: 1050000 }, + { id: "gpt-5.4-mini", name: "GPT-5.4 Mini", contextLength: 400000 }, + { id: "gpt-5.4-nano", name: "GPT-5.4 Nano", contextLength: 400000 }, + { id: "grok-4.3", name: "Grok 4.3", contextLength: 1000000 }, + { id: "grok-4.20", name: "Grok 4.20", contextLength: 2000000 }, + { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro", contextLength: 1048576 }, + { id: "gemini-3-flash-preview", name: "Gemini 3 Flash", contextLength: 1048576 }, + { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite", contextLength: 1048576 }, + { id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 262144 }, + { id: "kimi-k2.5", name: "Kimi K2.5", contextLength: 262144 }, + { id: "glm-5.1", name: "GLM 5.1", contextLength: 204800 }, + { id: "glm-5", name: "GLM 5", contextLength: 204800 }, + { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", contextLength: 1050000 }, + { id: "mimo-v2.5", name: "MiMo-V2.5", contextLength: 1050000 }, + { id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576 }, + { id: "minimax-m2.7", name: "MiniMax M2.7", contextLength: 204800 }, + { id: "minimax-m2.5", name: "MiniMax M2.5", contextLength: 204800 }, + { id: "qwen3.6-plus", name: "Qwen 3.6 Plus", contextLength: 1000000 }, + { id: "nemotron-3-super-120b-a12b", name: "Nemotron 3 Super", contextLength: 1000000 }, + ], +}; diff --git a/open-sse/providers/registry/bluesminds.js b/open-sse/providers/registry/bluesminds.js new file mode 100644 index 00000000..34577caf --- /dev/null +++ b/open-sse/providers/registry/bluesminds.js @@ -0,0 +1,38 @@ +export default { + id: "bluesminds", + alias: "bm", + aliases: ["blue-sminds"], + uiAlias: "bm", + hidden: true, + display: { + name: "BluesMinds", + icon: "psychology", + color: "#2563EB", + textIcon: "BM", + website: "https://bluesminds.com", + notice: { apiKeyUrl: "https://bluesminds.com" }, + }, + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://api.bluesminds.com/v1/chat/completions", + validateUrl: "https://api.bluesminds.com/v1/models", + }, + models: [ + { id: "gpt-4.1", name: "GPT-4.1", contextLength: 1048576 }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini", contextLength: 1048576 }, + { id: "gpt-4.1-nano", name: "GPT-4.1 Nano", contextLength: 1048576 }, + { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", contextLength: 200000 }, + { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", contextLength: 200000 }, + { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", contextLength: 1048576 }, + { id: "gemini-2.0-flash-exp", name: "Gemini 2.0 Flash (Exp)", contextLength: 1048576 }, + { id: "qwen-turbo", name: "Qwen Turbo", contextLength: 1000000 }, + { id: "kimi-k2", name: "Kimi K2", contextLength: 262144 }, + { id: "kimi-k2-thinking", name: "Kimi K2 Thinking", contextLength: 262144 }, + { id: "glm-4.7", name: "GLM 4.7", contextLength: 204800 }, + { id: "minimax-m2.5", name: "MiniMax M2.5", contextLength: 204800 }, + { id: "claude-opus-4-5", name: "Claude Opus 4.5 (VIP)", contextLength: 200000 }, + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (VIP)", contextLength: 1048576 }, + ], +}; diff --git a/open-sse/providers/registry/claude.js b/open-sse/providers/registry/claude.js index a2912701..8b030e5a 100644 --- a/open-sse/providers/registry/claude.js +++ b/open-sse/providers/registry/claude.js @@ -60,10 +60,9 @@ export default { }, }, models: [ + { id: "claude-opus-5", name: "Claude Opus 5" }, { id: "claude-fable-5", name: "Claude Fable 5" }, { id: "claude-sonnet-5", name: "Claude Sonnet 5" }, - { id: "claude-opus-4-8", name: "Claude Opus 4.8" }, - { id: "claude-opus-4-7", name: "Claude Opus 4.7" }, { id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" }, ], oauth: { diff --git a/open-sse/providers/registry/codebuddy-intl.js b/open-sse/providers/registry/codebuddy-intl.js new file mode 100644 index 00000000..7e69836c --- /dev/null +++ b/open-sse/providers/registry/codebuddy-intl.js @@ -0,0 +1,73 @@ +// CodeBuddy international (codebuddy.ai) — mirrors codebuddy-cn registry shape, +// swapping the Tencent CN domain for the .ai endpoint set. All OAuth/plugin URLs +// use the /v2/plugin prefix with platform=ide (CN uses platform=CLI). +export default { + id: "codebuddy-intl", + alias: "cbai", + uiAlias: "cbai", + hidden: false, + priority: 90, + display: { + name: "CodeBuddy", + icon: "smart_toy", + color: "#006EFF", + website: "https://www.codebuddy.ai", + notice: { + signupUrl: "https://www.codebuddy.ai", + }, + }, + category: "oauth", + authModes: ["oauth", "apikey"], + hasOAuth: true, + transport: { + // Chat gateway is OpenAI-compatible SSE (same /v2/chat/completions path as CN). + baseUrl: "https://www.codebuddy.ai/v2/chat/completions", + forceStream: true, + // CodeBuddy intl speaks the same unified OpenAI reasoning_effort shape as CN. + thinkingFormat: "openai", + headers: { + "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1", + "X-Product": "SaaS", + "X-IDE-Type": "IDE", + "X-IDE-Name": "IDE", + "x-requested-with": "XMLHttpRequest", + "x-codebuddy-request": "1", + }, + auth: { + combined: true, + header: "Authorization", + scheme: "bearer", + }, + }, + // Same model lineup exposed by the CN gateway — intl backend is the same catalog. + models: [ + { id: "glm-5.2", name: "GLM-5.2" }, + { id: "glm-5.1", name: "GLM-5.1" }, + { id: "glm-5.0", name: "GLM-5.0" }, + { id: "glm-5.0-turbo", name: "GLM-5.0-Turbo" }, + { id: "glm-5v-turbo", name: "GLM-5v-Turbo" }, + { id: "glm-4.7", name: "GLM-4.7" }, + { id: "minimax-m3", name: "MiniMax-M3" }, + { id: "minimax-m2.7", name: "MiniMax-M2.7" }, + { id: "kimi-k2.7", name: "Kimi-K2.7-Code" }, + { id: "kimi-k2.6", name: "Kimi-K2.6" }, + { id: "kimi-k2.5", name: "Kimi-K2.5" }, + { id: "hy3-preview", name: "Hy3 Preview" }, + { id: "deepseek-v4-pro", name: "DeepSeek-V4-Pro" }, + { id: "deepseek-v4-flash", name: "DeepSeek-V4-Flash" }, + { id: "deepseek-v3-2-volc", name: "DeepSeek-V3.2" }, + ], + oauth: { + baseUrl: "https://www.codebuddy.ai", + stateUrl: "https://www.codebuddy.ai/v2/plugin/auth/state", + tokenUrl: "https://www.codebuddy.ai/v2/plugin/auth/token", + refreshUrl: "https://www.codebuddy.ai/v2/plugin/auth/token/refresh", + userAgent: "IDE/2.63.2 CodeBuddy/2.63.2", + platform: "ide", + pollInterval: 5000, + }, + features: { + usage: true, + usageApikey: true, + }, +}; diff --git a/open-sse/providers/registry/deepseek.js b/open-sse/providers/registry/deepseek.js index 5c167f7a..86123b28 100644 --- a/open-sse/providers/registry/deepseek.js +++ b/open-sse/providers/registry/deepseek.js @@ -48,4 +48,8 @@ export default { { id: "deepseek-chat", name: "DeepSeek V3.2 Chat" }, { id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" }, ], + features: { + usage: true, + usageApikey: true, + }, }; diff --git a/open-sse/providers/registry/devin-cli.js b/open-sse/providers/registry/devin-cli.js new file mode 100644 index 00000000..09391649 --- /dev/null +++ b/open-sse/providers/registry/devin-cli.js @@ -0,0 +1,63 @@ +export default { + id: "devin-cli", + alias: "dv", + aliases: ["devin"], + uiAlias: "dv", + hidden: true, + display: { + name: "Devin CLI", + icon: "smart_toy", + color: "#6366F1", + textIcon: "DV", + website: "https://devin.ai", + notice: { + signupUrl: "https://cli.devin.ai", + text: "Install: `curl -fsSL https://cli.devin.ai/install.sh | bash` (macOS: `brew install --cask devin-cli`, Windows PowerShell: `irm https://static.devin.ai/cli/setup.ps1 | iex`). Then run `devin auth login`. No API key needed.", + }, + }, + category: "free", + authType: "none", + noAuth: true, + authModes: ["none"], + transport: { + baseUrl: "devin://acp/stdio", + format: "openai", + }, + models: [ + { id: "swe-1.6-fast", name: "SWE-1.6 Fast" }, + { id: "swe-1.6", name: "SWE-1.6" }, + { id: "swe-1.5-fast", name: "SWE-1.5 Fast" }, + { id: "swe-1.5", name: "SWE-1.5" }, + { id: "claude-opus-4.7-max", name: "Claude Opus 4.7 Max", contextLength: 200000 }, + { id: "claude-opus-4.7-high", name: "Claude Opus 4.7 High", contextLength: 200000 }, + { id: "claude-opus-4.7-medium", name: "Claude Opus 4.7 Medium", contextLength: 200000 }, + { id: "claude-opus-4.7-low", name: "Claude Opus 4.7 Low", contextLength: 200000 }, + { id: "claude-sonnet-4.6-thinking-1m", name: "Claude Sonnet 4.6 Thinking 1M", contextLength: 1000000 }, + { id: "claude-sonnet-4.6-thinking", name: "Claude Sonnet 4.6 Thinking", contextLength: 200000 }, + { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", contextLength: 200000 }, + { id: "claude-opus-4.6-thinking", name: "Claude Opus 4.6 Thinking", contextLength: 200000 }, + { id: "claude-opus-4.6", name: "Claude Opus 4.6", contextLength: 200000 }, + { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", contextLength: 200000 }, + { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", contextLength: 200000 }, + { id: "gpt-5.5-xhigh", name: "GPT-5.5 XHigh", contextLength: 200000 }, + { id: "gpt-5.5-high", name: "GPT-5.5 High", contextLength: 200000 }, + { id: "gpt-5.5-medium", name: "GPT-5.5 Medium", contextLength: 200000 }, + { id: "gpt-5.5-low", name: "GPT-5.5 Low", contextLength: 200000 }, + { id: "gpt-5.4-high", name: "GPT-5.4 High", contextLength: 200000 }, + { id: "gpt-5.4-medium", name: "GPT-5.4 Medium", contextLength: 200000 }, + { id: "gpt-5.4-low", name: "GPT-5.4 Low", contextLength: 200000 }, + { id: "gpt-5.3-codex-high", name: "GPT-5.3 Codex High", contextLength: 200000 }, + { id: "gpt-5.3-codex-medium", name: "GPT-5.3 Codex Medium", contextLength: 200000 }, + { id: "gpt-5.3-codex-low", name: "GPT-5.3 Codex Low", contextLength: 200000 }, + { id: "gpt-5.2-high", name: "GPT-5.2 High", contextLength: 200000 }, + { id: "gpt-5.2-medium", name: "GPT-5.2 Medium", contextLength: 200000 }, + { id: "gpt-5.2-low", name: "GPT-5.2 Low", contextLength: 200000 }, + { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High", contextLength: 1000000 }, + { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low", contextLength: 1000000 }, + { id: "gemini-3.0-flash-high", name: "Gemini 3 Flash High", contextLength: 1000000 }, + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", contextLength: 1000000 }, + { id: "deepseek-v4", name: "DeepSeek V4", contextLength: 1048576 }, + { id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 262144 }, + { id: "glm-5.1", name: "GLM-5.1", contextLength: 204800 }, + ], +}; diff --git a/open-sse/providers/registry/gemini.js b/open-sse/providers/registry/gemini.js index 5c811042..c9b0de9b 100644 --- a/open-sse/providers/registry/gemini.js +++ b/open-sse/providers/registry/gemini.js @@ -16,6 +16,8 @@ export default { }, }, category: "freeTier", + authType: "apikey", + authModes: ["apikey"], mediaPriority: 1, transport: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/models", @@ -34,6 +36,8 @@ export default { }, }, models: [ + { id: "gemini-3.6-flash", name: "Gemini 3.6 Flash" }, + { id: "gemini-3.5-flash-lite", name: "Gemini 3.5 Flash Lite" }, { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" }, { id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" }, diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index 265dc741..a82c60d9 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -1,106 +1,121 @@ -// Auto-generated: static imports for all registry entries -import p0 from "./alicode-intl.js"; -import p1 from "./alicode.js"; -import p2 from "./anthropic.js"; -import p3 from "./antigravity.js"; -import p4 from "./assemblyai.js"; -import p5 from "./aws-polly.js"; -import p6 from "./azure.js"; -import p7 from "./black-forest-labs.js"; -import p8 from "./blackbox.js"; -import p9 from "./brave-search.js"; -import p10 from "./byteplus.js"; -import p11 from "./cartesia.js"; -import p12 from "./cerebras.js"; -import p13 from "./chutes.js"; -import p14 from "./claude.js"; -import p15 from "./cline.js"; -import p16 from "./clinepass.js"; -import p17 from "./cloudflare-ai.js"; -import p18 from "./codebuddy-cn.js"; -import p19 from "./codex.js"; -import p20 from "./cohere.js"; -import p21 from "./comfyui.js"; -import p22 from "./commandcode.js"; -import p23 from "./coqui.js"; -import p24 from "./cursor.js"; -import p25 from "./deepgram.js"; -import p26 from "./deepseek.js"; -import p27 from "./edge-tts.js"; -import p28 from "./elevenlabs.js"; -import p29 from "./exa.js"; -import p30 from "./fal-ai.js"; -import p31 from "./featherless.js"; -import p32 from "./firecrawl.js"; -import p33 from "./fireworks.js"; -import p34 from "./gemini-cli.js"; -import p35 from "./gemini.js"; -import p36 from "./github.js"; -import p37 from "./gitlab.js"; -import p38 from "./glm-cn.js"; -import p39 from "./glm.js"; -import p40 from "./google-pse.js"; -import p41 from "./google-tts.js"; -import p42 from "./grok-cli.js"; -import p43 from "./grok-web.js"; -import p44 from "./groq.js"; -import p45 from "./huggingface.js"; -import p46 from "./hyperbolic.js"; -import p47 from "./iflow.js"; -import p48 from "./inworld.js"; -import p49 from "./jina-ai.js"; -import p50 from "./jina-reader.js"; -import p51 from "./kilocode.js"; -import p52 from "./kimchi.js"; -import p53 from "./kimi.js"; -import p54 from "./kiro.js"; -import p55 from "./linkup.js"; -import p56 from "./local-device.js"; -import p57 from "./mimo-free.js"; -import p58 from "./minimax-cn.js"; -import p59 from "./minimax.js"; -import p60 from "./mistral.js"; -import p61 from "./mmf.js"; -import p62 from "./nanobanana.js"; -import p63 from "./nebius.js"; -import p64 from "./nvidia.js"; -import p65 from "./ollama-local.js"; -import p66 from "./ollama.js"; -import p67 from "./openai.js"; -import p68 from "./opencode-go.js"; -import p69 from "./opencode.js"; -import p70 from "./openrouter.js"; -import p71 from "./perplexity-web.js"; -import p72 from "./perplexity.js"; -import p73 from "./perplexity-agent.js"; -import p74 from "./playht.js"; -import p75 from "./qoder.js"; -import p76 from "./qwen.js"; -import p77 from "./recraft.js"; -import p78 from "./runwayml.js"; -import p79 from "./sdwebui.js"; -import p80 from "./searchapi.js"; -import p81 from "./searxng.js"; -import p82 from "./serper.js"; -import p83 from "./siliconflow.js"; -import p84 from "./stability-ai.js"; -import p85 from "./tavily.js"; -import p86 from "./together.js"; -import p87 from "./topaz.js"; -import p88 from "./tortoise.js"; -import p89 from "./venice.js"; -import p90 from "./vercel-ai-gateway.js"; -import p91 from "./vertex-partner.js"; -import p92 from "./vertex.js"; -import p93 from "./volcengine-ark.js"; -import p94 from "./voyage-ai.js"; -import p95 from "./xai.js"; -import p96 from "./xiaomi-mimo.js"; -import p97 from "./xiaomi-tokenplan.js"; -import p98 from "./youcom.js"; -import p99 from "./alims-intl.js"; -import p100 from "./orbit-provider.js"; +// Auto-generated by scripts/generate-provider-registry.mjs. Do not edit manually. +import p0 from "./alicode.js"; +import p1 from "./alicode-intl.js"; +import p2 from "./alims-intl.js"; +import p3 from "./anthropic.js"; +import p4 from "./antigravity.js"; +import p5 from "./api-airforce.js"; +import p6 from "./assemblyai.js"; +import p7 from "./aws-polly.js"; +import p8 from "./azure.js"; +import p9 from "./baidu.js"; +import p10 from "./bazaarlink.js"; +import p11 from "./black-forest-labs.js"; +import p12 from "./blackbox.js"; +import p13 from "./bluesminds.js"; +import p14 from "./brave-search.js"; +import p15 from "./byteplus.js"; +import p16 from "./cartesia.js"; +import p17 from "./cerebras.js"; +import p18 from "./chutes.js"; +import p19 from "./claude.js"; +import p20 from "./cline.js"; +import p21 from "./clinepass.js"; +import p22 from "./cloudflare-ai.js"; +import p23 from "./codebuddy-cn.js"; +import p24 from "./codebuddy-intl.js"; +import p25 from "./codex.js"; +import p26 from "./cohere.js"; +import p27 from "./comfyui.js"; +import p28 from "./commandcode.js"; +import p29 from "./coqui.js"; +import p30 from "./cursor.js"; +import p31 from "./deepgram.js"; +import p32 from "./deepseek.js"; +import p33 from "./edge-tts.js"; +import p34 from "./elevenlabs.js"; +import p35 from "./exa.js"; +import p36 from "./fal-ai.js"; +import p37 from "./featherless.js"; +import p38 from "./firecrawl.js"; +import p39 from "./fireworks.js"; +import p40 from "./gemini.js"; +import p41 from "./gemini-cli.js"; +import p42 from "./github.js"; +import p43 from "./gitlab.js"; +import p44 from "./glm.js"; +import p45 from "./glm-cn.js"; +import p46 from "./google-pse.js"; +import p47 from "./google-tts.js"; +import p48 from "./grok-cli.js"; +import p49 from "./grok-web.js"; +import p50 from "./groq.js"; +import p51 from "./huggingface.js"; +import p52 from "./hyperbolic.js"; +import p53 from "./iflow.js"; +import p54 from "./inworld.js"; +import p55 from "./jina-ai.js"; +import p56 from "./jina-reader.js"; +import p57 from "./kilo-gateway.js"; +import p58 from "./kilocode.js"; +import p59 from "./kimchi.js"; +import p60 from "./kimi.js"; +import p61 from "./kiro.js"; +import p62 from "./linkup.js"; +import p63 from "./llm7.js"; +import p64 from "./local-device.js"; +import p65 from "./mimo-free.js"; +import p66 from "./minimax.js"; +import p67 from "./minimax-cn.js"; +import p68 from "./mistral.js"; +import p69 from "./mmf.js"; +import p70 from "./morph.js"; +import p71 from "./nanobanana.js"; +import p72 from "./nebius.js"; +import p73 from "./nvidia.js"; +import p74 from "./ollama.js"; +import p75 from "./ollama-local.js"; +import p76 from "./openai.js"; +import p77 from "./opencode.js"; +import p78 from "./opencode-go.js"; +import p79 from "./openrouter.js"; +import p80 from "./orbit-provider.js"; +import p81 from "./perplexity.js"; +import p82 from "./perplexity-agent.js"; +import p83 from "./perplexity-web.js"; +import p84 from "./playht.js"; +import p85 from "./poolside.js"; +import p86 from "./qoder.js"; +import p87 from "./qwen.js"; +import p88 from "./recraft.js"; +import p89 from "./runwayml.js"; +import p90 from "./sambanova.js"; +import p91 from "./sdwebui.js"; +import p92 from "./searchapi.js"; +import p93 from "./searxng.js"; +import p94 from "./serper.js"; +import p95 from "./siliconflow.js"; +import p96 from "./stability-ai.js"; +import p97 from "./tavily.js"; +import p98 from "./tencent.js"; +import p99 from "./together.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 [ p0, p1, @@ -203,4 +218,16 @@ export default [ p98, p99, p100, + p101, + p102, + p103, + p104, + p105, + p106, + p107, + p108, + p109, + p110, + p111, + p112, ]; diff --git a/open-sse/providers/registry/kilo-gateway.js b/open-sse/providers/registry/kilo-gateway.js new file mode 100644 index 00000000..7e11cddc --- /dev/null +++ b/open-sse/providers/registry/kilo-gateway.js @@ -0,0 +1,34 @@ +export default { + id: "kilo-gateway", + alias: "kgw", + aliases: [ + "kilo-gateway", + "kilogateway", + ], + uiAlias: "kgw", + category: "freeTier", + display: { + name: "Kilo Gateway", + icon: "login", + color: "#8B5CF6", + textIcon: "KG", + website: "https://kilo.ai", + notice: { + apiKeyUrl: "https://kilo.ai/dashboard?tab=apiKeys", + }, + }, + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://api.kilo.ai/api/gateway/chat/completions", + validateUrl: "https://api.kilo.ai/api/gateway/models", + }, + models: [ + { id: "kilo-auto/free", name: "Kilo Auto Free", contextLength: 256000 }, + { id: "nvidia/nemotron-3-super-120b-a12b:free", name: "Nemotron 3 Super 120B (Free)", contextLength: 262144 }, + { id: "nvidia/nemotron-3-ultra-550b-a55b:free", name: "Nemotron 3 Ultra 550B (Free)", contextLength: 1000000 }, + { id: "kwaipilot/kat-coder-pro-v2.5:free", name: "Kat Coder Pro v2.5 (Free)", contextLength: 256000 }, + { id: "kilo-auto/frontier", name: "Kilo Auto Frontier", contextLength: 1000000 }, + { id: "kilo-auto/balanced", name: "Kilo Auto Balanced", contextLength: 1000000 }, + ], +}; diff --git a/open-sse/providers/registry/kimchi.js b/open-sse/providers/registry/kimchi.js index 99facd53..c2ec22b1 100644 --- a/open-sse/providers/registry/kimchi.js +++ b/open-sse/providers/registry/kimchi.js @@ -13,7 +13,7 @@ export default { signupUrl: "https://app.kimchi.dev", }, }, - category: "oauth", + category: "freeTier", authModes: ["oauth"], hasOAuth: true, transport: { diff --git a/open-sse/providers/registry/kimi.js b/open-sse/providers/registry/kimi.js index 88bfde3e..299b824f 100644 --- a/open-sse/providers/registry/kimi.js +++ b/open-sse/providers/registry/kimi.js @@ -85,5 +85,8 @@ export default { }, features: { usage: true, + // API-key connections also hit /v1/usages (x-api-key) — need usageApikey + // so isUsageEligible + /api/usage allow non-oauth authType. + usageApikey: true, }, }; diff --git a/open-sse/providers/registry/kiro.js b/open-sse/providers/registry/kiro.js index 1a47adaa..f506b8d4 100644 --- a/open-sse/providers/registry/kiro.js +++ b/open-sse/providers/registry/kiro.js @@ -29,7 +29,6 @@ export default { headers: { "Content-Type": "application/json", Accept: "application/vnd.amazon.eventstream", - "X-Amz-Target": "AmazonCodeWhispererStreamingService.GenerateAssistantResponse", "User-Agent": "AWS-SDK-JS/3.0.0 kiro-ide/1.0.0", "X-Amz-User-Agent": "aws-sdk-js/3.0.0 kiro-ide/1.0.0", }, @@ -43,6 +42,10 @@ export default { }, models: [ // Opus (added per kiro.dev/changelog/models and kiro.dev/docs/models) + { id: "claude-opus-5", name: "Claude Opus 5" }, + { id: "claude-opus-5-thinking", name: "Claude Opus 5 (Thinking)" }, + { id: "claude-opus-5-agentic", name: "Claude Opus 5 (Agentic)" }, + { id: "claude-opus-5-thinking-agentic", name: "Claude Opus 5 (Thinking + Agentic)" }, { id: "claude-opus-4.8", name: "Claude Opus 4.8" }, { id: "claude-opus-4.8-thinking", name: "Claude Opus 4.8 (Thinking)" }, { id: "claude-opus-4.8-agentic", name: "Claude Opus 4.8 (Agentic)" }, diff --git a/open-sse/providers/registry/llm7.js b/open-sse/providers/registry/llm7.js new file mode 100644 index 00000000..6f4210a7 --- /dev/null +++ b/open-sse/providers/registry/llm7.js @@ -0,0 +1,35 @@ +export default { + id: "llm7", + alias: "llm7", + aliases: [ + "llm-7", + ], + uiAlias: "llm7", + display: { + name: "LLM7", + icon: "pool", + color: "#7C3AED", + textIcon: "L7", + website: "https://llm7.io", + notice: { + apiKeyUrl: "https://llm7.io", + }, + }, + category: "apikey", + authType: "apikey", + authModes: [ + "apikey", + ], + transport: { + baseUrl: "https://api.llm7.io/v1/chat/completions", + validateUrl: "https://api.llm7.io/v1/models", + }, + models: [ + { id: "gpt-5.5", name: "GPT-5.5 (LLM7)", contextLength: 1050000 }, + { id: "claude-opus-5", name: "Claude Opus 5 (LLM7)", contextLength: 1000000 }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash (LLM7)", contextLength: 1000000 }, + { id: "grok-4.5", name: "Grok 4.5 (LLM7)", contextLength: 500000 }, + { id: "kimi-k3", name: "Kimi K3 (LLM7)", contextLength: 1000000 }, + ], + passthroughModels: true, +}; diff --git a/open-sse/providers/registry/mimo-free.js b/open-sse/providers/registry/mimo-free.js index 4b9074d1..84911120 100644 --- a/open-sse/providers/registry/mimo-free.js +++ b/open-sse/providers/registry/mimo-free.js @@ -1,5 +1,8 @@ +// Xiaomi ended the free MiMo channel ("MiMo free API service has ended"). +// Hidden until/unless a replacement (OAuth MiMo Platform) is wired. export default { id: "mimo-free", + hidden: true, priority: 50, hasFree: true, alias: "mmf", diff --git a/open-sse/providers/registry/morph.js b/open-sse/providers/registry/morph.js new file mode 100644 index 00000000..e88a3639 --- /dev/null +++ b/open-sse/providers/registry/morph.js @@ -0,0 +1,29 @@ +export default { + id: "morph", + alias: "morph", + aliases: ["morphllm"], + uiAlias: "morph", + display: { + name: "Morph", + icon: "change_history", + color: "#14B8A6", + textIcon: "MP", + website: "https://morphllm.com", + notice: { apiKeyUrl: "https://morphllm.com" }, + }, + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://api.morphllm.com/v1/chat/completions", + validateUrl: "https://api.morphllm.com/v1/models", + }, + models: [ + { id: "morph-v3-large", name: "Morph v3 Large" }, + { id: "morph-v3-fast", name: "Morph v3 Fast" }, + { id: "morph-qwen35-397b", name: "Qwen 3.5 397B (Morph)", contextLength: 262144 }, + { id: "morph-minimax27-230b", name: "MiniMax M2.7 (Morph)", contextLength: 200704 }, + { id: "morph-qwen36-27b", name: "Qwen 3.6 27B (Morph)", contextLength: 262144 }, + { id: "morph-dsv4flash", name: "DeepSeek V4 Flash (Morph)", contextLength: 1048576 }, + ], +}; diff --git a/open-sse/providers/registry/nvidia.js b/open-sse/providers/registry/nvidia.js index 9522611a..4d375a17 100644 --- a/open-sse/providers/registry/nvidia.js +++ b/open-sse/providers/registry/nvidia.js @@ -15,6 +15,8 @@ export default { }, }, category: "freeTier", + authType: "apikey", + authModes: ["apikey"], transport: { baseUrl: "https://integrate.api.nvidia.com/v1/chat/completions", validateUrl: "https://integrate.api.nvidia.com/v1/models", diff --git a/open-sse/providers/registry/openrouter.js b/open-sse/providers/registry/openrouter.js index 4ac03641..a0df2a52 100644 --- a/open-sse/providers/registry/openrouter.js +++ b/open-sse/providers/registry/openrouter.js @@ -15,6 +15,8 @@ export default { }, }, category: "freeTier", + authType: "apikey", + authModes: ["apikey"], transport: { baseUrl: "https://openrouter.ai/api/v1/chat/completions", thinkingFormat: "openai", diff --git a/open-sse/providers/registry/poolside.js b/open-sse/providers/registry/poolside.js new file mode 100644 index 00000000..02882ad9 --- /dev/null +++ b/open-sse/providers/registry/poolside.js @@ -0,0 +1,30 @@ +export default { + id: "poolside", + priority: 60, + alias: "poolside", + aliases: [ + "ps", + ], + uiAlias: "ps", + display: { + name: "Poolside", + icon: "water_drop", + color: "#0EA5E9", + textIcon: "PS", + website: "https://poolside.ai", + notice: { + apiKeyUrl: "https://platform.poolside.ai/api-keys", + }, + }, + category: "freeTier", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://inference.poolside.ai/v1/chat/completions", + validateUrl: "https://inference.poolside.ai/v1/models", + }, + models: [ + { id: "poolside/laguna-s-2.1", name: "Laguna S 2.1" }, + { id: "poolside/laguna-xs-2.1", name: "Laguna XS 2.1" }, + ], +}; diff --git a/open-sse/providers/registry/qoder.js b/open-sse/providers/registry/qoder.js index 4ee2b52f..2b6c93ed 100644 --- a/open-sse/providers/registry/qoder.js +++ b/open-sse/providers/registry/qoder.js @@ -11,10 +11,11 @@ export default { notice: { signupUrl: "https://qoder.com", }, - deprecated: true, - deprecationNotice: "RISK_NOTICE", }, - category: "free", + category: "oauth", + authModes: ["oauth", "apikey"], + hasOAuth: true, + authHint: "Personal Access Token (pt-...) từ https://qoder.com/account/integrations", transport: { baseUrl: "https://api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation", headers: {}, @@ -25,18 +26,19 @@ export default { }, }, models: [ - // { id: "auto", name: "Qoder Auto" }, - // { id: "ultimate", name: "Qoder Ultimate" }, - // { id: "performance", name: "Qoder Performance" }, - // { id: "efficient", name: "Qoder Efficient" }, - // { id: "lite", name: "Qoder Lite" }, - // { id: "qmodel", name: "Qwen 3.6 Plus (Qoder)" }, - { id: "qmodel_latest", name: "Qoder Qwen 3.7 Max" }, - // { id: "dmodel", name: "DeepSeek V4 Pro (Qoder)" }, - // { id: "dfmodel", name: "DeepSeek V4 Flash (Qoder)" }, - // { id: "gm51model", name: "GLM 5.1 (Qoder)" }, - // { id: "kmodel", name: "Kimi K2.6 (Qoder)" }, - // { id: "mmodel", name: "MiniMax M2.7 (Qoder)" }, + { id: "ultimate", name: "Ultimate" }, + { id: "auto", name: "Auto" }, + { id: "performance", name: "Performance" }, + { id: "efficient", name: "Efficient" }, + { id: "qmodel_preview", name: "Qwen3.8-Max-Preview" }, + { id: "qmodel_latest", name: "Qwen3.7-Max" }, + { id: "qmodel", name: "Qwen3.7-Plus" }, + { id: "kmodel_latest", name: "Kimi-K3" }, + { id: "kmodel", name: "Kimi-K2.7-Code" }, + { id: "gm51model", name: "GLM-5.2" }, + { id: "dmodel", name: "DeepSeek-V4-Pro" }, + { id: "dfmodel", name: "DeepSeek-V4-Flash" }, + { id: "mmodel", name: "MiniMax-M3" }, ], oauth: { openApiBaseUrl: "https://openapi.qoder.sh", diff --git a/open-sse/providers/registry/sambanova.js b/open-sse/providers/registry/sambanova.js new file mode 100644 index 00000000..9c7e7174 --- /dev/null +++ b/open-sse/providers/registry/sambanova.js @@ -0,0 +1,27 @@ +export default { + id: "sambanova", + alias: "samba", + aliases: ["sambanova-ai"], + uiAlias: "samba", + hidden: true, + display: { + name: "SambaNova", + icon: "memory", + color: "#F97316", + textIcon: "SN", + website: "https://sambanova.ai", + notice: { + apiKeyUrl: "https://cloud.sambanova.ai/apis", + }, + }, + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://api.sambanova.ai/v1/chat/completions", + validateUrl: "https://api.sambanova.ai/v1/models", + }, + models: [ + { id: "MiniMax-M2.7", name: "MiniMax M2.7", contextLength: 196608 }, + ], +}; diff --git a/open-sse/providers/registry/tencent.js b/open-sse/providers/registry/tencent.js new file mode 100644 index 00000000..45876b67 --- /dev/null +++ b/open-sse/providers/registry/tencent.js @@ -0,0 +1,27 @@ +export default { + id: "tencent", + alias: "hunyuan", + aliases: ["hunyuan", "tencent-hunyuan"], + uiAlias: "hunyuan", + display: { + name: "Tencent Hunyuan", + icon: "cloud", + color: "#0052D9", + textIcon: "HY", + website: "https://cloud.tencent.com/product/hunyuan", + notice: { + apiKeyUrl: "https://console.cloud.tencent.com/hunyuan/api-key", + }, + }, + category: "apikey", + authType: "apikey", + authModes: ["apikey"], + transport: { + baseUrl: "https://api.hunyuan.cloud.tencent.com/v1/chat/completions", + validateUrl: "https://api.hunyuan.cloud.tencent.com/v1/models", + }, + models: [ + { id: "hunyuan-turbos-latest", name: "Hunyuan TurboS Latest", contextLength: 200000 }, + { id: "hunyuan-t1-latest", name: "Hunyuan T1 Latest", contextLength: 256000 }, + ], +}; diff --git a/open-sse/providers/registry/trae.js b/open-sse/providers/registry/trae.js new file mode 100644 index 00000000..2b4ace60 --- /dev/null +++ b/open-sse/providers/registry/trae.js @@ -0,0 +1,76 @@ +// Trae (ByteDance marscode) provider registry entry. +// Chat = SOLO remote agent API: +// POST {base}/chat_sessions → {data:{chat_session_id, message_id}} +// GET {base}/chat_sessions/{id}/events?reply_to_message_id=... → SSE +// Auth: Authorization: Cloud-IDE-JWT +export default { + id: "trae", + alias: "tr", + uiAlias: "tr", + aliases: ["marscode"], + category: "oauth", + authType: "oauth", + hasOAuth: true, + authModes: ["oauth"], + display: { + name: "Trae", + icon: "bolt", + color: "#FF6A00", + textIcon: "TR", + website: "https://www.trae.ai", + notice: { signupUrl: "https://www.trae.ai" }, + }, + transport: { + // SOLO remote agent base — verified working chat endpoint. + baseUrl: "https://core-normal.trae.ai/api/remote/v1", + format: "openai", + headers: { + "X-Trae-Client-Type": "web", + "X-Preferenced-Language": "en", + "Referer": "https://solo.trae.ai/", + }, + // Auth: Cloud-IDE-JWT scheme on Authorization — injected by executor buildHeaders. + auth: { + combined: true, + header: "Authorization", + scheme: "Cloud-IDE-JWT", + }, + usage: { + url: "https://api.marscode.com/cloudide/api/v3/trae/GetUserInfo", + }, + regions: { + cn: "https://api.marscode.com", + sg: "https://api.trae.ai", + us: "https://www.trae.ai", + }, + defaultRegion: "cn", + }, + oauth: { + clientId: "ono9krqynydwx5", + clientSecret: "-", + platform: "trae", + pollInterval: 1500, + // Login guidance returns LoginHost for browser open. + loginGuidanceUrl: "https://api.marscode.com/cloudide/api/v3/trae/GetLoginGuidance", + // ExchangeToken: refresh -> access (POST JSON, body below). + tokenUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken", + exchangeTokenUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken", + refreshUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken", + userInfoUrl: "https://api.marscode.com/cloudide/api/v3/trae/GetUserInfo", + // Trae refresh uses custom JSON body, not OAuth form — handled by refresh.js, not config-driven. + refresh: { encoding: "json" }, + }, + // Model catalog (IDE flow, core-normal.trae.ai). + models: [ + { id: "auto", name: "Auto (Server Picks)" }, + { id: "work", name: "Work (Fast)" }, + { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, + { id: "gemini-3-flash-solo", name: "Gemini 3 Flash" }, + { id: "minimax-m3", name: "MiniMax M3" }, + { id: "minimax-m2.7", name: "MiniMax M2.7" }, + { id: "kimi-k2.5", name: "Kimi K2.5" }, + { id: "gpt-5.4", name: "GPT 5.4" }, + { id: "gpt-5.2", name: "GPT 5.2" }, + ], + features: { usage: true }, +}; diff --git a/open-sse/providers/registry/windsurf.js b/open-sse/providers/registry/windsurf.js new file mode 100644 index 00000000..f0b23fa8 --- /dev/null +++ b/open-sse/providers/registry/windsurf.js @@ -0,0 +1,143 @@ +// Windsurf provider registry — Firebase+Codeium+Devin auth chain. +// Chat = Codeium gRPC-web protobuf: +// POST {base} Content-Type: application/grpc-web+proto +// Service: exa.language_server_pb.LanguageServerService / GetChatMessage +export default { + id: "windsurf", + alias: "ws", + uiAlias: "ws", + display: { + name: "Windsurf", + icon: "surfing", + color: "#14B8A6", + website: "https://windsurf.com", + notice: { signupUrl: "https://windsurf.com" }, + }, + category: "oauth", + authType: "oauth", + hasOAuth: true, + authModes: ["oauth", "apikey"], + + transport: { + baseUrl: "https://server.codeium.com/exa.language_server_pb.LanguageServerService/GetChatMessage", + format: "openai", + headers: { + "Content-Type": "application/grpc-web+proto", + "Accept": "application/grpc-web+proto", + "X-Grpc-Web": "1", + }, + // apiKey (sk-ws-... or Firebase-derived) as Bearer + in protobuf Metadata.api_key. + auth: { combined: true, header: "Authorization", scheme: "Bearer" }, + }, + + // Auth chain (4 terminal paths, all yield apiKey): + // 1) OAuth web → Firebase JWT → POST register.windsurf.com/.../RegisterUser {firebase_id_token} → {apiKey, apiServerUrl, name} + // 2) sk-ws-... direct API key (apiKey used as metadata.apiKey on GetUserStatus) + // 3) Firebase JWT (eyJ...) → same RegisterUser exchange as #1 + // 4) Devin auth1_... → self-serve chain → ide_token used as apiKey on server.self-serve.windsurf.com + oauth: { + clientId: "3GUryQ7ldAeKEuD2obYnppsnmj58eP5u", + firebaseApiKey: "AIzaSyDsOl-1XpT5err0Tcn0TFFod1H8gVGIycY", + firebaseSignInUrl: "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword", + registerUrl: "https://register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser", + apiServerUrl: "https://server.codeium.com", + auth1ApiServerUrl: "https://server.self-serve.windsurf.com", + platform: "windsurf", + // Quota (Connect RPC, protobuf): POST windsurf.com/_backend/.../GetPlanStatus, + // headers Content-Type:application/proto + Connect-Protocol-Version:1 + X-Auth-Token:, + // body = field1:session_token, field2:varint 1. + quotaUrl: "https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/GetPlanStatus", + }, + + // Catalog verified against model_configs_v2.bin from Devin CLI (2026.5.x). + // Dot-notation ids; the executor MODEL_ALIAS_MAP maps these to Windsurf modelUid. + // contextLength dropped — 9router schema uses id+name only. + models: [ + // Cognition / SWE + { id: "swe-1.6-fast", name: "SWE-1.6 Fast" }, + { id: "swe-1.6", name: "SWE-1.6" }, + { id: "swe-1.5-fast", name: "SWE-1.5 Fast" }, + { id: "swe-1.5", name: "SWE-1.5" }, + // Claude Opus 4.7 — effort-tiered + { id: "claude-opus-4.7-max", name: "Claude Opus 4.7 Max" }, + { id: "claude-opus-4.7-xhigh", name: "Claude Opus 4.7 XHigh" }, + { id: "claude-opus-4.7-high", name: "Claude Opus 4.7 High" }, + { id: "claude-opus-4.7-medium", name: "Claude Opus 4.7 Medium" }, + { id: "claude-opus-4.7-low", name: "Claude Opus 4.7 Low" }, + { id: "claude-opus-4.7-review", name: "Claude Opus 4.7 Review" }, + // Claude Sonnet/Opus 4.6 + { id: "claude-sonnet-4.6-thinking-1m", name: "Claude Sonnet 4.6 Thinking 1M" }, + { id: "claude-sonnet-4.6-1m", name: "Claude Sonnet 4.6 1M" }, + { id: "claude-sonnet-4.6-thinking", name: "Claude Sonnet 4.6 Thinking" }, + { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, + { id: "claude-opus-4.6-thinking", name: "Claude Opus 4.6 Thinking" }, + { id: "claude-opus-4.6", name: "Claude Opus 4.6" }, + // Claude 4.5 + { id: "claude-opus-4.5-thinking", name: "Claude Opus 4.5 Thinking" }, + { id: "claude-opus-4.5", name: "Claude Opus 4.5" }, + { id: "claude-sonnet-4.5-thinking", name: "Claude Sonnet 4.5 Thinking" }, + { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, + { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, + // GPT-5.5 — effort-tiered + { id: "gpt-5.5-xhigh-fast", name: "GPT-5.5 XHigh Fast" }, + { id: "gpt-5.5-xhigh", name: "GPT-5.5 XHigh" }, + { id: "gpt-5.5-high-fast", name: "GPT-5.5 High Fast" }, + { id: "gpt-5.5-high", name: "GPT-5.5 High" }, + { id: "gpt-5.5-medium-fast", name: "GPT-5.5 Medium Fast" }, + { id: "gpt-5.5-medium", name: "GPT-5.5 Medium" }, + { id: "gpt-5.5-low-fast", name: "GPT-5.5 Low Fast" }, + { id: "gpt-5.5-low", name: "GPT-5.5 Low" }, + { id: "gpt-5.5-none-fast", name: "GPT-5.5 None Fast" }, + { id: "gpt-5.5-none", name: "GPT-5.5 None" }, + // GPT-5.4 — effort-tiered + { id: "gpt-5.4-xhigh-fast", name: "GPT-5.4 XHigh Fast" }, + { id: "gpt-5.4-xhigh", name: "GPT-5.4 XHigh" }, + { id: "gpt-5.4-high-fast", name: "GPT-5.4 High Fast" }, + { id: "gpt-5.4-high", name: "GPT-5.4 High" }, + { id: "gpt-5.4-medium-fast", name: "GPT-5.4 Medium Fast" }, + { id: "gpt-5.4-medium", name: "GPT-5.4 Medium" }, + { id: "gpt-5.4-low-fast", name: "GPT-5.4 Low Fast" }, + { id: "gpt-5.4-low", name: "GPT-5.4 Low" }, + { id: "gpt-5.4-none-fast", name: "GPT-5.4 None Fast" }, + { id: "gpt-5.4-none", name: "GPT-5.4 None" }, + { id: "gpt-5.4-mini-xhigh", name: "GPT-5.4 Mini XHigh" }, + { id: "gpt-5.4-mini-high", name: "GPT-5.4 Mini High" }, + { id: "gpt-5.4-mini-medium", name: "GPT-5.4 Mini Medium" }, + { id: "gpt-5.4-mini-low", name: "GPT-5.4 Mini Low" }, + // GPT-5.3 Codex + { id: "gpt-5.3-codex-xhigh-fast", name: "GPT-5.3 Codex XHigh Fast" }, + { id: "gpt-5.3-codex-xhigh", name: "GPT-5.3 Codex XHigh" }, + { id: "gpt-5.3-codex-high-fast", name: "GPT-5.3 Codex High Fast" }, + { id: "gpt-5.3-codex-high", name: "GPT-5.3 Codex High" }, + { id: "gpt-5.3-codex-medium-fast", name: "GPT-5.3 Codex Medium Fast" }, + { id: "gpt-5.3-codex-medium", name: "GPT-5.3 Codex Medium" }, + { id: "gpt-5.3-codex-low-fast", name: "GPT-5.3 Codex Low Fast" }, + { id: "gpt-5.3-codex-low", name: "GPT-5.3 Codex Low" }, + // GPT-5.2 / 5 + { id: "gpt-5.2-xhigh", name: "GPT-5.2 XHigh" }, + { id: "gpt-5.2-high", name: "GPT-5.2 High" }, + { id: "gpt-5.2-medium", name: "GPT-5.2 Medium" }, + { id: "gpt-5.2-low", name: "GPT-5.2 Low" }, + { id: "gpt-5.2-none", name: "GPT-5.2 None" }, + { id: "gpt-5", name: "GPT-5" }, + // GPT-4.1 / 4o + { id: "gpt-4.1", name: "GPT-4.1" }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini" }, + { id: "gpt-4.1-nano", name: "GPT-4.1 Nano" }, + { id: "gpt-4o", name: "GPT-4o" }, + { id: "gpt-4o-mini", name: "GPT-4o Mini" }, + // Gemini + { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High" }, + { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low" }, + { id: "gemini-3.0-flash-high", name: "Gemini 3 Flash High" }, + { id: "gemini-3.0-flash-medium", name: "Gemini 3 Flash Medium" }, + { id: "gemini-3.0-flash-low", name: "Gemini 3 Flash Low" }, + { id: "gemini-3.0-flash-minimal", name: "Gemini 3 Flash Minimal" }, + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" }, + // Others + { id: "deepseek-v4", name: "DeepSeek V4" }, + { id: "kimi-k2.6", name: "Kimi K2.6" }, + { id: "kimi-k2.5", name: "Kimi K2.5" }, + { id: "glm-5.1", name: "GLM-5.1" }, + ], +}; diff --git a/open-sse/providers/registry/zed.js b/open-sse/providers/registry/zed.js new file mode 100644 index 00000000..9224cf95 --- /dev/null +++ b/open-sse/providers/registry/zed.js @@ -0,0 +1,71 @@ +// Zed provider — RSA keypair callback auth (NOT standard OAuth). +export default { + id: "zed", + priority: 10, + alias: "zd", + uiAlias: "zd", + hidden: true, + display: { + name: "Zed", + icon: "code", + color: "#A855F7", + website: "https://zed.dev", + notice: { + signupUrl: "https://zed.dev/native_app_signin", + }, + }, + category: "oauth", + authType: "oauth", + hasOAuth: true, + + transport: { + // Zed hosted LLM aggregator: cloud.zed.dev/completions is a + // multi-format proxy fronting Anthropic/OpenAI/Google/xAI depending on the model. + // Wire protocol = NDJSON/SSE-ish stream authenticated with a short-lived LLM bearer + // token exchanged from the RSA-decrypted access_token (see open-sse/shared/zedAuth). + baseUrl: "https://cloud.zed.dev/completions", + format: "openai", + forceStream: true, + headers: { + "content-type": "application/json", + }, + // Auth scheme is non-standard: "Authorization: " plus a duplicate + // x-zed-cloud-token header (verified in zed_account.rs build_authorization_header + + // cloud fetch). Executor builds both; scheme here is a marker for config-driven tooling. + auth: { + combined: true, + header: "Authorization", + scheme: " ", // placeholder — real value built in executor + }, + usage: { + url: "https://cloud.zed.dev/client/users/me", // verified in zed_account.rs + }, + // Live catalog discovery — Zed's hosted model list changes frequently and is fetched + // per-connection rather than hardcoded. + modelsUrl: "https://cloud.zed.dev/models", + }, + + // Empty static catalog + passthrough: Zed fronts a rotating set of upstream models + // (Claude/GPT/Gemini/Grok). Resolved live via modelsUrl; any client-sent model id is + // forwarded as-is rather than validated against a frozen list. + models: [], + passthroughModels: true, + + oauth: { + // Zed auth flow is RSA-based, NOT OAuth2/PKCE: + // 1. App generates RSA-2048 keypair locally (PKCS#1 DER, URL-safe base64). + // 2. Bind random TCP port on 127.0.0.1. + // 3. Open https://zed.dev/native_app_signin?native_app_port={port}&native_app_public_key={pub}. + // 4. After login, browser redirects http://127.0.0.1:{port}/?user_id=...&access_token=... + // where access_token = base64(RSA-encrypted plaintext token). + // 5. Decrypt with private key (OAEP-SHA256, fallback PKCS1v15). Store user_id + plaintext token. + // No clientId/clientSecret/tokenUrl/refreshUrl — long-lived access_token, no refresh. + authorizeUrl: "https://zed.dev/native_app_signin", + platform: "zed", + rsaKeyExchange: true, // new flag: signals frontend/router this flow needs local RSA + TCP listener. + }, + + features: { + usage: true, + }, +}; diff --git a/open-sse/providers/shared.js b/open-sse/providers/shared.js index 6f6a2c20..85b256a1 100644 --- a/open-sse/providers/shared.js +++ b/open-sse/providers/shared.js @@ -58,7 +58,7 @@ export const ANTHROPIC_COMPAT_BASE = "https://api.anthropic.com/v1"; // Keep this static even when 9router runs on Linux: the provider profile is // intentionally matching the IDE client, not the server host. export const ANTIGRAVITY_IDE_VERSION = "2.1.1"; -export const ANTIGRAVITY_IDE_BASE_URL = "https://cloudcode-pa.googleapis.com"; +export const ANTIGRAVITY_IDE_BASE_URL = "https://daily-cloudcode-pa.googleapis.com"; export const ANTIGRAVITY_IDE_USER_AGENT = `antigravity/ide/${ANTIGRAVITY_IDE_VERSION} darwin/arm64`; // Antigravity OAuth client credentials (public CLI client — duplicated in usage.js + src/lib/oauth) diff --git a/open-sse/providers/thinkingLevels.js b/open-sse/providers/thinkingLevels.js index ba998ee7..7b638700 100644 --- a/open-sse/providers/thinkingLevels.js +++ b/open-sse/providers/thinkingLevels.js @@ -2,6 +2,7 @@ // Reuses capabilities.js (thinkingFormat/canDisable) so this file only maps format→levels (DRY). import { getCapabilitiesForModel } from "./capabilities.js"; import { matchPattern } from "./pricing.js"; +import { resolveKiroEffortPath } from "../config/kiroConstants.js"; // Shared level sets (deduped) — verified against provider docs + wire in thinkingUnified.applyFormat. const L = { @@ -39,6 +40,7 @@ const PATTERN_THINKING = [ // Returns valid thinking levels for a model, or null when the model has no reasoning. export function getThinkingLevels(provider, model) { + if (provider === "kiro" && resolveKiroEffortPath(model) === null) return null; const caps = getCapabilitiesForModel(provider, model); if (!caps.reasoning) return null; const hit = PATTERN_THINKING.find((p) => matchPattern(p.pattern, model)); diff --git a/open-sse/services/projectId.js b/open-sse/services/projectId.js index f9a24e1a..3801ac69 100644 --- a/open-sse/services/projectId.js +++ b/open-sse/services/projectId.js @@ -83,7 +83,7 @@ startCacheCleanup(); * @param {string} accessToken - Valid OAuth access token * @returns {Promise} Real project ID or null */ -export async function getProjectIdForConnection(connectionId, accessToken) { +export async function getProjectIdForConnection(connectionId, accessToken, provider = "gemini-cli") { if (!connectionId || !accessToken) return null; // Return cached value if still fresh @@ -102,7 +102,7 @@ export async function getProjectIdForConnection(connectionId, accessToken) { const promise = (async () => { try { - const projectId = await fetchProjectId(accessToken, controller.signal); + const projectId = await fetchProjectId(accessToken, controller.signal, provider); if (projectId) { projectIdCache.set(connectionId, {projectId, fetchedAt: Date.now()}); return projectId; @@ -155,8 +155,9 @@ export function removeConnection(connectionId) { * @param {AbortSignal} signal * @returns {Promise} */ -async function fetchProjectId(accessToken, signal) { - const response = await fetch(CLOUD_CODE_API.loadCodeAssist, { +async function fetchProjectId(accessToken, signal, provider) { + const endpoints = CLOUD_CODE_API[provider] || CLOUD_CODE_API["gemini-cli"]; + const response = await fetch(endpoints.loadCodeAssist, { method: "POST", headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` }, body: JSON.stringify({ metadata: LOAD_CODE_ASSIST_METADATA }), @@ -185,7 +186,7 @@ async function fetchProjectId(accessToken, signal) { } } - return onboardUser(accessToken, tierID, signal); + return onboardUser(accessToken, tierID, signal, endpoints); } /** @@ -196,7 +197,7 @@ async function fetchProjectId(accessToken, signal) { * @param {AbortSignal} externalSignal – propagated from the connection's AbortController * @returns {Promise} */ -async function onboardUser(accessToken, tierID, externalSignal) { +async function onboardUser(accessToken, tierID, externalSignal, endpoints) { console.log(`[ProjectId] Onboarding user with tier: ${tierID}`); const reqBody = { tierId: tierID, metadata: LOAD_CODE_ASSIST_METADATA }; @@ -213,7 +214,7 @@ async function onboardUser(accessToken, tierID, externalSignal) { externalSignal?.addEventListener("abort", forwardAbort); try { - const response = await fetch(CLOUD_CODE_API.onboardUser, { + const response = await fetch(endpoints.onboardUser, { method: "POST", headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` }, body: JSON.stringify(reqBody), diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index 55f4582e..634fd633 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -13,6 +13,10 @@ import { refreshGitHubToken, refreshCopilotToken, refreshCodebuddyToken, + refreshCodebuddyIntlToken, + refreshTraeToken, + refreshZedToken, + refreshWindsurfToken, classifyOAuthRefreshError, } from "./tokenRefresh/providers.js"; @@ -29,6 +33,10 @@ export { refreshGitHubToken, refreshCopilotToken, refreshCodebuddyToken, + refreshCodebuddyIntlToken, + refreshTraeToken, + refreshZedToken, + refreshWindsurfToken, classifyOAuthRefreshError, }; @@ -138,6 +146,10 @@ const REFRESH_HANDLERS = { "grok-cli": (c, log) => refreshXaiToken(c.refreshToken, log), gcli: (c, log) => refreshXaiToken(c.refreshToken, log), "codebuddy-cn": (c, log) => refreshCodebuddyToken(c.refreshToken, log), + "codebuddy-intl": (c, log) => refreshCodebuddyIntlToken(c.refreshToken, log), + trae: (c, log) => refreshTraeToken(c.refreshToken, c, log), + zed: () => refreshZedToken(), + windsurf: (c, log) => refreshWindsurfToken(c, log), // Kimi Code OAuth (merged into id `kimi`); legacy id still routes here kimi: (c, log) => refreshKimiToken(c.refreshToken, c, log), "kimi-coding": (c, log) => refreshKimiToken(c.refreshToken, c, log), diff --git a/open-sse/services/tokenRefresh/providers.js b/open-sse/services/tokenRefresh/providers.js index 3fc8c4b2..7c13ae77 100644 --- a/open-sse/services/tokenRefresh/providers.js +++ b/open-sse/services/tokenRefresh/providers.js @@ -31,10 +31,68 @@ export async function refreshXaiToken(refreshToken, log) { }, log); } +// Per-provider refresh variants for the generic path. Keys not listed fall back +// to the default form-encoded OAuth2 refresh with client_id + client_secret. +const REFRESH_PROFILES = { + claude: { + bodyFormat: "json", + includeClientSecret: false, + url: () => OAUTH_ENDPOINTS.anthropic.token, + dedupKey: "claude", + }, + qwen: { + url: () => OAUTH_ENDPOINTS.qwen.token, + dedupKey: "qwen", + parse: (tokens) => tokens.resource_url ? { providerSpecificData: { resourceUrl: tokens.resource_url } } : {}, + }, + iflow: { + url: () => OAUTH_ENDPOINTS.iflow.token, + dedupKey: "iflow", + extraHeaders: (creds, cfg) => ({ + Authorization: `Basic ${btoa(`${cfg.clientId}:${cfg.clientSecret}`)}`, + }), + }, + github: { + url: () => OAUTH_ENDPOINTS.github.token, + dedupKey: "github", + includeClientSecret: (cfg) => !!cfg?.clientSecret, + }, + kimi: { + dedupKey: "kimi", + extraHeaders: (creds) => buildKimiHeaders(creds?.providerSpecificData?.deviceId), + }, +}; + +function resolveRefreshUrl(provider, config, profile) { + if (profile?.url) { + try { return profile.url(); } catch { /* fall through */ } + } + return config?.refreshUrl || PROVIDER_OAUTH[provider]?.tokenUrl || null; +} + +function buildRefreshBody(profile, config, refreshToken) { + const fmt = profile?.bodyFormat === "json" ? "json" : "form"; + const includeSecret = profile?.includeClientSecret === undefined + ? true + : typeof profile.includeClientSecret === "function" + ? profile.includeClientSecret(config) + : profile.includeClientSecret; + const payload = { + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: config.clientId, + }; + if (includeSecret && config.clientSecret) payload.client_secret = config.clientSecret; + if (fmt === "json") return { format: "json", body: JSON.stringify(payload) }; + return { format: "form", body: new URLSearchParams(payload) }; +} + export async function refreshAccessToken(provider, refreshToken, credentials, log) { const config = PROVIDERS[provider]; + const profile = REFRESH_PROFILES[provider] || {}; + const url = resolveRefreshUrl(provider, config, profile); - if (!config || !config.refreshUrl) { + if (!config || !url) { log?.warn?.("TOKEN_REFRESH", `No refresh URL configured for provider: ${provider}`); return null; } @@ -44,21 +102,17 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo return null; } - return dedupRefresh(provider, refreshToken, async () => { + const dedupKey = profile.dedupKey || provider; + + return dedupRefresh(dedupKey, refreshToken, async () => { try { - const response = await fetch(config.refreshUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: config.clientId, - client_secret: config.clientSecret, - }), - }); + const { format: bodyFormat, body } = buildRefreshBody(profile, config, refreshToken); + const headers = { + "Content-Type": bodyFormat === "json" ? "application/json" : "application/x-www-form-urlencoded", + Accept: "application/json", + ...(profile.extraHeaders ? (profile.extraHeaders(credentials, config) || {}) : {}), + }; + const response = await fetch(url, { method: "POST", headers, body }); if (!response.ok) { const errorText = await response.text(); @@ -81,6 +135,7 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in, + ...(profile.parse ? (profile.parse(tokens) || {}) : {}), }; } catch (error) { log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, { @@ -92,82 +147,14 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo } // 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) { - const config = PROVIDERS.kimi; - if (!config?.refreshUrl || !config?.clientId) { - log?.warn?.("TOKEN_REFRESH", "No Kimi refresh URL/clientId configured"); - return null; - } - if (!refreshToken) return null; - - return dedupRefresh("kimi", refreshToken, async () => { - try { - const headers = { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - ...buildKimiHeaders(credentials?.providerSpecificData?.deviceId), - }; - const response = await fetch(config.refreshUrl, { - method: "POST", - headers, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: config.clientId, - }), - }); - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", `Failed to refresh token for kimi`, { - status: response.status, - error: errorText, - }); - return null; - } - const tokens = await response.json(); - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Error refreshing token for kimi`, { error: error.message }); - return null; - } - }, log); + return refreshAccessToken("kimi", refreshToken, credentials, log); } +// Claude OAuth: JSON body, client_id only. Delegate to refreshAccessToken("claude", ...). export async function refreshClaudeOAuthToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("claude", refreshToken, async () => { - try { - const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.claude.clientId, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", { status: response.status, error: errorText }); - return null; - } - - const tokens = await response.json(); - log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in }); - return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`); - return null; - } - }, log); + return refreshAccessToken("claude", refreshToken, {}, log); } export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) { @@ -204,58 +191,9 @@ export async function refreshGoogleToken(refreshToken, clientId, clientSecret, l }, log); } +// Qwen: form body + clientId, surfaces resource_url. Delegate to refreshAccessToken("qwen", ...). export async function refreshQwenToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("qwen", refreshToken, async () => { - const endpoint = OAUTH_ENDPOINTS.qwen.token; - - try { - const response = await fetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.qwen.clientId, - }), - }); - - if (response.status === 200) { - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qwen token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - providerSpecificData: tokens.resource_url - ? { resourceUrl: tokens.resource_url } - : undefined, - }; - } else { - const errorText = await response.text().catch(() => ""); - log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, { - status: response.status, - error: errorText, - }); - } - } catch (error) { - log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, { - error: error.message, - }); - } - - log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token"); - return null; - }, log); + return refreshAccessToken("qwen", refreshToken, {}, log); } export function classifyOAuthRefreshError(errorText = "", status = 0) { @@ -480,95 +418,14 @@ export async function refreshKiroToken(refreshToken, providerSpecificData, log, }, log); } +// iFlow: Basic Auth + client_id+client_secret in body. Delegate to refreshAccessToken("iflow", ...). export async function refreshIflowToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("iflow", refreshToken, async () => { - const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`); - - const response = await fetch(OAUTH_ENDPOINTS.iflow.token, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - Authorization: `Basic ${basicAuth}`, - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.iflow.clientId, - client_secret: PROVIDERS.iflow.clientSecret, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh iFlow token", { - status: response.status, - error: errorText, - }); - return null; - } - - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed iFlow token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - }; - }, log); + return refreshAccessToken("iflow", refreshToken, {}, log); } +// GitHub: optional client_secret. Delegate to refreshAccessToken("github", ...). export async function refreshGitHubToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("github", refreshToken, async () => { - const params = { - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.github.clientId, - }; - if (PROVIDERS.github.clientSecret) { - params.client_secret = PROVIDERS.github.clientSecret; - } - - const response = await fetch(OAUTH_ENDPOINTS.github.token, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams(params), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh GitHub token", { - status: response.status, - error: errorText, - }); - return null; - } - - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitHub token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - }; - }, log); + return refreshAccessToken("github", refreshToken, {}, log); } export async function refreshCopilotToken(githubAccessToken, log) { @@ -668,3 +525,146 @@ export async function refreshCodebuddyToken(refreshToken, log) { }; }, log); } + +export async function refreshCodebuddyIntlToken(refreshToken, log) { + if (!refreshToken) return null; + return dedupRefresh("codebuddy-intl", refreshToken, async () => { + const oauth = PROVIDER_OAUTH["codebuddy-intl"] || {}; + const response = await fetch(oauth.refreshUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": oauth.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "www.codebuddy.ai", + "X-Refresh-Token": refreshToken, + "X-Auth-Refresh-Source": "plugin", + "X-Product": "SaaS", + }, + body: "{}", + }); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh CodeBuddy intl token", { + status: response.status, + error: errorText, + }); + return null; + } + + const data = await response.json(); + if (data.code !== 0 || !data.data?.accessToken) { + log?.error?.("TOKEN_REFRESH", "CodeBuddy intl token refresh returned no token", { + code: data.code, + msg: data.msg, + }); + return null; + } + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed CodeBuddy intl token", { + hasNewAccessToken: !!data.data.accessToken, + hasNewRefreshToken: !!data.data.refreshToken, + expiresIn: data.data.expiresIn, + }); + + return { + accessToken: data.data.accessToken, + refreshToken: data.data.refreshToken || refreshToken, + expiresIn: data.data.expiresIn, + }; + }, log); +} + +// Trae refresh — POST ExchangeToken with JSON body {ClientID, RefreshToken, ClientSecret, UserID}. +// Response: {Result: {AccessToken, RefreshToken, TokenType, ExpiresAt}}. +export async function refreshTraeToken(refreshToken, credentials, log) { + if (!refreshToken) return null; + const oauth = PROVIDER_OAUTH.trae || {}; + const url = oauth.exchangeTokenUrl || oauth.tokenUrl; + if (!url) { + log?.warn?.("TOKEN_REFRESH", "No Trae exchangeTokenUrl configured"); + return null; + } + + return dedupRefresh("trae", refreshToken, async () => { + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": "Trae/1.0.0 antigravity-cockpit-tools", + }, + body: JSON.stringify({ + ClientID: oauth.clientId || "ono9krqynydwx5", + RefreshToken: refreshToken, + ClientSecret: oauth.clientSecret || "-", + UserID: "", + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh Trae token", { + status: response.status, + error: errorText, + }); + return null; + } + + const payload = await response.json(); + const result = payload?.Result || payload?.result || payload; + const accessToken = result?.AccessToken || result?.accessToken; + if (!accessToken) { + log?.error?.("TOKEN_REFRESH", "Trae refresh returned no AccessToken", { payload }); + return null; + } + + const newRefresh = result?.RefreshToken || result?.refreshToken || refreshToken; + const expiresAt = result?.ExpiresAt || result?.expiresAt; + let expiresIn; + if (typeof expiresAt === "number") { + expiresIn = Math.max(1, expiresAt - Math.floor(Date.now() / 1000)); + } else if (typeof expiresAt === "string") { + const ms = new Date(expiresAt).getTime() - Date.now(); + expiresIn = ms > 0 ? Math.floor(ms / 1000) : undefined; + } + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Trae token", { + hasNewAccessToken: !!accessToken, + hasNewRefreshToken: newRefresh !== refreshToken, + expiresIn, + }); + + return { + accessToken, + refreshToken: newRefresh, + expiresIn, + }; + } catch (error) { + log?.error?.("TOKEN_REFRESH", `Error refreshing Trae token: ${error.message}`); + return null; + } + }, log); +} + +// Zed access_token is long-lived; auth flow returns no refresh_token. +// No refresh possible — re-login required when token expires/revoked. +// Mirrors cursor/kilocode null-refresh pattern. +export function refreshZedToken() { + return null; +} + +// Windsurf apiKey is the long-lived terminal credential (no OAuth2 refresh_token +// grant yields a fresh apiKey). Refresh handled out-of-band by the caller. +// TODO(firebase): if short-lived Firebase JWT credentials must be refreshed, +// re-run RegisterUser with the refreshed Firebase JWT (separate code path). +export async function refreshWindsurfToken(credentials, log) { + log?.info?.( + "TOKEN_REFRESH", + "windsurf: apiKey is long-lived (no refresh_token flow) — skipping" + ); + return null; +} diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index fe51115f..1d37169e 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -13,6 +13,8 @@ import { getMiniMaxUsage } from "./usage/minimax.js"; import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.js"; import { getGrokCliUsage } from "./usage/grok-cli.js"; import { getOrbitUsage } from "./usage/orbit.js"; +import { getKimiUsage } from "./usage/kimi.js"; +import { getDeepseekUsage } from "./usage/deepseek.js"; import { getQwenUsage, getIflowUsage, @@ -47,6 +49,8 @@ const USAGE_HANDLERS = { "codebuddy-cn": (c) => getCodeBuddyCnUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions), "grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, 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) { diff --git a/open-sse/services/usage/deepseek.js b/open-sse/services/usage/deepseek.js new file mode 100644 index 00000000..cb70a40d --- /dev/null +++ b/open-sse/services/usage/deepseek.js @@ -0,0 +1,112 @@ +/** + * DeepSeek usage — GET https://api.deepseek.com/user/balance + * Auth: Bearer + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { toFiniteNumber } from "./shared.js"; + +const BALANCE_URL = "https://api.deepseek.com/user/balance"; + +function parseBalanceInfos(data) { + const list = Array.isArray(data?.balance_infos) ? data.balance_infos : []; + const results = []; + for (const item of list) { + if (!item || typeof item !== "object") continue; + const currency = + typeof item.currency === "string" ? item.currency.toUpperCase() : ""; + if (!currency) continue; + const totalBalance = toFiniteNumber( + item.total_balance ?? item.totalBalance, + 0, + ); + results.push({ + currency, + totalBalance, + grantedBalance: toFiniteNumber( + item.granted_balance ?? item.grantedBalance, + 0, + ), + toppedUpBalance: toFiniteNumber( + item.topped_up_balance ?? item.toppedUpBalance, + 0, + ), + }); + } + return results; +} + +/** + * @param {string|null|undefined} apiKey + * @param {object|null} proxyOptions + */ +export async function getDeepseekUsage(apiKey = null, proxyOptions = null) { + if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) { + return { message: "DeepSeek API key not available. Add a key to view usage." }; + } + + try { + const response = await proxyAwareFetch( + BALANCE_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey.trim()}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + proxyOptions, + ); + + if (response.status === 401 || response.status === 403) { + return { + plan: "DeepSeek", + message: "DeepSeek authentication failed. Check the API key.", + }; + } + + if (!response.ok) { + const errText = await response.text().catch(() => ""); + return { + plan: "DeepSeek", + message: `DeepSeek balance API error (${response.status})${errText ? `: ${errText.slice(0, 120)}` : ""}`, + }; + } + + const data = await response.json().catch(() => null); + if (!data || typeof data !== "object") { + return { message: "DeepSeek balance response was not JSON." }; + } + + const balances = parseBalanceInfos(data); + if (balances.length === 0) { + return { + plan: "DeepSeek", + message: "DeepSeek connected. No balance data returned.", + }; + } + + const isAvailable = data.is_available === true || data.isAvailable === true; + const quotas = {}; + for (const b of balances) { + const total = Math.max(0, b.totalBalance); + // Credit pot: show full remaining against current balance; never set absolute + // `remaining` — QuotaTable treats it as a 0–100 percentage. + quotas[`Balance (${b.currency})`] = { + used: 0, + total, + remainingPercentage: total > 0 ? 100 : 0, + resetAt: null, + unlimited: total > 0, + }; + } + + return { + plan: isAvailable ? "DeepSeek" : "DeepSeek (Insufficient Balance)", + quotas, + }; + } catch (error) { + return { message: `DeepSeek error: ${error.message}` }; + } +} diff --git a/open-sse/services/usage/grok-cli.js b/open-sse/services/usage/grok-cli.js index 865baeb0..768192ad 100644 --- a/open-sse/services/usage/grok-cli.js +++ b/open-sse/services/usage/grok-cli.js @@ -29,11 +29,19 @@ import { GROK_CLI_USER_AGENT, GROK_CLI_VERSION, } from "../../config/grokCli.js"; +import { decodeGrokCreditsFrame } from "./grokCliQuotaFrame.js"; const USAGE = U("grok-cli"); const BILLING_URL = USAGE.url || "https://cli-chat-proxy.grok.com/v1/billing?format=credits"; const USER_URL = USAGE.userUrl || "https://cli-chat-proxy.grok.com/v1/user?include=subscription"; +// SuperGrok weekly pool. +const GRPC_CREDITS_URL = + "https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig"; +// Empty gRPC-web request frame (flag 0 + length 0). Without it upstream returns +// grpc-status 13 "Missing request message." with a 0-byte body. +const GRPC_WEB_EMPTY_REQUEST_FRAME = Buffer.from([0, 0, 0, 0, 0]); + /** Unwrap protobuf-json `{ val: n }` or plain numbers/strings. */ function unwrapVal(value, fallback = 0) { if (value == null) return fallback; @@ -198,6 +206,21 @@ export function parseGrokCliBilling(billing, user = null) { }; } + // SuperGrok weekly shared-pool usage (subscription tier). creditUsagePercent is + // the single total used %; productUsage is a breakdown legend, NOT independent + // quotas — never split it into separate bars. + const usedPct = unwrapVal( + config.creditUsagePercent ?? config.credit_usage_percent ?? root.creditUsagePercent, + NaN, + ); + if (Number.isFinite(usedPct) && usedPct >= 0) { + quotas["Weekly SuperGrok"] = makeQuota({ + used: Math.max(0, Math.min(100, usedPct)), + total: 100, + resetAt: periodEnd, + }); + } + // Opportunistic richer credit envelopes (future / other account types) const creditBags = [ root.credits, @@ -256,6 +279,50 @@ export function parseGrokCliBilling(billing, user = null) { }; } +/** + * Live SuperGrok weekly pool via gRPC-web GetGrokCreditsConfig. + * Fail-open: any network/auth/parse failure returns null. + * @returns {{ percentUsed: number, resetAt: string|null } | null} + */ +export async function fetchGrokCliCreditsConfig(accessToken, proxyOptions = null) { + if (!accessToken) return null; + try { + const res = await proxyAwareFetch( + GRPC_CREDITS_URL, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/grpc-web+proto", + "X-Grpc-Web": "1", + Accept: "application/grpc-web+proto", + }, + body: GRPC_WEB_EMPTY_REQUEST_FRAME, + }, + proxyOptions, + ); + if (!res?.ok) return null; + const arrayBuffer = await res.arrayBuffer().catch(() => null); + if (!arrayBuffer) return null; + return decodeGrokCreditsFrame(Buffer.from(arrayBuffer)); + } catch { + return null; + } +} + +function quotasFromGrpcCredits(decoded) { + if (!decoded || !Number.isFinite(decoded.percentUsed)) return null; + // Round for bar display (fixed32 ratio * 100 can be 34.999… for 0.35) + const used = Math.round(Math.max(0, Math.min(100, decoded.percentUsed))); + return { + "Weekly SuperGrok": makeQuota({ + used, + total: 100, + resetAt: decoded.resetAt || null, + }), + }; +} + /** * @param {string} accessToken * @param {object|null} providerSpecificData @@ -306,6 +373,16 @@ export async function getGrokCliUsage(accessToken, providerSpecificData = null, const parsed = parseGrokCliBilling(billing, user); if (!parsed.quotas || Object.keys(parsed.quotas).length === 0) { + // Paid SuperGrok often returns cap=0 over REST but exposes the shared + // weekly pool on GetGrokCreditsConfig — try that before giving up. + const grpc = await fetchGrokCliCreditsConfig(accessToken, proxyOptions); + const grpcQuotas = quotasFromGrpcCredits(grpc); + if (grpcQuotas) { + return { + plan: parsed.plan, + quotas: grpcQuotas, + }; + } return { plan: parsed.plan, message: parsed.subscriptionAccess diff --git a/open-sse/services/usage/grokCliQuotaFrame.js b/open-sse/services/usage/grokCliQuotaFrame.js new file mode 100644 index 00000000..1578bec6 --- /dev/null +++ b/open-sse/services/usage/grokCliQuotaFrame.js @@ -0,0 +1,191 @@ +/** + * gRPC-web frame decoder for xAI GetGrokCreditsConfig + * (grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig). + * + * Real response shape (live capture 2026-07-20): + * top-level field 1 (length-delimited) — nested credits info + * subfield 1 (fixed32 float) — usage ratio 0..1 + * subfield 5 (Timestamp{seconds,nanos}) — credit-pool reset time + * + * Fail-open: any malformed buffer returns null, never throws. + */ + +const FIELD_CREDITS_INFO = 1; +const CREDITS_FIELD_USAGE_RATIO = 1; +const CREDITS_FIELD_RESET_TIMESTAMP = 5; +const TIMESTAMP_FIELD_SECONDS = 1; +const TIMESTAMP_FIELD_NANOS = 2; + +const WIRE_TYPE_VARINT = 0; +const WIRE_TYPE_FIXED64 = 1; +const WIRE_TYPE_LENGTH_DELIMITED = 2; +const WIRE_TYPE_FIXED32 = 5; + +const GRPC_WEB_TRAILER_FLAG_BIT = 0x80; +const MAX_VARINT_SHIFT_BITS = 70n; + +/** + * Validate a gRPC-web frame header at `offset`. + * @returns {{ flag: number, payloadStart: number, payloadLength: number } | null} + */ +export function probeFrameHeader(buffer, offset = 0) { + if (!Buffer.isBuffer(buffer) || offset < 0 || buffer.length - offset < 5) return null; + const flag = buffer[offset]; + if (flag !== 0x00 && flag !== 0x01 && flag !== 0x80 && flag !== 0x81) return null; + const payloadStart = offset + 5; + const payloadLength = buffer.readUInt32BE(offset + 1); + if (payloadLength > buffer.length - payloadStart) return null; + return { flag, payloadStart, payloadLength }; +} + +function readVarint(buffer, offset) { + let result = 0n; + let shift = 0n; + let pos = offset; + for (;;) { + if (pos >= buffer.length) return null; + const byte = buffer[pos]; + result |= BigInt(byte & 0x7f) << shift; + pos += 1; + if ((byte & 0x80) === 0) break; + shift += 7n; + if (shift > MAX_VARINT_SHIFT_BITS) return null; + } + return { value: Number(result), next: pos }; +} + +function readLengthDelimitedField(buffer, offset) { + const lengthResult = readVarint(buffer, offset); + if (!lengthResult) return null; + const { value: length, next: bodyStart } = lengthResult; + if (length < 0 || bodyStart + length > buffer.length) return null; + return { + field: { wireType: WIRE_TYPE_LENGTH_DELIMITED, bytes: buffer.subarray(bodyStart, bodyStart + length) }, + next: bodyStart + length, + }; +} + +function readFixedWidthField(buffer, offset, width, wireType) { + if (offset + width > buffer.length) return null; + return { + field: { wireType, bytes: buffer.subarray(offset, offset + width) }, + next: offset + width, + }; +} + +function readField(buffer, offset) { + const tagResult = readVarint(buffer, offset); + if (!tagResult) return null; + const fieldNumber = tagResult.value >>> 3; + const wireType = tagResult.value & 0x7; + if (fieldNumber === 0) return null; + + if (wireType === WIRE_TYPE_VARINT) { + const valueResult = readVarint(buffer, tagResult.next); + if (!valueResult) return null; + return { + fieldNumber, + field: { wireType: WIRE_TYPE_VARINT, value: valueResult.value }, + next: valueResult.next, + }; + } + if (wireType === WIRE_TYPE_LENGTH_DELIMITED) { + const result = readLengthDelimitedField(buffer, tagResult.next); + return result ? { fieldNumber, field: result.field, next: result.next } : null; + } + if (wireType === WIRE_TYPE_FIXED64) { + const result = readFixedWidthField(buffer, tagResult.next, 8, WIRE_TYPE_FIXED64); + return result ? { fieldNumber, field: result.field, next: result.next } : null; + } + if (wireType === WIRE_TYPE_FIXED32) { + const result = readFixedWidthField(buffer, tagResult.next, 4, WIRE_TYPE_FIXED32); + return result ? { fieldNumber, field: result.field, next: result.next } : null; + } + return null; +} + +function decodeFields(buffer) { + const fields = new Map(); + let offset = 0; + while (offset < buffer.length) { + const result = readField(buffer, offset); + if (!result) return null; + fields.set(result.fieldNumber, result.field); + offset = result.next; + } + return fields; +} + +function findDataFramePayload(buffer) { + let offset = 0; + while (offset < buffer.length) { + const frame = probeFrameHeader(buffer, offset); + if (!frame) return null; + const frameEnd = frame.payloadStart + frame.payloadLength; + const isTrailer = (frame.flag & GRPC_WEB_TRAILER_FLAG_BIT) !== 0; + if (!isTrailer) { + return buffer.subarray(frame.payloadStart, frameEnd); + } + offset = frameEnd; + } + return null; +} + +function extractNestedMessage(field) { + if (!field || field.wireType !== WIRE_TYPE_LENGTH_DELIMITED) return null; + return decodeFields(field.bytes); +} + +function extractUsageRatio(field) { + if (!field) return 0; // proto3 omission = 0% used + if (field.wireType === WIRE_TYPE_FIXED32) return field.bytes.readFloatLE(0); + if (field.wireType === WIRE_TYPE_FIXED64) return field.bytes.readDoubleLE(0); + return null; +} + +function extractResetAt(field) { + if (!field || field.wireType !== WIRE_TYPE_LENGTH_DELIMITED) return null; + + const timestampFields = decodeFields(field.bytes); + if (!timestampFields) return null; + + const secondsField = timestampFields.get(TIMESTAMP_FIELD_SECONDS); + const nanosField = timestampFields.get(TIMESTAMP_FIELD_NANOS); + const seconds = secondsField?.wireType === WIRE_TYPE_VARINT ? secondsField.value : 0; + const nanos = nanosField?.wireType === WIRE_TYPE_VARINT ? nanosField.value : 0; + + const millis = seconds * 1000 + Math.round(nanos / 1_000_000); + const parsed = new Date(millis); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +} + +/** + * Decode GetGrokCreditsConfig response → `{ percentUsed: 0-100, resetAt }` or null. + * @param {Buffer} buffer + * @returns {{ percentUsed: number, resetAt: string|null } | null} + */ +export function decodeGrokCreditsFrame(buffer) { + if (!buffer || !Buffer.isBuffer(buffer) || buffer.length === 0) return null; + + try { + const framed = probeFrameHeader(buffer, 0) !== null; + const payload = framed ? findDataFramePayload(buffer) : buffer; + if (!payload) return null; + + const topLevelFields = decodeFields(payload); + if (!topLevelFields) return null; + + const creditsInfo = extractNestedMessage(topLevelFields.get(FIELD_CREDITS_INFO)); + if (!creditsInfo) return null; + + const usageRatio = extractUsageRatio(creditsInfo.get(CREDITS_FIELD_USAGE_RATIO)); + if (usageRatio === null || !Number.isFinite(usageRatio) || usageRatio < 0) return null; + + return { + percentUsed: Math.min(100, usageRatio * 100), + resetAt: extractResetAt(creditsInfo.get(CREDITS_FIELD_RESET_TIMESTAMP)), + }; + } catch { + return null; + } +} diff --git a/open-sse/services/usage/kimi.js b/open-sse/services/usage/kimi.js new file mode 100644 index 00000000..4400965b --- /dev/null +++ b/open-sse/services/usage/kimi.js @@ -0,0 +1,211 @@ +/** + * Kimi Coding usage — GET /v1/usages + * + * Dual auth (single provider id `kimi`): + * - apiKey present → x-api-key only (platform / coding API key) + * - else accessToken → Bearer + X-Msh-* (device-code OAuth) + * + * Note: chat messages use combined x-api-key; /usages OAuth is Bearer. + * 403 permission_denied is NOT auth-expired — account lacks usage feature / sub. + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { parseResetTime, toFiniteNumber } from "./shared.js"; +import { buildKimiHeaders } from "../../config/appConstants.js"; + +const USAGE_URL = "https://api.kimi.com/coding/v1/usages"; + +const PLAN_LEVELS = { + LEVEL_BASIC: "Moderato", + LEVEL_INTERMEDIATE: "Allegretto", + LEVEL_ADVANCED: "Allegro", + LEVEL_STANDARD: "Vivace", +}; + +function getKimiPlanName(level) { + if (!level) return ""; + const key = String(level); + if (PLAN_LEVELS[key]) return PLAN_LEVELS[key]; + return key.replace(/^LEVEL_/, "").toLowerCase(); +} + +/** Best-effort extract human message from Kimi error JSON (403 body is Connect-RPC-ish). */ +export function formatKimiUsageError(status, responseText) { + let parsed = null; + try { + parsed = JSON.parse(responseText || ""); + } catch { + /* plain text */ + } + + const detail0 = Array.isArray(parsed?.details) ? parsed.details[0] : null; + const debug = detail0?.debug || parsed?.debug || null; + const reason = debug?.reason || parsed?.reason || ""; + const localized = + debug?.localizedMessage?.message || + detail0?.localizedMessage?.message || + parsed?.message || + ""; + + if (status === 401) { + return "Kimi authentication expired. Please re-authorize."; + } + + // Live OAuth token without Kimi Code usage entitlement returns 403 + // REASON_FEATURE_NO_PERMISSION — not an expired session. + if ( + status === 403 && + (reason === "REASON_FEATURE_NO_PERMISSION" || + /permission_denied|do not have permission|subscribe/i.test( + `${parsed?.code || ""} ${localized} ${responseText || ""}`, + )) + ) { + return ( + localized || + "Kimi connected, but this account has no permission to view usage. Subscribe to Kimi Code to access quota." + ); + } + + const snippet = (localized || responseText || "").slice(0, 100); + return snippet + ? `Kimi Coding connected. API Error ${status}: ${snippet}` + : `Kimi Coding connected. API Error ${status}`; +} + +function makeQuota({ used, total, remaining, resetAt }) { + const safeTotal = Math.max(0, toFiniteNumber(total, 0)); + const safeUsed = Math.max(0, toFiniteNumber(used, 0)); + // Prefer provider remaining when present; never set absolute `remaining` + // on the quota object — QuotaTable treats it as a 0–100 percentage. + let remainingPct; + if (safeTotal > 0 && remaining != null && Number.isFinite(Number(remaining))) { + remainingPct = (Math.max(0, Number(remaining)) / safeTotal) * 100; + } else if (safeTotal > 0) { + remainingPct = (Math.max(0, safeTotal - safeUsed) / safeTotal) * 100; + } else { + remainingPct = 0; + } + return { + used: safeUsed, + total: safeTotal, + remainingPercentage: remainingPct, + resetAt: resetAt || null, + unlimited: false, + }; +} + +/** + * @param {string|null|undefined} accessToken + * @param {string|null|undefined} apiKey + * @param {object|null} proxyOptions + * @param {object|null} providerSpecificData + */ +export async function getKimiUsage( + accessToken = null, + apiKey = null, + proxyOptions = null, + providerSpecificData = null, +) { + const useApiKey = typeof apiKey === "string" && apiKey.length > 0; + const useOAuth = !useApiKey && typeof accessToken === "string" && accessToken.length > 0; + + if (!useApiKey && !useOAuth) { + return { message: "Kimi access token or API key not available." }; + } + + const authHeaders = useApiKey + ? { "x-api-key": apiKey } + : { + Authorization: `Bearer ${accessToken}`, + ...buildKimiHeaders(providerSpecificData?.deviceId), + }; + + try { + const response = await proxyAwareFetch( + USAGE_URL, + { + method: "GET", + headers: { + ...authHeaders, + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + proxyOptions, + ); + + const responseText = await response.text().catch(() => ""); + + if (!response.ok) { + return { + plan: "Kimi Coding", + message: formatKimiUsageError(response.status, responseText), + }; + } + + let data; + try { + data = JSON.parse(responseText || "{}"); + } catch { + return { + plan: "Kimi Coding", + message: "Kimi Coding connected. Invalid JSON response from API.", + }; + } + + const quotas = {}; + const usageObj = data?.usage && typeof data.usage === "object" ? data.usage : {}; + const usageLimit = toFiniteNumber(usageObj.limit ?? usageObj.Limit, 0); + const usageUsed = toFiniteNumber(usageObj.used ?? usageObj.Used, 0); + const usageRemainingRaw = usageObj.remaining ?? usageObj.Remaining; + const usageRemaining = + usageRemainingRaw != null && usageRemainingRaw !== "" + ? toFiniteNumber(usageRemainingRaw, NaN) + : NaN; + const usageResetTime = + usageObj.resetTime || usageObj.ResetTime || usageObj.reset_at || usageObj.resetAt; + + if (usageLimit > 0) { + quotas.Weekly = makeQuota({ + used: usageUsed, + total: usageLimit, + remaining: Number.isFinite(usageRemaining) ? usageRemaining : null, + resetAt: parseResetTime(usageResetTime), + }); + } + + const limitsArray = Array.isArray(data?.limits) ? data.limits : []; + for (const item of limitsArray) { + if (!item || typeof item !== "object") continue; + const detail = item.detail && typeof item.detail === "object" ? item.detail : {}; + const limit = toFiniteNumber(detail.limit ?? detail.Limit, 0); + const remaining = toFiniteNumber(detail.remaining ?? detail.Remaining, NaN); + const resetTime = detail.resetTime || detail.reset_at || detail.resetAt; + if (limit > 0) { + const rem = Number.isFinite(remaining) ? remaining : Math.max(0, limit); + quotas.Ratelimit = makeQuota({ + used: Math.max(0, limit - rem), + total: limit, + remaining: rem, + resetAt: parseResetTime(resetTime), + }); + } + } + + const membershipLevel = data?.user?.membership?.level; + const planName = getKimiPlanName(membershipLevel) || "Kimi Coding"; + + if (Object.keys(quotas).length > 0) { + return { plan: planName, quotas }; + } + + return { + plan: planName, + message: "Kimi Coding connected. Usage tracked per request.", + }; + } catch (error) { + return { + message: `Kimi Coding connected. Unable to fetch usage: ${error.message}`, + }; + } +} diff --git a/open-sse/shared/qoder/constants.js b/open-sse/shared/qoder/constants.js index 1d9ce303..184c35d6 100644 --- a/open-sse/shared/qoder/constants.js +++ b/open-sse/shared/qoder/constants.js @@ -20,6 +20,11 @@ export const QODER_USERINFO_URL = `${QODER_OPENAPI_BASE}/api/v1/userinfo`; export const QODER_QUOTA_USAGE_URL = `${QODER_OPENAPI_BASE}/api/v2/quota/usage`; export const QODER_REFRESH_TOKEN_URL = `${QODER_CENTER_BASE}/algo/api/v3/user/refresh_token`; +// PAT (Personal Access Token, pt-...) → short-lived job token (jt-...) exchange. +// PATs cannot sign COSY requests directly — they must be exchanged first. +// This endpoint is NOT COSY-signed (plain JSON POST). +export const QODER_JOB_TOKEN_EXCHANGE_URL = `${QODER_OPENAPI_BASE}/api/v1/jobToken/exchange`; + // Inference endpoints (under /algo on api3.qoder.sh, all COSY-signed) export const QODER_CHAT_SIG_PATH = "/api/v2/service/pro/sse/agent_chat_generation"; export const QODER_CHAT_URL = `${QODER_CHAT_BASE}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common`; diff --git a/open-sse/shared/zedAuth.js b/open-sse/shared/zedAuth.js new file mode 100644 index 00000000..aa3337d7 --- /dev/null +++ b/open-sse/shared/zedAuth.js @@ -0,0 +1,415 @@ +// Zed hosted LLM aggregator — auth + model-catalog helpers. +// +// Zed's cloud (cloud.zed.dev) authenticates native apps with a self-generated RSA +// keypair instead of a registered OAuth client_id/secret: +// 1. Client generates an ephemeral RSA keypair. +// 2. Sends the public key to zed.dev/native_app_signin. +// 3. User signs in via browser; Zed redirects to a local callback with the +// access token RSA-encrypted against the public key. +// 4. Client decrypts locally with the private key that never left the host. +// No embedded client_id/secret — the credential is a per-login keypair. + +import crypto from "node:crypto"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; + +export const ZED_WEB_BASE_URL = "https://zed.dev"; +export const ZED_CLOUD_BASE_URL = "https://cloud.zed.dev"; +export const ZED_LLM_BASE_URL = "https://cloud.zed.dev"; + +export const ZED_HEADERS = { + expiredToken: "x-zed-expired-token", + outdatedToken: "x-zed-outdated-token", + clientSupportsStatus: "x-zed-client-supports-status-messages", + clientSupportsStreamEnded: + "x-zed-client-supports-stream-ended-request-completion-status", + serverSupportsStatus: "x-zed-server-supports-status-messages", + clientSupportsXai: "x-zed-client-supports-x-ai", + systemId: "x-zed-system-id", +}; + +const PRIVATE_KEY_PREFIX = "zed-rsa-pkcs1:"; +const LLM_TOKEN_TTL_MS = 50 * 60 * 1000; +const MODEL_CACHE_TTL_MS = 60 * 60 * 1000; + +const llmTokenCache = new Map(); +const modelCache = new Map(); +const modelInflight = new Map(); + +function b64url(value) { + return Buffer.from(value).toString("base64url"); +} + +function b64urlPadded(buf) { + return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_"); +} + +function fromB64url(value) { + return Buffer.from(String(value || ""), "base64url").toString("utf8"); +} + +function normalizeBaseUrl(baseUrl, fallback) { + return String(baseUrl || fallback).replace(/\/+$/, ""); +} + +function zedUrl(config, key, path, fallbackBase) { + const base = normalizeBaseUrl(config?.[key], fallbackBase); + return `${base}${path}`; +} + +/** Encode a PEM private key as an opaque verifier (flows through the OAuth codeVerifier slot). */ +export function encodeZedPrivateKeyVerifier(privateKeyPem) { + return `${PRIVATE_KEY_PREFIX}${b64url(privateKeyPem)}`; +} + +export function decodeZedPrivateKeyVerifier(verifier) { + const value = String(verifier || ""); + if (!value.startsWith(PRIVATE_KEY_PREFIX)) { + throw new Error("Missing Zed private key verifier; restart the login flow"); + } + return fromB64url(value.slice(PRIVATE_KEY_PREFIX.length)); +} + +/** Generate a fresh RSA keypair + the zed.dev native_app_signin URL for it. */ +export function createZedNativeAuthData(config = {}, options = {}) { + const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "pkcs1", format: "der" }, + privateKeyEncoding: { type: "pkcs1", format: "pem" }, + }); + + const nativeAppPort = Number( + options.nativeAppPort || config.defaultNativeAppPort || 58443, + ); + const systemId = options.systemId || crypto.randomUUID(); + const publicKeyString = b64urlPadded(publicKey); + const signInUrl = new URL( + `${normalizeBaseUrl(config.webBaseUrl, ZED_WEB_BASE_URL)}/native_app_signin`, + ); + signInUrl.searchParams.set("native_app_port", String(nativeAppPort)); + signInUrl.searchParams.set("native_app_public_key", publicKeyString); + if (systemId) signInUrl.searchParams.set("system_id", systemId); + + return { + authUrl: signInUrl.toString(), + privateKeyVerifier: encodeZedPrivateKeyVerifier(privateKey), + nativeAppPort, + systemId, + publicKey: publicKeyString, + }; +} + +/** Parse the pasted native-app callback URL/JSON/query into userId + encrypted token. */ +export function parseZedCallbackPayload(input) { + const raw = String(input || "").trim(); + if (!raw) throw new Error("Missing Zed callback URL"); + + let data = {}; + try { + data = JSON.parse(raw); + } catch { + let url; + try { + url = new URL(raw); + } catch { + try { + url = new URL(`http://127.0.0.1/?${raw.replace(/^\?/, "")}`); + } catch { + throw new Error("Invalid Zed callback URL"); + } + } + url.searchParams.forEach((value, key) => { + data[key] = value; + }); + } + + const userId = data.user_id || data.userId; + const encryptedAccessToken = data.access_token || data.accessToken || data.token; + if (!userId || !encryptedAccessToken) { + throw new Error("Zed callback must include user_id and access_token"); + } + return { userId: String(userId), encryptedAccessToken: String(encryptedAccessToken) }; +} + +/** Decrypt the RSA-encrypted access token using the stored private key. */ +export function decryptZedAccessToken(encryptedAccessToken, privateKeyVerifier) { + const privateKey = decodeZedPrivateKeyVerifier(privateKeyVerifier); + const encrypted = Buffer.from(String(encryptedAccessToken), "base64url"); + try { + return crypto + .privateDecrypt( + { key: privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" }, + encrypted, + ) + .toString("utf8"); + } catch (oaepError) { + try { + return crypto + .privateDecrypt( + { key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, + encrypted, + ) + .toString("utf8"); + } catch { + const message = oaepError instanceof Error ? oaepError.message : String(oaepError); + throw new Error(`Failed to decrypt Zed access token: ${message}`); + } + } +} + +export function buildZedUserAuthHeader(credentials) { + const psd = credentials?.providerSpecificData || {}; + const userId = psd.userId || credentials?.userId; + const accessToken = credentials?.accessToken || credentials?.apiKey; + if (!userId || !accessToken) { + throw new Error("Zed credential is missing userId or accessToken"); + } + return `${userId} ${accessToken}`; +} + +function getSystemId(credentials) { + return String( + credentials?.providerSpecificData?.systemId || credentials?.systemId || "", + ); +} + +async function fetchJson(url, options) { + const res = await proxyAwareFetch(url, options); + const text = await res.text(); + let data = null; + if (text) { + try { + data = JSON.parse(text); + } catch { + data = { raw: text }; + } + } + if (!res.ok) { + const message = + data?.message || data?.error?.message || data?.error || text || `HTTP ${res.status}`; + const err = new Error(String(message)); + err.status = res.status; + err.body = data; + throw err; + } + return data; +} + +export async function fetchZedAuthenticatedUser(credentials, options = {}) { + const config = options.config || {}; + const headers = { + Accept: "application/json", + Authorization: buildZedUserAuthHeader(credentials), + }; + const systemId = getSystemId(credentials); + if (systemId) headers[ZED_HEADERS.systemId] = systemId; + + return fetchJson(zedUrl(config, "cloudBaseUrl", "/client/users/me", ZED_CLOUD_BASE_URL), { + method: "GET", + headers, + signal: options.signal ?? undefined, + }); +} + +function normalizeOrganizationId(value) { + if (!value) return ""; + if (typeof value === "string") return value; + if (typeof value === "object" && value !== null) { + if (typeof value[0] === "string") return value[0]; + if (typeof value.id === "string") return value.id; + } + return String(value); +} + +export function resolveZedOrganizationId(credentials, userInfo = null) { + const psd = credentials?.providerSpecificData || {}; + const explicit = normalizeOrganizationId(psd.organizationId || psd.defaultOrganizationId); + if (explicit) return explicit; + const fromUser = normalizeOrganizationId( + userInfo?.default_organization_id || userInfo?.defaultOrganizationId, + ); + if (fromUser) return fromUser; + const orgs = userInfo?.organizations || []; + const org = orgs.find((item) => item?.is_personal) || orgs[0]; + return normalizeOrganizationId(org?.id); +} + +function zedUserCacheKey(credentials, organizationId) { + const psd = credentials?.providerSpecificData || {}; + const userId = psd.userId || credentials?.userId || "unknown"; + const token = credentials?.accessToken || credentials?.apiKey || ""; + return `${userId}:${organizationId || "default"}:${token.slice(-16)}`; +} + +function zedModelCacheKey(credentials) { + const psd = credentials?.providerSpecificData || {}; + const org = psd.organizationId || psd.defaultOrganizationId || "default"; + const token = credentials?.accessToken || credentials?.apiKey || ""; + return `${psd.userId || "unknown"}:${org}:${token.slice(-16)}`; +} + +export async function fetchZedLlmToken(credentials, options = {}) { + const config = options.config || {}; + let organizationId = options.organizationId || resolveZedOrganizationId(credentials); + if (!organizationId) { + const userInfo = await fetchZedAuthenticatedUser(credentials, options); + organizationId = resolveZedOrganizationId(credentials, userInfo); + } + if (!organizationId) throw new Error("No Zed organization selected"); + + const cacheKey = zedUserCacheKey(credentials, organizationId); + const cached = llmTokenCache.get(cacheKey); + if (!options.forceRefresh && cached && cached.expiresAt > Date.now()) return cached.token; + + const headers = { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: buildZedUserAuthHeader(credentials), + }; + const systemId = getSystemId(credentials); + if (systemId) headers[ZED_HEADERS.systemId] = systemId; + + const data = await fetchJson( + zedUrl(config, "cloudBaseUrl", "/client/llm_tokens", ZED_CLOUD_BASE_URL), + { + method: "POST", + headers, + body: JSON.stringify({ organization_id: organizationId }), + signal: options.signal ?? undefined, + }, + ); + const token = + typeof data?.token === "string" ? data.token : data?.token?.[0] || data?.token?.value; + if (!token) throw new Error("Zed did not return an LLM token"); + llmTokenCache.set(cacheKey, { token, expiresAt: Date.now() + LLM_TOKEN_TTL_MS }); + return token; +} + +export function shouldRefreshZedLlmToken(response) { + return ( + response?.status === 401 || + !!response?.headers?.has?.(ZED_HEADERS.expiredToken) || + !!response?.headers?.has?.(ZED_HEADERS.outdatedToken) + ); +} + +export async function zedLlmFetch(credentials, path, options = {}) { + const config = options.config || {}; + const url = zedUrl(config, "llmBaseUrl", path, ZED_LLM_BASE_URL); + const buildRequest = async (forceRefresh) => { + const token = await fetchZedLlmToken(credentials, { ...options, forceRefresh }); + return proxyAwareFetch(url, { + ...options.fetchOptions, + headers: { + ...(options.fetchOptions?.headers || {}), + Authorization: `Bearer ${token}`, + }, + signal: options.signal ?? undefined, + }); + }; + + let response = await buildRequest(false); + if (shouldRefreshZedLlmToken(response)) { + response = await buildRequest(true); + } + return response; +} + +function normalizeZedModelId(id) { + if (!id) return ""; + if (typeof id === "string") return id; + if (typeof id === "object" && id !== null) { + if (typeof id[0] === "string") return id[0]; + if (typeof id.id === "string") return id.id; + } + return String(id); +} + +export function mapZedModel(model) { + const id = normalizeZedModelId(model?.id); + if (!id) return null; + return { + id, + name: model.display_name || model.displayName || id, + provider: model.provider, + isLatest: !!model.is_latest, + contextLength: model.max_token_count ?? model.maxTokenCount, + contextLengthInMaxMode: model.max_token_count_in_max_mode ?? model.maxTokenCountInMaxMode, + maxOutputTokens: model.max_output_tokens ?? model.maxOutputTokens, + supportsTools: !!model.supports_tools, + supportsImages: !!model.supports_images, + supportsThinking: !!model.supports_thinking, + supportsDisablingThinking: !!model.supports_disabling_thinking, + supportsFastMode: !!model.supports_fast_mode, + supportsServerSideCompaction: !!model.supports_server_side_compaction, + supportedEffortLevels: model.supported_effort_levels ?? model.supportedEffortLevels ?? [], + supportsStreamingTools: !!model.supports_streaming_tools, + supportsParallelToolCalls: !!model.supports_parallel_tool_calls, + isDisabled: !!model.is_disabled, + disabledReason: model.disabled_reason ?? null, + }; +} + +/** Resolve (and cache) the live Zed model catalog. Never hardcoded — always a live fetch. */ +export async function resolveZedModels(credentials, options = {}) { + if (!credentials?.accessToken) return null; + const key = zedModelCacheKey(credentials); + const cached = modelCache.get(key); + if (!options.forceRefresh && cached && cached.expiresAt > Date.now()) return cached; + + const existing = modelInflight.get(key); + if (existing && !options.forceRefresh) return existing; + + const promise = (async () => { + const response = await zedLlmFetch(credentials, "/models", { + ...options, + fetchOptions: { + method: "GET", + headers: { + Accept: "application/json", + [ZED_HEADERS.clientSupportsXai]: "true", + }, + }, + }); + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Zed models failed: ${response.status} ${text}`); + } + const data = await response.json(); + const rawModels = Array.isArray(data?.models) ? data.models : []; + const models = rawModels + .map(mapZedModel) + .filter(Boolean) + .filter((model) => !model.isDisabled); + const rawById = new Map(); + for (const raw of rawModels) { + const id = normalizeZedModelId(raw?.id); + if (id) rawById.set(id, raw); + } + const entry = { + expiresAt: Date.now() + MODEL_CACHE_TTL_MS, + models, + rawModels, + rawById, + defaultModel: normalizeZedModelId(data?.default_model ?? data?.defaultModel), + defaultFastModel: normalizeZedModelId(data?.default_fast_model ?? data?.defaultFastModel), + recommendedModels: (data?.recommended_models || data?.recommendedModels || []) + .map(normalizeZedModelId) + .filter(Boolean), + }; + modelCache.set(key, entry); + return entry; + })(); + + modelInflight.set(key, promise); + try { + return await promise; + } finally { + if (modelInflight.get(key) === promise) modelInflight.delete(key); + } +} + +export function clearZedCaches() { + llmTokenCache.clear(); + modelCache.clear(); + modelInflight.clear(); +} diff --git a/open-sse/translator/concerns/kiroConversation.js b/open-sse/translator/concerns/kiroConversation.js new file mode 100644 index 00000000..11d49dc7 --- /dev/null +++ b/open-sse/translator/concerns/kiroConversation.js @@ -0,0 +1,435 @@ +import { + KIRO_TOOL_DESCRIPTION_MAX_LENGTH, + KIRO_TOOL_ID_MAX_LENGTH, + KIRO_TOOL_NAME_MAX_LENGTH, +} from "../../config/kiroConstants.js"; + +const TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; +const TOOL_NAME_PATTERN = /[^a-zA-Z0-9_-]/g; + +function clone(value) { + return value == null ? value : JSON.parse(JSON.stringify(value)); +} + +function text(value) { + if (typeof value === "string") return value; + if (value == null) return ""; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function appendText(target, extra) { + if (!extra) return; + target.content = target.content ? `${target.content}\n\n${extra}` : extra; +} + +function trimCodePoints(value, limit) { + return [...String(value || "")].slice(0, limit).join(""); +} + +function uniqueName(rawName, index, usedNames) { + const cleaned = String(rawName || "") + .trim() + .replace(TOOL_NAME_PATTERN, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, ""); + const base = trimCodePoints(cleaned || `tool_${index + 1}`, KIRO_TOOL_NAME_MAX_LENGTH); + let candidate = base; + let suffix = 2; + while (usedNames.has(candidate)) { + const tail = `_${suffix++}`; + candidate = `${base.slice(0, KIRO_TOOL_NAME_MAX_LENGTH - tail.length)}${tail}`; + } + usedNames.add(candidate); + return candidate; +} + +function cleanSchemaValue(value) { + if (Array.isArray(value)) return value.map(cleanSchemaValue); + if (!value || typeof value !== "object") return value; + + const cleaned = {}; + for (const [key, child] of Object.entries(value)) { + if (key === "additionalProperties") continue; + if (key === "required" && Array.isArray(child) && child.length === 0) continue; + cleaned[key] = cleanSchemaValue(child); + } + return cleaned; +} + +function normalizeRootSchema(schema) { + const cleaned = cleanSchemaValue(schema && typeof schema === "object" ? clone(schema) : {}); + cleaned.type = "object"; + if (!cleaned.properties || typeof cleaned.properties !== "object" || Array.isArray(cleaned.properties)) { + cleaned.properties = {}; + } + if (Array.isArray(cleaned.required)) { + cleaned.required = [...new Set(cleaned.required.filter( + (name) => typeof name === "string" && Object.hasOwn(cleaned.properties, name) + ))]; + if (cleaned.required.length === 0) delete cleaned.required; + } + return cleaned; +} + +/** Normalize OpenAI- or Claude-shaped tool definitions into Kiro tool specs. */ +export function normalizeKiroToolSpecs(tools) { + const specs = []; + const nameMap = new Map(); + const usedNames = new Set(); + + for (const [index, tool] of (Array.isArray(tools) ? tools : []).entries()) { + if (!tool || typeof tool !== "object") continue; + const rawName = tool.function?.name ?? tool.name; + if (typeof rawName !== "string" || !rawName.trim()) continue; + + // A repeated definition with the same source name describes the same tool. + if (nameMap.has(rawName)) continue; + const name = uniqueName(rawName, index, usedNames); + nameMap.set(rawName, name); + + const rawDescription = tool.function?.description ?? tool.description ?? `Tool: ${rawName}`; + const description = trimCodePoints( + String(rawDescription || `Tool: ${rawName}`), + KIRO_TOOL_DESCRIPTION_MAX_LENGTH + ); + const schema = tool.function?.parameters ?? tool.parameters ?? tool.input_schema ?? {}; + specs.push({ + toolSpecification: { + name, + description, + inputSchema: { json: normalizeRootSchema(schema) }, + }, + }); + } + + return { specs, nameMap }; +} + +function toolCallText(toolUse) { + return `[Tool call: ${toolUse?.name || "unknown"}(${text(toolUse?.input || {})})]`; +} + +function toolResultText(toolResult) { + const content = Array.isArray(toolResult?.content) + ? toolResult.content.map((part) => text(part?.text ?? part)).filter(Boolean).join("\n") + : text(toolResult?.content); + return `[Tool result${toolResult?.status === "error" ? " (error)" : ""}: ${content}]`; +} + +function mergeUser(target, source) { + appendText(target, source.content); + if (Array.isArray(source.images) && source.images.length > 0) { + target.images = [...(target.images || []), ...source.images]; + } + const results = source.userInputMessageContext?.toolResults; + if (Array.isArray(results) && results.length > 0) { + target.userInputMessageContext ||= {}; + target.userInputMessageContext.toolResults = [ + ...(target.userInputMessageContext.toolResults || []), + ...results, + ]; + } +} + +function mergeAssistant(target, source) { + appendText(target, source.content); + if (Array.isArray(source.toolUses) && source.toolUses.length > 0) { + target.toolUses = [...(target.toolUses || []), ...source.toolUses]; + } +} + +function normalizeTurns(history, currentMessage, modelId) { + const rawTurns = [...(Array.isArray(history) ? history : [])]; + if (currentMessage) rawTurns.push(currentMessage); + const turns = []; + + for (const raw of rawTurns) { + const isUser = !!raw?.userInputMessage; + const isAssistant = !!raw?.assistantResponseMessage; + if (isUser === isAssistant) continue; + + const turn = isUser + ? { userInputMessage: clone(raw.userInputMessage) } + : { assistantResponseMessage: clone(raw.assistantResponseMessage) }; + const previous = turns[turns.length - 1]; + if (turn.userInputMessage && previous?.userInputMessage) { + mergeUser(previous.userInputMessage, turn.userInputMessage); + } else if (turn.assistantResponseMessage && previous?.assistantResponseMessage) { + mergeAssistant(previous.assistantResponseMessage, turn.assistantResponseMessage); + } else { + turns.push(turn); + } + } + + if (turns[0]?.assistantResponseMessage) { + turns.unshift({ userInputMessage: { content: "continue", modelId } }); + } + if (turns.length === 0 || turns[turns.length - 1]?.assistantResponseMessage) { + turns.push({ userInputMessage: { content: "continue", modelId } }); + } + + for (const turn of turns) { + if (turn.userInputMessage) { + turn.userInputMessage.content = text(turn.userInputMessage.content).trim() || "continue"; + turn.userInputMessage.modelId ||= modelId; + if (turn.userInputMessage.userInputMessageContext?.tools) { + delete turn.userInputMessage.userInputMessageContext.tools; + } + } else { + turn.assistantResponseMessage.content = + text(turn.assistantResponseMessage.content).trim() || "..."; + } + } + return turns; +} + +function rawId(value) { + return typeof value === "string" ? value : ""; +} + +function reserveToolId(value, turnIndex, callIndex, name, usedIds) { + const sanitized = rawId(value).replace(/[^a-zA-Z0-9_-]/g, ""); + const generated = `call_msg${turnIndex}_tc${callIndex}_${name || "tool"}`; + const base = trimCodePoints( + TOOL_ID_PATTERN.test(sanitized) && sanitized ? sanitized : generated, + KIRO_TOOL_ID_MAX_LENGTH + ); + let candidate = base; + let suffix = 2; + while (usedIds.has(candidate)) { + const tail = `_${suffix++}`; + candidate = `${base.slice(0, KIRO_TOOL_ID_MAX_LENGTH - tail.length)}${tail}`; + } + usedIds.add(candidate); + return candidate; +} + +function normalizeToolInput(input) { + if (input && typeof input === "object" && !Array.isArray(input)) return clone(input); + if (typeof input === "string") { + try { + const parsed = JSON.parse(input); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed; + } catch { + return null; + } + } + return input == null ? {} : null; +} + +function normalizeToolResult(result) { + const content = Array.isArray(result?.content) + ? result.content.map((part) => ({ text: text(part?.text ?? part) })) + : [{ text: text(result?.content) }]; + return { + toolUseId: rawId(result?.toolUseId), + status: result?.status === "error" ? "error" : "success", + content: content.length > 0 ? content : [{ text: "" }], + }; +} + +function flattenResults(userMessage, results) { + for (const result of results) appendText(userMessage, toolResultText(result)); +} + +function cleanUserContext(userMessage) { + const context = userMessage.userInputMessageContext; + if (!context) return; + if (!context.toolResults?.length) delete context.toolResults; + if (!context.tools?.length) delete context.tools; + if (Object.keys(context).length === 0) delete userMessage.userInputMessageContext; +} + +function reconcileToolPair(assistant, user, turnIndex, nameMap, specNames, usedIds, repairs) { + const calls = Array.isArray(assistant.toolUses) ? assistant.toolUses : []; + const results = Array.isArray(user.userInputMessageContext?.toolResults) + ? user.userInputMessageContext.toolResults.map(normalizeToolResult) + : []; + if (calls.length === 0) { + if (results.length > 0) { + flattenResults(user, results); + repairs.orphanResults += results.length; + } + if (user.userInputMessageContext) delete user.userInputMessageContext.toolResults; + cleanUserContext(user); + return; + } + + const callQueues = new Map(); + const callRecords = calls.map((call, callIndex) => { + const key = rawId(call?.toolUseId); + const mappedName = nameMap.get(call?.name) || call?.name; + const input = normalizeToolInput(call?.input); + const record = { call, callIndex, key, mappedName, input, result: null }; + const queue = callQueues.get(key) || []; + queue.push(record); + callQueues.set(key, queue); + return record; + }); + + const orphanResults = []; + for (const result of results) { + const queue = callQueues.get(rawId(result.toolUseId)); + const record = queue?.find((candidate) => !candidate.result); + if (record) record.result = result; + else orphanResults.push(result); + } + + const keptCalls = []; + const keptResults = []; + for (const record of callRecords) { + const hasSpec = typeof record.mappedName === "string" && specNames.has(record.mappedName); + const valid = !!record.result && hasSpec && record.input !== null; + if (!valid) { + appendText(assistant, toolCallText({ name: record.mappedName, input: record.call?.input })); + repairs.missingResults += record.result ? 0 : 1; + repairs.invalidToolUses += hasSpec && record.input !== null ? 0 : 1; + if (record.result) { + flattenResults(user, [record.result]); + repairs.orphanResults++; + } + continue; + } + + const toolUseId = reserveToolId( + record.key, + turnIndex, + record.callIndex, + record.mappedName, + usedIds + ); + keptCalls.push({ + toolUseId, + name: record.mappedName, + input: record.input, + }); + keptResults.push({ ...record.result, toolUseId }); + } + + if (orphanResults.length > 0) { + flattenResults(user, orphanResults); + repairs.orphanResults += orphanResults.length; + } + + if (keptCalls.length > 0) assistant.toolUses = keptCalls; + else delete assistant.toolUses; + user.userInputMessageContext ||= {}; + if (keptResults.length > 0) user.userInputMessageContext.toolResults = keptResults; + else delete user.userInputMessageContext.toolResults; + cleanUserContext(user); +} + +/** Validate the final Kiro wire conversation without mutating it. */ +export function validateKiroConversation(history, currentMessage, toolSpecs = []) { + const errors = []; + const turns = [...(history || []), currentMessage].filter(Boolean); + const specNames = new Set(toolSpecs.map((spec) => spec?.toolSpecification?.name).filter(Boolean)); + const usedIds = new Set(); + + for (let index = 0; index < turns.length; index++) { + const expectedUser = index % 2 === 0; + const isUser = !!turns[index]?.userInputMessage; + if (isUser !== expectedUser) errors.push(`role:${index}`); + if (!isUser) { + const calls = turns[index].assistantResponseMessage?.toolUses || []; + const results = turns[index + 1]?.userInputMessage?.userInputMessageContext?.toolResults || []; + const callIds = calls.map((call) => call.toolUseId); + const resultIds = results.map((result) => result.toolUseId); + if (calls.length !== results.length || callIds.some((id) => !resultIds.includes(id))) { + errors.push(`pair:${index}`); + } + for (const call of calls) { + if (!call.toolUseId || usedIds.has(call.toolUseId)) errors.push(`id:${index}`); + usedIds.add(call.toolUseId); + if (!specNames.has(call.name)) errors.push(`spec:${index}`); + } + } else if (index === 0) { + const results = turns[index].userInputMessage?.userInputMessageContext?.toolResults; + if (results?.length) errors.push("orphan:0"); + } + } + if (!currentMessage?.userInputMessage?.content) errors.push("current"); + return { valid: errors.length === 0, errors }; +} + +function flattenAllStructuredTools(turns, repairs) { + for (const turn of turns) { + if (turn.assistantResponseMessage?.toolUses?.length) { + for (const call of turn.assistantResponseMessage.toolUses) { + appendText(turn.assistantResponseMessage, toolCallText(call)); + } + repairs.invalidToolUses += turn.assistantResponseMessage.toolUses.length; + delete turn.assistantResponseMessage.toolUses; + } + const user = turn.userInputMessage; + const results = user?.userInputMessageContext?.toolResults; + if (results?.length) { + flattenResults(user, results); + repairs.orphanResults += results.length; + delete user.userInputMessageContext.toolResults; + cleanUserContext(user); + } + } +} + +/** + * Produce a strict Kiro conversation: alternating turns, current user message, + * adjacent one-to-one tool use/result pairs, and tool specs only on currentMessage. + */ +export function canonicalizeKiroConversation({ + history, + currentMessage, + modelId, + toolSpecs = [], + nameMap = new Map(), +} = {}) { + const turns = normalizeTurns(history, currentMessage, modelId); + const repairs = { missingResults: 0, orphanResults: 0, invalidToolUses: 0 }; + const specNames = new Set(toolSpecs.map((spec) => spec?.toolSpecification?.name).filter(Boolean)); + const usedIds = new Set(); + + for (let index = 0; index < turns.length; index += 2) { + const user = turns[index].userInputMessage; + if (index === 0) { + const leadingResults = user.userInputMessageContext?.toolResults || []; + if (leadingResults.length > 0) { + flattenResults(user, leadingResults); + repairs.orphanResults += leadingResults.length; + delete user.userInputMessageContext.toolResults; + cleanUserContext(user); + } + } + const assistant = turns[index + 1]?.assistantResponseMessage; + const nextUser = turns[index + 2]?.userInputMessage; + if (assistant && nextUser) { + reconcileToolPair(assistant, nextUser, index + 1, nameMap, specNames, usedIds, repairs); + } + } + + const finalCurrent = turns[turns.length - 1]; + finalCurrent.userInputMessage.userInputMessageContext ||= {}; + if (toolSpecs.length > 0) { + finalCurrent.userInputMessage.userInputMessageContext.tools = clone(toolSpecs); + } + cleanUserContext(finalCurrent.userInputMessage); + + let finalHistory = turns.slice(0, -1); + let validation = validateKiroConversation(finalHistory, finalCurrent, toolSpecs); + if (!validation.valid) { + flattenAllStructuredTools(turns, repairs); + finalHistory = turns.slice(0, -1); + validation = validateKiroConversation(finalHistory, finalCurrent, toolSpecs); + } + + return { + history: finalHistory, + currentMessage: finalCurrent, + repairs, + valid: validation.valid, + errors: validation.errors, + }; +} diff --git a/open-sse/translator/formats/gemini.js b/open-sse/translator/formats/gemini.js index bf6c4586..b1d4db34 100644 --- a/open-sse/translator/formats/gemini.js +++ b/open-sse/translator/formats/gemini.js @@ -353,6 +353,19 @@ export function cleanJSONSchemaForAntigravity(schema) { function addPlaceholders(obj) { if (!obj || typeof obj !== "object") return; + // Empty schema {} (no type, no properties) after $ref removal — treat as object with placeholder + if (Object.keys(obj).length === 0) { + obj.type = "object"; + obj.properties = { + reason: { + type: "string", + description: "Brief explanation of why you are calling this tool" + } + }; + obj.required = ["reason"]; + return; + } + if (obj.type === "object") { if (!obj.properties || Object.keys(obj.properties).length === 0) { obj.properties = { diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index 37e5bde6..48bd1530 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -62,8 +62,13 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream // Always ensure tool_calls have id (some providers require it) ensureToolCallIds(result); - // Fix missing tool responses (insert empty tool_result if needed) - fixMissingToolResponses(result); + // Kiro performs stricter source-aware reconciliation after session replay. + // The generic helper inserts OpenAI `role: tool` messages, which a direct + // Claude→Kiro translator cannot consume and which cannot repair partial + // parallel tool results. + if (targetFormat !== FORMATS.KIRO) { + fixMissingToolResponses(result); + } // Capture thinking intent from the original (pre-translation) body, before any // format conversion strips/renames the fields. Applied after translation. diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index 98531c73..8651e819 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -6,17 +6,10 @@ * direct `claude:kiro` route in ../index.js uses; it is NOT reached through the * claude→openai→kiro pivot. * - * It reproduces the two 400-guards that live in openai-to-kiro.js so that a - * Claude client which omits the `tools` array on a follow-up turn (typical - * after client-side compaction) does not trip Kiro's schema validator and get - * "Improperly formed request" (HTTP 400): - * - * 1. flattenClaudeToolInteractions — when the client sent NO tools, collapse - * every tool_use / tool_result block to plain text so no structured tool - * reference survives to trigger the "tools required" rule. - * 2. reconcileOrphanedToolResults — when tools ARE present, fold any - * tool_result whose tool_use_id has no matching tool_use back into the - * user text instead of leaving a dangling structured reference. + * After session replay it delegates to the shared Kiro conversation + * canonicalizer. That layer enforces adjacent one-to-one tool use/results, + * repairs partial parallel calls, and flattens compacted structured references + * that can no longer be represented safely. * * It also handles the 9router-synthetic `-agentic` / `-thinking` suffixes and * the `enabled` reasoning trigger, matching @@ -27,7 +20,8 @@ import { FORMATS } from "../formats.js"; import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js"; import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js"; import { - resolveKiroModel, + resolveKiroModelIntent, + applyKiroThinkingOverride, resolveKiroThinkingBudget, buildThinkingSystemPrefix, KIRO_AGENTIC_SYSTEM_PROMPT, @@ -37,82 +31,17 @@ import { } from "../../config/kiroConstants.js"; import { DEFAULT_IMAGE_MIME } from "../schema/index.js"; import { ROLE, CLAUDE_BLOCK } from "../schema/index.js"; - -/** Stringify a tool_use input as a readable line. */ -function toolUseToText(name, input) { - let argStr; - try { - argStr = typeof input === "string" ? input : JSON.stringify(input ?? {}); - } catch { - argStr = "{}"; - } - return `[Tool call: ${name || "unknown"}(${argStr})]`; -} - -/** Render a Claude tool_result block's content as a readable line. */ -function toolResultBlockToText(content) { - let text = ""; - if (typeof content === "string") { - text = content; - } else if (Array.isArray(content)) { - text = content - .map((c) => (typeof c === "string" ? c : c?.text || "")) - .filter(Boolean) - .join("\n"); - } else if (content) { - try { - text = JSON.stringify(content); - } catch { - text = ""; - } - } - return `[Tool result: ${text}]`; -} - -/** - * When the client sent no tools, rewrite every tool_use (assistant) and - * tool_result (user) content block into plain text. Keeps text + images. - * Returns a new messages array; never mutates the input. - */ -function flattenClaudeToolInteractions(messages) { - const out = []; - for (const msg of messages) { - if (!msg) continue; - - if (msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) { - const parts = []; - for (const block of msg.content) { - if (block.type === CLAUDE_BLOCK.TEXT && block.text) { - parts.push(block.text); - } else if (block.type === CLAUDE_BLOCK.TOOL_USE) { - parts.push(toolUseToText(block.name, block.input)); - } - } - out.push({ ...msg, content: parts.join("\n") }); - continue; - } - - if (msg.role === ROLE.USER && Array.isArray(msg.content)) { - const newContent = msg.content.map((block) => - block.type === CLAUDE_BLOCK.TOOL_RESULT - ? { type: CLAUDE_BLOCK.TEXT, text: toolResultBlockToText(block.content) } - : block - ); - out.push({ ...msg, content: newContent }); - continue; - } - - out.push(msg); - } - return out; -} +import { + canonicalizeKiroConversation, + normalizeKiroToolSpecs, +} from "../concerns/kiroConversation.js"; /** * Convert Claude messages to Kiro history + currentMessage. * Kiro requires alternating user/assistant turns; consecutive same-role * messages are merged. */ -function convertClaudeMessagesToKiro(messages, tools, model) { +function convertClaudeMessagesToKiro(messages, model) { const history = []; let currentMessage = null; @@ -121,27 +50,6 @@ function convertClaudeMessagesToKiro(messages, tools, model) { let pendingToolResults = []; let pendingImages = []; let currentRole = null; - let toolsInjected = false; - - const clientProvidedTools = Array.isArray(tools) && tools.length > 0; - - const buildToolSpecs = () => - tools.map((t) => { - const name = t.name; - const description = t.description || `Tool: ${name}`; - const schema = t.input_schema || {}; - const normalizedSchema = - Object.keys(schema).length === 0 - ? { type: "object", properties: {}, required: [] } - : { ...schema, required: schema.required ?? [] }; - return { - toolSpecification: { - name, - description, - inputSchema: { json: normalizedSchema }, - }, - }; - }); const flushPending = () => { if (currentRole === ROLE.USER) { @@ -156,15 +64,6 @@ function convertClaudeMessagesToKiro(messages, tools, model) { toolResults: pendingToolResults, }; } - // Attach tools to the first user turn only. - if (clientProvidedTools && !toolsInjected) { - if (!userMsg.userInputMessage.userInputMessageContext) { - userMsg.userInputMessage.userInputMessageContext = {}; - } - userMsg.userInputMessage.userInputMessageContext.tools = buildToolSpecs(); - toolsInjected = true; - } - history.push(userMsg); currentMessage = userMsg; pendingUserContent = []; @@ -208,7 +107,7 @@ function convertClaudeMessagesToKiro(messages, tools, model) { } pendingToolResults.push({ toolUseId: block.tool_use_id, - status: "success", + status: block.is_error ? "error" : "success", content: [{ text: resultContent }], }); } @@ -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) => { - if (item.userInputMessage?.userInputMessageContext?.tools) { - delete item.userInputMessage.userInputMessageContext.tools; - } if ( item.userInputMessage?.userInputMessageContext && Object.keys(item.userInputMessage.userInputMessageContext).length === 0 @@ -306,66 +198,9 @@ function convertClaudeMessagesToKiro(messages, tools, model) { currentMessage = { userInputMessage: { content: "", modelId: model } }; } - // Inject tools into currentMessage after cleanup if not already present. - if ( - firstHistoryTools?.length > 0 && - !currentMessage.userInputMessage.userInputMessageContext?.tools - ) { - if (!currentMessage.userInputMessage.userInputMessageContext) { - currentMessage.userInputMessage.userInputMessageContext = {}; - } - currentMessage.userInputMessage.userInputMessageContext.tools = - firstHistoryTools; - } - return { history: mergedHistory, currentMessage }; } -/** - * Fold orphaned toolResults (those whose toolUseId has no matching toolUse in - * any assistant turn) back into the user text, removing the dangling - * structured reference that makes Kiro 400. - */ -function reconcileOrphanedToolResults(history, currentMessage) { - const validIds = new Set(); - for (const h of history) { - const arm = h.assistantResponseMessage; - if (!arm) continue; - for (const tu of arm.toolUses || []) { - if (tu.toolUseId) validIds.add(tu.toolUseId); - } - } - - const carriers = currentMessage ? [...history, currentMessage] : history; - for (const item of carriers) { - const uim = item.userInputMessage; - const ctx = uim?.userInputMessageContext; - if (!ctx?.toolResults?.length) continue; - - const kept = []; - const salvaged = []; - for (const tr of ctx.toolResults) { - if (validIds.has(tr.toolUseId)) { - kept.push(tr); - } else { - const text = Array.isArray(tr.content) - ? tr.content.map((c) => c?.text || "").join("\n") - : ""; - salvaged.push(`[Tool result: ${text}]`); - } - } - - if (salvaged.length === 0) continue; - - const extra = salvaged.join("\n"); - uim.content = uim.content ? `${uim.content}\n\n${extra}` : extra; - ctx.toolResults = kept; - if (kept.length === 0 && !ctx.tools?.length) { - delete uim.userInputMessageContext; - } - } -} - function extractClaudeSystemText(system) { if (!system) return ""; if (typeof system === "string") return system; @@ -382,33 +217,21 @@ function extractClaudeSystemText(system) { * Build a Kiro payload directly from a Claude Messages API request body. */ export function claudeToKiroRequest(model, body, stream, credentials) { - let messages = Array.isArray(body.messages) ? body.messages : []; + const messages = Array.isArray(body.messages) ? body.messages : []; const tools = Array.isArray(body.tools) ? body.tools : []; - const clientProvidedTools = tools.length > 0; const maxTokens = body.max_tokens || 32000; const temperature = body.temperature; const topP = body.top_p; - const { upstream: upstreamModel, agentic } = resolveKiroModel(model); - const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model); - const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel); - const usesNativeGptEffort = usesKiroNativeGptEffort(body, upstreamModel); + const modelIntent = resolveKiroModelIntent(model); + const { upstream: upstreamModel, agentic } = modelIntent; + const thinkingBody = applyKiroThinkingOverride(body, modelIntent.thinkingOverride); + const thinkingBudget = resolveKiroThinkingBudget(thinkingBody, credentials?.rawHeaders, modelIntent.model); + const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(thinkingBody, upstreamModel); + const usesNativeGptEffort = usesKiroNativeGptEffort(thinkingBody, upstreamModel); - // Guard 1: no client tools → flatten all tool interactions to text. - if (!clientProvidedTools) { - messages = flattenClaudeToolInteractions(messages); - } - - const { history, currentMessage } = convertClaudeMessagesToKiro( - messages, - tools, - upstreamModel - ); - - // Guard 2: tools present → reconcile dangling tool_results. - if (clientProvidedTools) { - reconcileOrphanedToolResults(history, currentMessage); - } + const { specs: toolSpecs, nameMap } = normalizeKiroToolSpecs(tools); + const { history, currentMessage } = convertClaudeMessagesToKiro(messages, upstreamModel); // api_key / idc / external_idp must never use the shared default ARN (belongs // to another account → 403 "bearer token invalid"); OAuth/social fall back to it. @@ -457,7 +280,14 @@ export function claudeToKiroRequest(model, body, stream, credentials) { history, currentMessage, }); - const replayCurrent = replay.currentMessage?.userInputMessage || {}; + const canonical = canonicalizeKiroConversation({ + history: replay.history, + currentMessage: replay.currentMessage, + modelId: upstreamModel, + toolSpecs, + nameMap, + }); + const replayCurrent = canonical.currentMessage.userInputMessage; const userInputMessage = { content: replayCurrent.content || "", modelId: upstreamModel, @@ -479,7 +309,7 @@ export function claudeToKiroRequest(model, body, stream, credentials) { currentMessage: { userInputMessage, }, - history: replay.history, + history: canonical.history, }, agentMode: "vibe", }; diff --git a/open-sse/translator/request/openai-to-claude.js b/open-sse/translator/request/openai-to-claude.js index 580debfe..7cf9fd01 100644 --- a/open-sse/translator/request/openai-to-claude.js +++ b/open-sse/translator/request/openai-to-claude.js @@ -253,6 +253,10 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map()) { } } } 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)) { for (const part of msg.content) { if (part.type === OPENAI_BLOCK.TEXT && part.text) { diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index 069feffa..aa776949 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -8,7 +8,8 @@ import { v4 as uuidv4 } from "uuid"; import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js"; import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js"; import { - resolveKiroModel, + resolveKiroModelIntent, + applyKiroThinkingOverride, resolveKiroThinkingBudget, buildThinkingSystemPrefix, KIRO_AGENTIC_SYSTEM_PROMPT, @@ -19,148 +20,10 @@ import { import { parseDataUri } from "../concerns/image.js"; import { DEFAULT_IMAGE_MIME } from "../schema/index.js"; import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js"; - -/** Render a single tool call as a readable text line. */ -function toolCallToText(name, input) { - let argStr; - try { - argStr = typeof input === "string" ? input : JSON.stringify(input ?? {}); - } catch { - argStr = "{}"; - } - return `[Tool call: ${name || "unknown"}(${argStr})]`; -} - -/** Render a tool result (string or content-block array) as a text line. */ -function toolResultToText(content) { - const text = Array.isArray(content) - ? content.map(c => (typeof c === "string" ? c : c.text || "")).join("\n") - : (typeof content === "string" ? content : ""); - return `[Tool result: ${text}]`; -} - -/** - * Flatten all tool calls/results in a conversation into plain text. - * - * Kiro's schema validator requires a non-empty - * currentMessage.userInputMessageContext.tools array whenever the history - * references any tool use; otherwise it returns "Improperly formed request" - * (HTTP 400). A client can hit this by omitting the `tools` array on a - * follow-up request — typically after client-side compaction (e.g. OpenCode). - * - * Rather than fabricate stub tool specs — which would advertise tool-calling - * capability the client never requested and may not handle, risking a phantom - * tool call on an otherwise plain turn — we collapse the tool interaction into - * text. The request stays honest, and since no structured tool content - * remains, the validator's "tools required" rule never fires. - * - * Only invoked when the client did NOT send tools; when tools are present the - * structured form is preserved. - */ -function flattenToolInteractions(messages) { - const out = []; - - for (const msg of messages) { - // OpenAI tool-result message → user text line - if (msg.role === ROLE.TOOL) { - out.push({ role: ROLE.USER, content: toolResultToText(msg.content) }); - continue; - } - - if (msg.role === ROLE.ASSISTANT) { - const parts = []; - if (Array.isArray(msg.content)) { - for (const c of msg.content) { - if (c.type === CLAUDE_BLOCK.TOOL_USE) { - parts.push(toolCallToText(c.name, c.input)); - } else if (c.type === OPENAI_BLOCK.TEXT || c.text) { - parts.push(c.text || ""); - } - } - } else if (typeof msg.content === "string") { - parts.push(msg.content); - } - for (const tc of msg.tool_calls || []) { - parts.push(toolCallToText(tc.function?.name, tc.function?.arguments)); - } - out.push({ role: ROLE.ASSISTANT, content: parts.filter(Boolean).join("\n") }); - continue; - } - - // User messages: replace tool_result blocks with text, keep text + images. - if (msg.role === ROLE.USER && Array.isArray(msg.content)) { - const newContent = msg.content.map(c => - c.type === CLAUDE_BLOCK.TOOL_RESULT - ? { type: OPENAI_BLOCK.TEXT, text: toolResultToText(c.content) } - : c - ); - out.push({ ...msg, content: newContent }); - continue; - } - - out.push(msg); - } - - return out; -} - -/** - * Reconcile orphaned toolResults — those whose toolUseId has no matching - * toolUse in any assistant message. This happens when client-side compaction - * truncates the conversation and removes the assistant message containing the - * tool_use, but keeps the user message with the corresponding tool_result. - * - * A dangling structured reference makes Kiro return 400, so it must be removed. - * But the client deliberately kept the result content through compaction, so - * rather than discard it we fold it back into the user message as text — the - * same shape flattenToolInteractions() produces. The 400 trigger (the - * structured reference) is gone; the content survives. - * - * `messages` is every carrier that can hold toolResults — both history items - * and the popped-out currentMessage (orphans can land on either). - */ -function reconcileOrphanedToolResults(history, currentMessage) { - // Phase 1: collect all valid toolUseIds from assistant messages in history. - // (currentMessage is always a user turn, so it carries no toolUses.) - const validIds = new Set(); - for (const h of history) { - const arm = h.assistantResponseMessage; - if (!arm) continue; - for (const tu of arm.toolUses || []) { - if (tu.toolUseId) validIds.add(tu.toolUseId); - } - } - - // Phase 2: across history + currentMessage, keep results with a matching - // toolUse and salvage the rest as text. - const carriers = currentMessage ? [...history, currentMessage] : history; - for (const item of carriers) { - const uim = item.userInputMessage; - const ctx = uim?.userInputMessageContext; - if (!ctx?.toolResults?.length) continue; - - const kept = []; - const salvaged = []; - for (const tr of ctx.toolResults) { - if (validIds.has(tr.toolUseId)) { - kept.push(tr); - } else { - salvaged.push(toolResultToText(tr.content)); - } - } - - if (salvaged.length === 0) continue; // no orphans — leave untouched - - // Fold orphaned result content into the user text so it is not lost - const extra = salvaged.join("\n"); - uim.content = uim.content ? `${uim.content}\n\n${extra}` : extra; - - ctx.toolResults = kept; - if (kept.length === 0 && !ctx.tools?.length) { - delete uim.userInputMessageContext; - } - } -} +import { + canonicalizeKiroConversation, + normalizeKiroToolSpecs, +} from "../concerns/kiroConversation.js"; /** * Safely parse JSON string, returning fallback on failure. @@ -176,26 +39,15 @@ function safeJSONParse(str, fallback) { * * Returns { history, currentMessage }. */ -function convertMessages(messages, tools, model) { +function convertMessages(messages, model) { let history = []; let currentMessage = null; - const clientProvidedTools = tools && tools.length > 0; - - // When the client did not send tools, flatten any tool calls/results in the - // history into plain text (see flattenToolInteractions). This keeps the - // request honest and sidesteps Kiro's "tools required" 400, since no - // structured tool content survives to trigger it. - if (!clientProvidedTools) { - messages = flattenToolInteractions(messages); - } - let pendingUserContent = []; let pendingAssistantContent = []; let pendingToolResults = []; let pendingImages = []; let currentRole = null; - let toolsInjectedToFirstUserMsg = false; const flushPending = () => { if (currentRole === "user") { @@ -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); currentMessage = userMsg; pendingUserContent = []; @@ -326,7 +145,7 @@ function convertMessages(messages, tools, model) { pendingToolResults.push({ toolUseId: block.tool_use_id, - status: "success", + status: block.is_error ? "error" : "success", content: [{ text: text }] }); }); @@ -338,7 +157,7 @@ function convertMessages(messages, tools, model) { const toolContent = typeof msg.content === "string" ? msg.content : ""; pendingToolResults.push({ toolUseId: msg.tool_call_id, - status: "success", + status: msg.is_error || msg.status === "error" ? "error" : "success", content: [{ text: toolContent }] }); } else if (content) { @@ -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 history.forEach(item => { - if (item.userInputMessage?.userInputMessageContext?.tools) { - delete item.userInputMessage.userInputMessageContext.tools; - } if (item.userInputMessage?.userInputMessageContext && Object.keys(item.userInputMessage.userInputMessageContext).length === 0) { delete item.userInputMessage.userInputMessageContext; @@ -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 }; } @@ -524,12 +310,15 @@ export function openaiToKiroRequest(model, body, stream, credentials) { const temperature = body.temperature; const topP = body.top_p; - const { upstream: upstreamModel, agentic } = resolveKiroModel(model); - const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model); - const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel); - const usesNativeGptEffort = usesKiroNativeGptEffort(body, upstreamModel); + const modelIntent = resolveKiroModelIntent(model); + const { upstream: upstreamModel, agentic } = modelIntent; + const thinkingBody = applyKiroThinkingOverride(body, modelIntent.thinkingOverride); + const thinkingBudget = resolveKiroThinkingBudget(thinkingBody, credentials?.rawHeaders, modelIntent.model); + const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(thinkingBody, upstreamModel); + const usesNativeGptEffort = usesKiroNativeGptEffort(thinkingBody, upstreamModel); - const { history, currentMessage } = convertMessages(messages, tools, upstreamModel); + const { specs: toolSpecs, nameMap } = normalizeKiroToolSpecs(tools); + const { history, currentMessage } = convertMessages(messages, upstreamModel); // API-key (headless) auth uses a raw CodeWhisperer credential whose profile is // account-specific. Injecting the shared builder-id/social *default* placeholder @@ -583,7 +372,14 @@ export function openaiToKiroRequest(model, body, stream, credentials) { history, currentMessage, }); - const replayCurrent = replay.currentMessage?.userInputMessage || {}; + const canonical = canonicalizeKiroConversation({ + history: replay.history, + currentMessage: replay.currentMessage, + modelId: upstreamModel, + toolSpecs, + nameMap, + }); + const replayCurrent = canonical.currentMessage.userInputMessage; const payload = { conversationState: { @@ -604,7 +400,7 @@ export function openaiToKiroRequest(model, body, stream, credentials) { }) } }, - history: replay.history + history: canonical.history }, agentMode: "vibe", }; diff --git a/open-sse/utils/kiroSessionReplay.js b/open-sse/utils/kiroSessionReplay.js index 11cae9cd..d758eed6 100644 --- a/open-sse/utils/kiroSessionReplay.js +++ b/open-sse/utils/kiroSessionReplay.js @@ -42,6 +42,14 @@ function findFirstUserIndex(history) { return history.findIndex((item) => item?.userInputMessage); } +function hasToolResults(message) { + return !!message?.userInputMessage?.userInputMessageContext?.toolResults?.length; +} + +function canReplaceSessionStart(history, firstUserIndex) { + return firstUserIndex === 0 && !hasToolResults(history[firstUserIndex]); +} + function rememberSessionStart(key, entry) { if (sessionStartStore.size >= MAX_SESSION_STARTS) { sessionStartStore.delete(sessionStartStore.keys().next().value); @@ -73,10 +81,13 @@ export function applyKiroSessionReplay({ existing.lastUsed = Date.now(); const firstUserIndex = findFirstUserIndex(baseHistory); const sessionStart = ensureUserMessageModelId(clone(existing.sessionStart), modelId); - if (firstUserIndex >= 0) { + if (canReplaceSessionStart(baseHistory, firstUserIndex)) { baseHistory[firstUserIndex] = sessionStart; } else { baseHistory.unshift(sessionStart); + if (baseHistory.length === 1) { + baseHistory.push({ assistantResponseMessage: { content: "..." } }); + } } return { history: ensureHistoryModelIds(baseHistory, modelId), @@ -88,10 +99,18 @@ export function applyKiroSessionReplay({ const firstUserIndex = findFirstUserIndex(baseHistory); let sessionStart; let nextCurrent = ensureUserMessageModelId(baseCurrent, modelId); - if (firstUserIndex >= 0) { + if (canReplaceSessionStart(baseHistory, firstUserIndex)) { sessionStart = prefixUserMessage(baseHistory[firstUserIndex], contentPrefix, modelId); baseHistory[firstUserIndex] = clone(sessionStart); nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId); + } else if (firstUserIndex >= 0) { + sessionStart = prefixUserMessage( + { userInputMessage: { content: "", modelId } }, + contentPrefix, + modelId + ); + baseHistory.unshift(clone(sessionStart)); + nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId); } else { sessionStart = prefixUserMessage(baseCurrent, contentPrefix, modelId); nextCurrent = clone(sessionStart); diff --git a/package.json b/package.json index dc89873d..fbedad57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.5.40", + "version": "0.5.45", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/public/i18n/literals/pt-BR.json b/public/i18n/literals/pt-BR.json index 6edba2e7..f2b5f6d2 100644 --- a/public/i18n/literals/pt-BR.json +++ b/public/i18n/literals/pt-BR.json @@ -1,195 +1,988 @@ { - "Cancel": "Cancelar", - "Delete": "Excluir", - "Edit": "Editar", - "Save": "Salvar", - "Close": "Fechar", - "Add": "Adicionar", - "Remove": "Remover", - "Settings": "Configurações", - "Profile": "Perfil", - "Dashboard": "Painel de controle", - "Logout": "Sair", - "Login": "Conectar", - "Providers": "Provedores", - "Usage": "Estatísticas", + "(Caveman)": "(Caveman)", + "(Headroom)": "(Headroom)", + "(PXPIPE)": "(PXPIPE)", + "(Ponytail)": "(Ponytail)", + "(RTK)": "(RTK)", + "9Router (Entry)": "9Router (Inicial)", + "API": "API", + "API Endpoint": "Endpoint da API", "API Key": "Chave API", - "Connected": "Conectado", - "Disconnected": "Desconectado", - "Active": "Ativo", - "Inactive": "Inativo", - "Success": "Sucesso", - "Failed": "Falha", - "Error": "Erro", - "Warning": "Aviso", - "Info": "Informações", - "Loading": "Carregando", - "Search": "Pesquisar", - "Filter": "Filtrar", - "Sort": "Classificar", - "Export": "Exportar", - "Import": "Importar", - "Refresh": "Atualizar", - "Back": "Voltar", - "Next": "Próximo", - "Previous": "Anterior", - "Submit": "Enviar", - "Confirm": "Confirmar", - "Yes": "Sim", - "No": "Não", - "OK": "OK", - "Apply": "Aplicar", - "Reset": "Redefinir", - "Clear": "Limpar", - "Select": "Selecionar", - "Upload": "Enviar", - "Download": "Baixar", - "Copy": "Copiar", - "Paste": "Colar", - "Cut": "Cortar", - "Undo": "Desfazer", - "Redo": "Refazer", - "Name": "Nome", - "Description": "Descrição", - "Status": "Status", - "Type": "Tipo", - "Date": "Data", - "Time": "Hora", - "Created": "Criado", - "Updated": "Atualizado", - "Actions": "Ações", - "Details": "Detalhes", - "View": "Visualizar", - "New": "Novo", - "Total": "Total", - "Count": "Contagem", - "Price": "Preço", - "Cost": "Custo", - "Free": "Gratuito", - "Paid": "Pago", - "Enable": "Ativar", - "Disable": "Desativar", - "Enabled": "Ativado", - "Disabled": "Desativado", - "Online": "Online", - "Offline": "Offline", - "Available": "Disponível", - "Unavailable": "Indisponível", - "Required": "Obrigatório", - "Optional": "Opcional", - "Default": "Padrão", - "Custom": "Personalizado", - "Advanced": "Avançado", - "Basic": "Básico", - "Help": "Ajuda", - "Support": "Suporte", - "Documentation": "Documentação", - "Version": "Versão", - "Language": "Idioma", - "Theme": "Tema", - "Light": "Claro", - "Dark": "Escuro", - "Auto": "Automático", - "Endpoint": "Ponto de extremidade", - "Combos": "Combinações", - "Quota Tracker": "Rastreador de cota", - "MITM": "MITM", - "CLI Tools": "Ferramentas CLI", - "Console Log": "Log do console", - "System": "Sistema", - "Debug": "Depuração", - "Shutdown": "Desligar", - "Close Proxy": "Fechar proxy", - "Are you sure you want to close the proxy server?": "Tem certeza de que deseja fechar o servidor proxy?", - "Server Disconnected": "Servidor desconectado", - "The proxy server has been stopped.": "O servidor proxy foi parado.", - "Reload Page": "Recarregar página", - "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "O serviço está em execução no terminal. Você pode fechar esta página da web. O desligamento interromperá o serviço.", - "Manage your AI provider connections": "Gerencie suas conexões de provedor de IA", - "Model combos with fallback": "Combinações de modelos com fallback", - "Monitor your API usage, token consumption, and request logs": "Monitore seu uso de API, consumo de tokens e logs de solicitação", - "Intercept CLI tool traffic and route through 9Router": "Intercepte o tráfego da ferramenta CLI e roteie através do 9Router", - "Configure CLI tools": "Configurar ferramentas CLI", + "API Key (for Check)": "Chave API (para Verificação)", + "API Key Created": "Chave API Criada", + "API Keys": "Chaves de API", + "API Token": "Token de API", + "API Tokens": "Tokens de API", + "API Type": "Tipo de API", + "API Version": "Versão da API", "API endpoint configuration": "Configuração do ponto de extremidade da API", - "Manage your preferences": "Gerenciar suas preferências", - "Debug translation flow between formats": "Depurar fluxo de tradução entre formatos", - "Live server console output": "Saída do console do servidor ao vivo", + "AWS Builder ID": "AWS Builder ID", + "AWS IAM Identity Center": "AWS IAM Identity Center", + "AWS Region": "Região AWS", + "AWS region for the key (default: us-east-1)": "Região AWS para a chave (padrão: us-east-1)", + "AWS region for your Identity Center (default: us-east-1)": "Região AWS para seu Identity Center (padrão: us-east-1)", + "About": "Sobre", + "Access token will be auto-filled...": "O token de acesso será preenchido automaticamente...", + "Account": "Conta", + "Account ID": "ID da Conta", + "Account Resources": "Recursos da Conta", + "Accounts per page": "Contas por página", + "Action": "Ação", + "Actions": "Ações", + "Activate": "Ativar", + "Active": "Ativo", + "Active All": "Ativar Todos", + "Active:": "Ativo:", + "Add": "Adicionar", + "Add API Key": "Adicionar Chave de API", + "Add Anthropic Compatible": "Adicionar Compatível com Anthropic", + "Add Connection": "Adicionar Conexão", + "Add Custom Embedding": "Adicionar Embedding Personalizado", + "Add Custom MCP": "Adicionar MCP Personalizado", + "Add Custom Model": "Adicionar Modelo Personalizado", + "Add Model": "Adicionar Modelo", + "Add Model for GitHub Copilot": "Adicionar Modelo para GitHub Copilot", + "Add Model for OpenCode": "Adicionar Modelo para OpenCode", + "Add Model to Combo": "Adicionar Modelo ao Combo", + "Add New Provider": "Adicionar Novo Provedor", + "Add OpenAI Compatible": "Adicionar Compatível com OpenAI", + "Add Provider": "Adicionar Provedor", + "Add Proxy Pool": "Adicionar Pool de Proxy", + "Add a connection to enable importing models.": "Adicione uma conexão para ativar a importação de modelos.", + "Add connection using browser cookie": "Adicionar conexão usando cookie do navegador", + "Add model": "Adicionar modelo", + "Advanced": "Avançado", + "After PXPIPE": "Após PXPIPE", + "After authorization, copy the full URL from your browser address bar.": "Após a autorização, copie a URL completa da barra de endereço do navegador.", + "After installation, run": "Após a instalação, execute", + "All": "Todos", + "All AI Providers": "Todos os Provedores de IA", + "All Providers": "Todos os Provedores", + "All data stored on your machine": "Todos os dados armazenados em sua máquina", + "All models are responding normally.": "Todos os modelos estão respondendo normalmente.", + "All providers": "Todos os provedores", + "Allow dashboard access via tunnel": "Permitir acesso ao painel via túnel", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Solicitação do Antigravity/Copilot IDE → Redirecionamento DNS para localhost:443 → Proxy MITM intercepta → 9Router → resposta para Antigravity/Copilot", + "App Name": "Nome do App", + "Appearance": "Aparência", + "Appends (level) suffix to copied model names": "Adiciona sufixo (nível) aos nomes de modelo copiados", + "Apply": "Aplicar", + "Apply Proxy": "Aplicar Proxy", + "Applying...": "Aplicando...", + "Are you sure you want to close the proxy server?": "Tem certeza de que deseja fechar o servidor proxy?", + "Audio File": "Arquivo de Áudio", + "Auth Mode": "Modo de Autenticação", + "Authenticate": "Autenticar", + "Authentication Successful": "Autenticação Bem-sucedida", + "Authentication Successful!": "Autenticação Bem-sucedida!", + "Authless": "Sem Autenticação", + "Authorization Successful!": "Autorização Bem-sucedida!", + "Authorize": "Autorizar", + "Auto": "Automático", + "Auto (by priority)": "Auto (por prioridade)", + "Auto Refresh (3s)": "Atualização Automática (3s)", + "Auto-detect": "Detecção Automática", + "Auto-detecting token...": "Detectando token automaticamente...", + "Auto-detecting tokens...": "Detectando tokens automaticamente...", + "Auto-ping": "Ping Automático", + "Auto-refresh": "Atualização automática", + "Available": "Disponível", + "Azure Endpoint": "Endpoint Azure", + "Azure OpenAI Configuration": "Configuração Azure OpenAI", + "BXAuth=xxx; ...": "BXAuth=xxx; ...", + "Back": "Voltar", + "Back to CLI Tools": "Voltar para Ferramentas CLI", + "Back to Providers": "Voltar para Provedores", + "Base URL": "URL Base", + "Basic": "Básico", + "Batch Import": "Importar em Lote", + "Batch Import Proxies": "Importar Proxies em Lote", + "Batch Size": "Tamanho do lote", + "Bias the model toward minimal code: YAGNI, reuse stdlib, deletion over addition": "Tendenciar o modelo para código mínimo: YAGNI, reutilizar stdlib, deletar ao invés de adicionar", + "Binary File": "Arquivo Binário", + "Browse MCP Marketplace": "Explorar Marketplace MCP", + "Browse source, README, and examples.": "Navegue pelo código fonte, README e exemplos.", + "Browser Control (Browser MCP)": "Controle do Navegador (Browser MCP)", + "Bulk Add": "Adição em Massa", + "Bypassed": "Ignorado", + "CLI Tools": "Ferramentas CLI", + "Cache Creation": "Criação de Cache", + "Cache Creation:": "Criação de Cache:", + "Cached": "Em Cache", + "Cached Tokens": "Tokens em Cache", + "Cached Tokens:": "Tokens em Cache:", + "Cached:": "Em Cache:", + "Calls per account before switching": "Chamadas por conta antes de alternar", + "Calls per combo model before switching": "Chamadas por modelo de combo antes de alternar", + "Cancel": "Cancelar", + "Capacity auto-switch": "Troca automática de capacidade", + "Cert": "Certificado", + "Change Log": "Registro de Alterações", + "Changelog": "Registro de Alterações", + "Chat": "Chat", + "Chat / code-gen via OpenAI or Anthropic format with streaming.": "Chat / geração de código via formato OpenAI ou Anthropic com streaming.", + "Check again": "Verificar novamente", + "Checking Claude CLI...": "Verificando Claude CLI...", + "Checking Claude Cowork...": "Verificando Claude Cowork...", + "Checking Cline...": "Verificando Cline...", + "Checking Codex CLI...": "Verificando Codex CLI...", + "Checking Copilot config...": "Verificando configuração do Copilot...", + "Checking DeepSeek TUI...": "Verificando DeepSeek TUI...", + "Checking Factory Droid CLI...": "Verificando Factory Droid CLI...", + "Checking Grok Build...": "Verificando Grok Build...", + "Checking Hermes Agent...": "Verificando Hermes Agent...", + "Checking Kilo Code...": "Verificando Kilo Code...", + "Checking Open Claw CLI...": "Verificando Open Claw CLI...", + "Checking OpenCode CLI...": "Verificando OpenCode CLI...", + "Checking jcode CLI...": "Verificando jcode CLI...", + "Checking...": "Verificando...", + "Checking…": "Verificando…", + "Choose how to authenticate with GitLab Duo:": "Escolha como autenticar com GitLab Duo:", + "Choose your authentication method:": "Escolha seu método de autenticação:", + "Claude CLI - Manual Configuration": "Claude CLI - Configuração Manual", + "Claude CLI not detected locally": "Claude CLI não detectado localmente", + "Claude Cowork - Manual Configuration": "Claude Cowork - Configuração Manual", + "Claude Desktop (Cowork mode) not detected": "Claude Desktop (modo Cowork) não detectado", + "Clear": "Limpar", + "Clear (inherit main model for subagents)": "Limpar (herdar modelo principal para subagentes)", + "Clear (will use main model)": "Limpar (usará o modelo principal)", + "Clear Filters": "Limpar Filtros", + "Clear search": "Limpar pesquisa", + "Click": "Clique", + "Click a model to set/clear active": "Clique em um modelo para ativar/desativar", + "Click to add, click again to remove.": "Clique para adicionar, clique novamente para remover.", + "Click to add, click again to remove. Changes are saved automatically.": "Clique para adicionar, clique novamente para remover. As alterações são salvas automaticamente.", + "Click to edit": "Clique para editar", + "Client ID": "ID do Cliente", + "Client Secret": "Segredo do Cliente", + "Client Secret (optional for PKCE)": "Segredo do Cliente (opcional para PKCE)", + "Cline - Manual Configuration": "Cline - Configuração Manual", + "Cline not detected locally": "Cline não detectado localmente", + "Clone and run locally": "Clone e execute localmente", + "Close": "Fechar", + "Close Proxy": "Fechar proxy", + "Close menu": "Fechar menu", + "Close provider filter": "Fechar filtro de provedor", + "Close reset credit expiry modal": "Fechar redefinição de crédito", + "Close test results": "Fechar resultados de teste", + "Closing in": "Fechando em", + "Cloudflare Relay": "Cloudflare Relay", + "Cloudflare Tunnel": "Túnel Cloudflare", + "Cloudflare Workers AI": "Cloudflare Workers AI", + "Codex CLI - Manual Configuration": "Codex CLI - Configuração Manual", + "Codex CLI not detected locally": "Codex CLI não detectado localmente", + "Codex Reset Credit Expiry": "Redefinir Expiração de Crédito do Codex", + "Combo Name": "Nome do Combo", + "Combo Round Robin": "Combo Round Robin", + "Combo Sticky Limit": "Limite Fixo do Combo", + "Combos": "Combinações", + "Coming soon...": "Em breve...", + "Comma-separated hostnames/domains to bypass the proxy.": "Nomes de host/domínios separados por vírgula para contornar o proxy.", + "Company": "Empresa", + "Compress LLM output": "Comprimir saída do LLM", + "Compress context": "Comprimir contexto", + "Compress prompts as images": "Comprimir prompts como imagens", + "Compress prompts via /v1/compress before routing to the model": "Comprimir prompts via /v1/compress antes de rotear para o modelo", + "Compress tool output": "Comprimir saída da ferramenta", + "Compressed": "Comprimido", + "Compressed (est.)": "Comprimido (est.)", + "Compression extras": "Extras de compressão", + "Configure CLI tools": "Configurar ferramentas CLI", + "Configure a new AI provider to use with your applications.": "Configure um novo provedor de IA para usar com suas aplicações.", + "Configure pricing rates for cost tracking and calculations": "Configure taxas de preço para rastreamento e cálculo de custos", + "Configure providers and API keys via web interface": "Configure provedores e chaves de API via interface web", + "Confirm": "Confirmar", + "Confirm New Password": "Confirmar nova senha", + "Confirm Password": "Confirmar Senha", + "Confirm new password": "Confirme a nova senha", + "Connect": "Conectar", + "Connect Cursor IDE": "Conectar Cursor IDE", + "Connect GitLab Duo": "Conectar GitLab Duo", + "Connect Kiro": "Conectar Kiro", + "Connect with OAuth2": "Conectar com OAuth2", + "Connect your account using OAuth2 authentication.": "Conecte sua conta usando autenticação OAuth2.", + "Connected": "Conectado", + "Connected Successfully!": "Conectado com Sucesso!", + "Connection": "Conexão", + "Connection Failed": "Falha na Conexão", + "Connections": "Conexões", + "Console Log": "Log do console", + "Content": "Conteúdo", + "Continue": "Continuar", + "Continue to summary": "Continuar para resumo", + "Continue with GitHub": "Continuar com GitHub", + "Continue with Google": "Continuar com Google", + "Cookie": "Cookie", + "Cookie String": "String de Cookie", + "Copied": "Copiado", + "Copied!": "Copiado!", + "Copy": "Copiar", + "Copy This URL": "Copiar Esta URL", + "Copy combo name": "Copiar nome do combo", + "Copy install command": "Copiar comando de instalação", + "Copy link": "Copiar link", + "Copy the entire cookie string (must include BXAuth)": "Copie a string completa do cookie (deve incluir BXAuth)", + "Cost": "Custo", + "Cost Calculation:": "Cálculo de Custo:", + "Costs": "Custos", + "Could not read Cursor database automatically.": "Não foi possível ler o banco de dados do Cursor automaticamente.", + "Count": "Contagem", + "Create": "Criar", + "Create API Key": "Criar Chave API", + "Create Combo": "Criar Combo", + "Create Cowork Combo": "Criar Combo Cowork", + "Create Key": "Criar Chave", + "Create Provider": "Criar Provedor", + "Create Token": "Criar Token", + "Create a": "Criar um(a)", + "Create a proxy pool entry, then assign it to connections.": "Crie uma entrada de pool de proxy e atribua-a às conexões.", "Create model combos with fallback support": "Crie combinações de modelos com suporte a fallback", - "Local Mode": "Modo local", - "Running on your machine": "Executando em sua máquina", + "Create your first API key to get started": "Crie sua primeira chave de API para começar", + "Created": "Criado", + "Current": "Atual", + "Current Password": "Senha atual", + "Current Pricing Overview": "Visão Geral de Preços Atual", + "Current password": "Senha atual", + "Cursor IDE not detected. Please paste your tokens manually.": "Cursor IDE não detectado. Cole seus tokens manualmente.", + "Custom": "Personalizado", + "Custom Pricing:": "Preço Personalizado:", + "Custom Token": "Token Personalizado", + "Custom accounts per page": "Contas personalizadas por página", + "Custom...": "Personalizado...", + "Cut": "Cortar", + "Cycle through accounts to distribute load": "Percorrer contas para distribuir carga", + "Cycle through providers in combos instead of always starting with first": "Percorrer provedores em combos ao invés de sempre começar pelo primeiro", + "DNS off": "DNS desligado", + "Dark": "Escuro", + "Dashboard": "Painel", + "Data Location:": "Localização dos Dados:", + "Data flows seamlessly from your application through our intelligent routing layer to the best provider for the job.": "Os dados fluem perfeitamente da sua aplicação através de nossa camada de roteamento inteligente para o melhor provedor.", "Database Location": "Localização do banco de dados", - "Download Backup": "Baixar backup", - "Import Backup": "Importar backup", "Database backup downloaded": "Backup do banco de dados baixado", "Database imported successfully": "Banco de dados importado com sucesso", - "Security": "Segurança", - "Require login": "Exigir login", - "When ON, dashboard requires password. When OFF, access without login.": "Quando ATIVO, o painel requer senha. Quando DESATIVO, acesso sem login.", - "Current Password": "Senha atual", - "Enter current password": "Digite a senha atual", - "New Password": "Nova senha", - "Enter new password": "Digite a nova senha", - "Confirm New Password": "Confirmar nova senha", - "Confirm new password": "Confirme a nova senha", - "Update Password": "Atualizar senha", - "Set Password": "Definir senha", - "Password updated successfully": "Senha atualizada com sucesso", - "Passwords do not match": "As senhas não correspondem", - "Routing Strategy": "Estratégia de roteamento", - "Round Robin": "Round Robin", - "Cycle through accounts to distribute load": "Percorrer contas para distribuir carga", - "Sticky Limit": "Limite pegajoso", - "Calls per account before switching": "Chamadas por conta antes de alternar", - "Network": "Rede", - "Outbound Proxy": "Proxy de saída", - "Enable proxy for OAuth + provider outbound requests.": "Ativar proxy para OAuth + solicitações de saída do provedor.", - "Proxy URL": "URL do proxy", - "Leave empty to inherit existing env proxy (if any).": "Deixe em branco para herdar o proxy env existente (se houver).", - "No Proxy": "Sem proxy", - "Comma-separated hostnames/domains to bypass the proxy.": "Nomes de host/domínios separados por vírgula para contornar o proxy.", - "Test proxy URL": "Testar URL do proxy", - "Proxy settings applied": "Configurações de proxy aplicadas", - "Proxy enabled": "Proxy ativado", - "Proxy disabled": "Proxy desativado", - "Proxy test OK": "Teste de proxy OK", - "Proxy test failed": "Falha no teste de proxy", - "Please enter a Proxy URL to test": "Por favor, digite uma URL de proxy para testar", - "Observability": "Observabilidade", + "Date": "Data", + "DateTime": "Data e Hora", + "Deactivate": "Desativar", + "Debug": "Depuração", + "Debug translation flow between formats": "Depurar fluxo de tradução entre formatos", + "DeepSeek TUI - Manual Configuration": "DeepSeek TUI - Configuração Manual", + "DeepSeek TUI not detected locally": "DeepSeek TUI não detectado localmente", + "Default": "Padrão", + "Default Model": "Modelo Padrão", + "Delete": "Excluir", + "Delete connection": "Excluir conexão", + "Delete saved endpoint": "Excluir endpoint salvo", + "Delete selected preset": "Excluir predefinição selecionada", + "Deno Deploy API Token": "Token de API Deno Deploy", + "Deno Deploy v2 runs on a high-performance global edge network": "Deno Deploy v2 roda em uma rede edge global de alto desempenho", + "Deno Relay": "Deno Relay", + "Deploy Cloudflare Relay": "Implantar Relay Cloudflare", + "Deploy Deno Relay": "Implantar Relay Deno", + "Deploy Relay": "Implantar Relay", + "Deploy Vercel Relay": "Implantar Relay Vercel", + "Deploy multiple relays for maximum IP diversity": "Implantar múltiplos relays para máxima diversidade de IP", + "Deploy multiple relays on different accounts for more IP diversity": "Implantar múltiplos relays em contas diferentes para mais diversidade de IP", + "Deployment Name": "Nome da Implantação", + "Description": "Descrição", + "Detail": "Detalhe", + "Details": "Detalhes", + "Dimensions": "Dimensões", + "Disable": "Desativar", + "Disable All": "Desativar Todos", + "Disable Tailscale": "Desativar Tailscale", + "Disable Tunnel": "Desativar Túnel", + "Disable connections with depleted quota on the current page": "Desativar conexões com cota esgotada na página atual", + "Disable this model": "Desativar este modelo", + "Disabled": "Desativado", + "Disconnected": "Desconectado", + "Dismiss notification": "Dispensar notificação", + "Display Name": "Nome de Exibição", + "Display language": "Idioma de exibição", + "Docs": "Documentação", + "Documentation": "Documentação", + "Donate": "Doar", + "Done": "Concluído", + "Download": "Baixar", + "Download Backup": "Baixar backup", + "Drag to reorder": "Arraste para reordenar", + "Duration": "Duração", + "Edit": "Editar", + "Edit Connection": "Editar Conexão", + "Edit Pricing": "Editar Preços", + "Edit connection": "Editar conexão", + "Edit hosts file manually to add the following entries:": "Edite o arquivo hosts manualmente para adicionar as seguintes entradas:", + "Email": "E-mail", + "Embeddings": "Embeddings", + "Enable": "Ativar", + "Enable DNS per tool below to activate interception": "Ativar DNS para cada ferramenta abaixo para ativar a interceptação", "Enable Observability": "Ativar observabilidade", - "Turn request detail recording on/off globally": "Ativar/desativar globalmente o registro de detalhes da solicitação", + "Enable Tunnel": "Ativar Túnel", + "Enable connections that still have quota on the current page": "Ativar conexões que ainda têm cota na página atual", + "Enable proxy for OAuth + provider outbound requests.": "Ativar proxy para OAuth + solicitações de saída do provedor.", + "Enabled": "Ativado", + "End Date": "Data Final", + "Endpoint": "Ponto de extremidade", + "Endpoint is exposed without an API key.": "O endpoint está exposto sem uma chave de API.", + "Enter current password": "Digite a senha atual", + "Enter model id": "Digite o ID do modelo", + "Enter model id (provider-specific)": "Digite o ID do modelo (específico do provedor)", + "Enter new API key": "Digite a nova chave de API", + "Enter new password": "Digite a nova senha", + "Enter password": "Digite a senha", + "Enter sudo password": "Digite a senha sudo", + "Enter the model ID exactly as your compatible endpoint expects it.": "Digite o ID do modelo exatamente como seu endpoint compatível espera.", + "Enter your API key": "Digite sua chave de API", + "Enter your password to access the dashboard": "Digite sua senha para acessar o painel", + "Enter your sudo password to start/stop MITM server": "Digite sua senha sudo para iniciar/parar o servidor MITM", + "EnvironmentVariables": "Variáveis de Ambiente", + "Error": "Erro", + "Error updating setting:": "Erro ao atualizar configuração:", + "Est. Cost": "Custo Est.", + "Estimated, not actual billing": "Estimado, não é a cobrança real", + "Everything you need to manage your AI infrastructure in one place, built for scale.": "Tudo que você precisa para gerenciar sua infraestrutura de IA em um só lugar, projetado para escala.", + "Exa MCP": "Exa MCP", + "Example": "Exemplo", + "Expires At": "Expira Em", + "Expiring first": "Expirando primeiro", + "Export": "Exportar", + "External": "Externo", + "FREE": "GRATUITO", + "Factory Droid - Manual Configuration": "Factory Droid - Configuração Manual", + "Factory Droid CLI not detected locally": "Factory Droid CLI não detectado localmente", + "Fail request if proxy is unreachable instead of falling back to direct.": "Falhar requisição se o proxy estiver inacessível ao invés de cair para direto.", + "Failed": "Falha", + "Failed to load usage statistics.": "Falha ao carregar estatísticas de uso.", + "Failed to start proxy": "Falha ao iniciar proxy", + "Failed to start server": "Falha ao iniciar o servidor", + "Failed to stop server": "Falha ao parar o servidor", + "Fallback": "Fallback", + "Features": "Recursos", + "Filter": "Filtrar", + "Filter accounts by status": "Filtrar contas por status", + "Filter naming": "Filtrar nomenclatura", + "Filter naming requests": "Solicitações de filtro de nomenclatura", + "Filter quota providers": "Filtrar provedores de cota", + "Filters": "Filtros", + "Find MCPs →": "Encontrar MCPs →", + "First Page": "Primeira Página", + "Flush Interval (ms)": "Intervalo de liberação (ms)", + "For enterprise users with custom AWS IAM Identity Center.": "Para usuários empresariais com AWS IAM Identity Center personalizado.", + "Format": "Formato", + "Found on the right side of the Cloudflare dashboard overview page.": "Encontrado no lado direito da página de visão geral do painel Cloudflare.", + "Free": "Gratuito", + "Free Tier Providers": "Provedores Gratuitos", + "Free tier: 100,000 requests per day": "Camada gratuita: 100.000 requisições por dia", + "Free tier: 100GB bandwidth/month, 500K edge invocations": "Camada gratuita: 100GB largura de banda/mês, 500K invocações edge", + "Fresh API key obtained": "Nova chave de API obtida", + "Fusion": "Fusão", + "General": "Geral", + "Get 9Remote": "Obter 9Remote", + "Get API Key": "Obter Chave de API", + "Get API Key →": "Obter Chave de API →", + "Get Started": "Começar", + "Get Started in 30 Seconds": "Comece em 30 Segundos", + "Get started": "Começar", + "Get token →": "Obter token →", + "GitHub": "GitHub", + "GitHub Account": "Conta GitHub", + "GitHub Copilot - Manual Configuration": "GitHub Copilot - Configuração Manual", + "GitLab Access Tokens": "Tokens de Acesso GitLab", + "GitLab Applications": "Aplicativos GitLab", + "GitLab Base URL": "URL Base do GitLab", + "Go to": "Ir para", + "Google Account": "Conta Google", + "Granted At": "Concedido Em", + "Grok Build - Manual Configuration": "Grok Build - Configuração Manual", + "Grok Build not detected locally": "Grok Build não detectado localmente", + "Group models under one name, then pick a strategy per combo:": "Agrupe modelos sob um nome e escolha uma estratégia por combo:", + "Headroom": "Headroom", + "Headroom proxy is reachable. You can enable the token saver.": "Proxy Headroom está acessível. Você pode ativar o economizador de tokens.", + "Health check": "Verificação de integridade", + "Healthy": "Saudável", + "Help": "Ajuda", + "Hermes Agent - Manual Configuration": "Hermes Agent - Configuração Manual", + "Hermes Agent not detected locally": "Hermes Agent não detectado localmente", + "Hidden:": "Oculto:", + "Hide this quota row": "Ocultar esta linha de cota", + "High performance global routing and IP masking via Cloudflare Workers": "Roteamento global de alto desempenho e mascaramento de IP via Cloudflare Workers", + "History": "Histórico", + "How 9Router Works": "Como o 9Router Funciona", + "How Pricing Works": "Como Funcionam os Preços", + "How it Works": "Como Funciona", + "How it works:": "Como funciona:", + "How to generate API token:": "Como gerar o token de API:", + "How to generate your API Token:": "Como gerar seu Token de API:", + "How to get cookie:": "Como obter o cookie:", + "ID:": "ID:", + "Image Generation": "Geração de Imagens", + "Images": "Imagens", + "Import": "Importar", + "Import Backup": "Importar backup", + "Import CLIProxyAPI JSON": "Importar JSON CLIProxyAPI", + "Import Token": "Importar Token", + "In / Out": "Entrada / Saída", + "Inactive": "Inativo", + "Inactive pools are ignored by runtime resolution.": "Pools inativos são ignorados pela resolução em tempo de execução.", + "Info": "Informações", + "Initializing...": "Inicializando...", + "Input": "Entrada", + "Input Tokens": "Tokens de Entrada", + "Input Tokens:": "Tokens de Entrada:", + "Input:": "Entrada:", + "Install": "Instalar", + "Install 9Router": "Instalar 9Router", + "Install 9Router, configure your providers via web dashboard, and start routing AI requests.": "Instale o 9Router, configure seus provedores via painel web e comece a rotear requisições de IA.", + "Install Chrome extension": "Instalar extensão Chrome", + "Install Cline VS Code extension or CLI from": "Instalar extensão Cline VS Code ou CLI de", + "Install Kilo Code from": "Instalar Kilo Code de", + "Install Tailscale": "Instalar Tailscale", + "Install [ml]": "Instalar [ml]", + "Install command:": "Comando de instalação:", + "Install failed": "Falha na instalação", + "Install jcode to enable automatic configuration:": "Instale jcode para ativar a configuração automática:", + "Install then click Start:": "Instale e clique em Iniciar:", + "Install via npm:": "Instalar via npm:", + "Installation Guide": "Guia de Instalação", + "Installing Tailscale...": "Instalando Tailscale...", + "Installing…": "Instalando…", + "Interactive diagram visible on desktop": "Diagrama interativo visível no desktop", + "Intercept CLI tool traffic and route through 9Router": "Intercepte o tráfego da ferramenta CLI e roteie através do 9Router", + "Invalid": "Inválido", + "Issuer URL": "URL do Emissor", + "JSON Response": "Resposta JSON", + "Join developers who are streamlining their AI integrations with 9Router.": "Junte-se aos desenvolvedores que estão otimizando suas integrações de IA com o 9Router.", + "Judge": "Julgador", + "KeepAlive": "KeepAlive", + "Key Name": "Nome da Chave", + "Kill this process to start MITM Server?": "Encerrar este processo para iniciar o Servidor MITM?", + "Kilo Code - Manual Configuration": "Kilo Code - Configuração Manual", + "Kilo Code not detected locally": "Kilo Code não detectado localmente", + "Kiro IDE not detected. Please paste your refresh token manually.": "Kiro IDE não detectado. Cole seu token de atualização manualmente.", + "Kompress-v2 HF model for prose/agentic traces (~+1GB)": "Modelo Kompress-v2 HF para texto/rastros agênticos (~+1GB)", + "Label": "Rótulo", + "Language": "Idioma", + "Last Page": "Última Página", + "Latency": "Latência", + "Latency:": "Latência:", + "Lazy senior dev": "Dev sênior preguiçoso", + "Leave blank to inherit Main Model. Each override keeps its own context window.": "Deixe em branco para herdar o Modelo Principal. Cada substituição mantém sua própria janela de contexto.", + "Leave blank to keep existing secret": "Deixe em branco para manter o segredo existente", + "Leave empty for public PKCE app": "Deixe vazio para app PKCE público", + "Leave empty to inherit existing env proxy (if any).": "Deixe em branco para herdar o proxy env existente (se houver).", + "Legal": "Legal", + "Light": "Claro", + "Live server console output": "Saída do console do servidor ao vivo", + "Load": "Carregar", + "Loading": "Carregando", + "Loading logs...": "Carregando logs...", + "Loading pricing data...": "Carregando dados de preço...", + "Loading registry...": "Carregando registro...", + "Loading reset credits...": "Carregando créditos de redefinição...", + "Loading...": "Carregando...", + "Local": "Local", + "Local Mode": "Modo local", + "Local Mode - All data stored on your machine": "Modo Local - Todos os dados armazenados em sua máquina", + "Local Plugins": "Plugins Locais", + "Login": "Conectar", + "Login Button Label": "Texto do Botão de Login", + "Login URL": "URL de Login", + "Login to your account": "Conecte-se à sua conta", + "Login with your GitHub account (manual callback).": "Faça login com sua conta GitHub (callback manual).", + "Login with your Google account (manual callback).": "Faça login com sua conta Google (callback manual).", + "Logout": "Sair", + "Logs": "Logs", + "Logs are loaded from the request history database.": "Logs são carregados do banco de dados de histórico de requisições.", + "MCP": "MCP", + "MIT License": "Licença MIT", + "MITM": "MITM", + "MITM Server": "Servidor MITM", + "MITM Tools": "Ferramentas MITM", + "Machine ID will be auto-filled...": "O ID da máquina será preenchido automaticamente...", + "Main Model": "Modelo Principal", + "Manage": "Gerenciar", + "Manage your AI provider connections": "Gerencie suas conexões de provedor de IA", + "Manage your preferences": "Gerenciar suas preferências", + "Manual / current endpoint": "Endpoint manual / atual", + "Manual Callback Required": "Callback Manual Necessário", + "Manual Config": "Configuração Manual", + "Manual configuration is still available if 9router is deployed on a remote server.": "A configuração manual ainda está disponível se o 9Router estiver implantado em um servidor remoto.", + "Mask (URL)": "Máscara (URL)", + "Max JSON Size (KB)": "Tamanho máximo de JSON (KB)", "Max Records": "Número máximo de registros", "Maximum request detail records to keep (older records are auto-deleted)": "Número máximo de registros de detalhes de solicitação a manter (registros antigos são excluídos automaticamente)", - "Batch Size": "Tamanho do lote", - "Number of items to accumulate before writing to database (higher = better performance)": "Número de itens a acumular antes de gravar no banco de dados (maior = melhor desempenho)", - "Flush Interval (ms)": "Intervalo de liberação (ms)", - "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Tempo máximo de espera antes de liberar o buffer (evita perda de dados durante baixo tráfego)", - "Max JSON Size (KB)": "Tamanho máximo de JSON (KB)", "Maximum size for each JSON field (request/response) before truncation": "Tamanho máximo para cada campo JSON (solicitação/resposta) antes do truncamento", - "All data stored on your machine": "Todos os dados armazenados em sua máquina", - "MITM Server": "Servidor MITM", - "Running": "Executando", - "Stopped": "Parado", - "Cert": "Certificado", - "Server": "Servidor", - "Purpose:": "Propósito:", - "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Use Antigravity IDE e GitHub Copilot → com QUALQUER provedor/modelo do 9Router", - "How it works:": "Como funciona:", - "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Solicitação do Antigravity/Copilot IDE → Redirecionamento DNS para localhost:443 → Proxy MITM intercepta → 9Router → resposta para Antigravity/Copilot", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Tempo máximo de espera antes de liberar o buffer (evita perda de dados durante baixo tráfego)", + "Media Providers": "Provedores de Mídia", + "Menu": "Menu", + "Message AI": "IA de Mensagens", + "Messages": "Mensagens", + "Minimum prompt size (chars)": "Tamanho mínimo do prompt (caracteres)", + "Model": "Modelo", + "Model ID": "ID do Modelo", + "Model ID (for Check)": "ID do Modelo (para Verificação)", + "Model ID (from OpenRouter)": "ID do Modelo (do OpenRouter)", + "Model ID (optional)": "ID do Modelo (opcional)", + "Model Status": "Status do Modelo", + "Model combos with fallback": "Combinações de modelos com fallback", + "Model is reachable": "Modelo está acessível", + "Model list is filtered from connected providers.": "Lista de modelos é filtrada dos provedores conectados.", + "Model mappings will be available soon.": "Mapeamentos de modelo estarão disponíveis em breve.", + "Model:": "Modelo:", + "Models": "Modelos", + "Monitor your API usage, token consumption, and request logs": "Monitore seu uso de API, consumo de tokens e logs de solicitação", + "More on GitHub": "Mais no GitHub", + "Move down": "Mover para baixo", + "Move up": "Mover para cima", + "My Profile": "Meu Perfil", + "NPM": "NPM", + "Name": "Nome", + "Navigate to home": "Navegar para início", + "Network": "Rede", + "New": "Novo", + "New Password": "Nova senha", + "New password": "Nova senha", + "Next": "Próximo", + "Next accounts page": "Próxima página de contas", + "No": "Não", + "No API keys yet": "Nenhuma chave de API ainda", "No API keys — create one in Keys page": "Sem chaves de API — crie uma na página Chaves", - "sk_9router (default)": "sk_9router (padrão)", + "No MCPs added": "Nenhum MCP adicionado", + "No PXPIPE activity yet": "Nenhuma atividade PXPIPE ainda", + "No Providers Connected": "Nenhum Provedor Conectado", + "No Proxy": "Sem proxy", + "No active connections found for this group.": "Nenhuma conexão ativa encontrada para este grupo.", + "No active proxy pools available.": "Nenhum pool de proxy ativo disponível.", + "No authentication required": "Nenhuma autenticação necessária", + "No combos yet": "Nenhum combo ainda", + "No combos yet.": "Nenhum combo ainda.", + "No connections": "Nenhuma conexão", + "No connections yet": "Nenhuma conexão ainda", + "No console logs yet.": "Nenhum log de console ainda.", + "No conversations yet.": "Nenhuma conversa ainda.", + "No data for this period": "Nenhum dado para este período", + "No install log yet.": "Nenhum log de instalação ainda.", + "No key configured": "Nenhuma chave configurada", + "No language selected": "Nenhum idioma selecionado", + "No languages found.": "Nenhum idioma encontrado.", + "No logs recorded yet.": "Nenhum log registrado ainda.", + "No models": "Nenhum modelo", + "No models added yet": "Nenhum modelo adicionado ainda", + "No models found": "Nenhum modelo encontrado", + "No models selected": "Nenhum modelo selecionado", + "No per-request CPU time limits (unlike Vercel/Cloudflare)": "Sem limites de tempo de CPU por requisição (diferente de Vercel/Cloudflare)", + "No pricing data available": "Nenhum dado de preço disponível", + "No providers connected": "Nenhum provedor conectado", + "No providers match your search": "Nenhum provedor corresponde à sua pesquisa", + "No providers yet.": "Nenhum provedor ainda.", + "No providers.": "Nenhum provedor.", + "No proxy pool entries yet": "Nenhuma entrada no pool de proxy ainda", + "No quota data available": "Nenhum dado de cota disponível", + "No request details found": "Nenhum detalhe de requisição encontrado", + "No requests yet.": "Nenhuma requisição ainda.", + "No reset credit details returned for this account.": "Nenhum detalhe de crédito de redefinição retornado para esta conta.", + "No servers match filter": "Nenhum servidor corresponde ao filtro", + "No tools advertised by server.": "Nenhuma ferramenta anunciada pelo servidor.", + "No usage yet.": "Nenhum uso ainda.", + "None": "Nenhum", + "None (unbind all)": "Nenhum (desvincular todos)", + "Not configured": "Não configurado", + "Not installed": "Não instalado", + "Notice": "Aviso", + "Notifications": "Notificações", + "Number of items to accumulate before writing to database (higher = better performance)": "Número de itens a acumular antes de gravar no banco de dados (maior = melhor desempenho)", + "OAuth": "OAuth", + "OAuth App": "App OAuth", + "OAuth Providers": "Provedores OAuth", + "OIDC": "OIDC", + "OIDC Dashboard Login": "Login no Painel via OIDC", + "OK": "OK", + "Observability": "Observabilidade", + "Office Proxy": "Proxy de Escritório", + "Offline": "Offline", + "Ollama Host URL": "URL do Host Ollama", + "One key per line. Format:": "Uma chave por linha. Formato:", + "One-to-one (rotate)": "Um-para-um (rotacionar)", + "Online": "Online", + "Only from connected providers": "Apenas de provedores conectados", + "Only letters, numbers, -, _ and .": "Apenas letras, números, -, _ e .", + "Open": "Abrir", + "Open Claw - Manual Configuration": "Open Claw - Configuração Manual", + "Open Claw CLI not detected locally": "Open Claw CLI não detectado localmente", + "Open Dashboard": "Abrir Painel", + "Open DevTools (F12) → Application/Storage → Cookies": "Abra DevTools (F12) → Application/Storage → Cookies", + "Open Headroom Dashboard": "Abrir Painel Headroom", + "Open Logs": "Abrir Logs", + "Open menu": "Abrir menu", + "Open source": "Código aberto", + "Open source and free to start.": "Código aberto e gratuito para começar.", + "OpenAI / ElevenLabs / Edge / Google / Deepgram voices.": "Vozes OpenAI / ElevenLabs / Edge / Google / Deepgram.", + "OpenCode - Manual Configuration": "OpenCode - Configuração Manual", + "OpenCode CLI not detected locally": "OpenCode CLI não detectado localmente", + "OpenRouter supports any model. Add models and create aliases for quick access.": "OpenRouter suporta qualquer modelo. Adicione modelos e crie aliases para acesso rápido.", + "Optional": "Opcional", + "Or paste callback URL manually": "Ou cole a URL de callback manualmente", + "Organization": "Organização", + "Organization Domain": "Domínio da Organização", + "Organization ID": "ID da Organização", + "Organization Token": "Token da Organização", + "Organization Tokens": "Tokens da Organização", + "Original": "Original", + "Original (est.)": "Original (est.)", + "Original tokens": "Tokens originais", + "Other": "Outro", + "Our engine analyzes the prompt, checks provider health, and routes for lowest latency or cost.": "Nosso mecanismo analisa o prompt, verifica a integridade do provedor e roteia para menor latência ou custo.", + "Out": "Saída", + "Outbound Proxy": "Proxy de saída", + "Output": "Saída", + "Output Format": "Formato de Saída", + "Output Tokens": "Tokens de Saída", + "Output Tokens:": "Tokens de Saída:", + "Output:": "Saída:", + "PATH": "PATH", + "PXPIPE": "PXPIPE", + "PXPIPE Dashboard": "Painel PXPIPE", + "PXPIPE Logs": "Logs PXPIPE", + "PXPIPE install failed": "Falha na instalação do PXPIPE", + "PXPIPE is not installed.": "PXPIPE não está instalado.", + "PXPIPE restart failed": "Falha ao reiniciar PXPIPE", + "PXPIPE start failed": "Falha ao iniciar PXPIPE", + "PXPIPE stop failed": "Falha ao parar PXPIPE", + "Paid": "Pago", + "Partial preview": "Visualização parcial", + "Password": "Senha", + "Password updated successfully": "Senha atualizada com sucesso", + "Passwords do not match": "As senhas não correspondem", + "Paste": "Colar", + "Paste Proxy List (One per line)": "Cole a Lista de Proxy (um por linha)", + "Paste it below": "Cole abaixo", + "Paste refresh token from Kiro IDE.": "Cole o token de atualização do Kiro IDE.", + "Paste the command into your terminal and press Enter.": "Cole o comando no terminal e pressione Enter.", + "Paste this to your AI:": "Cole isto em sua IA:", + "Paste your Kiro API key...": "Cole sua chave de API Kiro...", + "Paused": "Pausado", + "Permissions": "Permissões", + "Personal Access Token": "Token de Acesso Pessoal", + "Pick the model that fuses panel answers": "Escolha o modelo que funde as respostas do painel", + "Please copy the URL from the address bar and paste it in the application.": "Copie a URL da barra de endereço e cole no aplicativo.", + "Please enter a Proxy URL to test": "Por favor, digite uma URL de proxy para testar", + "Please wait while we complete the authorization.": "Aguarde enquanto concluímos a autorização.", + "Point your CLI tools to http://localhost:20128": "Aponte suas ferramentas CLI para http://localhost:20128", + "Port 443 Already In Use": "Porta 443 já está em uso", + "Port 443 is currently used by another process:": "A porta 443 está sendo usada por outro processo:", + "Powerful Features": "Recursos Poderosos", + "Prefix": "Prefixo", + "Preset": "Predefinição", + "Previous": "Anterior", + "Previous accounts page": "Página anterior de contas", + "Price": "Preço", + "Pricing Configuration": "Configuração de Preços", + "Pricing Format:": "Formato de Preço:", + "Pricing Rates Format": "Formato das Taxas de Preço", + "Pricing Settings": "Configurações de Preço", + "Priority": "Prioridade", + "Privacy Policy": "Política de Privacidade", + "Probing server for tools...": "Verificando servidor por ferramentas...", + "Processing...": "Processando...", + "Product": "Produto", + "Production Key": "Chave de Produção", + "Profile": "Perfil", + "ProgramArguments": "Argumentos do Programa", + "Project Name": "Nome do Projeto", + "Prompt": "Prompt", + "Provider": "Provedor", + "Provider not found": "Provedor não encontrado", + "Provider:": "Provedor:", + "Providers": "Provedores", + "Proxy": "Proxy", + "Proxy Pool": "Pool de Proxy", + "Proxy Pools": "Pools de Proxy", + "Proxy URL": "URL do Proxy", + "Proxy disabled": "Proxy desativado", + "Proxy enabled": "Proxy ativado", + "Proxy settings applied": "Configurações de proxy aplicadas", + "Proxy test OK": "Teste de proxy OK", + "Proxy test failed": "Falha no teste de proxy", + "Purpose:": "Propósito:", + "Python >= 3.10 required for local managed mode.": "Python >= 3.10 necessário para modo gerenciado local.", + "Quota Tracker": "Rastreador de cota", + "Read Documentation": "Ler Documentação", + "Read this skill and use it:": "Leia esta skill e use-a:", + "Ready": "Pronto", + "Ready to Simplify Your AI Infrastructure?": "Pronto para Simplificar Sua Infraestrutura de IA?", + "Reasoning": "Raciocínio", + "Reasoning:": "Raciocínio:", + "Recent Requests": "Requisições Recentes", + "Recent chats": "Chats recentes", + "Recheck": "Verificar novamente", + "Record request details for inspection in the logs view": "Gravar detalhes da requisição para inspeção na visualização de logs", + "Redirect URI": "URI de Redirecionamento", + "Redo": "Refazer", + "Reduction": "Redução", + "Ref Image (URL)": "URL da Imagem de Referência", + "Refresh": "Atualizar", + "Refresh all": "Atualizar tudo", + "Refresh quota": "Atualizar cota", + "Region": "Região", + "Reload Page": "Recarregar página", + "Reload VS Code after applying for changes to take effect.": "Recarregue o VS Code após aplicar para que as alterações entrem em vigor.", + "Remaining": "Restante", + "Remote": "Remoto", + "Remove": "Remover", + "Remove [code] and its packages?": "Remover [code] e seus pacotes?", + "Remove [ml] and its packages?": "Remover [ml] e seus pacotes?", + "Remove attachment": "Remover anexo", + "Remove custom model": "Remover modelo personalizado", + "Remove failed": "Falha ao remover", + "Remove model": "Remover modelo", + "Repair": "Reparar", + "Replaces built-in WebSearch/WebFetch. Auto-strips duplicates from tool list.": "Substitui WebSearch/WebFetch nativos. Remove automaticamente duplicatas da lista de ferramentas.", + "Request": "Requisição", + "Request Details": "Detalhes da Requisição", + "Request Logs": "Logs de Requisições", + "Requests": "Requisições", + "Requests smaller than this bypass PXPIPE and are sent as-is.": "Requisições menores que isso ignoram PXPIPE e são enviadas como estão.", + "Requests without a valid key will be rejected": "Requisições sem uma chave válida serão rejeitadas", + "Require API key": "Exigir chave de API", + "Require login": "Exigir login", + "Required": "Obrigatório", + "Required for SSL certificate and DNS configuration": "Necessário para certificado SSL e configuração de DNS", + "Required for SSL certificate and server startup": "Necessário para certificado SSL e inicialização do servidor", + "Required to modify /etc/hosts and flush DNS cache": "Necessário para modificar /etc/hosts e limpar cache DNS", + "Requires Cloudflare Account ID and a Workers API Token": "Requer ID da Conta Cloudflare e um Token de API Workers", + "Requires outbound port 7844 (TCP/UDP). Connection may take 10-30s.": "Requer porta de saída 7844 (TCP/UDP). Conexão pode levar 10-30s.", + "Reset": "Redefinir", + "Reset Codex limit?": "Redefinir limite do Codex?", + "Reset Password to Default": "Redefinir Senha para Padrão", + "Reset judge to Auto": "Redefinir julgador para Automático", + "Reset to Defaults": "Redefinir para Padrões", + "Resources": "Recursos", + "Response": "Resposta", + "Response Format": "Formato da Resposta", + "Restart": "Reiniciar", + "Restart failed": "Falha ao reiniciar", + "Restarting proxy…": "Reiniciando proxy…", + "Restore model": "Restaurar modelo", + "Retry": "Tentar novamente", + "Risk Notice": "Aviso de Risco", + "Rotation Strategy": "Estratégia de Rotação", + "Round Robin": "Round Robin", + "Route Requests": "Rotear Requisições", + "Routing Strategy": "Estratégia de roteamento", + "Rows:": "Linhas:", + "Run": "Executar", + "Run npx command to start the server instantly": "Execute o comando npx para iniciar o servidor instantaneamente", + "RunAtLoad": "Executar ao Carregar", + "Running": "Executando", + "Running on your machine": "Executando em sua máquina", + "SSE URL": "URL SSE", + "START HERE": "COMECE AQUI", + "Save": "Salvar", + "Save Mappings": "Salvar Mapeamentos", + "Save auth mode": "Salvar modo de autenticação", + "Save current Base URL and API key as a browser-local preset": "Salvar URL Base e chave de API atuais como predefinição local do navegador", + "Save this key now!": "Salve esta chave agora!", + "Saved": "Salvo", + "Scopes": "Escopos", + "Scroll down to": "Role para baixo até", + "Search": "Pesquisar", + "Search by name or description...": "Pesquisar por nome ou descrição...", + "Search language...": "Pesquisar idioma...", + "Search models...": "Pesquisar modelos...", + "Search providers...": "Pesquisar provedores...", + "Search...": "Pesquisar...", + "Security": "Segurança", + "Security risk: no password set.": "Risco de segurança: nenhuma senha definida.", + "Security risk: no password set. You will be asked to set one when logging in remotely.": "Risco de segurança: nenhuma senha definida. Será solicitado que você defina uma ao fazer login remotamente.", + "Select": "Selecionar", + "Select All": "Selecionar Todos", + "Select Cowork Model": "Selecionar Modelo Cowork", + "Select Endpoint": "Selecionar Endpoint", + "Select Judge Model": "Selecionar Modelo Julgador", + "Select Language": "Selecionar Idioma", + "Select Model": "Selecionar Modelo", + "Select Model for Cline": "Selecionar Modelo para Cline", + "Select Model for Codex": "Selecionar Modelo para Codex", + "Select Model for DeepSeek TUI": "Selecionar Modelo para DeepSeek TUI", + "Select Model for Factory Droid": "Selecionar Modelo para Factory Droid", + "Select Model for Hermes Agent": "Selecionar Modelo para Hermes Agent", + "Select Model for Kilo Code": "Selecionar Modelo para Kilo Code", + "Select Model for Open Claw": "Selecionar Modelo para Open Claw", + "Select Model for jcode": "Selecionar Modelo para jcode", + "Select Subagent Model for Codex": "Selecionar Modelo de Subagente para Codex", + "Select Subagent Model for OpenCode": "Selecionar Modelo de Subagente para OpenCode", + "Select a provider": "Selecionar um provedor", + "Select language": "Selecionar idioma", + "Select your": "Selecione seu(sua)", + "Selected provider": "Provedor selecionado", + "Send": "Enviar", + "Server": "Servidor", + "Server Disconnected": "Servidor desconectado", + "Server off": "Servidor desligado", "Server started": "Servidor iniciado", - "Failed to start server": "Falha ao iniciar o servidor", "Server stopped — all DNS cleared": "Servidor parado — todo DNS foi limpo", - "Failed to stop server": "Falha ao parar o servidor", - "Sudo password is required": "Senha sudo é necessária", - "Stop Server": "Parar servidor", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "O serviço está em execução no terminal. Você pode fechar esta página da web. O desligamento interromperá o serviço.", + "Set Password": "Definir senha", + "Set a new password before accessing the dashboard remotely.": "Defina uma nova senha antes de acessar o painel remotamente.", + "Set password": "Definir senha", + "Settings": "Configurações", + "Setup": "Configurar", + "Setup + index of all capabilities. Start here — covers base URL, auth, model discovery, and links to every capability skill.": "Configuração + índice de todas as capacidades. Comece aqui — cobre URL base, autenticação, descoberta de modelos e links para todas as skills.", + "Setup Headroom": "Configurar Headroom", + "Setup PXPIPE": "Configurar PXPIPE", + "Show this quota row": "Mostrar esta linha de cota", + "Shutdown": "Desligar", + "Sign in with OIDC": "Entrar com OIDC", + "Single": "Único", + "Sort": "Classificar", + "Sort Codex quotas by remaining": "Ordenar cotas do Codex por saldo restante", + "Sort accounts by earliest quota reset time": "Ordenar contas pelo horário de redefinição de cota", + "Speech-to-Text": "Fala-para-Texto", + "StandardErrorPath": "Caminho do Erro Padrão", + "StandardOutPath": "Caminho da Saída Padrão", + "Start": "Iniciar", + "Start DNS": "Iniciar DNS", + "Start Date": "Data de Início", + "Start Free": "Começar Gratuito", + "Start Headroom": "Iniciar Headroom", + "Start Headroom separately at the configured URL, then recheck.": "Inicie o Headroom separadamente na URL configurada e verifique novamente.", + "Start MITM": "Iniciar MITM", "Start Server": "Iniciar servidor", - "Enable DNS per tool below to activate interception": "Ativar DNS para cada ferramenta abaixo para ativar a interceptação", - "Sudo Password Required": "Senha Sudo necessária", - "Enter your sudo password to start/stop MITM server": "Digite sua senha sudo para iniciar/parar o servidor MITM", + "Start Tunnel": "Iniciar Túnel", + "Start a conversation": "Iniciar uma conversa", + "Starting…": "Iniciando…", + "Status": "Status", + "Status:": "Status:", + "Step 1: Open this URL in your browser": "Passo 1: Abra esta URL no seu navegador", + "Step 2: Paste the callback URL here": "Passo 2: Cole a URL de callback aqui", + "Sticky Limit": "Limite pegajoso", + "Sticky:": "Fixo:", + "Stop": "Parar", + "Stop DNS": "Parar DNS", + "Stop Headroom": "Parar Headroom", + "Stop MITM": "Parar MITM", + "Stop Server": "Parar servidor", + "Stopped": "Parado", + "Stopping…": "Parando…", + "Strict Proxy": "Proxy Estrito", + "Subagent Model": "Modelo de Subagente", + "Subagent model overrides": "Substituições de modelo de subagente", + "Submit": "Enviar", + "Success": "Sucesso", "Sudo Password": "Senha sudo", - "Click to add, click again to remove. Changes are saved automatically.": "Clique para adicionar, clique novamente para remover. As alterações são salvas automaticamente.", - "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ Aviso de Risco: Este provedor usa uma sessão de assinatura/OAuth não licenciada oficialmente para uso de proxy/roteador. A conta pode ser restrita ou banida. Use por sua conta e risco.", + "Sudo Password Required": "Senha Sudo necessária", + "Sudo password is required": "Senha sudo é necessária", + "Support": "Suporte", + "Switch language": "Trocar idioma", + "System": "Sistema", + "TTFT:": "TTFT:", + "Tailscale": "Tailscale", + "Tailscale Funnel": "Tailscale Funnel", + "Tailscale Funnel will be stopped.": "O Tailscale Funnel será parado.", + "Tailscale installed": "Tailscale instalado", + "Tailscale is not installed. Install it to enable Funnel.": "Tailscale não está instalado. Instale para ativar o Funnel.", + "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.": "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.", + "Temperature": "Temperatura", + "Terms of Service": "Termos de Serviço", + "Terse-style system prompt → ~65% fewer output tokens (up to 87%)": "Prompt de sistema conciso → ~65% menos tokens de saída (até 87%)", + "Test Example": "Exemplo de Teste", + "Test Results": "Resultados do Teste", + "Test all API Key connections": "Testar todas as conexões de Chave API", + "Test all Free connections": "Testar todas as conexões Gratuitas", + "Test all Free provider connections": "Testar todas as conexões de provedores Gratuitos", + "Test all OAuth connections": "Testar todas as conexões OAuth", + "Test connection": "Testar conexão", + "Test proxy": "Testar proxy", + "Test proxy URL": "Testar URL do proxy", + "Text-to-Speech": "Texto-para-Fala", + "Text-to-image via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…": "Texto-para-imagem via DALL-E, Imagen, FLUX, MiniMax, SDWebUI…", + "The Cloudflare tunnel will be disconnected.": "O túnel Cloudflare será desconectado.", + "The proxy server has been stopped.": "O servidor proxy foi parado.", + "The unified endpoint for AI generation. Connect, route, and manage your AI providers with ease.": "O endpoint unificado para geração de IA. Conecte, roteie e gerencie seus provedores de IA com facilidade.", + "The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "A interface unificada para infraestrutura de IA moderna. Segura, observável e escalável.", + "Theme": "Tema", + "Thinking Process": "Processo de Raciocínio", + "This is the only time you will see this key. Store it securely.": "Esta é a única vez que você verá esta chave. Armazene-a com segurança.", + "Time": "Hora", + "Timestamp": "Timestamp", + "Timestamp:": "Timestamp:", + "Toggle auto-ping": "Alternar ping automático", + "Token Saver": "Economizador de Tokens", + "Token Saver settings": "Configurações do Economizador de Tokens", + "Token Types:": "Tipos de Token:", + "Tokens": "Tokens", + "Tools": "Ferramentas", + "Total": "Total", + "Total Input Tokens": "Total de Tokens de Entrada", + "Total Models": "Total de Modelos", + "Total Requests": "Total de Requisições", + "Total:": "Total:", + "Transcribe audio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…": "Transcreva áudio via OpenAI Whisper, Groq, Gemini, Deepgram, AssemblyAI…", + "Translator Debug": "Depuração do Tradutor", + "Tried in order (top-down) or rotated when round-robin is on.": "Tentado em ordem (de cima para baixo) ou rotacionado quando round-robin está ativo.", + "Trust Cert": "Confiar Certificado", + "Try Again": "Tentar Novamente", + "Tunnel": "Túnel", + "Turn off Empty": "Desligar Vazio", + "Turn on Available": "Ligar Disponível", + "Turn request detail recording on/off globally": "Ativar/desativar globalmente o registro de detalhes da solicitação", + "Twitter": "Twitter", + "Type": "Tipo", + "URL → markdown / text / HTML via Firecrawl, Jina, Tavily, Exa.": "URL → markdown / texto / HTML via Firecrawl, Jina, Tavily, Exa.", + "Unavailable": "Indisponível", + "Under": "Abaixo", + "Undo": "Desfazer", + "Uninstall": "Desinstalar", + "Uninstalling…": "Desinstalando…", + "Update": "Atualizar", + "Update 9Router": "Atualizar 9Router", + "Update Password": "Atualizar senha", + "Update now": "Atualizar agora", + "Updated": "Atualizado", + "Upload": "Enviar", + "Uptime": "Tempo de atividade", + "Usage": "Uso", + "Usage Logs": "Logs de Uso", + "Usage:": "Uso:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Use Antigravity IDE e GitHub Copilot → com QUALQUER provedor/modelo do 9Router", + "Use a GitLab OAuth application": "Usar um aplicativo OAuth GitLab", + "Use a GitLab PAT with api scope": "Usar um PAT GitLab com escopo de API", + "Valid": "Válido", + "Vectors for RAG / semantic search via OpenAI, Gemini, Mistral…": "Vetores para RAG / busca semântica via OpenAI, Gemini, Mistral…", + "Vercel API Token": "Token de API Vercel", + "Vercel Relay": "Vercel Relay", + "Version": "Versão", + "View": "Visualizar", + "View Codex reset credit expiry": "Ver expiração de crédito do Codex", + "View Full Details": "Ver Detalhes Completos", + "View on GitHub": "Ver no GitHub", + "Visit the login URL below and authorize:": "Visite a URL de login abaixo e autorize:", + "Voice": "Voz", + "Voice ID": "ID de Voz", + "Voyage AI": "Voyage AI", + "Waiting for authorization...": "Aguardando autorização...", + "Warning": "Aviso", + "Web Fetch": "Fetch Web", + "Web Search": "Busca Web", + "What is Cloudflare Relay?": "O que é Cloudflare Relay?", + "What is Deno Relay?": "O que é Deno Relay?", + "What is Vercel Relay?": "O que é Vercel Relay?", + "When": "Quando", + "When ON, dashboard requires password. When OFF, access without login.": "Quando ATIVO, o painel requer senha. Quando DESATIVO, acesso sem login.", + "Windows: Run terminal (9Router) as Administrator to enable MITM": "Windows: Execute o terminal (9Router) como Administrador para ativar MITM", + "Worker Name": "Nome do Worker", + "Writes to": "Grava em", + "Yes": "Sim", + "You will be asked to set one when logging in remotely.": "Será solicitado que você defina uma ao fazer login remotamente.", + "Your Account Name": "Nome da Sua Conta", + "Your Code": "Seu Código", + "Your OAuth application client ID": "ID do cliente do seu aplicativo OAuth", + "Your requests start from your favorite tools or our unified SDK.": "Suas requisições começam de suas ferramentas favoritas ou do nosso SDK unificado.", + "[ml] downloads ~1 GB (torch + huggingface-hub). Continue?": "[ml] baixa ~1 GB (torch + huggingface-hub). Continuar?", + "extras status failed": "falha no status dos extras", + "git/grep/ls/tree/logs → 60-90% fewer input tokens": "git/grep/ls/tree/logs → 60-90% menos tokens de entrada", + "not installed": "não instalado", + "sk_9router (default)": "sk_9router (padrão)", + "tree-sitter AST compression for code responses": "Compressão AST tree-sitter para respostas de código", "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM intercepta tráfego HTTPS de ferramentas IDE (Antigravity, GitHub Copilot, Kiro) via CA local para redirecionar solicitações aos seus provedores. Pode violar ToS → risco de banimento de conta. Use por sua conta e risco.", - "Endpoint is exposed without an API key.": "O endpoint está exposto sem uma chave de API." -} + "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ Aviso de Risco: Este provedor usa uma sessão de assinatura/OAuth não licenciada oficialmente para uso de proxy/roteador. A conta pode ser restrita ou banida. Use por sua conta e risco." +} \ No newline at end of file diff --git a/public/providers/api-airforce.png b/public/providers/api-airforce.png new file mode 100644 index 00000000..9fa71e6a Binary files /dev/null and b/public/providers/api-airforce.png differ diff --git a/public/providers/baidu.png b/public/providers/baidu.png new file mode 100644 index 00000000..8ad826f2 Binary files /dev/null and b/public/providers/baidu.png differ diff --git a/public/providers/bazaarlink.png b/public/providers/bazaarlink.png new file mode 100644 index 00000000..2a16fd77 Binary files /dev/null and b/public/providers/bazaarlink.png differ diff --git a/public/providers/bluesminds.png b/public/providers/bluesminds.png new file mode 100644 index 00000000..1e5ef31e Binary files /dev/null and b/public/providers/bluesminds.png differ diff --git a/public/providers/codebuddy-intl.png b/public/providers/codebuddy-intl.png new file mode 100644 index 00000000..c836282f Binary files /dev/null and b/public/providers/codebuddy-intl.png differ diff --git a/public/providers/devin-cli.png b/public/providers/devin-cli.png new file mode 100644 index 00000000..1fe62d23 Binary files /dev/null and b/public/providers/devin-cli.png differ diff --git a/public/providers/kilo-gateway.png b/public/providers/kilo-gateway.png new file mode 100644 index 00000000..99ea60b0 Binary files /dev/null and b/public/providers/kilo-gateway.png differ diff --git a/public/providers/llm7.png b/public/providers/llm7.png new file mode 100644 index 00000000..4f2a70a0 Binary files /dev/null and b/public/providers/llm7.png differ diff --git a/public/providers/morph.png b/public/providers/morph.png index 3034fa5e..82f938f3 100644 Binary files a/public/providers/morph.png and b/public/providers/morph.png differ diff --git a/public/providers/poolside.png b/public/providers/poolside.png new file mode 100644 index 00000000..592d604e Binary files /dev/null and b/public/providers/poolside.png differ diff --git a/public/providers/sambanova.png b/public/providers/sambanova.png new file mode 100644 index 00000000..38b016c7 Binary files /dev/null and b/public/providers/sambanova.png differ diff --git a/public/providers/tencent.png b/public/providers/tencent.png new file mode 100644 index 00000000..6293ff7d Binary files /dev/null and b/public/providers/tencent.png differ diff --git a/public/providers/trae.png b/public/providers/trae.png new file mode 100644 index 00000000..c056daf0 Binary files /dev/null and b/public/providers/trae.png differ diff --git a/public/providers/windsurf.png b/public/providers/windsurf.png new file mode 100644 index 00000000..c97179a2 Binary files /dev/null and b/public/providers/windsurf.png differ diff --git a/public/providers/workbuddy.png b/public/providers/workbuddy.png new file mode 100644 index 00000000..c836282f Binary files /dev/null and b/public/providers/workbuddy.png differ diff --git a/public/providers/zed.png b/public/providers/zed.png new file mode 100644 index 00000000..009c3e9d Binary files /dev/null and b/public/providers/zed.png differ diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index fb5e4557..096a6f81 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -276,6 +276,16 @@ export default function ProvidersPage() { })) .filter((p) => matchSearch(p.name)); + // Dual-auth providers (oauth + apikey) store API keys as authType "apikey" + // (and sometimes "api_key"). Card stats must count both so totals match detail. + // kiro has no authModes in registry but accepts both (headless uses "api_key"). + const dualAuthTypes = (info, key) => { + if (key === "kiro") return ["oauth", "apikey", "api_key"]; + const modes = info?.authModes; + if (!Array.isArray(modes) || !modes.includes("apikey")) return "oauth"; + return ["oauth", "apikey", "api_key"]; + }; + const oauthEntries = sortByPriority( Object.entries(OAUTH_PROVIDERS).filter(([, info]) => !info.hidden && matchSearch(info.name)), "oauth", @@ -287,15 +297,27 @@ export default function ProvidersPage() { matchSearch(info.name), ) .sort(([, a], [, b]) => (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0)); - const freeTierEntries = sortByPriority( - Object.entries(FREE_TIER_PROVIDERS).filter( + // Free Tier cards may be oauth-only (e.g. kimchi) or dual-auth, so count via + // dualAuthTypes per provider instead of a fixed "apikey" — otherwise oauth + // connections are invisible here (mismatch with the detail page). + const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS) + .filter( ([, info]) => !info.hidden && matchSearch(info.name) && (info.serviceKinds ?? ["llm"]).includes("llm"), - ), - "freeTier", - ).sort(([, a], [, b]) => (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0)); + ) + .sort(([ka, a], [kb, b]) => { + const pa = a.priority ?? 999; + const pb = b.priority ?? 999; + if (pa !== pb) return pa - pb; + const noAuthDiff = (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0); + if (noAuthDiff !== 0) return noAuthDiff; + const ca = getProviderStats(ka, dualAuthTypes(a, ka)).connected > 0 ? 0 : 1; + const cb = getProviderStats(kb, dualAuthTypes(b, kb)).connected > 0 ? 0 : 1; + if (ca !== cb) return ca - cb; + return (a.name || "").localeCompare(b.name || ""); + }); // API Key: connected providers first, then alphabetical by name const apikeyEntries = Object.entries(APIKEY_PROVIDERS) .filter( @@ -429,16 +451,19 @@ export default function ProvidersPage() {
- {oauthEntries.map(([key, info]) => ( - handleToggleProvider(key, "oauth", active)} - /> - ))} + {oauthEntries.map(([key, info]) => { + const authTypes = dualAuthTypes(info, key); + return ( + handleToggleProvider(key, authTypes, active)} + /> + ); + })}
)} @@ -471,12 +496,9 @@ export default function ProvidersPage() {
{freeEntries.map(([key, info]) => { - // Kiro accepts both OAuth and api-key connections; count/toggle both - // so the card total matches the provider detail page (#kiro-apikey). - // Kiro's headless api-key flow persists authType "api_key" (underscore), - // while generic apikey providers use "apikey" — include both spellings. - const freeAuthTypes = - key === "kiro" ? ["oauth", "apikey", "api_key"] : "oauth"; + // Dual-auth (e.g. kiro): count/toggle oauth + apikey/api_key so the + // card total matches the provider detail page. + const freeAuthTypes = dualAuthTypes(info, key); return ( ); })} - {freeTierEntries.map(([key, info]) => ( - handleToggleProvider(key, "apikey", active)} - /> - ))} + {freeTierEntries.map(([key, info]) => { + const freeAuthTypes = dualAuthTypes(info, key); + return ( + handleToggleProvider(key, freeAuthTypes, active)} + /> + ); + })}
)} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js index 073c0f88..b782bb2e 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js @@ -146,103 +146,100 @@ export default function QuotaTable({ )} -
- - - {currentPageRows.map((quota) => { - const colors = getColorClasses(quota.remaining); - const countdown = formatResetTime(quota.resetAt); - const resetDisplay = formatResetTimeDisplay(quota.resetAt); - // recurring defaults true: a missing flag means the quota - // refreshes at resetAt. Bonus/one-shot packs set recurring:false - // and their resetAt is a hard expiry, so word it as "expires". - const recurring = quota.recurring !== false; - const countdownLabel = recurring ? `in ${countdown}` : `expires in ${countdown}`; +
+ {currentPageRows.map((quota) => { + const colors = getColorClasses(quota.remaining); + const countdown = formatResetTime(quota.resetAt); + const resetDisplay = formatResetTimeDisplay(quota.resetAt); + // recurring defaults true: a missing flag means the quota + // refreshes at resetAt. Bonus/one-shot packs set recurring:false + // and their resetAt is a hard expiry, so word it as "expires". + const recurring = quota.recurring !== false; + const countdownLabel = recurring ? `in ${countdown}` : `expires in ${countdown}`; - return ( -
+ {/* Name */} +
+ {colors.emoji} + + {quota.name} + +
+ + {/* Progress + used/total */} +
+
+
+
+ +
+ 0 ? quota.total.toLocaleString() : "∞"}`} + > + {quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"} + + + {quota.remaining}% + +
+
+ + {/* Reset time */} +
+ {countdown !== "-" || resetDisplay ? ( + compact ? ( +
+ {countdown !== "-" ? countdownLabel : resetDisplay} +
+ ) : ( +
+ {countdown !== "-" && ( +
+ {countdownLabel} +
+ )} + {resetDisplay && ( +
+ {resetDisplay} +
+ )} +
+ ) + ) : ( +
N/A
+ )} +
+ + {/* Hide action */} + {hasHideAction && ( +
- - - - - - {hasHideAction && ( - - )} - - ); - })} - -
-
- {colors.emoji} - - {quota.name} - -
-
-
-
-
-
- -
- - {quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"} - - - {quota.remaining}% - -
-
-
- {countdown !== "-" || resetDisplay ? ( - compact ? ( -
- {countdown !== "-" ? countdownLabel : resetDisplay} -
- ) : ( -
- {countdown !== "-" && ( -
- {countdownLabel} -
- )} - {resetDisplay && ( -
- {resetDisplay} -
- )} -
- ) - ) : ( -
N/A
- )} -
- -
+ + visibility_off + + + )} +
+ ); + })} {totalPages > 1 && ( diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index 9d01e0a8..00fc63ee 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -1241,7 +1241,7 @@ export default function ProviderLimits() { visibility_off Hidden: -
+
{hiddenQuotaRows.map((quotaRow) => ( + +
+ + {authMode === "browser" && ( + <> + {step === "waiting" && ( +
+ progress_activity + Waiting for browser authorization… +
+ )} + {step === "input" && ( +
+

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

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

{PASTE_TOKEN_PROVIDERS[provider].instructions}

+ setPasteToken(e.target.value)} + placeholder={PASTE_TOKEN_PROVIDERS[provider].placeholder} + className="font-mono text-xs" + /> +
+ + +
+
+ )} + + )} + + {/* Waiting + Manual Input combined (non-device-code, non-proxy) */} + {(step === "waiting" || step === "input") && !isDeviceCode && !PROXY_OAUTH_PROVIDERS.has(provider) && ( <> {/* Option A: Auto via popup */}
diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 29488b24..ddbfc7a9 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -8,9 +8,12 @@ export const MITM_TOOLS = { description: "Google Antigravity IDE with MITM", configType: "mitm", mitmDomain: "daily-cloudcode-pa.googleapis.com", - modelAliases: ["gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"], + modelAliases: ["gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"], defaultModels: [ - { id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium) / Default", alias: "gemini-3.5-flash-low" }, + { id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", alias: "gemini-3.6-flash-high" }, + { id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", alias: "gemini-3.6-flash-medium" }, + { id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)", alias: "gemini-3.6-flash-low" }, + { id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium) / Default", alias: "gemini-3.5-flash-low", mandatory: true }, { id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)", alias: "gemini-3-flash-agent" }, { id: "gemini-3.5-flash-extra-low", name: "Gemini 3.5 Flash (Low)", alias: "gemini-3.5-flash-extra-low" }, { id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)", alias: "gemini-3.1-pro-low" }, @@ -105,7 +108,7 @@ export const CLI_TOOLS = { settingsFile: "~/.claude/settings.json", defaultModels: [ { id: "fable", name: "Claude Fable", alias: "fable", envKey: "ANTHROPIC_DEFAULT_FABLE_MODEL", defaultValue: "cc/claude-fable-5" }, - { id: "opus", name: "Claude Opus", alias: "opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-8" }, + { id: "opus", name: "Claude Opus", alias: "opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-5" }, { id: "sonnet", name: "Claude Sonnet", alias: "sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-5" }, { id: "haiku", name: "Claude Haiku", alias: "haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" }, ], @@ -164,6 +167,182 @@ export const CLI_TOOLS = { description: "GitHub Copilot in VS Code via custom models", configType: "custom", }, + kilo: { + id: "kilo", + name: "Kilo Code", + image: "/providers/kilocode.png", + color: "#FF6B6B", + description: "Kilo Code AI Assistant", + configType: "custom", + }, + roo: { + id: "roo", + name: "Roo", + image: "/providers/roo.png", + color: "#FF6B6B", + description: "Roo AI Assistant", + configType: "guide", + guideSteps: [ + { step: 1, title: "Open Settings", desc: "Go to Roo Settings panel" }, + { step: 2, title: "Select Provider", desc: "Choose API Provider → Ollama" }, + { step: 3, title: "Base URL", value: "{{baseUrl}}", copyable: true }, + { step: 4, title: "API Key", type: "apiKeySelector" }, + { step: 5, title: "Select Model", type: "modelSelector" }, + ], + }, + continue: { + id: "continue", + name: "Continue", + image: "/providers/continue.png", + color: "#7C3AED", + description: "Continue AI Assistant", + configType: "guide", + guideSteps: [ + { step: 1, title: "Open Config", desc: "Open Continue configuration file" }, + { step: 2, title: "API Key", type: "apiKeySelector" }, + { step: 3, title: "Select Model", type: "modelSelector" }, + { step: 4, title: "Add Model Config", desc: "Add the following configuration to your models array:" }, + ], + codeBlock: { + language: "json", + code: `{ + "apiBase": "{{baseUrl}}", + "title": "{{model}}", + "model": "{{model}}", + "provider": "openai", + "apiKey": "{{apiKey}}" +}`, + }, + }, + amp: { + id: "amp", + name: "Amp CLI", + image: "/providers/amp.png", + color: "#F97316", + description: "Sourcegraph Amp coding assistant CLI", + docsUrl: "/docs?section=cli-tools&tool=amp", + configType: "guide", + defaultCommand: "amp", + modelAliases: ["g25p", "g25f", "cs45", "g54"], + notes: [ + { type: "info", text: "Use 9Router model aliases to keep Amp shorthand mappings stable across provider updates." }, + { type: "warning", text: "Suggested shorthand examples: g25p → gemini/gemini-2.5-pro, g25f → gemini/gemini-2.5-flash, cs45 → cc/claude-sonnet-4-5-20250929." }, + ], + guideSteps: [ + { step: 1, title: "Install Amp", desc: "Install the Amp CLI using the package manager supported by your environment." }, + { step: 2, title: "API Key", type: "apiKeySelector" }, + { step: 3, title: "Base URL", value: "{{baseUrl}}", copyable: true }, + { step: 4, title: "Select Model", type: "modelSelector" }, + { step: 5, title: "Add Shorthands", desc: "Map Amp shorthand names such as g25p or cs45 to 9Router aliases in your local config." }, + ], + codeBlock: { + language: "bash", + code: `export OPENAI_API_KEY="{{apiKey}}" +export OPENAI_BASE_URL="{{baseUrl}}" +amp --model "{{model}}" +# Example shorthand aliases you can map locally: +# g25p -> gemini/gemini-2.5-pro +# cs45 -> cc/claude-sonnet-4-5-20250929`, + }, + }, + qwen: { + id: "qwen", + name: "Qwen Code", + image: "/providers/qwen.png", + color: "#10B981", + description: "Alibaba Qwen Code CLI — supports OpenAI, Anthropic & Gemini providers via 9Router", + docsUrl: "https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/", + configType: "guide", + defaultCommand: "qwen", + notes: [ + { type: "info", text: "Qwen Code supports multiple provider types (openai, anthropic, gemini) via modelProviders in settings.json. 9Router works as an OpenAI-compatible endpoint." }, + { type: "info", text: "Any model available in 9Router can be used — not just Qwen models. Select from Qwen, Claude, Gemini, GPT, and more." }, + { type: "warning", text: "Config path: Linux/macOS ~/.qwen/settings.json • Windows %USERPROFILE%\\.qwen\\settings.json" }, + { type: "error", text: "Qwen OAuth free tier was discontinued on 2026-04-15. Use 9Router with alicode/openrouter/anthropic/gemini providers instead." }, + ], + modelAliases: ["coder-model", "qwen3-coder-plus", "qwen3-coder-flash", "vision-model", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gemini-3-flash", "gemini-3.1-pro-high"], + defaultModels: [ + { id: "coder-model", name: "Coder Model (Qwen 3.6 Plus)", alias: "coder-model", envKey: "OPENAI_MODEL", defaultValue: "coder-model", isTopLevel: true }, + { id: "qwen3-coder-plus", name: "Qwen 3 Coder Plus", alias: "qwen3-coder-plus", envKey: "OPENAI_MODEL", defaultValue: "qwen3-coder-plus" }, + { id: "qwen3-coder-flash", name: "Qwen 3 Coder Flash", alias: "qwen3-coder-flash", envKey: "OPENAI_MODEL", defaultValue: "qwen3-coder-flash" }, + { id: "vision-model", name: "Vision Model (Multimodal)", alias: "vision-model", envKey: "OPENAI_MODEL", defaultValue: "vision-model" }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", alias: "claude-sonnet-4-6", envKey: "OPENAI_MODEL", defaultValue: "claude-sonnet-4-6" }, + { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking", alias: "claude-opus-4-6-thinking", envKey: "OPENAI_MODEL", defaultValue: "claude-opus-4-6-thinking" }, + { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High", alias: "gemini-3.1-pro-high", envKey: "OPENAI_MODEL", defaultValue: "gemini-3.1-pro-high" }, + { id: "gemini-3-flash", name: "Gemini 3 Flash", alias: "gemini-3-flash", envKey: "OPENAI_MODEL", defaultValue: "gemini-3-flash" }, + ], + guideSteps: [ + { step: 1, title: "Install Qwen Code", desc: "npm install -g @qwen-code/qwen-code" }, + { step: 2, title: "API Key", type: "apiKeySelector" }, + { step: 3, title: "Base URL", value: "{{baseUrl}}", copyable: true }, + { step: 4, title: "Select Model", type: "modelSelector" }, + { step: 5, title: "Save Config", desc: "Copy the JSON below to your ~/.qwen/settings.json file." }, + ], + codeBlock: { + language: "json", + code: `{ + "security": { + "auth": { + "selectedType": "openai", + "apiKey": "{{apiKey}}", + "baseUrl": "{{baseUrl}}" + } + }, + "model": { + "name": "{{model}}" + } +}`, + }, + }, + "deepseek-tui": { + id: "deepseek-tui", + name: "DeepSeek TUI", + image: "/providers/deepseek-tui.png", + color: "#4D6BFE", + description: "DeepSeek Terminal Coding Agent (Rust TUI)", + docsUrl: "https://github.com/DeepSeek-TUI/DeepSeek-TUI", + configType: "custom", + defaultCommand: "deepseek", + modelAliases: ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-chat", "deepseek-reasoner"], + defaultModels: [ + { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", alias: "deepseek-v4-pro" }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", alias: "deepseek-v4-flash" }, + { id: "deepseek-chat", name: "DeepSeek V3 Chat", alias: "deepseek-chat" }, + ], + notes: [ + { type: "info", text: "DeepSeek TUI uses ~/.deepseek/config.toml for configuration. 9Router will update the provider to 'openai' mode with your base_url, api_key, and model." }, + { type: "warning", text: "Config path: Linux/macOS ~/.deepseek/config.toml • Windows %USERPROFILE%\\.deepseek\\config.toml" }, + ], + }, + jcode: { + id: "jcode", + name: "jcode", + image: "/providers/jcode.png", + color: "#FF6B35", + description: "High-performance Rust-based coding agent harness", + configType: "custom", + docsUrl: "https://github.com/1jehuang/jcode", + notes: [ + { + type: "info", + text: "jcode is a Rust-based coding agent with semantic memory, multi-agent swarms, and extreme performance (27.8 MB RAM, 14ms boot)." + }, + { + type: "info", + text: "Configure 9router as an OpenAI-compatible provider to route all jcode requests through 9router's optimization layer." + }, + { + type: "warning", + text: "Requires jcode installed. Install via: curl -fsSL https://raw.githubusercontent.com/1jehuang/jcode/master/scripts/install.sh | bash" + }, + ], + defaultModels: [ + { id: "claude-opus-5", name: "Claude Opus 5", alias: "opus", defaultValue: "cc/claude-opus-5" }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", alias: "sonnet", defaultValue: "cc/claude-sonnet-4-6" }, + { id: "gpt-5.5", name: "GPT 5.5", alias: "gpt5", defaultValue: "cx/gpt-5.5" }, + { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", alias: "gemini", defaultValue: "gemini/gemini-3.1-pro" }, + ], + }, "grok-build": { id: "grok-build", name: "Grok Build", @@ -178,6 +357,32 @@ export const CLI_TOOLS = { { type: "warning", text: "Config path: Linux/macOS ~/.grok/config.toml • Windows %USERPROFILE%\\.grok\\config.toml" }, ], }, + devin: { + id: "devin", + name: "Devin CLI", + image: "/providers/devin-cli.png", + color: "#6366F1", + description: "Cognition Devin CLI — local binary called by the Devin CLI provider via ACP/stdio", + configType: "guide", + installUrl: "https://cli.devin.ai", + notes: [ + { type: "info", text: "This is a local dependency, not a routed CLI. The Devin CLI provider spawns `devin acp --agent-type summarizer` and relays its output." }, + { type: "warning", text: "Install the Devin CLI and run `devin auth login` — without it, the provider returns a spawn error on first request." }, + ], + guideSteps: [ + { step: 1, title: "Install Devin CLI", desc: "Install via the official installer at cli.devin.ai.", docsUrl: "https://cli.devin.ai" }, + { step: 2, title: "Authenticate", desc: "Log in once so the binary stores its own credentials." }, + { step: 3, title: "Use the provider", desc: "Pick any Devin CLI model under the Providers tab — no API key field needed." }, + ], + codeBlock: { + language: "bash", + code: `# Install Devin CLI (see https://cli.devin.ai for options) +devin auth login + +# Verify detection (optional) +devin --version`, + }, + }, // HIDDEN: gemini-cli // "gemini-cli": { // id: "gemini-cli", diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index de8d7f47..96373f9a 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -253,7 +253,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re // Ensure real project ID is available for providers that need it (P0 fix: cold miss) if ((provider === "antigravity" || provider === "gemini-cli") && !refreshedCredentials.projectId) { - const pid = await getProjectIdForConnection(credentials.connectionId, refreshedCredentials.accessToken); + const pid = await getProjectIdForConnection(credentials.connectionId, refreshedCredentials.accessToken, provider); if (pid) { refreshedCredentials.projectId = pid; // Persist to DB in background so subsequent requests have it immediately diff --git a/src/sse/handlers/embeddings.js b/src/sse/handlers/embeddings.js index 16ca5da7..f55534de 100644 --- a/src/sse/handlers/embeddings.js +++ b/src/sse/handlers/embeddings.js @@ -14,6 +14,16 @@ import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js"; import * as log from "../utils/logger.js"; import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js"; import { getDeletedModelResponse } from "../services/deletedModels.js"; +import { saveRequestUsage } from "@/lib/usageDb.js"; + +function exactEmbeddingUsage(raw) { + if (!raw || typeof raw !== "object" || Array.isArray(raw) || raw.estimated === true) return null; + const promptTokens = raw.prompt_tokens ?? raw.input_tokens; + const completionTokens = raw.completion_tokens ?? raw.output_tokens ?? 0; + const totalTokens = raw.total_tokens; + if (!Number.isSafeInteger(promptTokens) || promptTokens <= 0 || completionTokens !== 0 || totalTokens !== promptTokens) return null; + return { prompt_tokens: promptTokens, completion_tokens: 0, total_tokens: totalTokens }; +} /** * Handle embeddings request for the SSE/Next.js server. @@ -130,7 +140,21 @@ export async function handleEmbeddings(request) { } }); - if (result.success) return result.response; + if (result.success) { + const usage = exactEmbeddingUsage(result.usage); + if (usage) { + saveRequestUsage({ + provider, + model, + connectionId: credentials.connectionId, + apiKey, + endpoint: url.pathname, + tokens: usage, + status: "success", + }).catch(() => {}); + } + return result.response; + } const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model); diff --git a/src/sse/handlers/fetch.js b/src/sse/handlers/fetch.js index 6144580c..7edbedb6 100644 --- a/src/sse/handlers/fetch.js +++ b/src/sse/handlers/fetch.js @@ -101,7 +101,7 @@ export async function handleFetch(request) { return handleComboChat({ body, models: comboModels, - handleSingleModel: (b, m) => handleSingleProviderFetch(b, m, request, apiKey, settings), + handleSingleModel: (b, m) => handleSingleProviderFetch(b, m, request, apiKey, settings, ownerId), log, comboName: providerInput, comboId: combo.id, @@ -110,10 +110,10 @@ export async function handleFetch(request) { }); } - return handleSingleProviderFetch(body, providerInput, request, apiKey, settings); + return handleSingleProviderFetch(body, providerInput, request, apiKey, settings, ownerId); } -async function handleSingleProviderFetch(body, providerInput, request, apiKey, settings) { +async function handleSingleProviderFetch(body, providerInput, request, apiKey, settings, ownerId) { const targetUrl = body.url; const format = body.format; const maxCharacters = body.max_characters; @@ -199,13 +199,11 @@ async function handleSingleProviderFetch(body, providerInput, request, apiKey, s providerSpecificData: newCreds.providerSpecificData, testStatus: "active" }); - }, - onRequestSuccess: async () => { - await clearAccountError(credentials.connectionId, credentials); } }); if (result.success) { + await clearAccountError(credentials.connectionId, credentials); return new Response(JSON.stringify(result.data), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }); diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index 7fdea2e4..db862767 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -289,7 +289,13 @@ export async function clearAccountError(connectionId, currentConnection, model = // Only reset error state if no active locks remain if (remainingActiveLocks.length === 0) { - Object.assign(clearObj, { testStatus: "active", lastError: null, lastErrorAt: null, backoffLevel: 0 }); + Object.assign(clearObj, { + testStatus: "active", + lastError: null, + errorCode: null, + lastErrorAt: null, + backoffLevel: 0 + }); } await updateProviderConnection(connectionId, clearObj); diff --git a/tests/__baseline__/alias-baseline.json b/tests/__baseline__/alias-baseline.json index 6e189878..5aa2e0f3 100644 --- a/tests/__baseline__/alias-baseline.json +++ b/tests/__baseline__/alias-baseline.json @@ -93,7 +93,30 @@ "polly": "aws-polly", "aws-polly": "aws-polly", "bb": "blackbox", - "blackbox": "blackbox" + "blackbox": "blackbox", + "af": "api-airforce", + "airforce": "api-airforce", + "api-airforce": "api-airforce", + "llm7": "llm7", + "llm-7": "llm7", + "samba": "sambanova", + "sambanova": "sambanova", + "bm": "bluesminds", + "bluesminds": "bluesminds", + "bzl": "bazaarlink", + "bazaarlink": "bazaarlink", + "kgw": "kilo-gateway", + "kilo-gateway": "kilo-gateway", + "hunyuan": "tencent", + "tencent": "tencent", + "qianfan": "baidu", + "baidu": "baidu", + "ernie": "baidu", + "dv": "dv", + "devin": "devin", + "devin-cli": "devin-cli", + "morph": "morph", + "morphllm": "morph" }, "idToAlias": { "alicode": "alicode", @@ -101,9 +124,13 @@ "alims-intl": "alims-intl", "anthropic": "anthropic", "antigravity": "ag", + "api-airforce": "af", "assemblyai": "assemblyai", "azure": "azure", + "baidu": "qianfan", + "bazaarlink": "bzl", "blackbox": "blackbox", + "bluesminds": "bm", "byteplus": "byteplus", "cerebras": "cerebras", "chutes": "chutes", @@ -112,6 +139,7 @@ "clinepass": "clinepass", "cloudflare-ai": "cloudflare-ai", "codebuddy-cn": "cbcn", + "codebuddy-intl": "cbai", "codex": "cx", "cohere": "cohere", "commandcode": "commandcode", @@ -131,15 +159,18 @@ "groq": "groq", "hyperbolic": "hyperbolic", "iflow": "if", + "kilo-gateway": "kgw", "kilocode": "kc", "kimchi": "kimchi", "kimi": "kimi", "kiro": "kr", + "llm7": "llm7", "mimo-free": "mmf", "minimax": "minimax", "minimax-cn": "minimax-cn", "mistral": "mistral", "mmf": "mmf", + "morph": "morph", "nanobanana": "nanobanana", "nebius": "nebius", "nvidia": "nvidia", @@ -149,12 +180,16 @@ "opencode": "oc", "opencode-go": "opencode-go", "openrouter": "openrouter", + "orbit-provider": "orbit", "perplexity": "perplexity", "perplexity-agent": "perplexity-agent", "perplexity-web": "perplexity-web", + "poolside": "poolside", "qoder": "qd", "qwen": "qw", + "sambanova": "samba", "siliconflow": "siliconflow", + "tencent": "hunyuan", "together": "together", "venice": "venice", "vercel-ai-gateway": "vercel-ai-gateway", @@ -163,9 +198,11 @@ "volcengine-ark": "volcengine-ark", "xai": "xai", "xiaomi-mimo": "xiaomi-mimo", - "xiaomi-tokenplan": "xiaomi-tokenplan" + "xiaomi-tokenplan": "xiaomi-tokenplan", + "zed": "zd" }, "modelKeys": [ + "af", "ag", "alicode", "alicode-intl", @@ -174,7 +211,10 @@ "assemblyai", "black-forest-labs", "blackbox", + "bm", "byteplus", + "bzl", + "cbai", "cbcn", "cc", "cerebras", @@ -205,17 +245,21 @@ "grok-web", "groq", "huggingface", + "hunyuan", "hyperbolic", "if", "kc", + "kgw", "kimchi", "kimi", "kr", + "llm7", "local-device", "minimax", "minimax-cn", "mistral", "mmf", + "morph", "nanobanana", "nebius", "nvidia", @@ -228,13 +272,17 @@ "openrouter", "openrouter-tts-models", "openrouter-tts-voices", + "orbit", "perplexity", "perplexity-agent", "perplexity-web", + "poolside", "qd", + "qianfan", "qw", "recraft", "runwayml", + "samba", "sdwebui", "siliconflow", "stability-ai", @@ -246,6 +294,7 @@ "voyage-ai", "xai", "xiaomi-mimo", - "xiaomi-tokenplan" + "xiaomi-tokenplan", + "zd" ] } \ No newline at end of file diff --git a/tests/__baseline__/known-fails.txt b/tests/__baseline__/known-fails.txt index 9ffd63f9..49fd6a11 100644 --- a/tests/__baseline__/known-fails.txt +++ b/tests/__baseline__/known-fails.txt @@ -1,4 +1,3 @@ -tests/unit/antigravity-mitm.test.js :: Antigravity MITM model handling flags the out-of-box agent/Default model mandatory tests/unit/claude-header-forwarding.test.js :: proxyAwareFetch — api.anthropic.com routing routes api.anthropic.com to gotScraping (non-streaming) and returns ok response tests/unit/oauth-cursor-auto-import.test.js :: GET /api/oauth/cursor/auto-import extracts tokens using exact keys tests/unit/oauth-cursor-auto-import.test.js :: GET /api/oauth/cursor/auto-import falls back to fuzzy key matching on macOS when exact keys are missing diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json index 232c5d59..6637bce6 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -1,14 +1,22 @@ { - "alicode-intl": { - "baseUrl": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", + "alicode": { + "baseUrl": "https://coding.dashscope.aliyuncs.com/v1/chat/completions", "headers": {}, "quirks": { "preserveCacheControl": true }, "format": "openai" }, - "alicode": { - "baseUrl": "https://coding.dashscope.aliyuncs.com/v1/chat/completions", + "alicode-intl": { + "baseUrl": "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions", + "headers": {}, + "quirks": { + "preserveCacheControl": true + }, + "format": "openai" + }, + "alims-intl": { + "baseUrl": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", "headers": {}, "quirks": { "preserveCacheControl": true @@ -25,7 +33,7 @@ }, "antigravity": { "baseUrls": [ - "https://cloudcode-pa.googleapis.com" + "https://daily-cloudcode-pa.googleapis.com" ], "format": "antigravity", "headers": { @@ -51,6 +59,15 @@ "clientSecret": "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", "tokenUrl": "https://oauth2.googleapis.com/token" }, + "api-airforce": { + "baseUrl": "https://api.airforce/v1/chat/completions", + "validateUrl": "https://api.airforce/v1/models", + "headers": { + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy" + }, + "format": "openai" + }, "assemblyai": { "baseUrl": "https://api.assemblyai.com/v1/audio/transcriptions", "validateUrl": "https://api.assemblyai.com/v1/account", @@ -61,11 +78,26 @@ "headers": {}, "format": "openai" }, + "baidu": { + "baseUrl": "https://qianfan.baidubce.com/v2/chat/completions", + "validateUrl": "https://qianfan.baidubce.com/v2/models", + "format": "openai" + }, + "bazaarlink": { + "baseUrl": "https://bazaarlink.ai/api/v1/chat/completions", + "validateUrl": "https://bazaarlink.ai/api/v1/models", + "format": "openai" + }, "blackbox": { "baseUrl": "https://api.blackbox.ai/v1/chat/completions", "thinkingFormat": "openai", "format": "openai" }, + "bluesminds": { + "baseUrl": "https://api.bluesminds.com/v1/chat/completions", + "validateUrl": "https://api.bluesminds.com/v1/models", + "format": "openai" + }, "byteplus": { "baseUrl": "https://ark.ap-southeast.bytepluses.com/api/coding/v3/chat/completions", "headers": {}, @@ -191,6 +223,26 @@ "format": "openai", "tokenUrl": "https://copilot.tencent.com/v2/plugin/auth/token" }, + "codebuddy-intl": { + "baseUrl": "https://www.codebuddy.ai/v2/chat/completions", + "forceStream": true, + "thinkingFormat": "openai", + "headers": { + "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1", + "X-Product": "SaaS", + "X-IDE-Type": "IDE", + "X-IDE-Name": "IDE", + "x-requested-with": "XMLHttpRequest", + "x-codebuddy-request": "1" + }, + "auth": { + "combined": true, + "header": "Authorization", + "scheme": "bearer" + }, + "format": "openai", + "tokenUrl": "https://www.codebuddy.ai/v2/plugin/auth/token" + }, "codex": { "baseUrl": "https://chatgpt.com/backend-api/codex/responses", "format": "openai-responses", @@ -279,19 +331,6 @@ "validateUrl": "https://api.fireworks.ai/inference/v1/models", "format": "openai" }, - "gemini-cli": { - "baseUrl": "https://cloudcode-pa.googleapis.com/v1internal", - "format": "gemini-cli", - "cliVersion": "0.34.0", - "apiClient": "google-genai-sdk/1.41.0 gl-node/v22.19.0", - "usage": { - "quotaUrl": "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", - "loadCodeAssistUrl": "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" - }, - "clientId": "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com", - "clientSecret": "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", - "tokenUrl": "https://oauth2.googleapis.com/token" - }, "gemini": { "baseUrl": "https://generativelanguage.googleapis.com/v1beta/models", "format": "gemini", @@ -308,6 +347,19 @@ } } }, + "gemini-cli": { + "baseUrl": "https://cloudcode-pa.googleapis.com/v1internal", + "format": "gemini-cli", + "cliVersion": "0.34.0", + "apiClient": "google-genai-sdk/1.41.0 gl-node/v22.19.0", + "usage": { + "quotaUrl": "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", + "loadCodeAssistUrl": "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" + }, + "clientId": "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com", + "clientSecret": "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", + "tokenUrl": "https://oauth2.googleapis.com/token" + }, "github": { "baseUrl": "https://api.githubcopilot.com/chat/completions", "responsesUrl": "https://api.githubcopilot.com/responses", @@ -346,14 +398,6 @@ }, "format": "openai" }, - "glm-cn": { - "baseUrl": "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", - "headers": {}, - "usage": { - "url": "https://open.bigmodel.cn/api/monitor/usage/quota/limit" - }, - "format": "openai" - }, "glm": { "baseUrl": "https://api.z.ai/api/anthropic/v1/messages", "format": "claude", @@ -396,6 +440,14 @@ } ] }, + "glm-cn": { + "baseUrl": "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", + "headers": {}, + "usage": { + "url": "https://open.bigmodel.cn/api/monitor/usage/quota/limit" + }, + "format": "openai" + }, "grok-cli": { "baseUrl": "https://cli-chat-proxy.grok.com/v1/responses", "format": "openai-responses", @@ -458,6 +510,11 @@ "clientSecret": "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW", "tokenUrl": "https://iflow.cn/oauth/token" }, + "kilo-gateway": { + "baseUrl": "https://api.kilo.ai/api/gateway/chat/completions", + "validateUrl": "https://api.kilo.ai/api/gateway/models", + "format": "openai" + }, "kilocode": { "baseUrl": "https://api.kilo.ai/api/openrouter/chat/completions", "headers": {}, @@ -548,7 +605,6 @@ "headers": { "Content-Type": "application/json", "Accept": "application/vnd.amazon.eventstream", - "X-Amz-Target": "AmazonCodeWhispererStreamingService.GenerateAssistantResponse", "User-Agent": "AWS-SDK-JS/3.0.0 kiro-ide/1.0.0", "X-Amz-User-Agent": "aws-sdk-js/3.0.0 kiro-ide/1.0.0" }, @@ -560,62 +616,16 @@ "limitsPath": "/getUsageLimits" } }, + "llm7": { + "baseUrl": "https://api.llm7.io/v1/chat/completions", + "validateUrl": "https://api.llm7.io/v1/models", + "format": "openai" + }, "mimo-free": { "baseUrl": "https://api.xiaomimimo.com/api/free-ai/openai/chat", "noAuth": true, "format": "openai" }, - "minimax-cn": { - "baseUrl": "https://api.minimaxi.com/anthropic/v1/messages", - "format": "claude", - "urlSuffix": "?beta=true", - "headers": { - "Anthropic-Version": "2023-06-01", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" - }, - "quirks": { - "dropOutputConfig": true - }, - "reasoningInject": { - "scope": "all" - }, - "auth": { - "combined": true, - "header": "x-api-key", - "scheme": "raw" - }, - "usage": { - "urls": [ - "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains", - "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains" - ] - }, - "transports": [ - { - "format": "openai", - "baseUrl": "https://api.minimaxi.com/v1/chat/completions", - "auth": { - "combined": true, - "header": "Authorization", - "scheme": "bearer" - } - }, - { - "format": "claude", - "baseUrl": "https://api.minimaxi.com/anthropic/v1/messages", - "urlSuffix": "?beta=true", - "headers": { - "Anthropic-Version": "2023-06-01", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" - }, - "auth": { - "combined": true, - "header": "x-api-key", - "scheme": "raw" - } - } - ] - }, "minimax": { "baseUrl": "https://api.minimax.io/anthropic/v1/messages", "format": "claude", @@ -667,6 +677,57 @@ } ] }, + "minimax-cn": { + "baseUrl": "https://api.minimaxi.com/anthropic/v1/messages", + "format": "claude", + "urlSuffix": "?beta=true", + "headers": { + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" + }, + "quirks": { + "dropOutputConfig": true + }, + "reasoningInject": { + "scope": "all" + }, + "auth": { + "combined": true, + "header": "x-api-key", + "scheme": "raw" + }, + "usage": { + "urls": [ + "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains", + "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains" + ] + }, + "transports": [ + { + "format": "openai", + "baseUrl": "https://api.minimaxi.com/v1/chat/completions", + "auth": { + "combined": true, + "header": "Authorization", + "scheme": "bearer" + } + }, + { + "format": "claude", + "baseUrl": "https://api.minimaxi.com/anthropic/v1/messages", + "urlSuffix": "?beta=true", + "headers": { + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14" + }, + "auth": { + "combined": true, + "header": "x-api-key", + "scheme": "raw" + } + } + ] + }, "mistral": { "baseUrl": "https://api.mistral.ai/v1/chat/completions", "validateUrl": "https://api.mistral.ai/v1/models", @@ -680,6 +741,11 @@ "noAuth": true, "format": "openai" }, + "morph": { + "baseUrl": "https://api.morphllm.com/v1/chat/completions", + "validateUrl": "https://api.morphllm.com/v1/models", + "format": "openai" + }, "nanobanana": { "baseUrl": "https://api.nanobananaapi.ai/v1/chat/completions", "validateUrl": "https://api.nanobananaapi.ai/v1/models", @@ -695,25 +761,20 @@ "validateUrl": "https://integrate.api.nvidia.com/v1/models", "format": "openai" }, - "ollama-local": { - "baseUrl": "http://localhost:11434/api/chat", - "format": "ollama" - }, "ollama": { "baseUrl": "https://ollama.com/api/chat", "validateUrl": "https://ollama.com/api/tags", "format": "ollama" }, + "ollama-local": { + "baseUrl": "http://localhost:11434/api/chat", + "format": "ollama" + }, "openai": { "baseUrl": "https://api.openai.com/v1/chat/completions", "forceStream": true, "format": "openai" }, - "opencode-go": { - "baseUrl": "https://opencode.ai/zen/go/v1/chat/completions", - "headers": {}, - "format": "openai" - }, "opencode": { "baseUrl": "https://opencode.ai", "headers": { @@ -722,6 +783,11 @@ "noAuth": true, "format": "openai" }, + "opencode-go": { + "baseUrl": "https://opencode.ai/zen/go/v1/chat/completions", + "headers": {}, + "format": "openai" + }, "openrouter": { "baseUrl": "https://openrouter.ai/api/v1/chat/completions", "thinkingFormat": "openai", @@ -731,10 +797,15 @@ }, "format": "openai" }, - "perplexity-web": { - "baseUrl": "https://www.perplexity.ai/rest/sse/perplexity_ask", - "format": "perplexity-web", - "authType": "cookie" + "orbit-provider": { + "baseUrl": "https://api.orbit-provider.com/anthropic/v1/messages", + "format": "claude", + "headers": { + "anthropic-version": "2023-06-01" + }, + "usage": { + "url": "https://api.orbit-provider.com/v1/usage" + } }, "perplexity": { "baseUrl": "https://api.perplexity.ai/chat/completions", @@ -746,6 +817,16 @@ "validateUrl": "https://api.perplexity.ai/v1/models", "format": "openai-responses" }, + "perplexity-web": { + "baseUrl": "https://www.perplexity.ai/rest/sse/perplexity_ask", + "format": "perplexity-web", + "authType": "cookie" + }, + "poolside": { + "baseUrl": "https://inference.poolside.ai/v1/chat/completions", + "validateUrl": "https://inference.poolside.ai/v1/models", + "format": "openai" + }, "qoder": { "baseUrl": "https://api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation", "headers": {}, @@ -762,12 +843,22 @@ "clientId": "f0304373b74a44d2b584a3fb70ca9e56", "tokenUrl": "https://chat.qwen.ai/api/v1/oauth2/token" }, + "sambanova": { + "baseUrl": "https://api.sambanova.ai/v1/chat/completions", + "validateUrl": "https://api.sambanova.ai/v1/models", + "format": "openai" + }, "siliconflow": { "baseUrl": "https://api.siliconflow.com/v1/chat/completions", "validateUrl": "https://api.siliconflow.com/v1/models", "thinkingFormat": "openai", "format": "openai" }, + "tencent": { + "baseUrl": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions", + "validateUrl": "https://api.hunyuan.cloud.tencent.com/v1/models", + "format": "openai" + }, "together": { "baseUrl": "https://api.together.xyz/v1/chat/completions", "validateUrl": "https://api.together.xyz/v1/models", @@ -790,14 +881,14 @@ }, "format": "openai" }, - "vertex-partner": { - "baseUrl": "https://aiplatform.googleapis.com", - "format": "openai" - }, "vertex": { "baseUrl": "https://aiplatform.googleapis.com", "format": "vertex" }, + "vertex-partner": { + "baseUrl": "https://aiplatform.googleapis.com", + "format": "openai" + }, "volcengine-ark": { "baseUrl": "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions", "headers": {}, @@ -873,12 +964,21 @@ } ] }, - "alims-intl": { - "baseUrl": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", - "headers": {}, - "quirks": { - "preserveCacheControl": true + "zed": { + "baseUrl": "https://cloud.zed.dev/completions", + "format": "openai", + "forceStream": true, + "headers": { + "content-type": "application/json" }, - "format": "openai" + "auth": { + "combined": true, + "header": "Authorization", + "scheme": " " + }, + "usage": { + "url": "https://cloud.zed.dev/client/users/me" + }, + "modelsUrl": "https://cloud.zed.dev/models" } } \ No newline at end of file diff --git a/tests/__baseline__/verify-alias.mjs b/tests/__baseline__/verify-alias.mjs index 56caca53..a3fc1f91 100644 --- a/tests/__baseline__/verify-alias.mjs +++ b/tests/__baseline__/verify-alias.mjs @@ -20,6 +20,9 @@ const ALIAS_TOKENS = [ "xmtp","xiaomi-tokenplan","cf", "cloudflare-ai","fal","fal-ai","stability","stability-ai","bfl","black-forest-labs","recraft", "topaz","runway","runwayml","jina","jina-ai","polly","aws-polly","bb","blackbox", + "af","airforce","api-airforce","llm7","llm-7","samba","sambanova","bm","bluesminds", + "bzl","bazaarlink","kgw","kilo-gateway","hunyuan","tencent","qianfan","baidu","ernie", + "dv","devin","devin-cli","morph","morphllm", ]; // Sort idToAlias by key — runtime accesses by key, order is irrelevant (content-based) diff --git a/tests/__baseline__/verify-no-regression.mjs b/tests/__baseline__/verify-no-regression.mjs index 26466c27..21bdca45 100644 --- a/tests/__baseline__/verify-no-regression.mjs +++ b/tests/__baseline__/verify-no-regression.mjs @@ -1,28 +1,34 @@ -// Gate: so kết quả test hiện tại với baseline known-fails. +// Gate: so kết quả test hiện tại với baseline kết quả đã commit. // PASS nếu KHÔNG có test nào pass(baseline) → fail(now). Test mới được phép. // Usage: node tests/__baseline__/verify-no-regression.mjs import { readFileSync } from "fs"; -const knownFails = new Set( - readFileSync(new URL("./known-fails.txt", import.meta.url), "utf8") - .split("\n").map(s => s.trim()).filter(Boolean) -); - const resultsPath = process.argv[2]; if (!resultsPath) { console.error("Missing results.json path"); process.exit(2); } const r = JSON.parse(readFileSync(resultsPath, "utf8")); +const normalizeTestPath = (filePath) => { + const normalized = String(filePath || "").replaceAll("\\", "/"); + const testsIndex = normalized.lastIndexOf("/tests/"); + return testsIndex >= 0 ? normalized.slice(testsIndex + 1) : normalized; +}; +const assertionStatuses = (results) => new Map(results.testResults.flatMap(f => + f.assertionResults.map(a => [normalizeTestPath(f.name) + " :: " + a.fullName, a.status]) +)); + +const baseline = JSON.parse(readFileSync(new URL("./baseline-results.json", import.meta.url), "utf8")); +const baselineStatuses = assertionStatuses(baseline); const nowFails = r.testResults.flatMap(f => f.assertionResults.filter(a => a.status === "failed") - .map(a => f.name.split("/app/")[1] + " :: " + a.fullName) + .map(a => normalizeTestPath(f.name) + " :: " + a.fullName) ); -// Regression = fail bây giờ NHƯNG không có trong baseline known-fails -const regressions = nowFails.filter(f => !knownFails.has(f)); +// Regression = test từng pass trong baseline nhưng fail bây giờ. +const regressions = nowFails.filter(name => baselineStatuses.get(name) === "passed"); if (regressions.length) { console.error(`\n❌ REGRESSION: ${regressions.length} test pass→fail:\n`); regressions.forEach(f => console.error(" - " + f)); process.exit(1); } -console.log(`✅ No regression. (now fails=${nowFails.length}, baseline known=${knownFails.size}, all known)`); +console.log(`✅ No regression. (now fails=${nowFails.length}, baseline assertions=${baselineStatuses.size})`); diff --git a/tests/translator/__snapshots__/golden-url-header.test.js.snap b/tests/translator/__snapshots__/golden-url-header.test.js.snap index c9a0db21..2f1e352b 100644 --- a/tests/translator/__snapshots__/golden-url-header.test.js.snap +++ b/tests/translator/__snapshots__/golden-url-header.test.js.snap @@ -38,6 +38,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > alicode-intl → hea } `; +exports[`GOLDEN buildHeaders (default executor providers) > alims-intl → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > anthropic → headers (apiKey / oauth) 1`] = ` { "apiKey": { @@ -66,6 +85,31 @@ exports[`GOLDEN buildHeaders (default executor providers) > anthropic → header } `; +exports[`GOLDEN buildHeaders (default executor providers) > api-airforce → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > assemblyai → headers (apiKey / oauth) 1`] = ` { "apiKey": { @@ -85,6 +129,44 @@ exports[`GOLDEN buildHeaders (default executor providers) > assemblyai → heade } `; +exports[`GOLDEN buildHeaders (default executor providers) > baidu → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > bazaarlink → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > blackbox → headers (apiKey / oauth) 1`] = ` { "apiKey": { @@ -104,6 +186,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > blackbox → headers } `; +exports[`GOLDEN buildHeaders (default executor providers) > bluesminds → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > byteplus → headers (apiKey / oauth) 1`] = ` { "apiKey": { @@ -370,6 +471,43 @@ exports[`GOLDEN buildHeaders (default executor providers) > codebuddy-cn → hea } `; +exports[`GOLDEN buildHeaders (default executor providers) > codebuddy-intl → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1", + "X-IDE-Name": "IDE", + "X-IDE-Type": "IDE", + "X-Product": "SaaS", + "x-codebuddy-request": "1", + "x-requested-with": "XMLHttpRequest", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1", + "X-IDE-Name": "IDE", + "X-IDE-Type": "IDE", + "X-Product": "SaaS", + "x-codebuddy-request": "1", + "x-requested-with": "XMLHttpRequest", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1", + "X-IDE-Name": "IDE", + "X-IDE-Type": "IDE", + "X-Product": "SaaS", + "x-codebuddy-request": "1", + "x-requested-with": "XMLHttpRequest", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > cohere → headers (apiKey / oauth) 1`] = ` { "apiKey": { @@ -619,6 +757,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > hyperbolic → heade } `; +exports[`GOLDEN buildHeaders (default executor providers) > kilo-gateway → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > kilocode → headers (apiKey / oauth) 1`] = ` { "apiKey": { @@ -722,6 +879,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > kimi-coding → head } `; +exports[`GOLDEN buildHeaders (default executor providers) > llm7 → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > minimax → headers (apiKey / oauth) 1`] = ` { "apiKey": { @@ -810,6 +986,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > mmf → headers (api } `; +exports[`GOLDEN buildHeaders (default executor providers) > morph → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > nanobanana → headers (apiKey / oauth) 1`] = ` { "apiKey": { @@ -930,6 +1125,28 @@ exports[`GOLDEN buildHeaders (default executor providers) > openrouter → heade } `; +exports[`GOLDEN buildHeaders (default executor providers) > orbit-provider → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + "x-api-key": "", + }, + "nonStream": { + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + "x-api-key": "", + }, + "oauth": { + "Accept": "text/event-stream", + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + "x-api-key": "", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > perplexity → headers (apiKey / oauth) 1`] = ` { "apiKey": { @@ -968,6 +1185,44 @@ exports[`GOLDEN buildHeaders (default executor providers) > perplexity-agent → } `; +exports[`GOLDEN buildHeaders (default executor providers) > poolside → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > sambanova → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → headers (apiKey / oauth) 1`] = ` { "apiKey": { @@ -987,6 +1242,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → head } `; +exports[`GOLDEN buildHeaders (default executor providers) > tencent → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > together → headers (apiKey / oauth) 1`] = ` { "apiKey": { @@ -1101,6 +1375,28 @@ exports[`GOLDEN buildHeaders (default executor providers) > xiaomi-mimo → head } `; +exports[`GOLDEN buildHeaders (default executor providers) > zed → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "", + "Content-Type": "application/json", + "content-type": "application/json", + }, + "nonStream": { + "Authorization": "", + "Content-Type": "application/json", + "content-type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "", + "Content-Type": "application/json", + "content-type": "application/json", + }, +} +`; + exports[`GOLDEN buildUrl (default executor providers) > alicode → url (stream + non-stream) 1`] = ` { "nonStream": "https://coding.dashscope.aliyuncs.com/v1/chat/completions", @@ -1115,6 +1411,13 @@ exports[`GOLDEN buildUrl (default executor providers) > alicode-intl → url (st } `; +exports[`GOLDEN buildUrl (default executor providers) > alims-intl → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", + "stream": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > anthropic → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.anthropic.com/v1/messages", @@ -1122,6 +1425,13 @@ exports[`GOLDEN buildUrl (default executor providers) > anthropic → url (strea } `; +exports[`GOLDEN buildUrl (default executor providers) > api-airforce → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.airforce/v1/chat/completions", + "stream": "https://api.airforce/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > assemblyai → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.assemblyai.com/v1/audio/transcriptions", @@ -1129,6 +1439,20 @@ exports[`GOLDEN buildUrl (default executor providers) > assemblyai → url (stre } `; +exports[`GOLDEN buildUrl (default executor providers) > baidu → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://qianfan.baidubce.com/v2/chat/completions", + "stream": "https://qianfan.baidubce.com/v2/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > bazaarlink → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://bazaarlink.ai/api/v1/chat/completions", + "stream": "https://bazaarlink.ai/api/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > blackbox → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.blackbox.ai/chat/completions", @@ -1136,6 +1460,13 @@ exports[`GOLDEN buildUrl (default executor providers) > blackbox → url (stream } `; +exports[`GOLDEN buildUrl (default executor providers) > bluesminds → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.bluesminds.com/v1/chat/completions", + "stream": "https://api.bluesminds.com/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > byteplus → url (stream + non-stream) 1`] = ` { "nonStream": "https://ark.ap-southeast.bytepluses.com/api/coding/v3/chat/completions", @@ -1192,6 +1523,13 @@ exports[`GOLDEN buildUrl (default executor providers) > codebuddy-cn → url (st } `; +exports[`GOLDEN buildUrl (default executor providers) > codebuddy-intl → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://www.codebuddy.ai/v2/chat/completions", + "stream": "https://www.codebuddy.ai/v2/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > cohere → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.cohere.ai/v1/chat/completions", @@ -1276,6 +1614,13 @@ exports[`GOLDEN buildUrl (default executor providers) > hyperbolic → url (stre } `; +exports[`GOLDEN buildUrl (default executor providers) > kilo-gateway → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.kilo.ai/api/gateway/chat/completions", + "stream": "https://api.kilo.ai/api/gateway/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > kilocode → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.kilo.ai/api/openrouter/chat/completions", @@ -1304,6 +1649,13 @@ exports[`GOLDEN buildUrl (default executor providers) > kimi-coding → url (str } `; +exports[`GOLDEN buildUrl (default executor providers) > llm7 → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.llm7.io/v1/chat/completions", + "stream": "https://api.llm7.io/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > minimax → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.minimax.io/anthropic/v1/messages?beta=true", @@ -1332,6 +1684,13 @@ exports[`GOLDEN buildUrl (default executor providers) > mmf → url (stream + no } `; +exports[`GOLDEN buildUrl (default executor providers) > morph → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.morphllm.com/v1/chat/completions", + "stream": "https://api.morphllm.com/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > nanobanana → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.nanobananaapi.ai/v1/chat/completions", @@ -1374,6 +1733,13 @@ exports[`GOLDEN buildUrl (default executor providers) > openrouter → url (stre } `; +exports[`GOLDEN buildUrl (default executor providers) > orbit-provider → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.orbit-provider.com/anthropic/v1/messages", + "stream": "https://api.orbit-provider.com/anthropic/v1/messages", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > perplexity → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.perplexity.ai/chat/completions", @@ -1388,6 +1754,20 @@ exports[`GOLDEN buildUrl (default executor providers) > perplexity-agent → url } `; +exports[`GOLDEN buildUrl (default executor providers) > poolside → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://inference.poolside.ai/v1/chat/completions", + "stream": "https://inference.poolside.ai/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > sambanova → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.sambanova.ai/v1/chat/completions", + "stream": "https://api.sambanova.ai/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.siliconflow.com/v1/chat/completions", @@ -1395,6 +1775,13 @@ exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (str } `; +exports[`GOLDEN buildUrl (default executor providers) > tencent → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions", + "stream": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > together → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.together.xyz/v1/chat/completions", @@ -1436,3 +1823,10 @@ exports[`GOLDEN buildUrl (default executor providers) > xiaomi-mimo → url (str "stream": "https://api.xiaomimimo.com/v1/chat/completions", } `; + +exports[`GOLDEN buildUrl (default executor providers) > zed → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://cloud.zed.dev/completions", + "stream": "https://cloud.zed.dev/completions", +} +`; diff --git a/tests/translator/claude-kiro-direct.test.js b/tests/translator/claude-kiro-direct.test.js index 3c2d964b..3fca7be1 100644 --- a/tests/translator/claude-kiro-direct.test.js +++ b/tests/translator/claude-kiro-direct.test.js @@ -98,6 +98,18 @@ describe("Claude → Kiro (direct route)", () => { expect(out.systemPrompt).toContain("24576"); }); + it("normalizes an unsupported Kiro intensity suffix while preserving agentic behavior", () => { + const out = C2K( + { messages: [{ role: "user", content: "hello" }] }, + null, + "claude-sonnet-4.5-thinking-agentic(high)", + ); + + expect(out.conversationState.currentMessage.userInputMessage.modelId).toBe("claude-sonnet-4.5"); + expect(out.additionalModelRequestFields).toBeUndefined(); + expect(out.systemPrompt).toContain("CHUNKED WRITE PROTOCOL"); + }); + it("maps output_config.effort high to Kiro CLI-style additionalModelRequestFields for effort models", () => { const out = C2K({ output_config: { effort: "high" }, diff --git a/tests/unit/antigravity-retry-hook.test.js b/tests/unit/antigravity-retry-hook.test.js index 989dd88f..adb8bd25 100644 --- a/tests/unit/antigravity-retry-hook.test.js +++ b/tests/unit/antigravity-retry-hook.test.js @@ -67,8 +67,8 @@ describe("antigravity computeRetryDelay hook (D3)", () => { expect(out.request.tools[0].functionDeclarations.map(fn => fn.name)).toEqual(["read_file"]); }); - it("registry uses the official IDE cloudcode host and user agent", () => { - expect(antigravity.transport.baseUrls).toEqual(["https://cloudcode-pa.googleapis.com"]); + it("registry uses the daily IDE cloudcode host and user agent", () => { + expect(antigravity.transport.baseUrls).toEqual(["https://daily-cloudcode-pa.googleapis.com"]); expect(antigravity.transport.headers["User-Agent"]).toBe("antigravity/ide/2.1.1 darwin/arm64"); }); diff --git a/tests/unit/antigravity-stream-options.test.js b/tests/unit/antigravity-stream-options.test.js new file mode 100644 index 00000000..47da8769 --- /dev/null +++ b/tests/unit/antigravity-stream-options.test.js @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js"; + +const credentials = { + projectId: "synthetic-project", + connectionId: "synthetic-connection", +}; + +function requestBody(stream) { + return { + stream, + stream_options: { include_usage: true }, + request: { + contents: [{ role: "user", parts: [{ text: "Reply only OK" }] }], + }, + }; +} + +describe("AntigravityExecutor stream_options normalization", () => { + it("removes stream_options from a non-streaming request", () => { + const executor = new AntigravityExecutor(); + const output = executor.transformRequest( + "gpt-oss-120b-medium", + requestBody(false), + false, + credentials, + ); + + expect(output.stream).toBe(false); + expect(output.stream_options).toBeUndefined(); + }); + + it("preserves stream_options for a streaming request", () => { + const executor = new AntigravityExecutor(); + const output = executor.transformRequest( + "gpt-oss-120b-medium", + requestBody(true), + true, + credentials, + ); + + expect(output.stream).toBe(true); + expect(output.stream_options).toEqual({ include_usage: true }); + }); +}); diff --git a/tests/unit/capabilities-opus-context.test.js b/tests/unit/capabilities-opus-context.test.js index 9bb7518f..2ef9f956 100644 --- a/tests/unit/capabilities-opus-context.test.js +++ b/tests/unit/capabilities-opus-context.test.js @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js"; // Claude Opus 4.6+ ships a 1M-token context window (GA, standard pricing). -// The registry exposes dashed ids (claude-opus-4-8, claude-opus-4-7), which +// The registry exposes dashed ids (claude-opus-5, claude-opus-4-8, claude-opus-4-7), which // must resolve to the 1M context + adaptive thinking caps rather than falling // through to the generic *claude*opus* pattern (200k / budget thinking). describe("Claude Opus 1M context capabilities", () => { @@ -17,6 +17,10 @@ describe("Claude Opus 1M context capabilities", () => { }; for (const model of [ + "claude-opus-5", + "claude-opus-5-thinking", + "claude-opus-5-agentic", + "claude-opus-5-thinking-agentic", "claude-opus-4-8", "claude-opus-4.8", "claude-opus-4-7", diff --git a/tests/unit/capabilities.test.js b/tests/unit/capabilities.test.js index ccfddfa5..a5c7b03d 100644 --- a/tests/unit/capabilities.test.js +++ b/tests/unit/capabilities.test.js @@ -20,6 +20,18 @@ describe("getCapabilitiesForModel", () => { search: true, }; + it("reports Kiro Claude Opus 5 variants as 1M adaptive-thinking models", () => { + for (const model of [ + "claude-opus-5", + "anthropic/claude-opus-5", + "claude-opus-5-thinking", + "claude-opus-5-agentic", + "claude-opus-5-thinking-agentic", + ]) { + expect(getCapabilitiesForModel("kiro", model)).toMatchObject(claudeSonnet5Expected); + } + }); + it("reports Kiro Claude Opus 4.8 as a 1M context model", () => { expect(getCapabilitiesForModel("kiro", "claude-opus-4.8").contextWindow).toBe(1000000); expect(getCapabilitiesForModel("kiro", "anthropic/claude-opus-4.8").contextWindow).toBe(1000000); diff --git a/tests/unit/codex-image-fetch.test.js b/tests/unit/codex-image-fetch.test.js index d50614a2..0bd113b3 100644 --- a/tests/unit/codex-image-fetch.test.js +++ b/tests/unit/codex-image-fetch.test.js @@ -11,7 +11,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; // Mock DNS so the SSRF guard treats example.com as public. -vi.mock("node:dns/promises", () => ({ lookup: async () => ({ address: "93.184.216.34" }) })); +vi.mock("node:dns/promises", () => ({ lookup: async () => [{ address: "93.184.216.34", family: 4 }] })); import { CodexExecutor } from "../../open-sse/executors/codex.js"; import * as proxyFetchModule from "../../open-sse/utils/proxyFetch.js"; diff --git a/tests/unit/cursor-agent-exec-request.test.js b/tests/unit/cursor-agent-exec-request.test.js new file mode 100644 index 00000000..347e159f --- /dev/null +++ b/tests/unit/cursor-agent-exec-request.test.js @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest"; + +import { CursorExecutor } from "../../open-sse/executors/cursor.js"; +import { encodeField, wrapConnectRPCFrame } from "../../open-sse/utils/cursorProtobuf.js"; + +const LEN = 2; + +// agent.v1.AgentServerMessage.exec_request (field 2) carrying one ExecServerMessage variant. +function execRequestFrame(execField) { + const execServerMessage = Buffer.from(encodeField(execField, LEN, new Uint8Array())); + return Buffer.from(wrapConnectRPCFrame(encodeField(2, LEN, execServerMessage))); +} + +// agent.v1.AgentServerMessage.interaction_update (field 1) → text delta. +function textFrame(text) { + const textPart = Buffer.from(encodeField(1, LEN, text)); + const update = Buffer.from(encodeField(1, LEN, textPart)); + return Buffer.from(wrapConnectRPCFrame(encodeField(1, LEN, update))); +} + +function stubAgentSession(executor, frames) { + const written = []; + const queue = [...frames]; + executor.openAgentHttp2Stream = () => ({ + responseHeaders: Promise.resolve({ ":status": 200 }), + write: (frame) => written.push(Buffer.from(frame)), + end() {}, + close() {}, + async read() { + if (!queue.length) return { value: undefined, done: true }; + return { value: queue.shift(), done: false }; + }, + }); + return written; +} + +const credentials = { + accessToken: "test-token", + providerSpecificData: { machineId: "a".repeat(64) }, +}; + +function parseSSE(text) { + return text + .split("\n\n") + .filter((chunk) => chunk.startsWith("data: ")) + .map((chunk) => chunk.slice("data: ".length)) + .filter((data) => data !== "[DONE]") + .map((data) => JSON.parse(data)); +} + +async function runAgent({ frames, stream }) { + const executor = new CursorExecutor(); + const written = stubAgentSession(executor, frames); + const result = await executor.executeAgent({ + model: "gpt-5.2", + body: { messages: [{ role: "user", content: "hi" }] }, + stream, + credentials, + }); + return { result, written }; +} + +describe("CursorExecutor AgentService exec_request handling", () => { + it("acknowledges a request-context exec request without ending the turn", async () => { + const { result, written } = await runAgent({ + frames: [execRequestFrame(10), textFrame("hello")], + stream: true, + }); + + expect(written.length).toBe(2); // run frame + request-context reply + const events = parseSSE(await result.response.text()); + const content = events.map((e) => e.choices?.[0]?.delta?.content || "").join(""); + expect(content).toBe("hello"); + }); + + it("does not render an unsupported exec request as assistant content", async () => { + const { result } = await runAgent({ + frames: [textFrame("partial answer"), execRequestFrame(2)], + stream: true, + }); + + const body = await result.response.text(); + expect(body).not.toContain("unsupported IDE tool\\n"); + const events = parseSSE(body); + const content = events.map((e) => e.choices?.[0]?.delta?.content || "").join(""); + expect(content).toBe("partial answer"); + + const errorEvent = events.find((e) => e.error); + expect(errorEvent?.error?.message).toContain("unsupported IDE tool"); + expect(events.some((e) => e.choices?.[0]?.finish_reason === "stop")).toBe(false); + }); + + it("drops frames batched behind an unsupported exec request in the same read", async () => { + const { result } = await runAgent({ + frames: [Buffer.concat([execRequestFrame(2), textFrame("late")])], + stream: true, + }); + + const body = await result.response.text(); + expect(body).toContain("unsupported IDE tool"); + expect(body).not.toContain("late"); + }); + + it("returns a non-200 error body for an unsupported exec request when not streaming", async () => { + const { result } = await runAgent({ + frames: [execRequestFrame(11)], + stream: false, + }); + + expect(result.response.status).not.toBe(200); + const payload = await result.response.json(); + expect(payload.error.message).toContain("unsupported IDE tool"); + }); +}); diff --git a/tests/unit/db-concurrent.test.js b/tests/unit/db-concurrent.test.js index c00b77b9..cfbe80d9 100644 --- a/tests/unit/db-concurrent.test.js +++ b/tests/unit/db-concurrent.test.js @@ -8,6 +8,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; const originalDataDir = process.env.DATA_DIR; let tempDir; let db; +let adminOwnerId; beforeAll(async () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-concurrent-")); @@ -15,6 +16,8 @@ beforeAll(async () => { vi.resetModules(); db = await import("@/lib/db/index.js"); await db.initDb(); + const admin = await db.createUser({ username: "concurrency-admin", password: "password", role: "admin" }); + adminOwnerId = admin.id; }); afterAll(() => { @@ -102,6 +105,7 @@ describe("DB Concurrency — atomic safety", () => { const conn = await db.createProviderConnection({ provider: "oauth-test", authType: "oauth", email: "x@y.com", accessToken: "initial", refreshToken: "rt-initial", + ownerId: adminOwnerId, }); // 20 parallel updates each with a unique field diff --git a/tests/unit/db-sqlite-vs-lowdb.test.js b/tests/unit/db-sqlite-vs-lowdb.test.js index c7326ad9..65b52c71 100644 --- a/tests/unit/db-sqlite-vs-lowdb.test.js +++ b/tests/unit/db-sqlite-vs-lowdb.test.js @@ -8,6 +8,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; const originalDataDir = process.env.DATA_DIR; let tempDir; let sqliteDb; +let adminOwnerId; beforeAll(async () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-db-compare-")); @@ -15,6 +16,8 @@ beforeAll(async () => { vi.resetModules(); sqliteDb = await import("@/lib/db/index.js"); await sqliteDb.initDb(); + const admin = await sqliteDb.createUser({ username: "db-parity-admin", password: "password", role: "admin" }); + adminOwnerId = admin.id; }); afterAll(() => { @@ -76,9 +79,9 @@ describe("DB SQLite layer — public API parity", () => { }); it("providerConnections: CRUD + reorder by priority", async () => { - const c1 = await sqliteDb.createProviderConnection({ provider: "test", authType: "apikey", name: "a", apiKey: "k1" }); - const c2 = await sqliteDb.createProviderConnection({ provider: "test", authType: "apikey", name: "b", apiKey: "k2" }); - const c3 = await sqliteDb.createProviderConnection({ provider: "test", authType: "apikey", name: "c", apiKey: "k3" }); + const c1 = await sqliteDb.createProviderConnection({ provider: "test", authType: "apikey", name: "a", apiKey: "k1", ownerId: adminOwnerId }); + const c2 = await sqliteDb.createProviderConnection({ provider: "test", authType: "apikey", name: "b", apiKey: "k2", ownerId: adminOwnerId }); + const c3 = await sqliteDb.createProviderConnection({ provider: "test", authType: "apikey", name: "c", apiKey: "k3", ownerId: adminOwnerId }); const list = await sqliteDb.getProviderConnections({ provider: "test" }); expect(list).toHaveLength(3); @@ -103,6 +106,7 @@ describe("DB SQLite layer — public API parity", () => { provider: "p2", authType: "oauth", email: "x@y.com", accessToken: "tok", refreshToken: "rtok", expiresAt: 12345, providerSpecificData: { foo: "bar" }, + ownerId: adminOwnerId, }); const back = await sqliteDb.getProviderConnectionById(c.id); expect(back.accessToken).toBe("tok"); @@ -112,8 +116,8 @@ describe("DB SQLite layer — public API parity", () => { }); it("providerConnections: scopes retrieval and lookup to connection owner", async () => { - const ownerOne = await sqliteDb.createUser({ username: "connection-owner-one", password: "password", role: "user" }); - const ownerTwo = await sqliteDb.createUser({ username: "connection-owner-two", password: "password", role: "user" }); + const ownerOne = await sqliteDb.createUser({ username: "connection-owner-one", password: "password", role: "admin" }); + const ownerTwo = await sqliteDb.createUser({ username: "connection-owner-two", password: "password", role: "admin" }); const firstConnection = await sqliteDb.createProviderConnection({ provider: "owner-test-one", authType: "apikey", @@ -200,6 +204,7 @@ describe("DB SQLite layer — public API parity", () => { authType: "oauth", accessToken: "tok", providerSpecificData: { githubLogin: "octocat" }, + ownerId: adminOwnerId, }); expect(c.name).toBe("octocat"); diff --git a/tests/unit/deepseek-usage.test.js b/tests/unit/deepseek-usage.test.js new file mode 100644 index 00000000..0be26edc --- /dev/null +++ b/tests/unit/deepseek-usage.test.js @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: vi.fn(), +})); + +import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js"; +import { getUsageForProvider } from "../../open-sse/services/usage.js"; +import { + USAGE_SUPPORTED_PROVIDERS, + USAGE_APIKEY_PROVIDERS, +} from "../../src/shared/constants/providers.js"; +import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js"; + +const BALANCE_URL = "https://api.deepseek.com/user/balance"; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const ACTIVE_BALANCE = { + is_available: true, + balance_infos: [ + { + currency: "USD", + total_balance: "12.50", + granted_balance: "2.50", + topped_up_balance: "10.00", + }, + { + currency: "CNY", + total_balance: "0.00", + granted_balance: "0.00", + topped_up_balance: "0.00", + }, + ], +}; + +describe("deepseek registry usage flags", () => { + it("is listed for apikey quota dashboard", () => { + expect(USAGE_SUPPORTED_PROVIDERS).toContain("deepseek"); + expect(USAGE_APIKEY_PROVIDERS).toContain("deepseek"); + }); +}); + +describe("getUsageForProvider(deepseek)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("GETs /user/balance with Bearer apiKey", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_BALANCE)); + + const usage = await getUsageForProvider({ + provider: "deepseek", + apiKey: "sk-ds-test", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.plan).toBe("DeepSeek"); + expect(proxyAwareFetch).toHaveBeenCalledTimes(1); + const [url, opts] = proxyAwareFetch.mock.calls[0]; + expect(url).toBe(BALANCE_URL); + expect(opts.method).toBe("GET"); + expect(opts.headers.Authorization).toBe("Bearer sk-ds-test"); + }); + + it("maps balances without absolute remaining (UI treats remaining as %)", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_BALANCE)); + + const usage = await getUsageForProvider({ + provider: "deepseek", + apiKey: "sk-ds-test", + }); + + expect(usage.quotas["Balance (USD)"]).toMatchObject({ + used: 0, + total: 12.5, + remainingPercentage: 100, + }); + expect(usage.quotas["Balance (USD)"].remaining).toBeUndefined(); + // Zero CNY still listed so user sees currency row + expect(usage.quotas["Balance (CNY)"]).toMatchObject({ + used: 0, + total: 0, + remainingPercentage: 0, + }); + }); + + it("marks plan unavailable when is_available false", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ + is_available: false, + balance_infos: [ + { + currency: "USD", + total_balance: "0", + granted_balance: "0", + topped_up_balance: "0", + }, + ], + }), + ); + + const usage = await getUsageForProvider({ + provider: "deepseek", + apiKey: "sk-ds-test", + }); + + expect(usage.plan).toMatch(/insufficient|unavailable/i); + expect(usage.quotas["Balance (USD)"].remainingPercentage).toBe(0); + }); + + it("returns message on missing key / 401", async () => { + const missing = await getUsageForProvider({ provider: "deepseek" }); + expect(missing.message).toMatch(/api key/i); + expect(proxyAwareFetch).not.toHaveBeenCalled(); + + proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "no" }, 401)); + const auth = await getUsageForProvider({ + provider: "deepseek", + apiKey: "bad", + }); + expect(auth.message).toMatch(/auth|key|401/i); + }); +}); + +describe("parseQuotaData(deepseek)", () => { + it("forwards remainingPercentage for balance rows", () => { + const rows = parseQuotaData("deepseek", { + plan: "DeepSeek", + quotas: { + "Balance (USD)": { + used: 0, + total: 12.5, + remainingPercentage: 100, + }, + }, + }); + expect(rows[0]).toMatchObject({ + name: "Balance (USD)", + total: 12.5, + remainingPercentage: 100, + }); + }); +}); diff --git a/tests/unit/devin-cli-executor.test.js b/tests/unit/devin-cli-executor.test.js new file mode 100644 index 00000000..5fe37a4a --- /dev/null +++ b/tests/unit/devin-cli-executor.test.js @@ -0,0 +1,437 @@ +import { describe, it, expect, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import os from "node:os"; + +// `vi.hoisted` runs before the mocked module is evaluated, so the factory can +// safely reference the mock fn. +const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })); + +vi.mock("node:child_process", () => ({ + spawn: (...args) => spawnMock(...args), +})); + +const { default: DevinCliExecutor } = await import("open-sse/executors/devin-cli.js"); + +// Fake devin ACP subprocess. Mirrors the real CLI's session/new validation: +// it requires `mcpServers` to be an array, otherwise returns -32602 — this is +// the exact error the dashboard "test" button hit ("Invalid params"). +function makeFakeChild() { + const child = new EventEmitter(); + child.writes = []; + child.stdin = new EventEmitter(); + child.stdin.destroyed = false; + child.stdin.write = (data) => { + child.writes.push(String(data)); + try { + const msg = JSON.parse(String(data).trim()); + handle(msg); + } catch { + /* ignore */ + } + return true; + }; + child.stdin.end = () => { + child.stdin.destroyed = true; + }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.killed = false; + child.kill = () => { + child.killed = true; + }; + + const send = (obj) => + child.stdout.emit("data", Buffer.from(JSON.stringify(obj) + "\n")); + + function handle(msg) { + if (msg.method === "initialize") { + send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } }); + } else if (msg.method === "session/new") { + // Mirror devin 3000.2.x: `mcpServers` is a required sequence. + if (Array.isArray(msg.params && msg.params.mcpServers)) { + send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "fake-session" } }); + } else if (!msg.params || msg.params.mcpServers === undefined) { + send({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32602, message: "Invalid params", data: { error: "missing field `mcpServers`" } }, + }); + } else { + send({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32602, message: "Invalid params", data: { error: "invalid type: map, expected a sequence" } }, + }); + } + } else if (msg.method === "session/prompt") { + // devin 3000.2.x requires `prompt` (a sequence), not `content`. + if (Array.isArray(msg.params && msg.params.prompt)) { + // Agent requests permission to run a tool before replying. + send({ + jsonrpc: "2.0", + id: 777, + method: "session/request_permission", + params: { + sessionId: "fake-session", + options: [ + { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + ], + }, + }); + // New ACP shape: streaming via session/update with params.update.sessionUpdate. + send({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "fake-session", update: { sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "(thinking)" } } }, + }); + send({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "fake-session", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "hello world" } } }, + }); + // Stop signal: _cognition.ai/agent_stopped notification. + send({ jsonrpc: "2.0", method: "_cognition.ai/agent_stopped", params: { cause: "complete" } }); + } else { + send({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32602, message: "Invalid params", data: { error: "missing field `prompt`" } }, + }); + } + } + } + + return child; +} + +async function runExecute(credentials = {}) { + const child = makeFakeChild(); + spawnMock.mockImplementation((bin, args, opts) => { + child.bin = bin; + child.args = args; + child.opts = opts; + return child; + }); + const exec = new DevinCliExecutor(); + const { response } = await exec.execute({ + model: "swe-1.6-fast", + body: { messages: [{ role: "user", content: "hi" }] }, + credentials, + log: { info() {}, debug() {} }, + }); + const reader = response.body.getReader(); + let acc = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + acc += new TextDecoder().decode(value); + } + return { acc, child }; +} + +describe("DevinCliExecutor ACP session/new", () => { + it("sends session/new with mcpServers as an array", async () => { + const { child } = await runExecute(); + const writes = child.writes.map((w) => JSON.parse(w.trim())); + const newMsg = writes.find((m) => m.method === "session/new"); + expect(newMsg).toBeTruthy(); + expect(Array.isArray(newMsg.params.mcpServers)).toBe(true); + }); + + it("defaults session/new cwd to os.tmpdir when request has no workspace cwd", async () => { + const { child } = await runExecute(); + const writes = child.writes.map((w) => JSON.parse(w.trim())); + const newMsg = writes.find((m) => m.method === "session/new"); + expect(newMsg.params.cwd).toBe(os.tmpdir()); + }); + + it("uses client env context for session/new and spawn", async () => { + const child = makeFakeChild(); + spawnMock.mockImplementation((bin, args, opts) => { + child.args = args; + child.opts = opts; + return child; + }); + const workspace = os.tmpdir(); // known existing absolute dir + const exec = new DevinCliExecutor(); + const { response } = await exec.execute({ + model: "swe-1.6-fast", + body: { + messages: [ + { + role: "user", + content: `\n ${workspace}\n\nhi`, + }, + ], + }, + credentials: {}, + log: { info() {}, debug() {} }, + }); + const reader = response.body.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } + expect(child.opts.cwd).toBe(workspace); + const writes = child.writes.map((w) => JSON.parse(w.trim())); + const newMsg = writes.find((m) => m.method === "session/new"); + expect(newMsg.params.cwd).toBe(workspace); + }); + + it("sends session/prompt with prompt (not content) as an array", async () => { + const { child } = await runExecute(); + const writes = child.writes.map((w) => JSON.parse(w.trim())); + const promptMsg = writes.find((m) => m.method === "session/prompt"); + expect(promptMsg).toBeTruthy(); + expect(Array.isArray(promptMsg.params.prompt)).toBe(true); + expect(promptMsg.params.content).toBeUndefined(); + }); + + it("completes the prompt without a -32602 Invalid params error", async () => { + const { acc } = await runExecute(); + expect(acc).not.toContain("-32602"); + expect(acc).not.toContain("Invalid params"); + expect(acc.toLowerCase()).toContain("hello world"); + }); + + it("emits agent_message_chunk content and skips agent_thought_chunk", async () => { + // devin 3000.2.x streams via params.update.sessionUpdate. + const { acc } = await runExecute(); + // Reply text is delivered, finish chunk present, thinking is not surfaced. + expect(acc.toLowerCase()).toContain("hello world"); + expect(acc).toContain("finish_reason"); + expect(acc.toLowerCase()).not.toContain("(thinking)"); + expect(acc).toContain("[DONE]"); + }); + + it("spawns the default agent (with built-in tools) by default", async () => { + const { child } = await runExecute(); + expect(child.args).toEqual(["acp"]); + }); + + it("seeds MCP with tool_result from prior client round-trip", async () => { + const fs = await import("node:fs"); + const child = makeFakeChild(); + let capturedCfg = null; + let capturedPrompt = null; + spawnMock.mockImplementation((bin, args, opts) => { + child.args = args; + child.opts = opts; + // Capture config at spawn time (finish() cleans the temp dir). + if (opts?.env?.XDG_CONFIG_HOME) { + capturedCfg = JSON.parse( + fs.readFileSync(opts.env.XDG_CONFIG_HOME + "/devin/config.json", "utf8") + ); + } + return child; + }); + const origWrite = child.stdin.write; + child.stdin.write = (data) => { + const s = String(data); + try { + const msg = JSON.parse(s.trim()); + if (msg.method === "session/prompt") { + capturedPrompt = msg.params.prompt[0].text; + } + } catch { + /* ignore */ + } + return origWrite.call(child.stdin, data); + }; + const exec = new DevinCliExecutor(); + const { response } = await exec.execute({ + model: "swe-1.6-fast", + body: { + messages: [ + { role: "user", content: "weather?" }, + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "get_weather", arguments: '{"city":"Paris"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: "28C sunny" }, + ], + tools: [ + { + type: "function", + function: { + name: "get_weather", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }, + }, + ], + }, + credentials: {}, + log: { info() {}, debug() {} }, + }); + const reader = response.body.getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } + expect(capturedCfg).toBeTruthy(); + const results = JSON.parse(capturedCfg.mcpServers.clientTools.env.DEVIN_MCP_RESULTS); + expect(results.mcp_get_weather).toBe("28C sunny"); + expect(capturedPrompt).toContain("get_weather"); + expect(capturedPrompt).toContain("28C sunny"); + }); + + it("bridges a client-tool MCP call to an OpenAI tool_use", async () => { + // Custom fake: on session/prompt, report devin calling our exposed MCP tool. + const child = new EventEmitter(); + child.writes = []; + child.stdin = new EventEmitter(); + child.stdin.destroyed = false; + child.stdin.write = (data) => { child.writes.push(String(data)); handle(JSON.parse(String(data).trim())); return true; }; + child.stdin.end = () => { child.stdin.destroyed = true; }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.killed = false; + child.kill = () => { child.killed = true; }; + child.args = ["acp"]; + child.opts = { env: {} }; + spawnMock.mockReturnValue(child); + const send = (o) => child.stdout.emit("data", Buffer.from(JSON.stringify(o) + "\n")); + function handle(msg) { + if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } }); + else if (msg.method === "session/new") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "s1" } }); + else if (msg.method === "session/prompt") { + // Mirror real ACP: title on first event, rawInput on a later update. + send({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "s1", update: { sessionUpdate: "tool_call", toolCallId: "call_abc", title: "Calling mcp_get_weather from clientTools" } }, + }); + send({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "s1", update: { sessionUpdate: "tool_call_update", toolCallId: "call_abc", rawInput: { city: "Paris" } } }, + }); + } + } + + const exec = new DevinCliExecutor(); + const { response } = await exec.execute({ + model: "swe-1.6-fast", + body: { + messages: [{ role: "user", content: "weather?" }], + tools: [{ type: "function", function: { name: "get_weather", parameters: { type: "object" } } }], + }, + credentials: {}, + log: { info() {}, debug() {} }, + }); + const reader = response.body.getReader(); + let acc = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + acc += new TextDecoder().decode(value); + if (acc.includes("[DONE]")) break; + } + const tc = JSON.parse(acc.match(/"tool_calls":\[(\{.*?\})\]/)?.[1] ?? "{}"); + expect(tc.function.name).toBe("get_weather"); // mcp_ prefix stripped, MCP-real untouched + expect(tc.id).toBe("call_abc"); + expect(JSON.parse(tc.function.arguments).city).toBe("Paris"); + expect(acc).toContain('"finish_reason":"tool_calls"'); + expect(acc).toContain("[DONE]"); + }); + + it("overrides the agent type via CLI_DEVIN_AGENT_TYPE", async () => { + process.env.CLI_DEVIN_AGENT_TYPE = "summarizer"; + try { + const { child } = await runExecute(); + expect(child.args).toEqual(["acp", "--agent-type", "summarizer"]); + } finally { + delete process.env.CLI_DEVIN_AGENT_TYPE; + } + }); + + it("sets DEVIN_PERMISSION_MODE=bypass so tool calls don't hang on permission prompts", async () => { + const { child } = await runExecute(); + expect(child.opts.env.DEVIN_PERMISSION_MODE).toBe("bypass"); + }); + + it("does not inject WINDSURF_API_KEY — devin-cli uses stored CLI creds (devin auth login)", async () => { + // Provider is noAuth; devin must fall back to ~/.local/share/devin/credentials.toml. + // Injecting a bogus WINDSURF_API_KEY makes devin reject stored creds → -32000. + const { child } = await runExecute({ accessToken: "bogus-token", apiKey: "bogus-key" }); + expect(child.opts.env.WINDSURF_API_KEY).toBeUndefined(); + }); + + it("respects an explicit DEVIN_PERMISSION_MODE override", async () => { + process.env.DEVIN_PERMISSION_MODE = "accept-edits"; + try { + const { child } = await runExecute(); + expect(child.opts.env.DEVIN_PERMISSION_MODE).toBe("accept-edits"); + } finally { + delete process.env.DEVIN_PERMISSION_MODE; + } + }); + + it("auto-approves session/request_permission with the first allow option", async () => { + const { child } = await runExecute(); + const writes = child.writes.map((w) => JSON.parse(w.trim())); + const resp = writes.find((m) => m.id === 777 && m.result); + expect(resp).toBeTruthy(); + expect(resp.result.outcome.outcome).toBe("selected"); + expect(resp.result.outcome.optionId).toBe("allow-once"); + }); + + it("sets XDG_CONFIG_HOME when DEVIN_MCP_SERVERS is provided", async () => { + process.env.DEVIN_MCP_SERVERS = JSON.stringify({ + echo: { command: "/usr/bin/node", args: ["/srv/echo.js"] }, + }); + try { + const { child } = await runExecute(); + expect(child.opts.env.XDG_CONFIG_HOME).toBeTruthy(); + // devin reads $XDG_CONFIG_HOME/devin/config.json (E2E verifies content). + } finally { + delete process.env.DEVIN_MCP_SERVERS; + } + }); + + it("does not set XDG_CONFIG_HOME when DEVIN_MCP_SERVERS is absent", async () => { + const { child } = await runExecute(); + expect(child.opts.env.XDG_CONFIG_HOME).toBeUndefined(); + }); + + it("exposes body.tools as an MCP server (sets XDG_CONFIG_HOME + writes script)", async () => { + const fs = await import("node:fs"); + const os = await import("node:os"); + const path = await import("node:path"); + const child = makeFakeChild(); + spawnMock.mockImplementation((bin, args, opts) => { + child.args = args; + child.opts = opts; + return child; + }); + const exec = new DevinCliExecutor(); + const { response } = await exec.execute({ + model: "swe-1.6-fast", + body: { + messages: [{ role: "user", content: "weather?" }], + tools: [ + { type: "function", function: { name: "get_weather", description: "Get weather", parameters: { type: "object", properties: { city: { type: "string" } } } } }, + ], + }, + credentials: {}, + log: { info() {}, debug() {} }, + }); + const reader = response.body.getReader(); + await reader.read(); + // XDG_CONFIG_HOME set so devin loads the generated config. + expect(child.opts.env.XDG_CONFIG_HOME).toBeTruthy(); + // Static MCP bridge script written to disk. + const scriptPath = path.join(os.tmpdir(), "9router-devin-client-tools.mjs"); + expect(fs.existsSync(scriptPath)).toBe(true); + expect(fs.readFileSync(scriptPath, "utf8")).toContain("clientTools"); + expect(fs.readFileSync(scriptPath, "utf8")).toContain("DEVIN_MCP_TOOLS"); + }); +}); diff --git a/tests/unit/embedding-usage-persistence.test.js b/tests/unit/embedding-usage-persistence.test.js new file mode 100644 index 00000000..9328b42e --- /dev/null +++ b/tests/unit/embedding-usage-persistence.test.js @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + handleEmbeddingsCore: vi.fn(), + saveRequestUsage: vi.fn(), +})); + +vi.mock("../../src/sse/services/auth.js", () => ({ + getProviderCredentials: async () => ({ + apiKey: "provider-secret", + connectionId: "connection-a", + connectionName: "Provider A", + }), + markAccountUnavailable: vi.fn(), + clearAccountError: vi.fn(), + extractApiKey: () => "client-key", + getApiKeyOwnerId: async () => "user-a", + isValidApiKey: vi.fn(), +})); +vi.mock("@/lib/localDb", () => ({ getSettings: async () => ({ requireApiKey: false }) })); +vi.mock("../../src/sse/services/model.js", () => ({ + getModelInfo: async () => ({ provider: "openai", model: "text-embedding-3-small" }), +})); +vi.mock("../../open-sse/handlers/embeddingsCore.js", () => ({ + handleEmbeddingsCore: mocks.handleEmbeddingsCore, +})); +vi.mock("../../open-sse/utils/error.js", () => ({ + errorResponse: (status, message) => Response.json({ error: message }, { status }), + unavailableResponse: (status, message) => Response.json({ error: message }, { status }), +})); +vi.mock("../../src/sse/utils/logger.js", () => ({ + request: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn(), maskKey: vi.fn(), +})); +vi.mock("../../src/sse/services/tokenRefresh.js", () => ({ + updateProviderCredentials: vi.fn(), + checkAndRefreshToken: async (_provider, credentials) => credentials, +})); +vi.mock("@/lib/usageDb.js", () => ({ saveRequestUsage: mocks.saveRequestUsage })); + +import { handleEmbeddings } from "../../src/sse/handlers/embeddings.js"; + +describe("embedding usage persistence", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.saveRequestUsage.mockResolvedValue(undefined); + mocks.handleEmbeddingsCore.mockResolvedValue({ + success: true, + usage: { prompt_tokens: 12, total_tokens: 12 }, + response: Response.json({ data: [] }), + }); + }); + + it("records exact provider usage for successful embedding requests", async () => { + await handleEmbeddings(new Request("http://localhost/v1/embeddings", { + method: "POST", + body: JSON.stringify({ model: "openai/text-embedding-3-small", input: "hello" }), + })); + + expect(mocks.saveRequestUsage).toHaveBeenCalledWith(expect.objectContaining({ + provider: "openai", + model: "text-embedding-3-small", + connectionId: "connection-a", + apiKey: "client-key", + endpoint: "/v1/embeddings", + status: "success", + tokens: { prompt_tokens: 12, completion_tokens: 0, total_tokens: 12 }, + })); + }); + + it.each([ + null, + {}, + { prompt_tokens: 0, total_tokens: 0 }, + { prompt_tokens: "12", total_tokens: 12 }, + { prompt_tokens: 12, total_tokens: 13 }, + { prompt_tokens: 12, completion_tokens: 1, total_tokens: 12 }, + { prompt_tokens: 12, total_tokens: 12, estimated: true }, + ])("does not record inexact usage %#", async (usage) => { + mocks.handleEmbeddingsCore.mockResolvedValue({ + success: true, + usage, + response: Response.json({ data: [] }), + }); + + await handleEmbeddings(new Request("http://localhost/v1/embeddings", { + method: "POST", + body: JSON.stringify({ model: "openai/text-embedding-3-small", input: "hello" }), + })); + + expect(mocks.saveRequestUsage).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/fetch-success-clears-account.test.js b/tests/unit/fetch-success-clears-account.test.js new file mode 100644 index 00000000..1002ceb2 --- /dev/null +++ b/tests/unit/fetch-success-clears-account.test.js @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getProviderCredentials: vi.fn(), + markAccountUnavailable: vi.fn(), + clearAccountError: vi.fn(), + extractApiKey: vi.fn(() => null), + isValidApiKey: vi.fn(), + getSettings: vi.fn(), + getCombos: vi.fn(), + handleFetchCore: vi.fn(), + checkAndRefreshToken: vi.fn(), +})); + +vi.mock("@/sse/services/auth.js", () => ({ + getProviderCredentials: mocks.getProviderCredentials, + markAccountUnavailable: mocks.markAccountUnavailable, + clearAccountError: mocks.clearAccountError, + extractApiKey: mocks.extractApiKey, + getApiKeyOwnerId: vi.fn(async () => null), + isValidApiKey: mocks.isValidApiKey, +})); + +vi.mock("@/lib/localDb", () => ({ + getSettings: mocks.getSettings, + getCombos: mocks.getCombos, +})); + +vi.mock("open-sse/handlers/fetch/index.js", () => ({ + handleFetchCore: mocks.handleFetchCore, +})); + +vi.mock("@/sse/services/tokenRefresh.js", () => ({ + checkAndRefreshToken: mocks.checkAndRefreshToken, + updateProviderCredentials: vi.fn(), +})); + +vi.mock("@/sse/utils/logger.js", () => ({ + request: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + maskKey: vi.fn(() => "masked"), +})); + +vi.mock("@/shared/utils/ssrfGuard.js", () => ({ + assertPublicUrl: vi.fn(), +})); + +import { handleFetch } from "@/sse/handlers/fetch.js"; + +describe("web fetch account state", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSettings.mockResolvedValue({ requireApiKey: false }); + mocks.getCombos.mockResolvedValue([]); + mocks.getProviderCredentials.mockResolvedValue({ + apiKey: "jina-test-key", + connectionId: "jina-connection", + connectionName: "Jina Test", + _connection: { + testStatus: "unavailable", + lastError: "old error", + modelLock___all: "2026-01-01T00:00:00.000Z", + }, + }); + mocks.checkAndRefreshToken.mockImplementation(async (_provider, credentials) => credentials); + mocks.handleFetchCore.mockResolvedValue({ + success: true, + data: { provider: "jina-reader", content: { text: "ok" } }, + }); + }); + + it("clears a stale provider lock after a successful fetch", async () => { + const response = await handleFetch(new Request("http://localhost/v1/web/fetch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "jina-reader", + url: "https://example.com/article", + }), + })); + + expect(response.status).toBe(200); + expect(mocks.clearAccountError).toHaveBeenCalledWith( + "jina-connection", + expect.objectContaining({ connectionName: "Jina Test" }), + ); + expect(mocks.markAccountUnavailable).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/gemini-36-integration.test.js b/tests/unit/gemini-36-integration.test.js new file mode 100644 index 00000000..e401f923 --- /dev/null +++ b/tests/unit/gemini-36-integration.test.js @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createRequire } from "node:module"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +import { getModelUpstreamId } from "../../open-sse/config/providerModels.js"; +import { AntigravityExecutor } from "../../open-sse/executors/antigravity.js"; +import { applyThinking, stripThinkingSuffix } from "../../open-sse/translator/concerns/thinkingUnified.js"; +import antigravity from "../../open-sse/providers/registry/antigravity.js"; +import geminiCli from "../../open-sse/providers/registry/gemini-cli.js"; +import gemini from "../../open-sse/providers/registry/gemini.js"; +import { MODEL_PRICING } from "../../open-sse/providers/pricing.js"; +import { + getProjectIdForConnection, + removeConnection, +} from "../../open-sse/services/projectId.js"; + +const require = createRequire(import.meta.url); +const mitmConfig = require("../../src/mitm/config.js"); +const here = dirname(fileURLToPath(import.meta.url)); + +function cloudCodeResponse(projectId) { + return { + ok: true, + json: async () => ({ cloudaicompanionProject: { id: projectId } }), + }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Gemini Cloud Code endpoint isolation", () => { + it("keeps Gemini CLI on the official cloudcode host", async () => { + const connectionId = "gemini-cli-endpoint-test"; + const fetchMock = vi.fn(async () => cloudCodeResponse("gemini-project")); + vi.stubGlobal("fetch", fetchMock); + + await getProjectIdForConnection(connectionId, "token", "gemini-cli"); + + expect(fetchMock).toHaveBeenCalledWith( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + expect.objectContaining({ method: "POST" }) + ); + expect(geminiCli.transport.baseUrl).toBe("https://cloudcode-pa.googleapis.com/v1internal"); + removeConnection(connectionId); + }); + + it("uses the prod cloudcode host for Antigravity discovery but daily for chat", async () => { + const connectionId = "antigravity-endpoint-test"; + const fetchMock = vi.fn(async () => cloudCodeResponse("antigravity-project")); + vi.stubGlobal("fetch", fetchMock); + + await getProjectIdForConnection(connectionId, "token", "antigravity"); + + // Discovery (loadCodeAssist) on PROD — daily host rejects auth/onboarding calls. + expect(fetchMock).toHaveBeenCalledWith( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + expect.objectContaining({ method: "POST" }) + ); + // Chat transport still uses the daily host to bypass prod 429. + expect(antigravity.transport.baseUrls).toEqual(["https://daily-cloudcode-pa.googleapis.com"]); + removeConnection(connectionId); + }); +}); + +describe("Gemini 3.6 Antigravity tiers", () => { + it.each(["high", "medium", "low"])( + "maps the %s tier to the shared upstream model with matching thinking level", + (tier) => { + const publicModel = `gemini-3.6-flash-${tier}`; + const upstreamModel = getModelUpstreamId("ag", publicModel); + const body = { + model: stripThinkingSuffix(upstreamModel), + request: { + contents: [{ role: "user", parts: [{ text: "hello" }] }], + generationConfig: {}, + }, + }; + + applyThinking("antigravity", upstreamModel, body, "antigravity"); + const finalBody = new AntigravityExecutor().transformRequest( + publicModel, + body, + true, + { projectId: "project", connectionId: "connection" } + ); + + expect(upstreamModel).toBe(`gemini-3.6-flash-tiered(${tier})`); + expect(finalBody.model).toBe("gemini-3.6-flash-tiered"); + expect(finalBody.request.generationConfig.thinkingConfig).toEqual({ + thinkingLevel: tier, + includeThoughts: true, + }); + } + ); +}); + +describe("Gemini 3.6 MITM model extraction", () => { + it("exports the model extractor from the side-effect-free MITM config module", () => { + expect(mitmConfig.extractModel).toBeTypeOf("function"); + }); + + it.each(["high", "medium", "low"])("extracts the %s thinking tier", (tier) => { + const body = Buffer.from(JSON.stringify({ + request: { generationConfig: { thinkingConfig: { thinkingLevel: tier } } }, + })); + + expect(mitmConfig.extractModel( + "/v1internal/models/gemini-3.6-flash-tiered:streamGenerateContent", + body + )).toBe(`gemini-3.6-flash-${tier}`); + }); + + it("defaults invalid or missing thinking levels to medium", () => { + const body = Buffer.from(JSON.stringify({ + request: { generationConfig: { thinkingConfig: { thinkingLevel: "unknown" } } }, + })); + + expect(mitmConfig.extractModel( + "/v1internal/models/gemini-3.6-flash-tiered:streamGenerateContent", + body + )).toBe("gemini-3.6-flash-medium"); + }); +}); + +describe("Gemini 3.6 catalogs and pricing", () => { + it("exposes the direct Gemini API models and their pricing", () => { + const ids = gemini.models.map((model) => model.id); + expect(ids).toContain("gemini-3.6-flash"); + expect(ids).toContain("gemini-3.5-flash-lite"); + expect(MODEL_PRICING["gemini-3.6-flash"]).toMatchObject({ input: 1.5, output: 7.5 }); + expect(MODEL_PRICING["gemini-3.5-flash-lite"]).toMatchObject({ input: 0.3, output: 2.5 }); + }); + + it("keeps the standalone CLI Gemini catalog synchronized", () => { + const source = readFileSync(join(here, "../../cli/src/cli/menus/providers.js"), "utf8"); + const geminiCatalog = source.match(/\n gemini: \[([\s\S]*?)\n \],/)?.[1] || ""; + + expect(geminiCatalog).toContain("gemini-3.6-flash"); + expect(geminiCatalog).toContain("gemini-3.5-flash-lite"); + }); +}); diff --git a/tests/unit/grok-cli-quota-frame.test.js b/tests/unit/grok-cli-quota-frame.test.js new file mode 100644 index 00000000..cd86ab4a --- /dev/null +++ b/tests/unit/grok-cli-quota-frame.test.js @@ -0,0 +1,229 @@ +import { describe, it, expect } from "vitest"; +import { + decodeGrokCreditsFrame, + probeFrameHeader, +} from "../../open-sse/services/usage/grokCliQuotaFrame.js"; + +/** + * Minimal protobuf encoder for fixtures — real GetGrokCreditsConfig wire shape + * (nested field 1 / fixed32 ratio / Timestamp reset + optional trailer 0x80). + */ + +function encodeVarint(value) { + const bytes = []; + let v = BigInt(value); + do { + let byte = Number(v & 0x7fn); + v >>= 7n; + if (v !== 0n) byte |= 0x80; + bytes.push(byte); + } while (v !== 0n); + return Buffer.from(bytes); +} + +function encodeTag(fieldNumber, wireType) { + return encodeVarint((fieldNumber << 3) | wireType); +} + +function encodeFixed32Field(fieldNumber, value) { + const body = Buffer.alloc(4); + body.writeFloatLE(value, 0); + return Buffer.concat([encodeTag(fieldNumber, 5), body]); +} + +function encodeLengthDelimited(fieldNumber, body) { + return Buffer.concat([encodeTag(fieldNumber, 2), encodeVarint(body.length), body]); +} + +function encodeVarintField(fieldNumber, value) { + return Buffer.concat([encodeTag(fieldNumber, 0), encodeVarint(value)]); +} + +function encodeTimestampField(fieldNumber, seconds, nanos) { + const parts = []; + if (seconds !== 0) parts.push(encodeVarintField(1, seconds)); + if (nanos !== 0) parts.push(encodeVarintField(2, nanos)); + return encodeLengthDelimited(fieldNumber, Buffer.concat(parts)); +} + +function encodeCreditsInfo(shape) { + const parts = []; + if (shape.usageRatio !== undefined) parts.push(encodeFixed32Field(1, shape.usageRatio)); + if (shape.asOfSeconds !== undefined) { + parts.push(encodeTimestampField(4, shape.asOfSeconds, shape.asOfNanos ?? 0)); + } + if (shape.resetSeconds !== undefined) { + parts.push(encodeTimestampField(5, shape.resetSeconds, shape.resetNanos ?? 0)); + } + return Buffer.concat(parts); +} + +function encodeTopLevelMessage(creditsInfo) { + return encodeLengthDelimited(1, creditsInfo); +} + +function frameData(payload) { + const header = Buffer.alloc(5); + header[0] = 0x00; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +function frameTrailer(statusText = "grpc-status:0\r\n") { + const body = Buffer.from(statusText, "utf8"); + const header = Buffer.alloc(5); + header[0] = 0x80; + header.writeUInt32BE(body.length, 1); + return Buffer.concat([header, body]); +} + +const REAL_USAGE_RATIO = 1.0; +const REAL_ASOF_SECONDS = 1784221140; +const REAL_ASOF_NANOS = 867850000; +const REAL_RESET_SECONDS = 1784825940; +const REAL_RESET_NANOS = 867850000; +const PERCENT_TOLERANCE = 1e-4; + +function isoFromEpoch(seconds, nanos) { + return new Date(seconds * 1000 + Math.round(nanos / 1_000_000)).toISOString(); +} + +describe("decodeGrokCreditsFrame", () => { + it("decodes real GetGrokCreditsConfig shape (nested, fixed32, Timestamp, trailer)", () => { + const creditsInfo = encodeCreditsInfo({ + usageRatio: REAL_USAGE_RATIO, + asOfSeconds: REAL_ASOF_SECONDS, + asOfNanos: REAL_ASOF_NANOS, + resetSeconds: REAL_RESET_SECONDS, + resetNanos: REAL_RESET_NANOS, + }); + const buffer = Buffer.concat([frameData(encodeTopLevelMessage(creditsInfo)), frameTrailer()]); + + const result = decodeGrokCreditsFrame(buffer); + expect(result).toBeTruthy(); + expect(result.percentUsed).toBe(100); + expect(result.resetAt).toBe(isoFromEpoch(REAL_RESET_SECONDS, REAL_RESET_NANOS)); + }); + + it("ignores trailing gRPC-web trailer frame (flag 0x80)", () => { + const creditsInfo = encodeCreditsInfo({ + usageRatio: 0.5, + resetSeconds: REAL_RESET_SECONDS, + resetNanos: 0, + }); + const topMessage = encodeTopLevelMessage(creditsInfo); + const withoutTrailer = frameData(topMessage); + const withTrailer = Buffer.concat([frameData(topMessage), frameTrailer()]); + + const a = decodeGrokCreditsFrame(withoutTrailer); + const b = decodeGrokCreditsFrame(withTrailer); + expect(a).toBeTruthy(); + expect(b).toBeTruthy(); + expect(b.percentUsed).toBe(a.percentUsed); + expect(b.resetAt).toBe(a.resetAt); + expect(b.percentUsed).toBe(50); + }); + + it("decodes raw unframed protobuf payload", () => { + const creditsInfo = encodeCreditsInfo({ + usageRatio: 0.75, + resetSeconds: REAL_RESET_SECONDS, + resetNanos: REAL_RESET_NANOS, + }); + const payload = encodeTopLevelMessage(creditsInfo); + expect(probeFrameHeader(payload)).toBeNull(); + + const result = decodeGrokCreditsFrame(payload); + expect(result).toBeTruthy(); + expect(Math.abs(result.percentUsed - 75)).toBeLessThan(PERCENT_TOLERANCE); + expect(result.resetAt).toBe(isoFromEpoch(REAL_RESET_SECONDS, REAL_RESET_NANOS)); + }); + + it("treats omitted usage-ratio as 0% (proto3 default)", () => { + const creditsInfo = encodeCreditsInfo({ + resetSeconds: REAL_RESET_SECONDS, + resetNanos: REAL_RESET_NANOS, + }); + const result = decodeGrokCreditsFrame(frameData(encodeTopLevelMessage(creditsInfo))); + expect(result).toBeTruthy(); + expect(result.percentUsed).toBe(0); + expect(result.resetAt).toBe(isoFromEpoch(REAL_RESET_SECONDS, REAL_RESET_NANOS)); + }); + + it("clamps usage ratio above 1.0 to percentUsed 100", () => { + const creditsInfo = encodeCreditsInfo({ usageRatio: 1.5 }); + const result = decodeGrokCreditsFrame(frameData(encodeTopLevelMessage(creditsInfo))); + expect(result).toBeTruthy(); + expect(result.percentUsed).toBe(100); + }); + + it("returns null for negative usage ratio", () => { + const creditsInfo = encodeCreditsInfo({ usageRatio: -0.1 }); + expect(decodeGrokCreditsFrame(frameData(encodeTopLevelMessage(creditsInfo)))).toBeNull(); + }); + + it("returns null when top-level field 1 is not length-delimited", () => { + expect(decodeGrokCreditsFrame(frameData(encodeVarintField(1, 42)))).toBeNull(); + }); + + it("returns null when nested usage-ratio has unexpected wire type", () => { + const creditsInfo = encodeLengthDelimited(1, Buffer.from("not-a-float", "utf8")); + expect(decodeGrokCreditsFrame(frameData(encodeTopLevelMessage(creditsInfo)))).toBeNull(); + }); + + it("returns null when top-level has no field 1", () => { + expect(decodeGrokCreditsFrame(frameData(encodeVarintField(9, 1)))).toBeNull(); + }); + + it("returns null for truncated buffer", () => { + const creditsInfo = encodeCreditsInfo({ + usageRatio: 0.5, + resetSeconds: REAL_RESET_SECONDS, + resetNanos: REAL_RESET_NANOS, + }); + const buffer = frameData(encodeTopLevelMessage(creditsInfo)); + expect(decodeGrokCreditsFrame(buffer.subarray(0, buffer.length - 3))).toBeNull(); + }); + + it("returns null for trailer-only body", () => { + expect(decodeGrokCreditsFrame(frameTrailer())).toBeNull(); + }); + + it("returns null for empty buffer", () => { + expect(decodeGrokCreditsFrame(Buffer.alloc(0))).toBeNull(); + }); +}); + +describe("probeFrameHeader", () => { + it("rejects declared length that exceeds body", () => { + const header = Buffer.alloc(5); + header[0] = 0x00; + header.writeUInt32BE(9999, 1); + expect(probeFrameHeader(Buffer.concat([header, Buffer.from([0x01, 0x02])]))).toBeNull(); + }); + + it("rejects invalid compression flag", () => { + const header = Buffer.alloc(5); + header[0] = 0x07; + expect(probeFrameHeader(header)).toBeNull(); + }); + + it("accepts trailer frame header (flag 0x80)", () => { + const result = probeFrameHeader(frameTrailer()); + expect(result).toBeTruthy(); + expect(result.flag).toBe(0x80); + }); + + it("reads frame header at non-zero offset", () => { + const creditsInfo = encodeCreditsInfo({ usageRatio: 0.5 }); + const buffer = Buffer.concat([ + frameData(encodeTopLevelMessage(creditsInfo)), + frameTrailer(), + ]); + const first = probeFrameHeader(buffer); + expect(first).toBeTruthy(); + const second = probeFrameHeader(buffer, first.payloadStart + first.payloadLength); + expect(second).toBeTruthy(); + expect(second.flag).toBe(0x80); + }); +}); diff --git a/tests/unit/grok-cli-usage.test.js b/tests/unit/grok-cli-usage.test.js index 0c52a2cd..a0e28a5c 100644 --- a/tests/unit/grok-cli-usage.test.js +++ b/tests/unit/grok-cli-usage.test.js @@ -113,6 +113,43 @@ describe("parseGrokCliBilling", () => { expect(parsed.exhausted).toBe(false); }); + it("maps creditUsagePercent to a single Weekly SuperGrok bar (not productUsage)", () => { + const parsed = parseGrokCliBilling( + { + config: { + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + start: "2026-07-17T12:42:26.494595+00:00", + end: "2026-07-24T12:42:26.494595+00:00", + }, + creditUsagePercent: 99.0, + onDemandCap: { val: 0 }, + onDemandUsed: { val: 0 }, + productUsage: [ + { product: "GrokBuild", usagePercent: 97.0 }, + { product: "GrokImagine", usagePercent: 2.0 }, + ], + isUnifiedBillingUser: true, + prepaidBalance: { val: 0 }, + billingPeriodStart: "2026-07-17T12:42:26.494595+00:00", + billingPeriodEnd: "2026-07-24T12:42:26.494595+00:00", + }, + }, + { subscriptionTier: "XPremiumPlus", hasGrokCodeAccess: true }, + ); + // Single shared-pool bar from creditUsagePercent + expect(parsed.quotas["Weekly SuperGrok"]).toMatchObject({ + used: 99, + total: 100, + remainingPercentage: 1, + resetAt: "2026-07-24T12:42:26.494Z", + unlimited: false, + }); + // productUsage must NOT become independent quota bars + expect(Object.keys(parsed.quotas)).toEqual(["Weekly SuperGrok"]); + expect(parsed.exhausted).toBe(false); + }); + it("maps current monthly fields and snake-case subscription tier", () => { const parsed = parseGrokCliBilling({ monthlyLimit: { val: 1000 }, @@ -132,6 +169,67 @@ describe("parseGrokCliBilling", () => { }); }); +function encodeVarint(value) { + const bytes = []; + let v = BigInt(value); + do { + let byte = Number(v & 0x7fn); + v >>= 7n; + if (v !== 0n) byte |= 0x80; + bytes.push(byte); + } while (v !== 0n); + return Buffer.from(bytes); +} + +function encodeTag(fieldNumber, wireType) { + return encodeVarint((fieldNumber << 3) | wireType); +} + +function encodeFixed32Field(fieldNumber, value) { + const body = Buffer.alloc(4); + body.writeFloatLE(value, 0); + return Buffer.concat([encodeTag(fieldNumber, 5), body]); +} + +function encodeLengthDelimited(fieldNumber, body) { + return Buffer.concat([encodeTag(fieldNumber, 2), encodeVarint(body.length), body]); +} + +function encodeVarintField(fieldNumber, value) { + return Buffer.concat([encodeTag(fieldNumber, 0), encodeVarint(value)]); +} + +function encodeTimestampField(fieldNumber, seconds, nanos) { + const parts = []; + if (seconds !== 0) parts.push(encodeVarintField(1, seconds)); + if (nanos !== 0) parts.push(encodeVarintField(2, nanos)); + return encodeLengthDelimited(fieldNumber, Buffer.concat(parts)); +} + +/** Framed GetGrokCreditsConfig response for a usage ratio 0..1. */ +function buildCreditsResponseBuffer(usageRatio, resetSeconds = 1784825940, resetNanos = 867850000) { + const creditsInfo = Buffer.concat([ + encodeFixed32Field(1, usageRatio), + encodeTimestampField(5, resetSeconds, resetNanos), + ]); + const topMessage = encodeLengthDelimited(1, creditsInfo); + const header = Buffer.alloc(5); + header[0] = 0x00; + header.writeUInt32BE(topMessage.length, 1); + return Buffer.concat([header, topMessage]); +} + +function binaryResponse(buffer, status = 200) { + return new Response(buffer, { + status, + headers: { "content-type": "application/grpc-web+proto" }, + }); +} + +const EMPTY_GRPC_WEB_FRAME = Buffer.from([0, 0, 0, 0, 0]); +const GRPC_CREDITS_URL = + "https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig"; + describe("getUsageForProvider(grok-cli)", () => { beforeEach(() => { vi.clearAllMocks(); @@ -174,6 +272,8 @@ describe("getUsageForProvider(grok-cli)", () => { expect(billingCall[1].headers["x-userid"]).toBe( "d84768dd-224d-4052-ba49-0d336fa9160c", ); + // REST already has numeric quotas — do not hit gRPC fallback + expect(proxyAwareFetch.mock.calls).toHaveLength(2); }); it("surfaces auth-expired message on 401", async () => { @@ -187,6 +287,8 @@ describe("getUsageForProvider(grok-cli)", () => { }); expect(usage.message).toMatch(/expired|re-authorize/i); + // Auth failure must not attempt gRPC fallback + expect(proxyAwareFetch.mock.calls).toHaveLength(2); }); it("returns depleted on-demand bar without blocking message when cap is zero", async () => { @@ -204,15 +306,62 @@ describe("getUsageForProvider(grok-cli)", () => { expect(usage.message).toBeUndefined(); expect(usage.quotas["On-demand"].remainingPercentage).toBe(0); expect(usage.quotas["On-demand"].total).toBe(1); + // Exhausted free already has a quota bar — no gRPC fallback + expect(proxyAwareFetch.mock.calls).toHaveLength(2); }); - it("reports active paid access when provider exposes no numeric quota", async () => { + it("falls back to GetGrokCreditsConfig gRPC when paid sub has no REST numeric quota", async () => { + const resetSeconds = 1784825940; + const resetNanos = 867850000; + const resetAt = new Date( + resetSeconds * 1000 + Math.round(resetNanos / 1_000_000), + ).toISOString(); + proxyAwareFetch .mockResolvedValueOnce(jsonResponse(EXHAUSTED_BILLING)) - .mockResolvedValueOnce(jsonResponse({ - ...USER_PROFILE, - subscriptionTier: "XPremiumPlus", - })); + .mockResolvedValueOnce( + jsonResponse({ + ...USER_PROFILE, + subscriptionTier: "XPremiumPlus", + }), + ) + .mockResolvedValueOnce(binaryResponse(buildCreditsResponseBuffer(0.35, resetSeconds, resetNanos))); + + const usage = await getUsageForProvider({ + provider: "grok-cli", + accessToken: "test-token", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.plan).toBe("XPremiumPlus"); + expect(usage.quotas["Weekly SuperGrok"]).toMatchObject({ + used: 35, + total: 100, + remainingPercentage: 65, + resetAt, + unlimited: false, + }); + + const grpcCall = proxyAwareFetch.mock.calls[2]; + expect(grpcCall[0]).toBe(GRPC_CREDITS_URL); + expect(grpcCall[1].method).toBe("POST"); + expect(grpcCall[1].headers.Authorization).toBe("Bearer test-token"); + expect(grpcCall[1].headers["Content-Type"]).toBe("application/grpc-web+proto"); + expect(grpcCall[1].headers["X-Grpc-Web"]).toBe("1"); + // Empty gRPC-web request frame is required (flag 0 + length 0) + expect(Buffer.from(grpcCall[1].body)).toEqual(EMPTY_GRPC_WEB_FRAME); + }); + + it("keeps subscription message when REST empty and gRPC fails open", async () => { + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse(EXHAUSTED_BILLING)) + .mockResolvedValueOnce( + jsonResponse({ + ...USER_PROFILE, + subscriptionTier: "XPremiumPlus", + }), + ) + .mockResolvedValueOnce(binaryResponse(Buffer.alloc(0), 500)); const usage = await getUsageForProvider({ provider: "grok-cli", @@ -223,6 +372,26 @@ describe("getUsageForProvider(grok-cli)", () => { expect(usage.message).toMatch(/active.*numeric included quota/i); expect(usage.quotas).toEqual({}); }); + + it("does not throw when gRPC network fails after empty REST quotas", async () => { + proxyAwareFetch + .mockResolvedValueOnce(jsonResponse(EXHAUSTED_BILLING)) + .mockResolvedValueOnce( + jsonResponse({ + ...USER_PROFILE, + subscriptionTier: "XPremiumPlus", + }), + ) + .mockRejectedValueOnce(new Error("network down")); + + const usage = await getUsageForProvider({ + provider: "grok-cli", + accessToken: "test-token", + }); + + expect(usage.message).toMatch(/active.*numeric included quota/i); + expect(usage.quotas).toEqual({}); + }); }); describe("parseQuotaData(grok-cli)", () => { diff --git a/tests/unit/jina-reader-fetch.test.js b/tests/unit/jina-reader-fetch.test.js new file mode 100644 index 00000000..3a6512da --- /dev/null +++ b/tests/unit/jina-reader-fetch.test.js @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { handleFetchCore } from "../../open-sse/handlers/fetch/index.js"; + +const originalFetch = global.fetch; + +describe("Jina Reader fetch", () => { + beforeEach(() => { + global.fetch = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("uses Jina's JSON POST API instead of embedding the URL in the path", async () => { + global.fetch.mockResolvedValueOnce(new Response([ + "Title: Example page", + "", + "URL Source: https://example.com/article", + "", + "Markdown Content:", + "Hello", + ].join("\n"))); + + const result = await handleFetchCore({ + url: "https://example.com/article", + format: "markdown", + provider: "jina-reader", + providerConfig: { timeoutMs: 30000 }, + credentials: { apiKey: "jina-test-key" }, + }); + + expect(result.success).toBe(true); + expect(result.data.title).toBe("Example page"); + expect(global.fetch).toHaveBeenCalledTimes(1); + + const [requestUrl, init] = global.fetch.mock.calls[0]; + expect(requestUrl).toBe("https://r.jina.ai/"); + expect(init.method).toBe("POST"); + expect(init.headers).toEqual({ + "content-type": "application/json", + authorization: "Bearer jina-test-key", + }); + expect(JSON.parse(init.body)).toEqual({ url: "https://example.com/article" }); + }); + + it("returns the upstream status and error body", async () => { + global.fetch.mockResolvedValueOnce(new Response( + JSON.stringify({ detail: "Payment required" }), + { status: 402, headers: { "Content-Type": "application/json" } }, + )); + + const result = await handleFetchCore({ + url: "https://example.com/article", + provider: "jina-reader", + providerConfig: { timeoutMs: 30000 }, + credentials: { apiKey: "jina-test-key" }, + }); + + expect(result).toMatchObject({ + success: false, + status: 402, + }); + expect(result.error).toContain("Payment required"); + }); +}); diff --git a/tests/unit/kimi-usage.test.js b/tests/unit/kimi-usage.test.js new file mode 100644 index 00000000..9d944a37 --- /dev/null +++ b/tests/unit/kimi-usage.test.js @@ -0,0 +1,299 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: vi.fn(), +})); + +import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js"; +import { getUsageForProvider } from "../../open-sse/services/usage.js"; +import { USAGE_SUPPORTED_PROVIDERS, USAGE_APIKEY_PROVIDERS } from "../../src/shared/constants/providers.js"; +import { PROVIDERS } from "../../open-sse/providers/index.js"; +import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js"; + +const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages"; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const ACTIVE_USAGE = { + user: { + membership: { level: "LEVEL_ADVANCED" }, + }, + usage: { + limit: "100", + used: "35", + remaining: "65", + resetTime: "2026-08-01T00:00:00Z", + }, + limits: [ + { + window: { type: "rate" }, + detail: { + limit: "60", + remaining: "40", + resetTime: "2026-07-29T12:00:00Z", + }, + }, + ], +}; + +describe("kimi registry usage flags", () => { + it("exposes usage + usageApikey so OAuth and apikey cards appear on /quota", () => { + expect(USAGE_SUPPORTED_PROVIDERS).toContain("kimi"); + expect(USAGE_APIKEY_PROVIDERS).toContain("kimi"); + }); + + it("registers transport.usage url when present (optional)", () => { + // Provider may or may not put usage url on transport; handler has its own constant. + expect(PROVIDERS.kimi).toBeTruthy(); + }); +}); + +describe("getUsageForProvider(kimi) auth selection", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("OAuth path: Bearer + X-Msh-* (not chat x-api-key)", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE)); + + const usage = await getUsageForProvider({ + provider: "kimi", + accessToken: "tok-abc", + providerSpecificData: { deviceId: "stable-device-1" }, + }); + + expect(usage.message).toBeUndefined(); + expect(usage.plan).toBe("Allegro"); + expect(usage.quotas.Weekly).toMatchObject({ + used: 35, + total: 100, + remainingPercentage: 65, + }); + + expect(proxyAwareFetch).toHaveBeenCalledTimes(1); + const [url, opts] = proxyAwareFetch.mock.calls[0]; + expect(url).toBe(KIMI_USAGE_URL); + expect(opts.method).toBe("GET"); + expect(opts.headers.Authorization).toBe("Bearer tok-abc"); + expect(opts.headers["x-api-key"]).toBeUndefined(); + expect(opts.headers["X-Msh-Platform"]).toBe("9router"); + expect(opts.headers["X-Msh-Device-Id"]).toBe("stable-device-1"); + expect(opts.headers["X-Msh-Version"]).toBeTruthy(); + }); + + it("apikey path: x-api-key only (no Bearer / X-Msh)", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE)); + + const usage = await getUsageForProvider({ + provider: "kimi", + apiKey: "sk-test-123", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.quotas.Weekly.used).toBe(35); + + const [, opts] = proxyAwareFetch.mock.calls[0]; + expect(opts.headers["x-api-key"]).toBe("sk-test-123"); + expect(opts.headers.Authorization).toBeUndefined(); + expect(opts.headers["X-Msh-Platform"]).toBeUndefined(); + }); + + it("prefers apiKey over accessToken when both present", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE)); + + await getUsageForProvider({ + provider: "kimi", + accessToken: "tok-abc", + apiKey: "sk-prefer-me", + }); + + const [, opts] = proxyAwareFetch.mock.calls[0]; + expect(opts.headers["x-api-key"]).toBe("sk-prefer-me"); + expect(opts.headers.Authorization).toBeUndefined(); + expect(opts.headers["X-Msh-Platform"]).toBeUndefined(); + }); + + it("maps membership levels to plan display names", async () => { + for (const [level, plan] of [ + ["LEVEL_BASIC", "Moderato"], + ["LEVEL_INTERMEDIATE", "Allegretto"], + ["LEVEL_ADVANCED", "Allegro"], + ["LEVEL_STANDARD", "Vivace"], + ]) { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ + user: { membership: { level } }, + usage: { limit: "10", used: "1", remaining: "9" }, + }), + ); + const usage = await getUsageForProvider({ + provider: "kimi", + accessToken: "t", + }); + expect(usage.plan).toBe(plan); + } + }); + + it("parses Weekly + Ratelimit; does not put absolute remaining on quota rows", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE)); + + const usage = await getUsageForProvider({ + provider: "kimi", + accessToken: "tok", + }); + + // Absolute remaining would break getRemainingPercentage (treats it as 0-100 %) + expect(usage.quotas.Weekly.remaining).toBeUndefined(); + expect(usage.quotas.Weekly.remainingPercentage).toBe(65); + expect(usage.quotas.Ratelimit).toMatchObject({ + used: 20, + total: 60, + remainingPercentage: expect.closeTo(40 / 60 * 100, 5), + }); + expect(usage.quotas.Ratelimit.remaining).toBeUndefined(); + }); + + it("surfaces re-authorize message only on 401 unauthenticated", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse( + { + code: "unauthenticated", + details: [ + { + debug: { + reason: "REASON_INVALID_AUTH_TOKEN", + localizedMessage: { message: "Invalid auth token" }, + }, + }, + ], + }, + 401, + ), + ); + + const usage = await getUsageForProvider({ + provider: "kimi", + accessToken: "expired", + }); + + expect(usage.message).toMatch(/expired|re-authorize/i); + expect(usage.message).not.toMatch(/subscribe|permission/i); + expect(usage.quotas).toBeUndefined(); + }); + + it("maps 403 REASON_FEATURE_NO_PERMISSION to subscribe message (not expired)", async () => { + // Live capture: valid OAuth JWT still returns 403 permission_denied when + // the account has no Kimi Code usage entitlement. + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse( + { + code: "permission_denied", + details: [ + { + type: "common.error.v1.ErrorDetail", + debug: { + reason: "REASON_FEATURE_NO_PERMISSION", + localizedMessage: { + locale: "en-US", + message: + "You do not have permission to use this feature. Please subscribe to access.", + }, + }, + }, + ], + }, + 403, + ), + ); + + const usage = await getUsageForProvider({ + provider: "kimi", + accessToken: "valid-but-no-sub", + providerSpecificData: { deviceId: "stable-device-1" }, + }); + + expect(usage.message).toMatch(/permission|subscribe/i); + expect(usage.message).not.toMatch(/expired|re-authorize/i); + // Must not trip usage-route AUTH_EXPIRED_PATTERNS force-refresh loop + expect(usage.message.toLowerCase()).not.toMatch(/expired|re-authorize|unauthorized|401/); + }); + + it("formatKimiUsageError distinguishes 401 vs 403 feature gate", async () => { + const { formatKimiUsageError } = await import( + "../../open-sse/services/usage/kimi.js" + ); + expect(formatKimiUsageError(401, '{"code":"unauthenticated"}')).toMatch( + /expired|re-authorize/i, + ); + expect( + formatKimiUsageError( + 403, + JSON.stringify({ + code: "permission_denied", + details: [ + { + debug: { + reason: "REASON_FEATURE_NO_PERMISSION", + localizedMessage: { + message: "You do not have permission to use this feature.", + }, + }, + }, + ], + }), + ), + ).toMatch(/permission|subscribe/i); + }); + + it("returns tracked-per-request message when usage limit missing", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ + user: { membership: { level: "LEVEL_BASIC" } }, + usage: {}, + }), + ); + + const usage = await getUsageForProvider({ + provider: "kimi", + accessToken: "tok", + }); + + expect(usage.plan).toBe("Moderato"); + expect(usage.message).toMatch(/tracked per request/i); + }); + + it("returns missing-credentials message when neither token nor key", async () => { + const usage = await getUsageForProvider({ provider: "kimi" }); + expect(usage.message).toMatch(/token|key|credential/i); + expect(proxyAwareFetch).not.toHaveBeenCalled(); + }); +}); + +describe("parseQuotaData(kimi)", () => { + it("forwards remainingPercentage for dashboard bars", () => { + const rows = parseQuotaData("kimi", { + plan: "Allegro", + quotas: { + Weekly: { + used: 35, + total: 100, + remainingPercentage: 65, + resetAt: "2026-08-01T00:00:00.000Z", + }, + }, + }); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + name: "Weekly", + used: 35, + total: 100, + remainingPercentage: 65, + }); + }); +}); diff --git a/tests/unit/kiro-api-key-endpoint-routing.test.js b/tests/unit/kiro-api-key-endpoint-routing.test.js new file mode 100644 index 00000000..a0750adc --- /dev/null +++ b/tests/unit/kiro-api-key-endpoint-routing.test.js @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { KiroExecutor } from "../../open-sse/executors/kiro.js"; + +const RUNTIME = "https://runtime.us-east-1.kiro.dev/generateAssistantResponse"; +const CODEWHISPERER = "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse"; +const Q = "https://q.us-east-1.amazonaws.com/generateAssistantResponse"; + +function credentials(authMethod, region = "us-east-1") { + return { providerSpecificData: { authMethod, region } }; +} + +describe("Kiro auth-aware endpoint routing", () => { + const executor = new KiroExecutor(); + + it("routes API-key inference through Amazon Q before other surfaces", () => { + expect(executor.getOrderedBaseUrls(credentials("api_key"))).toEqual([ + Q, + CODEWHISPERER, + RUNTIME, + ]); + }); + + it("keeps Builder ID OAuth on the Kiro runtime surface", () => { + expect(executor.getOrderedBaseUrls(credentials("builder-id"))).toEqual([ + RUNTIME, + CODEWHISPERER, + Q, + ]); + }); + + it("keeps external IdP on CodeWhisperer before Amazon Q", () => { + expect(executor.getOrderedBaseUrls(credentials("external_idp"))).toEqual([ + CODEWHISPERER, + Q, + RUNTIME, + ]); + }); + + it("regionalizes AWS endpoints for IDC without changing Kiro runtime", () => { + expect(executor.getOrderedBaseUrls(credentials("idc", "eu-west-1"))).toEqual([ + "https://codewhisperer.eu-west-1.amazonaws.com/generateAssistantResponse", + "https://q.eu-west-1.amazonaws.com/generateAssistantResponse", + RUNTIME, + ]); + }); + + it("retries only endpoint/auth-surface failures, not payload-invalid 400s", () => { + expect(executor.shouldRetry(400, 0)).toBe(false); + expect(executor.shouldRetry(401, 1)).toBe(true); + expect(executor.shouldRetry(403, 2)).toBe(false); + expect(executor.shouldRetry(422, 0)).toBe(false); + }); + + it("builds endpoint-specific headers", () => { + const auth = { accessToken: "test-key", providerSpecificData: { authMethod: "api_key" } }; + const qHeaders = executor.buildHeaders(auth, true, Q); + const codeWhispererHeaders = executor.buildHeaders(auth, true, CODEWHISPERER); + const runtimeHeaders = executor.buildHeaders(auth, true, RUNTIME); + + expect(qHeaders.TokenType).toBe("API_KEY"); + expect(qHeaders["X-Amz-Target"]).toBeUndefined(); + expect(codeWhispererHeaders["X-Amz-Target"]).toBe( + "AmazonCodeWhispererStreamingService.GenerateAssistantResponse" + ); + expect(runtimeHeaders["X-Amz-Target"]).toBeUndefined(); + }); +}); diff --git a/tests/unit/kiro-conversation-canonicalization.test.js b/tests/unit/kiro-conversation-canonicalization.test.js new file mode 100644 index 00000000..e19984d1 --- /dev/null +++ b/tests/unit/kiro-conversation-canonicalization.test.js @@ -0,0 +1,372 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + canonicalizeKiroConversation, + normalizeKiroToolSpecs, + validateKiroConversation, +} from "../../open-sse/translator/concerns/kiroConversation.js"; +import { clearKiroSessionReplayStore } from "../../open-sse/utils/kiroSessionReplay.js"; +import { clearSessionStore } from "../../open-sse/utils/sessionManager.js"; +import { claudeToKiroRequest } from "../../open-sse/translator/request/claude-to-kiro.js"; +import { openaiToKiroRequest } from "../../open-sse/translator/request/openai-to-kiro.js"; + +const modelId = "claude-opus-5"; + +function tool(name, schema = { type: "object", properties: {} }) { + return { name, description: `Tool ${name}`, input_schema: schema }; +} + +function specState(names = ["first", "second"]) { + const source = names.map((name) => tool(name)); + return normalizeKiroToolSpecs(source); +} + +function user(content, toolResults = []) { + return { + userInputMessage: { + content, + modelId, + ...(toolResults.length > 0 && { userInputMessageContext: { toolResults } }), + }, + }; +} + +function assistant(content, toolUses = []) { + return { + assistantResponseMessage: { + content, + ...(toolUses.length > 0 && { toolUses }), + }, + }; +} + +function result(toolUseId, value, status = "success") { + return { toolUseId, status, content: [{ text: value }] }; +} + +describe("Kiro conversation canonicalizer", () => { + beforeEach(() => { + clearKiroSessionReplayStore(); + clearSessionStore(); + }); + + it("keeps complete parallel tool pairs structured", () => { + const { specs, nameMap } = specState(); + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [ + { toolUseId: "t1", name: "first", input: { n: 1 } }, + { toolUseId: "t2", name: "second", input: { n: 2 } }, + ]), + ], + currentMessage: user("continue", [result("t1", "one"), result("t2", "two")]), + modelId, + toolSpecs: specs, + nameMap, + }); + + const calls = canonical.history[1].assistantResponseMessage.toolUses; + const results = canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults; + expect(calls.map((call) => call.toolUseId)).toEqual(["t1", "t2"]); + expect(results.map((item) => item.toolUseId)).toEqual(["t1", "t2"]); + expect(canonical.valid).toBe(true); + }); + + it("keeps the answered parallel call and flattens only the missing one", () => { + const { specs, nameMap } = specState(); + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [ + { toolUseId: "t1", name: "first", input: {} }, + { toolUseId: "t2", name: "second", input: {} }, + ]), + ], + currentMessage: user("continue", [result("t1", "one")]), + modelId, + toolSpecs: specs, + nameMap, + }); + + const assistantMessage = canonical.history[1].assistantResponseMessage; + expect(assistantMessage.toolUses).toHaveLength(1); + expect(assistantMessage.toolUses[0].toolUseId).toBe("t1"); + expect(assistantMessage.content).toContain("[Tool call: second("); + expect(canonical.repairs.missingResults).toBe(1); + expect(canonical.valid).toBe(true); + }); + + it("flattens non-adjacent and orphaned tool results", () => { + const { specs, nameMap } = specState(["first"]); + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]), + user("result missing here"), + assistant("later"), + ], + currentMessage: user("late result", [result("t1", "too late")]), + modelId, + toolSpecs: specs, + nameMap, + }); + + expect(JSON.stringify(canonical)).not.toContain('"toolUseId":"t1"'); + expect(canonical.history[1].assistantResponseMessage.content).toContain("[Tool call:"); + expect(canonical.currentMessage.userInputMessage.content).toContain("too late"); + expect(canonical.valid).toBe(true); + }); + + it("remaps duplicate tool IDs together with their adjacent results", () => { + const { specs, nameMap } = specState(); + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [ + { toolUseId: "duplicate", name: "first", input: {} }, + { toolUseId: "duplicate", name: "second", input: {} }, + ]), + ], + currentMessage: user("continue", [ + result("duplicate", "one"), + result("duplicate", "two"), + ]), + modelId, + toolSpecs: specs, + nameMap, + }); + + const calls = canonical.history[1].assistantResponseMessage.toolUses; + const results = canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults; + expect(new Set(calls.map((call) => call.toolUseId)).size).toBe(2); + expect(results.map((item) => item.toolUseId)).toEqual(calls.map((call) => call.toolUseId)); + expect(canonical.valid).toBe(true); + }); + + it("deduplicates extra results without losing their text", () => { + const { specs, nameMap } = specState(["first"]); + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]), + ], + currentMessage: user("continue", [result("t1", "one"), result("t1", "duplicate")]), + modelId, + toolSpecs: specs, + nameMap, + }); + + const current = canonical.currentMessage.userInputMessage; + expect(current.userInputMessageContext.toolResults).toHaveLength(1); + expect(current.content).toContain("duplicate"); + expect(canonical.valid).toBe(true); + }); + + it("flattens a trailing unanswered assistant tool call and creates a current user turn", () => { + const { specs, nameMap } = specState(["first"]); + const canonical = canonicalizeKiroConversation({ + history: [user("start")], + currentMessage: assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]), + modelId, + toolSpecs: specs, + nameMap, + }); + + expect(canonical.currentMessage.userInputMessage.content).toBe("continue"); + expect(canonical.history[1].assistantResponseMessage.toolUses).toBeUndefined(); + expect(canonical.history[1].assistantResponseMessage.content).toContain("[Tool call:"); + expect(canonical.valid).toBe(true); + }); + + it("flattens malformed input and tool uses missing from the current specs", () => { + const { specs, nameMap } = specState(["first"]); + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [ + { toolUseId: "t1", name: "first", input: "{bad json" }, + { toolUseId: "t2", name: "removed_tool", input: {} }, + ]), + ], + currentMessage: user("continue", [result("t1", "one"), result("t2", "two")]), + modelId, + toolSpecs: specs, + nameMap, + }); + + expect(canonical.history[1].assistantResponseMessage.toolUses).toBeUndefined(); + expect(canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults).toBeUndefined(); + expect(canonical.currentMessage.userInputMessage.content).toContain("one"); + expect(canonical.currentMessage.userInputMessage.content).toContain("two"); + expect(canonical.valid).toBe(true); + }); + + it("repairs a 30-call parallel turn with one missing result", () => { + const names = Array.from({ length: 30 }, (_, index) => `tool_${index}`); + const { specs, nameMap } = specState(names); + const calls = names.map((name, index) => ({ + toolUseId: `t${index}`, + name, + input: { index }, + })); + const results = names.slice(0, -1).map((_, index) => result(`t${index}`, `r${index}`)); + const canonical = canonicalizeKiroConversation({ + history: [user("start"), assistant("run", calls)], + currentMessage: user("continue", results), + modelId, + toolSpecs: specs, + nameMap, + }); + + expect(canonical.history[1].assistantResponseMessage.toolUses).toHaveLength(29); + expect(canonical.currentMessage.userInputMessage.userInputMessageContext.toolResults).toHaveLength(29); + expect(canonical.repairs.missingResults).toBe(1); + expect(canonical.valid).toBe(true); + }); + + it("flattens structured history when the client sent no tool specs", () => { + const canonical = canonicalizeKiroConversation({ + history: [ + user("start"), + assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]), + ], + currentMessage: user("continue", [result("t1", "one")]), + modelId, + }); + + expect(JSON.stringify(canonical)).not.toContain("toolUses"); + expect(JSON.stringify(canonical)).not.toContain("toolResults"); + expect(canonical.history[1].assistantResponseMessage.content).toContain("[Tool call:"); + expect(canonical.currentMessage.userInputMessage.content).toContain("[Tool result:"); + }); + + it("normalizes names and recursively removes unsupported schema fields", () => { + const longDescription = "x".repeat(11000); + const { specs, nameMap } = normalizeKiroToolSpecs([{ + name: "bad tool/name", + description: longDescription, + input_schema: { + additionalProperties: false, + properties: { + nested: { + type: "object", + additionalProperties: true, + properties: {}, + required: [], + }, + }, + required: [], + }, + }]); + + const specification = specs[0].toolSpecification; + expect(nameMap.get("bad tool/name")).toBe("bad_tool_name"); + expect(specification.name.length).toBeLessThanOrEqual(64); + expect(specification.description.length).toBe(10237); + expect(JSON.stringify(specification.inputSchema.json)).not.toContain("additionalProperties"); + expect(JSON.stringify(specification.inputSchema.json)).not.toContain('"required":[]'); + }); + + it("does not mutate the source conversation or tool definitions", () => { + const sourceTools = [tool("first")]; + const sourceHistory = [ + user("start"), + assistant("run", [{ toolUseId: "t1", name: "first", input: {} }]), + ]; + const sourceCurrent = user("continue", [result("t1", "one")]); + const before = JSON.stringify({ sourceTools, sourceHistory, sourceCurrent }); + const { specs, nameMap } = normalizeKiroToolSpecs(sourceTools); + + canonicalizeKiroConversation({ + history: sourceHistory, + currentMessage: sourceCurrent, + modelId, + toolSpecs: specs, + nameMap, + }); + + expect(JSON.stringify({ sourceTools, sourceHistory, sourceCurrent })).toBe(before); + }); + + it("preserves Claude tool_result errors", () => { + const output = claudeToKiroRequest(modelId, { + tools: [tool("first")], + messages: [ + { role: "user", content: "start" }, + { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "first", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", is_error: true, content: "failed" }] }, + ], + }, true, {}); + + const item = output.conversationState.currentMessage.userInputMessage + .userInputMessageContext.toolResults[0]; + expect(item.status).toBe("error"); + }); + + it("repairs partial parallel results in both direct translators", () => { + const claude = claudeToKiroRequest(modelId, { + tools: [tool("first"), tool("second")], + messages: [ + { role: "user", content: "start" }, + { role: "assistant", content: [ + { type: "tool_use", id: "t1", name: "first", input: {} }, + { type: "tool_use", id: "t2", name: "second", input: {} }, + ] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "one" }] }, + ], + }, true, {}); + const openai = openaiToKiroRequest(modelId, { + tools: [ + { type: "function", function: { name: "first", parameters: { type: "object", properties: {} } } }, + { type: "function", function: { name: "second", parameters: { type: "object", properties: {} } } }, + ], + messages: [ + { role: "user", content: "start" }, + { role: "assistant", content: "", tool_calls: [ + { id: "t1", type: "function", function: { name: "first", arguments: "{}" } }, + { id: "t2", type: "function", function: { name: "second", arguments: "{}" } }, + ] }, + { role: "tool", tool_call_id: "t1", content: "one" }, + ], + }, true, {}); + + for (const payload of [claude, openai]) { + const state = payload.conversationState; + const validation = validateKiroConversation( + state.history, + state.currentMessage, + state.currentMessage.userInputMessage.userInputMessageContext.tools + ); + expect(validation.valid).toBe(true); + expect(state.history[1].assistantResponseMessage.toolUses).toHaveLength(1); + } + }); + + it("does not let session replay replace a tool-result turn", () => { + const credentials = { + rawHeaders: { "x-session-id": "kiro-replay-tool-result-regression" }, + connectionId: "kiro-account", + }; + claudeToKiroRequest(modelId, { + messages: [{ role: "user", content: "frozen session start" }], + }, true, credentials); + + const output = claudeToKiroRequest(modelId, { + tools: [tool("first")], + messages: [ + { role: "assistant", content: [{ type: "tool_use", id: "t1", name: "first", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "kept" }] }, + ], + }, true, credentials); + const state = output.conversationState; + const allText = JSON.stringify(state); + + expect(allText).toContain("frozen session start"); + expect(allText).toContain("kept"); + expect(validateKiroConversation( + state.history, + state.currentMessage, + state.currentMessage.userInputMessage.userInputMessageContext.tools + ).valid).toBe(true); + }); +}); diff --git a/tests/unit/kiro-profile-arn.test.js b/tests/unit/kiro-profile-arn.test.js index 1925582b..bfa3906c 100644 --- a/tests/unit/kiro-profile-arn.test.js +++ b/tests/unit/kiro-profile-arn.test.js @@ -4,10 +4,8 @@ import { KiroService } from "../../src/lib/oauth/services/kiro.js"; /** * Regression tests for Kiro API-key auth. * - * KiroService.validateApiKey resolves a profileArn with the key (via - * CodeWhisperer ListAvailableProfiles) and returns a credential shaped for - * persistence with authMethod="api_key". The response profile field name - * varies (`arn` vs `profileArn`) — both are accepted by listAvailableProfiles. + * KiroService.validateApiKey validates against the Amazon Q model catalog and + * returns an account-bound credential without inventing a profileArn. * * Note: OAuth (Builder ID / IDC) profileArn resolution is handled upstream by * fetchKiroProfileArn in providers.js and is covered there — not here. @@ -16,11 +14,10 @@ describe("kiro API-key auth (KiroService.validateApiKey)", () => { beforeEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks()); - it("validates an API key and resolves a credential with profileArn", async () => { - const expectedArn = "arn:aws:codewhisperer:us-east-1:444:profile/KEY"; + it("validates an API key against Amazon Q without inventing profileArn", async () => { const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, - json: async () => ({ profiles: [{ arn: expectedArn }] }), + json: async () => ({ models: [{ modelId: "claude-opus-5" }] }), }); const svc = new KiroService(); @@ -29,17 +26,18 @@ describe("kiro API-key auth (KiroService.validateApiKey)", () => { expect(cred).toEqual({ accessToken: "my-secret-key", refreshToken: null, - profileArn: expectedArn, + profileArn: null, region: "us-east-1", authMethod: "api_key", }); const [url, init] = fetchMock.mock.calls[0]; - expect(url).toBe("https://codewhisperer.us-east-1.amazonaws.com"); - expect(init.headers.Authorization).toBe("Bearer my-secret-key"); - expect(init.headers["x-amz-target"]).toBe( - "AmazonCodeWhispererService.ListAvailableProfiles" + expect(url).toBe( + "https://q.us-east-1.amazonaws.com/ListAvailableModels?origin=AI_EDITOR" ); + expect(init.method).toBe("GET"); + expect(init.headers.Authorization).toBe("Bearer my-secret-key"); + expect(init.headers.TokenType).toBe("API_KEY"); }); it("rejects an empty API key without a network call", async () => { @@ -60,4 +58,15 @@ describe("kiro API-key auth (KiroService.validateApiKey)", () => { /API key validation failed/ ); }); + + it("rejects a 200 response with an empty model catalog", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ models: [] }), + }); + const svc = new KiroService(); + await expect(svc.validateApiKey("empty-key")).rejects.toThrow( + /returned no available models/ + ); + }); }); diff --git a/tests/unit/openai-to-kiro.test.js b/tests/unit/openai-to-kiro.test.js index dbaf7c85..17773bdb 100644 --- a/tests/unit/openai-to-kiro.test.js +++ b/tests/unit/openai-to-kiro.test.js @@ -424,6 +424,31 @@ describe("openaiToKiroRequest", () => { expect(result.additionalModelRequestFields).toBeUndefined(); }); + it.each([ + ["claude-sonnet-4.5-thinking-agentic(high)", "claude-sonnet-4.5"], + ["glm-5-thinking-agentic(medium)", "glm-5"], + ])("normalizes unsupported Kiro intensity suffix for %s", (model, upstream) => { + const result = openaiToKiroRequest(model, { + messages: [{ role: "user", content: "hello" }], + }, true, {}); + + expect(result.conversationState.currentMessage.userInputMessage.modelId).toBe(upstream); + expect(result.additionalModelRequestFields).toBeUndefined(); + expect(systemPromptOf(result)).toContain("CHUNKED WRITE PROTOCOL"); + }); + + it("maps a supported Kiro Claude intensity suffix to native effort fields", () => { + const result = openaiToKiroRequest("claude-sonnet-5-thinking-agentic(high)", { + messages: [{ role: "user", content: "hello" }], + }, true, {}); + + expect(result.conversationState.currentMessage.userInputMessage.modelId).toBe("claude-sonnet-5"); + expect(result.additionalModelRequestFields).toEqual({ + thinking: { type: "adaptive", display: "summarized" }, + output_config: { effort: "high" }, + }); + }); + it("does not send additionalModelRequestFields for date-suffixed Claude 4 model ids", () => { const body = { reasoning_effort: "high", diff --git a/tests/unit/provider-test-models-routing.test.js b/tests/unit/provider-test-models-routing.test.js index 00f3a735..595cd89d 100644 --- a/tests/unit/provider-test-models-routing.test.js +++ b/tests/unit/provider-test-models-routing.test.js @@ -11,6 +11,10 @@ vi.mock("@/lib/localDb", () => ({ getApiKeys: mocks.getApiKeys, })); +vi.mock("@/lib/providers/connectionAccess", () => ({ + getProviderConnectionAccess: vi.fn(async () => ({ ownerId: "admin-a" })), +})); + vi.mock("@/shared/utils/machineId", () => ({ getConsistentMachineId: mocks.getConsistentMachineId, })); diff --git a/tests/unit/thinking-levels-kiro.test.js b/tests/unit/thinking-levels-kiro.test.js new file mode 100644 index 00000000..edb3b7aa --- /dev/null +++ b/tests/unit/thinking-levels-kiro.test.js @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { getThinkingLevels } from "../../open-sse/providers/thinkingLevels.js"; + +describe("getThinkingLevels for Kiro", () => { + it("does not advertise native intensity for legacy Kiro models", () => { + expect(getThinkingLevels("kiro", "claude-sonnet-4.5")).toBeNull(); + expect(getThinkingLevels("kiro", "glm-5")).toBeNull(); + }); + + it("advertises native levels for supported Kiro models", () => { + expect(getThinkingLevels("kiro", "claude-sonnet-5")).toContain("high"); + expect(getThinkingLevels("kiro", "gpt-5.6-sol")).toContain("xhigh"); + }); +}); diff --git a/tests/unit/token-refresh-generic.test.js b/tests/unit/token-refresh-generic.test.js new file mode 100644 index 00000000..d4507c2e --- /dev/null +++ b/tests/unit/token-refresh-generic.test.js @@ -0,0 +1,146 @@ +/** + * Generic OAuth2 token refresh — config-driven profiles. + * + * Verifies refreshAccessToken() handles the 5 foldable providers + * (qwen, iflow, github, kimi, claude) via a REFRESH_PROFILES table, + * while preserving the legacy generic path for unknown providers. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const originalFetch = global.fetch; + +function mockFetchOnce(payload, { ok = true, status = 200 } = {}) { + const fn = vi.fn().mockResolvedValue({ + ok, + status, + json: () => Promise.resolve(payload), + text: () => Promise.resolve(JSON.stringify(payload)), + }); + global.fetch = fn; + return fn; +} + +describe("refreshAccessToken — config-driven profiles", () => { + beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); global.fetch = originalFetch; }); + afterEach(() => { global.fetch = originalFetch; }); + + it("qwen: form body + clientId, surfaces resource_url as providerSpecificData", async () => { + const fm = mockFetchOnce({ + access_token: "qw-acc", + refresh_token: "qw-refresh-rotated", + expires_in: 7200, + resource_url: "https://dashscope.aliyuncs.com", + }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + const out = await refreshAccessToken("qwen", "qw-old-refresh", {}, console); + + expect(out).toEqual({ + accessToken: "qw-acc", + refreshToken: "qw-refresh-rotated", + expiresIn: 7200, + providerSpecificData: { resourceUrl: "https://dashscope.aliyuncs.com" }, + }); + const [url, init] = fm.mock.calls[0]; + expect(init.method).toBe("POST"); + expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); + const body = new URLSearchParams(init.body); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("qw-old-refresh"); + expect(body.get("client_id")).toBeTruthy(); + }); + + it("iflow: Basic Auth header from clientId:clientSecret, form body keeps client_secret", async () => { + const fm = mockFetchOnce({ access_token: "if-acc", refresh_token: "if-rot", expires_in: 3600 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + await refreshAccessToken("iflow", "if-old", {}, console); + + const [, init] = fm.mock.calls[0]; + expect(init.headers["Authorization"]).toMatch(/^Basic /); + const body = new URLSearchParams(init.body); + expect(body.get("client_id")).toBeTruthy(); + expect(body.get("client_secret")).toBeTruthy(); + }); + + it("github: omits client_secret when config has none", async () => { + const fm = mockFetchOnce({ access_token: "gh-acc", expires_in: 28800 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + const out = await refreshAccessToken("github", "gh-old", {}, console); + + const body = new URLSearchParams(fm.mock.calls[0][1].body); + expect(body.get("client_secret")).toBeNull(); + expect(out.accessToken).toBe("gh-acc"); + expect(out.refreshToken).toBe("gh-old"); + }); + + it("kimi: merges X-Msh-* headers from credentials.providerSpecificData.deviceId", async () => { + const fm = mockFetchOnce({ access_token: "km-acc", expires_in: 86400 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + await refreshAccessToken("kimi", "km-old", { + providerSpecificData: { deviceId: "dev-xyz" }, + }, console); + + const headers = fm.mock.calls[0][1].headers; + // Kimi's buildKimiHeaders must contribute at least one X-Msh- header + const mshKeys = Object.keys(headers).filter((k) => k.toLowerCase().startsWith("x-msh-")); + expect(mshKeys.length).toBeGreaterThan(0); + }); + + it("claude: JSON body, client_id only (no client_secret)", async () => { + const fm = mockFetchOnce({ access_token: "cl-acc", refresh_token: "cl-rot", expires_in: 3600 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + await refreshAccessToken("claude", "cl-old", {}, console); + + const [, init] = fm.mock.calls[0]; + expect(init.headers["Content-Type"]).toBe("application/json"); + const parsed = JSON.parse(init.body); + expect(parsed.grant_type).toBe("refresh_token"); + expect(parsed.client_id).toBeTruthy(); + expect(parsed).not.toHaveProperty("client_secret"); + }); + + it("returns null on non-ok response", async () => { + mockFetchOnce({ error: "invalid_grant" }, { ok: false, status: 400 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + const out = await refreshAccessToken("qwen", "dead", {}, console); + expect(out).toBeNull(); + }); + + it("returns null when refreshToken missing", async () => { + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + const out = await refreshAccessToken("qwen", "", {}, console); + expect(out).toBeNull(); + }); + + it("dedupes concurrent calls with same refresh token (same dedupKey)", async () => { + const fm = mockFetchOnce({ access_token: "dd-acc", expires_in: 3600 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + const creds = { providerSpecificData: { deviceId: "d" } }; + await Promise.all([ + refreshAccessToken("kimi", "dup-refresh", creds, console), + refreshAccessToken("kimi", "dup-refresh", creds, console), + ]); + expect(fm).toHaveBeenCalledTimes(1); + }); +}); + +describe("refreshAccessToken — legacy generic path (no profile)", () => { + beforeEach(() => { vi.clearAllMocks(); vi.resetModules(); global.fetch = originalFetch; }); + afterEach(() => { global.fetch = originalFetch; }); + + it("still works for an unprofiled provider via config.refreshUrl/clientId/clientSecret", async () => { + const fm = mockFetchOnce({ access_token: "gen-acc", expires_in: 3600 }); + const { refreshAccessToken } = await import("open-sse/services/tokenRefresh/providers.js"); + + await refreshAccessToken("cline", "gen-old", {}, console); + + const body = new URLSearchParams(fm.mock.calls[0][1].body); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("client_id")).toBeTruthy(); + }); +}); diff --git a/tests/unit/usage-dispatch.test.js b/tests/unit/usage-dispatch.test.js index 097aa83b..e150d376 100644 --- a/tests/unit/usage-dispatch.test.js +++ b/tests/unit/usage-dispatch.test.js @@ -15,8 +15,8 @@ const load = () => import("../../open-sse/services/usage.js"); const SUPPORTED = [ "github", "gemini-cli", "antigravity", "claude", "codex", "kiro", "qoder", "qwen", "iflow", "ollama", "glm", "glm-cn", - "minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", - "orbit-provider", + "minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "kimi", + "deepseek", "orbit-provider", ]; describe("usage dispatch", () => { diff --git a/tests/unit/windsurf-executor.test.js b/tests/unit/windsurf-executor.test.js new file mode 100644 index 00000000..952358f8 --- /dev/null +++ b/tests/unit/windsurf-executor.test.js @@ -0,0 +1,199 @@ +import { describe, it, expect } from "vitest"; +import { + resolveWsModelId, + buildGetChatMessageRequest, + grpcWebFrame, + decodeCompletionChunk, + default as WindsurfExecutor, +} from "open-sse/executors/windsurf.js"; +import { PROVIDERS } from "open-sse/config/providers.js"; + +// ─── Protobuf helpers for building expected wire bytes in tests ────────────── + +function encodeVarint(value) { + const bytes = []; + let v = value >>> 0; + while (v > 0x7f) { bytes.push((v & 0x7f) | 0x80); v >>>= 7; } + bytes.push(v & 0x7f); + return new Uint8Array(bytes); +} +function encodeLenField(fieldNum, payload) { + const tag = encodeVarint((fieldNum << 3) | 2); + const len = encodeVarint(payload.length); + const out = new Uint8Array(tag.length + len.length + payload.length); + out.set(tag, 0); out.set(len, tag.length); out.set(payload, tag.length + len.length); + return out; +} +function encodeStringField(fieldNum, str) { + return encodeLenField(fieldNum, new TextEncoder().encode(str)); +} + +describe("windsurf MODEL_ALIAS_MAP", () => { + it("maps SWE models to snake-case wire names", () => { + expect(resolveWsModelId("swe-1.6-fast")).toBe("swe-1-6-fast"); + expect(resolveWsModelId("swe-1.5")).toBe("swe-1-5"); + }); + it("maps Claude 4.5 to MODEL_PRIVATE_* aliases", () => { + expect(resolveWsModelId("claude-sonnet-4.5")).toBe("MODEL_PRIVATE_2"); + expect(resolveWsModelId("claude-opus-4.5")).toBe("MODEL_CLAUDE_4_5_OPUS"); + }); + it("applies default effort level for bare gpt-5.x ids", () => { + expect(resolveWsModelId("gpt-5.5")).toBe("gpt-5-5-medium"); + expect(resolveWsModelId("gpt-5.4")).toBe("gpt-5-4-medium"); + }); + it("passes through unknown ids as-is", () => { + expect(resolveWsModelId("custom-model")).toBe("custom-model"); + }); +}); + +describe("grpcWebFrame", () => { + it("prepends a 5-byte header: 0x00 flag + big-endian length", () => { + const payload = new Uint8Array([1, 2, 3, 4, 5]); + const frame = grpcWebFrame(payload); + expect(frame[0]).toBe(0x00); + const view = new DataView(frame.buffer); + expect(view.getUint32(1, false)).toBe(5); // big-endian length + expect(Array.from(frame.slice(5))).toEqual([1, 2, 3, 4, 5]); + }); + it("encodes empty payload as a 5-byte frame", () => { + const frame = grpcWebFrame(new Uint8Array(0)); + expect(frame.length).toBe(5); + expect(frame[0]).toBe(0x00); + }); +}); + +describe("buildGetChatMessageRequest", () => { + it("emits metadata (field 1), cascade_id (2), model (3), messages (4+)", () => { + const payload = buildGetChatMessageRequest("sk-ws-test", "swe-1.6", [ + { role: "user", content: "hello" }, + ]); + expect(payload.length).toBeGreaterThan(10); + // First byte 0x0a = field 1, wire type 2 (length-delimited) → metadata present + expect(payload[0]).toBe(0x0a); + }); + + it("embeds the apiKey inside the metadata sub-message", () => { + const payload = buildGetChatMessageRequest("sk-ws-secret", "gpt-5", []); + // The metadata bytes are the first length-delimited field — should contain the key. + const asString = new TextDecoder().decode(payload); + expect(asString).toContain("sk-ws-secret"); + // And the IDE identification fields. + expect(asString).toContain("windsurf"); + expect(asString).toContain("3.14.0"); + }); + + it("appends one field-4 message per chat message", () => { + // Proper top-level protobuf field counter (byte 0x22 collides with content bytes). + const countField = (buf, target) => { + let offset = 0; + let count = 0; + while (offset < buf.length) { + let result = 0, shift = 0; + while (offset < buf.length) { + const b = buf[offset++]; + result |= (b & 0x7f) << shift; + if ((b & 0x80) === 0) break; + shift += 7; + } + const fieldNum = result >>> 3; + const wireType = result & 0x07; + if (wireType === 2) { + let len = 0, ls = 0; + while (offset < buf.length) { + const b = buf[offset++]; + len |= (b & 0x7f) << ls; + if ((b & 0x80) === 0) break; + ls += 7; + } + if (fieldNum === target) count++; + offset += len; + } else if (wireType === 0) { + while (offset < buf.length) { + const b = buf[offset++]; + if ((b & 0x80) === 0) break; + } + } else if (wireType === 1) { + offset += 8; + } else if (wireType === 5) { + offset += 4; + } else { + break; + } + } + return count; + }; + const one = buildGetChatMessageRequest("k", "m", [{ role: "user", content: "a" }]); + const two = buildGetChatMessageRequest("k", "m", [ + { role: "user", content: "a" }, + { role: "assistant", content: "b" }, + ]); + expect(countField(one, 4)).toBe(1); + expect(countField(two, 4)).toBe(2); + }); +}); + +describe("decodeCompletionChunk", () => { + it("decodes a ContentChunk (field 1 → text)", () => { + const chunk = encodeLenField(1, encodeStringField(1, "hello world")); + const decoded = decodeCompletionChunk(chunk); + expect(decoded).toEqual({ kind: "content", text: "hello world" }); + }); + + it("decodes an ErrorChunk (field 4 → message)", () => { + const chunk = encodeLenField(4, encodeStringField(1, "quota exhausted")); + const decoded = decodeCompletionChunk(chunk); + expect(decoded).toEqual({ kind: "error", message: "quota exhausted" }); + }); + + it("decodes a DoneChunk (field 3 → UsageStats with prompt/completion tokens)", () => { + // UsageStats: field 1 = prompt_tokens (varint), field 2 = completion_tokens (varint) + const usage = new Uint8Array([...encodeVarint((1 << 3) | 0), ...encodeVarint(42), ...encodeVarint((2 << 3) | 0), ...encodeVarint(99)]); + const doneChunk = encodeLenField(3, encodeLenField(1, usage)); + const decoded = decodeCompletionChunk(doneChunk); + expect(decoded.kind).toBe("done"); + expect(decoded.promptTokens).toBe(42); + expect(decoded.completionTokens).toBe(99); + }); + + it("returns { kind: 'unknown' } for empty buffer", () => { + expect(decodeCompletionChunk(new Uint8Array(0))).toEqual({ kind: "unknown" }); + }); +}); + +describe("WindsurfExecutor class", () => { + it("constructor wires the current Codeium chat endpoint", () => { + const ex = new WindsurfExecutor(); + expect(ex.provider).toBe("windsurf"); + expect(ex.config).toBeDefined(); + expect(ex.config.baseUrl).toContain("server.codeium.com"); + expect(typeof ex.execute).toBe("function"); + }); + + it("buildHeaders emits grpc-web+proto + Bearer token", () => { + const ex = new WindsurfExecutor(); + const h = ex.buildHeaders({ accessToken: "sk-ws-abc" }); + expect(h["Content-Type"]).toBe("application/grpc-web+proto"); + expect(h.Accept).toBe("application/grpc-web+proto"); + expect(h["X-Grpc-Web"]).toBe("1"); + expect(h.Authorization).toBe("Bearer sk-ws-abc"); + expect(h["User-Agent"]).toMatch(/^windsurf\//); + }); + + it("buildHeaders omits Authorization when no token", () => { + const ex = new WindsurfExecutor(); + const h = ex.buildHeaders({}); + expect(h.Authorization).toBeUndefined(); + }); + + it("buildUrl returns the GetChatMessage endpoint", () => { + const ex = new WindsurfExecutor(); + expect(ex.buildUrl()).toBe("https://server.codeium.com/exa.language_server_pb.LanguageServerService/GetChatMessage"); + }); + + it("uses a valid fallback while the provider remains hidden from the public registry", () => { + expect(PROVIDERS.windsurf).toBeUndefined(); + expect(new WindsurfExecutor().config.baseUrl).toBe( + "https://server.codeium.com/exa.language_server_pb.LanguageServerService/GetChatMessage" + ); + }); +});