merge(master): sync master into dev
@@ -4,6 +4,37 @@
|
|||||||
- **Dokploy**: deploy production Compose from GitHub Actions with serialized rollout tracking, terminal-status handling, health verification, and deployment summaries
|
- **Dokploy**: deploy production Compose from GitHub Actions with serialized rollout tracking, terminal-status handling, health verification, and deployment summaries
|
||||||
- **GitHub Actions**: remove unrelated Docker image publishing and GitBook Pages deployment workflows
|
- **GitHub Actions**: remove unrelated Docker image publishing and GitBook Pages deployment workflows
|
||||||
|
|
||||||
|
# v0.5.45 (2026-07-30)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- **Providers**: add Poolside (OpenAI-compatible)
|
||||||
|
- **Providers**: add api-airforce, baidu, bazaarlink, bluesminds, kilo-gateway, llm7, morph, sambanova, tencent
|
||||||
|
- **OAuth**: zed / trae / windsurf providers + harden callback proxies
|
||||||
|
- **CLI tools**: set Claude Code max context tokens
|
||||||
|
- **Qoder**: PAT auth + refresh model list
|
||||||
|
- **Gemini**: Gemini 3.6 Flash tier routing + Gemini 3.5 Flash Lite
|
||||||
|
- **Claude**: bump default Opus to `claude-opus-5`
|
||||||
|
- **Kiro**: add Claude Opus 5 models
|
||||||
|
- **Usage**: Kimi and DeepSeek usage handlers
|
||||||
|
- **Usage**: SuperGrok weekly pool via gRPC-web
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
- **Refresh**: rotate `refresh_token` between retry attempts
|
||||||
|
- **Kiro**: canonicalize tool history and route API keys correctly
|
||||||
|
- **Kiro**: normalize dashboard thinking intensity models
|
||||||
|
- **Cursor**: stop leaking agent tool errors as text
|
||||||
|
- **Gemini**: fill empty tool schemas after `$ref` strip
|
||||||
|
- **Antigravity**: strip `stream_options` from non-stream requests
|
||||||
|
- **Jina-reader**: recover after transient errors, use JSON POST API
|
||||||
|
- **Usage**: record exact embedding tokens
|
||||||
|
- **Console-log**: initialize capture at server boot + prevent SSE proxy buffering
|
||||||
|
- **Dashboard**: count dual-auth, free-tier OAuth and API-key connections correctly
|
||||||
|
- **Dashboard**: flex quota rows, thin global scrollbars, no hidden-row overflow
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
- **i18n**: expand pt-BR translation to 986 terms
|
||||||
|
- README: Indonesian translation
|
||||||
|
|
||||||
# v0.5.40 (2026-07-20)
|
# v0.5.40 (2026-07-20)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com)
|
[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com)
|
||||||
|
|
||||||
[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md)
|
[🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) • [🇮🇩 Indonesia](./i18n/README.id-ID.md)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "9router",
|
"name": "9router",
|
||||||
"version": "0.5.40",
|
"version": "0.5.45",
|
||||||
"description": "9Router CLI - Start and manage 9Router server",
|
"description": "9Router CLI - Start and manage 9Router server",
|
||||||
"bin": {
|
"bin": {
|
||||||
"9router": "./cli.js"
|
"9router": "./cli.js"
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ const PROVIDER_MODELS = {
|
|||||||
{ id: "glm-4.7" },
|
{ id: "glm-4.7" },
|
||||||
],
|
],
|
||||||
ag: [
|
ag: [
|
||||||
|
{ id: "gemini-3.6-flash-high" },
|
||||||
|
{ id: "gemini-3.6-flash-medium" },
|
||||||
|
{ id: "gemini-3.6-flash-low" },
|
||||||
{ id: "gemini-3-flash-agent" },
|
{ id: "gemini-3-flash-agent" },
|
||||||
{ id: "gemini-3.5-flash-low" },
|
{ id: "gemini-3.5-flash-low" },
|
||||||
{ id: "gemini-3.5-flash-extra-low" },
|
{ id: "gemini-3.5-flash-extra-low" },
|
||||||
@@ -95,6 +98,8 @@ const PROVIDER_MODELS = {
|
|||||||
{ id: "claude-3-5-sonnet-20241022" },
|
{ id: "claude-3-5-sonnet-20241022" },
|
||||||
],
|
],
|
||||||
gemini: [
|
gemini: [
|
||||||
|
{ id: "gemini-3.6-flash" },
|
||||||
|
{ id: "gemini-3.5-flash-lite" },
|
||||||
{ id: "gemini-3-pro-preview" },
|
{ id: "gemini-3-pro-preview" },
|
||||||
{ id: "gemini-2.5-pro" },
|
{ id: "gemini-2.5-pro" },
|
||||||
{ id: "gemini-2.5-flash" },
|
{ id: "gemini-2.5-flash" },
|
||||||
|
|||||||
@@ -0,0 +1,951 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img src="../images/9router.png?1" alt="9Router Dashboard" width="800"/>
|
||||||
|
|
||||||
|
# 9Router - Router AI Gratis
|
||||||
|
|
||||||
|
**Jangan berhenti ngoding. Otomatis dialihkan ke model AI gratis & murah dengan smart fallback.**
|
||||||
|
|
||||||
|
**Hubungkan semua tool AI coding (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) ke 40+ provider AI dan 100+ model.**
|
||||||
|
|
||||||
|
[](https://www.npmjs.com/package/9router)
|
||||||
|
[](https://www.npmjs.com/package/9router)
|
||||||
|
[](https://github.com/decolua/9router/blob/main/LICENSE)
|
||||||
|
|
||||||
|
[🚀 Mulai Cepat](#-mulai-cepat) • [💡 Fitur](#-fitur-utama) • [📖 Setup](#-panduan-setup) • [🌐 Website](https://9router.com)
|
||||||
|
|
||||||
|
[🇻🇳 Tiếng Việt](./README.vi.md) • [🇨🇳 中文](./README.zh-CN.md) • [🇯🇵 日本語](./README.ja-JP.md) • [🇮🇩 Bahasa Indonesia](./README.id-ID.md)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤔 Kenapa 9Router?
|
||||||
|
|
||||||
|
**Berhenti buang-buang uang dan terhambat limit:**
|
||||||
|
|
||||||
|
- ❌ Kuota langganan hangus tiap bulan tanpa terpakai
|
||||||
|
- ❌ Rate limit bikin ngoding berhenti di tengah jalan
|
||||||
|
- ❌ API mahal ($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
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
### 📺 Panduan Setup Lengkap - 9Router + Claude Code Gratis
|
||||||
|
|
||||||
|
[](https://www.youtube.com/watch?v=raEyZPg5xE0)
|
||||||
|
|
||||||
|
**🎬 Tonton tutorial langkah demi langkah:**
|
||||||
|
- ✅ Install dan setup 9Router
|
||||||
|
- ✅ Konfigurasi Claude Sonnet 4.5 gratis
|
||||||
|
- ✅ Integrasi dengan Claude Code
|
||||||
|
- ✅ Demo live coding
|
||||||
|
|
||||||
|
**⏱️ Durasi:** 20 menit | **👥 Dibuat oleh:** Developer Community
|
||||||
|
|
||||||
|
[▶️ Tonton di YouTube](https://www.youtube.com/watch?v=o3qYCyjrFYg)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Tool CLI yang Didukung
|
||||||
|
|
||||||
|
9Router bekerja mulus dengan semua tool AI coding utama:
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="120">
|
||||||
|
<img src="../public/providers/claude.png" width="60" alt="Claude Code"/><br/>
|
||||||
|
<b>Claude-Code</b>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="120">
|
||||||
|
<img src="../public/providers/openclaw.png" width="60" alt="OpenClaw"/><br/>
|
||||||
|
<b>OpenClaw</b>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="120">
|
||||||
|
<img src="../public/providers/codex.png" width="60" alt="Codex"/><br/>
|
||||||
|
<b>Codex</b>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="120">
|
||||||
|
<img src="../public/providers/opencode.png" width="60" alt="OpenCode"/><br/>
|
||||||
|
<b>OpenCode</b>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="120">
|
||||||
|
<img src="../public/providers/cursor.png" width="60" alt="Cursor"/><br/>
|
||||||
|
<b>Cursor</b>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="120">
|
||||||
|
<img src="../public/providers/antigravity.png" width="60" alt="Antigravity"/><br/>
|
||||||
|
<b>Antigravity</b>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="120">
|
||||||
|
<img src="../public/providers/cline.png" width="60" alt="Cline"/><br/>
|
||||||
|
<b>Cline</b>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="120">
|
||||||
|
<img src="../public/providers/continue.png" width="60" alt="Continue"/><br/>
|
||||||
|
<b>Continue</b>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="120">
|
||||||
|
<img src="../public/providers/droid.png" width="60" alt="Droid"/><br/>
|
||||||
|
<b>Droid</b>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="120">
|
||||||
|
<img src="../public/providers/roo.png" width="60" alt="Roo"/><br/>
|
||||||
|
<b>Roo</b>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="120">
|
||||||
|
<img src="../public/providers/copilot.png" width="60" alt="Copilot"/><br/>
|
||||||
|
<b>Copilot</b>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="120">
|
||||||
|
<img src="../public/providers/kilocode.png" width="60" alt="Kilo Code"/><br/>
|
||||||
|
<b>Kilo Code</b>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 Provider yang Didukung
|
||||||
|
|
||||||
|
### 🔐 Provider OAuth
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="120">
|
||||||
|
<img src="../public/providers/claude.png" width="60" alt="Claude Code"/><br/>
|
||||||
|
<b>Claude-Code</b>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="120">
|
||||||
|
<img src="../public/providers/antigravity.png" width="60" alt="Antigravity"/><br/>
|
||||||
|
<b>Antigravity</b>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="120">
|
||||||
|
<img src="../public/providers/codex.png" width="60" alt="Codex"/><br/>
|
||||||
|
<b>Codex</b>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="120">
|
||||||
|
<img src="../public/providers/github.png" width="60" alt="GitHub"/><br/>
|
||||||
|
<b>GitHub</b>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="120">
|
||||||
|
<img src="../public/providers/cursor.png" width="60" alt="Cursor"/><br/>
|
||||||
|
<b>Cursor</b>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### 🆓 Provider Gratis
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="150">
|
||||||
|
<img src="../public/providers/iflow.png" width="70" alt="iFlow"/><br/>
|
||||||
|
<b>iFlow AI</b><br/>
|
||||||
|
<sub>8+ model • unlimited</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="150">
|
||||||
|
<img src="../public/providers/qwen.png" width="70" alt="Qwen"/><br/>
|
||||||
|
<b>Qwen Code</b><br/>
|
||||||
|
<sub>3+ model • unlimited</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="150">
|
||||||
|
<img src="../public/providers/gemini-cli.png" width="70" alt="Gemini CLI"/><br/>
|
||||||
|
<b>Gemini CLI</b><br/>
|
||||||
|
<sub>180 ribu request/bulan gratis</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="150">
|
||||||
|
<img src="../public/providers/kiro.png" width="70" alt="Kiro"/><br/>
|
||||||
|
<b>Kiro AI</b><br/>
|
||||||
|
<sub>Claude • unlimited</sub>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### 🔑 Provider API Key (40+)
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="100">
|
||||||
|
<img src="../public/providers/openrouter.png" width="50" alt="OpenRouter"/><br/>
|
||||||
|
<sub>OpenRouter</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="100">
|
||||||
|
<img src="../public/providers/glm.png" width="50" alt="GLM"/><br/>
|
||||||
|
<sub>GLM</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="100">
|
||||||
|
<img src="../public/providers/kimi.png" width="50" alt="Kimi"/><br/>
|
||||||
|
<sub>Kimi</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="100">
|
||||||
|
<img src="../public/providers/minimax.png" width="50" alt="MiniMax"/><br/>
|
||||||
|
<sub>MiniMax</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="100">
|
||||||
|
<img src="../public/providers/openai.png" width="50" alt="OpenAI"/><br/>
|
||||||
|
<sub>OpenAI</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="100">
|
||||||
|
<img src="../public/providers/anthropic.png" width="50" alt="Anthropic"/><br/>
|
||||||
|
<sub>Anthropic</sub>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="100">
|
||||||
|
<img src="../public/providers/gemini.png" width="50" alt="Gemini"/><br/>
|
||||||
|
<sub>Gemini</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="100">
|
||||||
|
<img src="../public/providers/deepseek.png" width="50" alt="DeepSeek"/><br/>
|
||||||
|
<sub>DeepSeek</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="100">
|
||||||
|
<img src="../public/providers/groq.png" width="50" alt="Groq"/><br/>
|
||||||
|
<sub>Groq</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="100">
|
||||||
|
<img src="../public/providers/xai.png" width="50" alt="xAI"/><br/>
|
||||||
|
<sub>xAI</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="100">
|
||||||
|
<img src="../public/providers/mistral.png" width="50" alt="Mistral"/><br/>
|
||||||
|
<sub>Mistral</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="100">
|
||||||
|
<img src="../public/providers/perplexity.png" width="50" alt="Perplexity"/><br/>
|
||||||
|
<sub>Perplexity</sub>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" width="100">
|
||||||
|
<img src="../public/providers/together.png" width="50" alt="Together"/><br/>
|
||||||
|
<sub>Together AI</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="100">
|
||||||
|
<img src="../public/providers/fireworks.png" width="50" alt="Fireworks"/><br/>
|
||||||
|
<sub>Fireworks</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="100">
|
||||||
|
<img src="../public/providers/cerebras.png" width="50" alt="Cerebras"/><br/>
|
||||||
|
<sub>Cerebras</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="100">
|
||||||
|
<img src="../public/providers/cohere.png" width="50" alt="Cohere"/><br/>
|
||||||
|
<sub>Cohere</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="100">
|
||||||
|
<img src="../public/providers/nvidia.png" width="50" alt="NVIDIA"/><br/>
|
||||||
|
<sub>NVIDIA</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" width="100">
|
||||||
|
<img src="../public/providers/siliconflow.png" width="50" alt="SiliconFlow"/><br/>
|
||||||
|
<sub>SiliconFlow</sub>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p><i>...dan 20+ provider lain seperti Nebius, Chutes, Hyperbolic, serta endpoint custom yang kompatibel dengan OpenAI/Anthropic</i></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Fitur Utama
|
||||||
|
|
||||||
|
| Fitur | Ringkasan | Manfaat |
|
||||||
|
|-------|-----------|---------|
|
||||||
|
| 🎯 **Smart Fallback 3 Tingkat** | Routing otomatis: langganan → murah → gratis | Ngoding tanpa berhenti, zero downtime |
|
||||||
|
| 📊 **Pelacakan Kuota Real-time** | Hitungan token live + hitung mundur reset | Nilai langganan termanfaatkan maksimal |
|
||||||
|
| 🔄 **Konversi Format** | OpenAI ↔ Claude ↔ Gemini mulus | Bekerja dengan tool CLI apa pun |
|
||||||
|
| 👥 **Dukungan Multi-akun** | Beberapa akun per provider | Load balancing + redundansi |
|
||||||
|
| 🔄 **Auto Refresh Token** | Token OAuth diperbarui otomatis | Tidak perlu login ulang manual |
|
||||||
|
| 🎨 **Combo Kustom** | Buat kombinasi model tanpa batas | Fallback sesuai kebutuhanmu |
|
||||||
|
| 📝 **Log Request** | Log lengkap request/response | Troubleshooting jadi mudah |
|
||||||
|
| 💾 **Cloud Sync** | Sinkronkan pengaturan antar perangkat | Setup sama di mana pun |
|
||||||
|
| 📊 **Analitik Penggunaan** | Lacak token, biaya, dan tren | Optimalkan pengeluaran |
|
||||||
|
| 🌐 **Deploy di Mana Saja** | Localhost, VPS, Docker, Cloudflare Workers | Opsi deployment fleksibel |
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>📖 Detail Fitur</b></summary>
|
||||||
|
|
||||||
|
### 🎯 Smart Fallback 3 Tingkat
|
||||||
|
|
||||||
|
Buat combo dengan fallback otomatis:
|
||||||
|
|
||||||
|
```
|
||||||
|
Combo: "my-coding-stack"
|
||||||
|
1. cc/claude-opus-4-6 (langganan)
|
||||||
|
2. glm/glm-4.7 (backup murah, $0.6/1M)
|
||||||
|
3. if/kimi-k2-thinking (fallback gratis)
|
||||||
|
|
||||||
|
→ Otomatis beralih saat kuota habis atau terjadi error
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📊 Pelacakan Kuota Real-time
|
||||||
|
|
||||||
|
- Konsumsi token per provider
|
||||||
|
- Hitung mundur reset (5 jam, harian, mingguan)
|
||||||
|
- Estimasi biaya untuk tier berbayar
|
||||||
|
- Laporan pengeluaran bulanan
|
||||||
|
|
||||||
|
### 🔄 Konversi Format
|
||||||
|
|
||||||
|
Konversi mulus antar format:
|
||||||
|
- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **OpenAI Responses**
|
||||||
|
- Tool CLI mengirim dalam format OpenAI → 9Router mengonversi → provider menerima dalam format nativenya
|
||||||
|
- Bekerja dengan semua tool yang mendukung custom OpenAI endpoint
|
||||||
|
|
||||||
|
### 👥 Dukungan Multi-akun
|
||||||
|
|
||||||
|
- Tambahkan beberapa akun per provider
|
||||||
|
- Round-robin otomatis atau routing berbasis prioritas
|
||||||
|
- Saat satu akun mencapai kuota, fallback ke akun berikutnya
|
||||||
|
|
||||||
|
### 🔄 Auto Refresh Token
|
||||||
|
|
||||||
|
- Token OAuth di-refresh otomatis sebelum kedaluwarsa
|
||||||
|
- Tidak perlu autentikasi ulang manual
|
||||||
|
- Pengalaman mulus di semua provider
|
||||||
|
|
||||||
|
### 🎨 Combo Kustom
|
||||||
|
|
||||||
|
- Buat kombinasi model tanpa batas
|
||||||
|
- Campur tier langganan, murah, dan gratis
|
||||||
|
- Beri nama combo agar mudah diakses
|
||||||
|
- Bagikan combo antar perangkat lewat cloud sync
|
||||||
|
|
||||||
|
### 📝 Log Request
|
||||||
|
|
||||||
|
- Log lengkap request/response dalam mode debug
|
||||||
|
- Lacak API call, header, dan payload
|
||||||
|
- Troubleshoot masalah integrasi
|
||||||
|
- Ekspor log untuk dianalisis
|
||||||
|
|
||||||
|
### 💾 Cloud Sync
|
||||||
|
|
||||||
|
- Sinkronkan provider, combo, dan pengaturan antar perangkat
|
||||||
|
- Sinkronisasi latar belakang otomatis
|
||||||
|
- Penyimpanan terenkripsi yang aman
|
||||||
|
- Akses setup dari mana saja
|
||||||
|
|
||||||
|
#### Catatan tentang cloud runtime
|
||||||
|
|
||||||
|
- Untuk produksi, disarankan memakai variabel cloud sisi server:
|
||||||
|
- `BASE_URL` (URL callback internal yang dipakai scheduler sinkronisasi)
|
||||||
|
- `CLOUD_URL` (base URL endpoint cloud sync)
|
||||||
|
- `NEXT_PUBLIC_BASE_URL` dan `NEXT_PUBLIC_CLOUD_URL` masih didukung untuk kompatibilitas/UI, tetapi runtime server memprioritaskan `BASE_URL`/`CLOUD_URL`.
|
||||||
|
- Request cloud sync memakai timeout + perilaku fail-fast untuk menghindari UI menggantung saat DNS/jaringan cloud tidak tersedia.
|
||||||
|
|
||||||
|
### 📊 Analitik Penggunaan
|
||||||
|
|
||||||
|
- Lacak pemakaian token per provider dan per model
|
||||||
|
- Estimasi biaya dan tren pengeluaran
|
||||||
|
- Laporan dan insight bulanan
|
||||||
|
- Optimalkan pengeluaran AI
|
||||||
|
|
||||||
|
> **💡 PENTING - tentang biaya di dashboard:**
|
||||||
|
>
|
||||||
|
> "Biaya" yang ditampilkan pada analitik penggunaan **hanya untuk pelacakan dan perbandingan**.
|
||||||
|
> 9Router sendiri **tidak menagih apa pun**. Kamu hanya membayar langsung ke provider jika memakai layanan berbayar.
|
||||||
|
>
|
||||||
|
> **Contoh:** jika dashboard menampilkan "Total biaya $290" untuk pemakaian model iFlow,
|
||||||
|
> itu adalah jumlah yang seharusnya kamu bayar bila memakai API berbayar secara langsung. Biaya sebenarnya = **$0** (iFlow gratis tanpa batas).
|
||||||
|
>
|
||||||
|
> Anggap saja ini "pelacak penghematan" yang menunjukkan berapa banyak yang kamu hemat lewat model gratis dan routing 9Router!
|
||||||
|
|
||||||
|
### 🌐 Deploy di Mana Saja
|
||||||
|
|
||||||
|
- 💻 **Localhost** - default, jalan offline
|
||||||
|
- ☁️ **VPS/Cloud** - berbagi antar perangkat
|
||||||
|
- 🐳 **Docker** - deploy satu perintah
|
||||||
|
- 🚀 **Cloudflare Workers** - jaringan edge global
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💰 Ringkasan Harga
|
||||||
|
|
||||||
|
| Tier | Provider | Biaya | Reset Kuota | Cocok Untuk |
|
||||||
|
|------|----------|-------|-------------|-------------|
|
||||||
|
| **💳 Langganan** | Claude Code (Pro) | $20/bulan | 5 jam + mingguan | Yang sudah punya langganan |
|
||||||
|
| | Codex (Plus/Pro) | $20-200/bulan | 5 jam + mingguan | Pengguna OpenAI |
|
||||||
|
| | Gemini CLI | **Gratis** | 180rb/bulan + 1rb/hari | Semua orang! |
|
||||||
|
| | GitHub Copilot | $10-19/bulan | Bulanan | Pengguna GitHub |
|
||||||
|
| **💰 Murah** | GLM-4.7 | $0.6/1M | Setiap hari jam 10.00 | Backup hemat |
|
||||||
|
| | MiniMax M2.1 | $0.2/1M | Rolling 5 jam | Opsi paling murah |
|
||||||
|
| | Kimi K2 | $9/bulan flat | 10 juta token/bulan | Biaya yang bisa diprediksi |
|
||||||
|
| **🆓 Gratis** | iFlow | $0 | Unlimited | 8 model gratis |
|
||||||
|
| | Qwen | $0 | Unlimited | 3 model gratis |
|
||||||
|
| | Kiro | $0 | Unlimited | Claude gratis |
|
||||||
|
|
||||||
|
**💡 Tips pro:** combo Gemini CLI (180rb request/bulan gratis) + iFlow (gratis unlimited) = biaya $0!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📊 Tentang Biaya dan Penagihan 9Router
|
||||||
|
|
||||||
|
**Fakta soal penagihan 9Router:**
|
||||||
|
|
||||||
|
✅ **Software 9Router = gratis selamanya** (open source, tanpa tagihan)
|
||||||
|
✅ **"Biaya" di dashboard = tampilan/pelacakan saja** (bukan tagihan sungguhan)
|
||||||
|
✅ **Pembayaran langsung ke provider** (langganan atau biaya API)
|
||||||
|
✅ **Provider gratis tetap gratis** (iFlow, Kiro, Qwen = $0 unlimited)
|
||||||
|
❌ **9Router tidak mengirim invoice** atau menagih kartumu
|
||||||
|
|
||||||
|
**Cara kerja tampilan biaya:**
|
||||||
|
|
||||||
|
Dashboard menampilkan **estimasi biaya** seandainya kamu memakai API berbayar secara langsung. Ini **bukan tagihan**, melainkan alat pembanding yang menunjukkan penghematanmu.
|
||||||
|
|
||||||
|
**Contoh skenario:**
|
||||||
|
```
|
||||||
|
Tampilan dashboard:
|
||||||
|
• Total request: 1.662
|
||||||
|
• Total token: 47 juta
|
||||||
|
• Biaya tertampil: $290
|
||||||
|
|
||||||
|
Kenyataannya:
|
||||||
|
• Provider: iFlow (gratis unlimited)
|
||||||
|
• Yang benar-benar dibayar: $0.00
|
||||||
|
• Arti $290: jumlah yang kamu hemat dengan memakai model gratis!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Aturan pembayaran:**
|
||||||
|
- **Provider langganan** (Claude Code, Codex): bayar langsung di website masing-masing
|
||||||
|
- **Provider murah** (GLM, MiniMax): bayar langsung, 9Router hanya melakukan routing
|
||||||
|
- **Provider gratis** (iFlow, Kiro, Qwen): benar-benar gratis selamanya, tanpa biaya tersembunyi
|
||||||
|
- **9Router**: tidak menagih apa pun
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Studi Kasus
|
||||||
|
|
||||||
|
### Kasus 1: "Saya punya langganan Claude Pro"
|
||||||
|
|
||||||
|
**Masalah:** kuota hangus tanpa terpakai, kena rate limit saat ngoding berat
|
||||||
|
|
||||||
|
**Solusi:**
|
||||||
|
```
|
||||||
|
Combo: "maximize-claude"
|
||||||
|
1. cc/claude-opus-4-6 (manfaatkan langganan semaksimal mungkin)
|
||||||
|
2. glm/glm-4.7 (backup murah saat kuota habis)
|
||||||
|
3. if/kimi-k2-thinking (fallback darurat gratis)
|
||||||
|
|
||||||
|
Biaya bulanan: $20 (langganan) + ~$5 (backup) = total $25
|
||||||
|
vs. $20 + kena limit = frustrasi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kasus 2: "Saya mau biaya nol"
|
||||||
|
|
||||||
|
**Masalah:** tidak mampu bayar langganan, tapi butuh AI coding yang andal
|
||||||
|
|
||||||
|
**Solusi:**
|
||||||
|
```
|
||||||
|
Combo: "free-forever"
|
||||||
|
1. gc/gemini-3-flash (180rb request/bulan gratis)
|
||||||
|
2. if/kimi-k2-thinking (gratis unlimited)
|
||||||
|
3. qw/qwen3-coder-plus (gratis unlimited)
|
||||||
|
|
||||||
|
Biaya bulanan: $0
|
||||||
|
Kualitas: model siap produksi
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kasus 3: "Ngoding 24/7 tanpa terputus"
|
||||||
|
|
||||||
|
**Masalah:** deadline mepet, downtime tidak dapat ditoleransi
|
||||||
|
|
||||||
|
**Solusi:**
|
||||||
|
```
|
||||||
|
Combo: "always-on"
|
||||||
|
1. cc/claude-opus-4-6 (kualitas terbaik)
|
||||||
|
2. cx/gpt-5.2-codex (langganan kedua)
|
||||||
|
3. glm/glm-4.7 (murah, reset harian)
|
||||||
|
4. minimax/MiniMax-M2.1 (paling murah, reset 5 jam)
|
||||||
|
5. if/kimi-k2-thinking (gratis unlimited)
|
||||||
|
|
||||||
|
Hasil: 5 lapis fallback = zero downtime
|
||||||
|
Biaya bulanan: $20-200 (langganan) + $10-20 (backup)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kasus 4: "Saya mau pakai AI gratis di OpenClaw"
|
||||||
|
|
||||||
|
**Masalah:** butuh asisten AI di aplikasi pesan (WhatsApp, Telegram, Slack...), sepenuhnya gratis
|
||||||
|
|
||||||
|
**Solusi:**
|
||||||
|
```
|
||||||
|
Combo: "openclaw-free"
|
||||||
|
1. if/glm-4.7 (gratis unlimited)
|
||||||
|
2. if/minimax-m2.1 (gratis unlimited)
|
||||||
|
3. if/kimi-k2-thinking (gratis unlimited)
|
||||||
|
|
||||||
|
Biaya bulanan: $0
|
||||||
|
Cara akses: WhatsApp, Telegram, Slack, Discord, iMessage, Signal...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ FAQ
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>📊 Kenapa dashboard menampilkan biaya yang besar?</b></summary>
|
||||||
|
|
||||||
|
Dashboard melacak pemakaian token dan menampilkan **estimasi biaya** seandainya kamu memakai API berbayar secara langsung. Ini **bukan tagihan nyata**, melainkan acuan untuk melihat berapa banyak yang kamu hemat dengan memakai model gratis atau langganan yang sudah ada lewat 9Router.
|
||||||
|
|
||||||
|
**Contoh:**
|
||||||
|
- **Tampilan dashboard:** "Total biaya $290"
|
||||||
|
- **Kenyataan:** sedang memakai iFlow (gratis unlimited)
|
||||||
|
- **Biaya sebenarnya:** **$0.00**
|
||||||
|
- **Arti $290:** jumlah yang **dihemat** karena memakai model gratis alih-alih API berbayar!
|
||||||
|
|
||||||
|
Tampilan biaya adalah "pelacak penghematan" untuk memahami pola pemakaian dan peluang optimasi.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>💳 Apakah 9Router menagih saya?</b></summary>
|
||||||
|
|
||||||
|
**Tidak.** 9Router adalah software open source gratis yang berjalan di komputermu sendiri. Tidak ada penagihan sama sekali.
|
||||||
|
|
||||||
|
**Kamu membayar ke:**
|
||||||
|
- ✅ **Provider langganan** (Claude Code $20/bulan, Codex $20-200/bulan) → bayar langsung di website masing-masing
|
||||||
|
- ✅ **Provider murah** (GLM, MiniMax) → bayar langsung, 9Router hanya me-routing request
|
||||||
|
- ❌ **9Router sendiri** → **tidak menagih apa pun**
|
||||||
|
|
||||||
|
9Router adalah proxy/router lokal. Ia tidak menyimpan informasi kartu kredit, tidak bisa mengirim invoice, dan tidak punya sistem penagihan. Sepenuhnya software gratis.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>🆓 Apakah provider gratis benar-benar unlimited?</b></summary>
|
||||||
|
|
||||||
|
**Ya!** Provider yang ditandai gratis (iFlow, Kiro, Qwen) benar-benar unlimited dan **tanpa biaya tersembunyi**.
|
||||||
|
|
||||||
|
Ini adalah layanan gratis yang disediakan masing-masing perusahaan:
|
||||||
|
- **iFlow**: akses gratis unlimited ke 8+ model via OAuth
|
||||||
|
- **Kiro**: model Claude gratis unlimited via AWS Builder ID
|
||||||
|
- **Qwen**: akses gratis unlimited ke model Qwen via device authentication
|
||||||
|
|
||||||
|
9Router hanya me-routing request — tidak ada "jebakan" atau tagihan di kemudian hari. Layanannya memang gratis, dan 9Router membuatnya lebih mudah dipakai dengan dukungan fallback.
|
||||||
|
|
||||||
|
**Catatan:** beberapa provider langganan (Antigravity, GitHub Copilot) punya masa preview gratis dan bisa jadi berbayar nanti, tetapi hal itu diumumkan secara jelas oleh provider tersebut, bukan oleh 9Router.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>💰 Bagaimana cara menekan biaya AI seminimal mungkin?</b></summary>
|
||||||
|
|
||||||
|
**Strategi free-first:**
|
||||||
|
|
||||||
|
1. **Mulai dari combo 100% gratis:**
|
||||||
|
```
|
||||||
|
1. gc/gemini-3-flash (180rb/bulan gratis dari Google)
|
||||||
|
2. if/kimi-k2-thinking (gratis unlimited dari iFlow)
|
||||||
|
3. qw/qwen3-coder-plus (gratis unlimited dari Qwen)
|
||||||
|
```
|
||||||
|
**Biaya: $0/bulan**
|
||||||
|
|
||||||
|
2. **Tambahkan backup murah hanya bila perlu:**
|
||||||
|
```
|
||||||
|
4. glm/glm-4.7 ($0.6 per 1 juta token)
|
||||||
|
```
|
||||||
|
**Tambahan biaya: bayar sesuai pemakaian saja**
|
||||||
|
|
||||||
|
3. **Gunakan provider langganan paling akhir:**
|
||||||
|
- Hanya jika kamu memang sudah punya
|
||||||
|
- 9Router memaksimalkan nilainya lewat pelacakan kuota
|
||||||
|
|
||||||
|
**Hasil:** sebagian besar pengguna bisa jalan dengan $0/bulan hanya dengan tier gratis!
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>📈 Bagaimana kalau pemakaian tiba-tiba melonjak?</b></summary>
|
||||||
|
|
||||||
|
Smart fallback 9Router mencegah tagihan tak terduga:
|
||||||
|
|
||||||
|
**Skenario:** kuota habis di tengah sprint coding
|
||||||
|
|
||||||
|
**Tanpa 9Router:**
|
||||||
|
- ❌ Kena rate limit → kerja berhenti → frustrasi
|
||||||
|
- ❌ Atau: tagihan API mahal tanpa disengaja
|
||||||
|
|
||||||
|
**Dengan 9Router:**
|
||||||
|
- ✅ Langganan mencapai batas → otomatis fallback ke tier murah
|
||||||
|
- ✅ Tier murah jadi mahal → otomatis fallback ke tier gratis
|
||||||
|
- ✅ Ngoding tidak berhenti → biaya tetap terprediksi
|
||||||
|
|
||||||
|
**Kamu yang pegang kendali:** atur batas pengeluaran per provider di dashboard, dan 9Router akan mematuhinya.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 Panduan Setup
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>🔐 Provider Langganan (maksimalkan nilainya)</b></summary>
|
||||||
|
|
||||||
|
### Claude Code (Pro/Max)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Dashboard → Providers → hubungkan Claude Code
|
||||||
|
→ login OAuth → refresh token otomatis
|
||||||
|
→ pelacakan kuota 5 jam + mingguan
|
||||||
|
|
||||||
|
Model:
|
||||||
|
cc/claude-opus-4-6
|
||||||
|
cc/claude-sonnet-4-5-20250929
|
||||||
|
cc/claude-haiku-4-5-20251001
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tips pro:** pakai Opus untuk tugas kompleks, Sonnet kalau mengutamakan kecepatan. 9Router melacak kuota per model!
|
||||||
|
|
||||||
|
### OpenAI Codex (Plus/Pro)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Dashboard → Providers → hubungkan Codex
|
||||||
|
→ login OAuth (port 1455)
|
||||||
|
→ reset 5 jam + mingguan
|
||||||
|
|
||||||
|
Model:
|
||||||
|
cx/gpt-5.2-codex
|
||||||
|
cx/gpt-5.1-codex-max
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gemini CLI (180rb request/bulan gratis!)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Dashboard → Providers → hubungkan Gemini CLI
|
||||||
|
→ Google OAuth
|
||||||
|
→ 180rb/bulan + 1rb/hari
|
||||||
|
|
||||||
|
Model:
|
||||||
|
gc/gemini-3-flash-preview
|
||||||
|
gc/gemini-2.5-pro
|
||||||
|
```
|
||||||
|
|
||||||
|
**Value terbaik:** free tier-nya besar sekali! Pakai ini sebelum tier berbayar.
|
||||||
|
|
||||||
|
### GitHub Copilot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Dashboard → Providers → hubungkan GitHub
|
||||||
|
→ OAuth via GitHub
|
||||||
|
→ reset bulanan (tanggal 1 tiap bulan)
|
||||||
|
|
||||||
|
Model:
|
||||||
|
gh/gpt-5
|
||||||
|
gh/claude-4.5-sonnet
|
||||||
|
gh/gemini-3-pro
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>💰 Provider Murah (backup)</b></summary>
|
||||||
|
|
||||||
|
### GLM-4.7 (reset harian, $0.6/1M)
|
||||||
|
|
||||||
|
1. Daftar: [Zhipu AI](https://open.bigmodel.cn/)
|
||||||
|
2. Ambil API key dari Coding Plan
|
||||||
|
3. Dashboard → tambahkan API key:
|
||||||
|
- Provider: `glm`
|
||||||
|
- API Key: `your-key`
|
||||||
|
|
||||||
|
**Pemakaian:** `glm/glm-4.7`
|
||||||
|
|
||||||
|
**Tips pro:** Coding Plan memberi kuota 3x lipat dengan biaya 1/7! Reset setiap hari jam 10.00.
|
||||||
|
|
||||||
|
### MiniMax M2.1 (reset 5 jam, $0.20/1M)
|
||||||
|
|
||||||
|
1. Daftar: [MiniMax](https://www.minimax.io/)
|
||||||
|
2. Ambil API key
|
||||||
|
3. Dashboard → tambahkan API key
|
||||||
|
|
||||||
|
**Pemakaian:** `minimax/MiniMax-M2.1`
|
||||||
|
|
||||||
|
**Tips pro:** opsi termurah dengan konteks panjang (1 juta token)!
|
||||||
|
|
||||||
|
### Kimi K2 ($9/bulan flat)
|
||||||
|
|
||||||
|
1. Berlangganan: [Moonshot AI](https://platform.moonshot.ai/)
|
||||||
|
2. Ambil API key
|
||||||
|
3. Dashboard → tambahkan API key
|
||||||
|
|
||||||
|
**Pemakaian:** `kimi/kimi-latest`
|
||||||
|
|
||||||
|
**Tips pro:** $9/bulan flat untuk 10 juta token = biaya efektif $0.90/1M!
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>🆓 Provider Gratis (backup darurat)</b></summary>
|
||||||
|
|
||||||
|
### iFlow (8 model gratis)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Dashboard → hubungkan iFlow
|
||||||
|
→ login OAuth iFlow
|
||||||
|
→ pemakaian unlimited
|
||||||
|
|
||||||
|
Model:
|
||||||
|
if/kimi-k2-thinking
|
||||||
|
if/qwen3-coder-plus
|
||||||
|
if/glm-4.7
|
||||||
|
if/minimax-m2
|
||||||
|
if/deepseek-r1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Qwen (3 model gratis)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Dashboard → hubungkan Qwen
|
||||||
|
→ autentikasi device code
|
||||||
|
→ pemakaian unlimited
|
||||||
|
|
||||||
|
Model:
|
||||||
|
qw/qwen3-coder-plus
|
||||||
|
qw/qwen3-coder-flash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kiro (Claude gratis)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
Dashboard → hubungkan Kiro
|
||||||
|
→ AWS Builder ID atau Google/GitHub
|
||||||
|
→ pemakaian unlimited
|
||||||
|
|
||||||
|
Model:
|
||||||
|
kr/claude-sonnet-4.5
|
||||||
|
kr/claude-haiku-4.5
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>🎨 Membuat Combo</b></summary>
|
||||||
|
|
||||||
|
### Contoh 1: maksimalkan langganan → backup murah
|
||||||
|
|
||||||
|
```
|
||||||
|
Dashboard → Combos → buat baru
|
||||||
|
|
||||||
|
Nama: premium-coding
|
||||||
|
Model:
|
||||||
|
1. cc/claude-opus-4-6 (langganan, utama)
|
||||||
|
2. glm/glm-4.7 (backup murah, $0.6/1M)
|
||||||
|
3. minimax/MiniMax-M2.1 (fallback termurah, $0.20/1M)
|
||||||
|
|
||||||
|
Pemakaian di CLI: premium-coding
|
||||||
|
|
||||||
|
Contoh biaya bulanan (100 juta token):
|
||||||
|
80 juta lewat Claude (langganan): tambahan $0
|
||||||
|
15 juta lewat GLM: $9
|
||||||
|
5 juta lewat MiniMax: $1
|
||||||
|
Total: $10
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contoh 2: combo 100% gratis
|
||||||
|
|
||||||
|
```
|
||||||
|
Nama: free-forever
|
||||||
|
Model:
|
||||||
|
1. gc/gemini-3-flash (180rb request/bulan gratis)
|
||||||
|
2. if/kimi-k2-thinking (gratis unlimited)
|
||||||
|
3. qw/qwen3-coder-plus (gratis unlimited)
|
||||||
|
4. kr/claude-sonnet-4.5 (gratis unlimited)
|
||||||
|
|
||||||
|
Biaya bulanan: $0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tips membuat combo
|
||||||
|
|
||||||
|
- Urutkan dari kualitas/prioritas tertinggi ke fallback paling murah
|
||||||
|
- Selalu taruh minimal satu provider gratis di posisi terakhir
|
||||||
|
- Pakai nama combo yang deskriptif agar mudah dipilih dari CLI
|
||||||
|
- Aktifkan cloud sync agar combo ikut tersedia di perangkat lain
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐳 Deployment
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Docker</b></summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name 9router \
|
||||||
|
-p 20128:20128 \
|
||||||
|
-v 9router-data:/app/data \
|
||||||
|
-e PORT=20128 \
|
||||||
|
-e BASE_URL=http://localhost:20128 \
|
||||||
|
ghcr.io/decolua/9router:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Dashboard: `http://localhost:20128/dashboard`
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>VPS / Cloud</b></summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g 9router
|
||||||
|
PORT=20128 HOSTNAME=0.0.0.0 BASE_URL=https://your-domain.com 9router
|
||||||
|
```
|
||||||
|
|
||||||
|
Disarankan menaruhnya di belakang reverse proxy (Nginx/Caddy) dengan HTTPS, dan membatasi akses hanya untuk dirimu sendiri.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Cloudflare Workers</b></summary>
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npx wrangler deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Atur `BASE_URL` dan `CLOUD_URL` sebagai environment variable di dashboard Cloudflare.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Troubleshooting
|
||||||
|
|
||||||
|
| Masalah | Kemungkinan Penyebab | Solusi |
|
||||||
|
|---------|----------------------|--------|
|
||||||
|
| Tool CLI tidak bisa konek | Endpoint salah | Pastikan `http://localhost:20128/v1` |
|
||||||
|
| 401 / Unauthorized | API key salah | Salin ulang key dari dashboard |
|
||||||
|
| Model tidak ditemukan | Prefix provider salah | Pakai format `provider/model`, mis. `if/kimi-k2-thinking` |
|
||||||
|
| Selalu fallback ke gratis | Kuota langganan habis | Cek hitung mundur reset di dashboard |
|
||||||
|
| OAuth gagal | Port callback terpakai | Tutup proses lain (mis. port 1455 untuk Codex) |
|
||||||
|
| UI menggantung saat sync | DNS/jaringan cloud bermasalah | Cek `CLOUD_URL`; sync memakai timeout fail-fast |
|
||||||
|
|
||||||
|
Aktifkan mode debug di dashboard untuk melihat log lengkap request/response.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 Kontribusi
|
||||||
|
|
||||||
|
Kontribusi sangat diterima!
|
||||||
|
|
||||||
|
1. Fork repo ini
|
||||||
|
2. Buat branch fitur (`git checkout -b feature/nama-fitur`)
|
||||||
|
3. Commit perubahanmu (`git commit -m 'feat: tambah fitur X'`)
|
||||||
|
4. Push ke branch (`git push origin feature/nama-fitur`)
|
||||||
|
5. Buka Pull Request
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 Lisensi
|
||||||
|
|
||||||
|
MIT License — lihat [LICENSE](https://github.com/decolua/9router/blob/main/LICENSE) untuk detailnya.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
**Kalau 9Router membantumu, kasih ⭐ di [GitHub](https://github.com/decolua/9router)!**
|
||||||
|
|
||||||
|
[🌐 Website](https://9router.com) • [📦 npm](https://www.npmjs.com/package/9router) • [🐛 Laporkan Bug](https://github.com/decolua/9router/issues)
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -134,10 +134,19 @@ export const ANTIGRAVITY_HEADERS = {
|
|||||||
"User-Agent": ANTIGRAVITY_IDE_USER_AGENT
|
"User-Agent": ANTIGRAVITY_IDE_USER_AGENT
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cloud Code Assist API
|
// Cloud Code Assist API endpoints differ by client ecosystem.
|
||||||
export const CLOUD_CODE_API = {
|
export const CLOUD_CODE_API = {
|
||||||
loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
"gemini-cli": {
|
||||||
onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
|
loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||||
|
onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
|
||||||
|
},
|
||||||
|
// Project discovery (loadCodeAssist/onboardUser) stays on PROD — the daily host
|
||||||
|
// rejects these auth/onboarding calls. Only chat traffic uses the daily host
|
||||||
|
// (see transport.apiEndpoint in registry/antigravity.js, set to bypass prod 429).
|
||||||
|
antigravity: {
|
||||||
|
loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||||
|
onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LOAD_CODE_ASSIST_HEADERS = {
|
export const LOAD_CODE_ASSIST_HEADERS = {
|
||||||
|
|||||||
@@ -15,11 +15,17 @@
|
|||||||
* fiction. The suffix is stripped before the request leaves this process.
|
* fiction. The suffix is stripped before the request leaves this process.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { extractThinking } from "../translator/concerns/thinkingUnified.js";
|
import { extractThinking, parseSuffix } from "../translator/concerns/thinkingUnified.js";
|
||||||
import { effortToBudget } from "../translator/concerns/thinking.js";
|
import { effortToBudget } from "../translator/concerns/thinking.js";
|
||||||
|
|
||||||
export const KIRO_AGENTIC_SUFFIX = "-agentic";
|
export const KIRO_AGENTIC_SUFFIX = "-agentic";
|
||||||
export const KIRO_THINKING_SUFFIX = "-thinking";
|
export const KIRO_THINKING_SUFFIX = "-thinking";
|
||||||
|
export const KIRO_TOOL_NAME_MAX_LENGTH = 64;
|
||||||
|
export const KIRO_TOOL_DESCRIPTION_MAX_LENGTH = 10237;
|
||||||
|
export const KIRO_TOOL_ID_MAX_LENGTH = 64;
|
||||||
|
export const KIRO_CODEWHISPERER_TARGET =
|
||||||
|
"AmazonCodeWhispererStreamingService.GenerateAssistantResponse";
|
||||||
|
export const KIRO_ENDPOINT_FALLBACK_STATUSES = new Set([401, 403, 404]);
|
||||||
|
|
||||||
// Public default CodeWhisperer profile ARNs (us-east-1), keyed by auth method.
|
// Public default CodeWhisperer profile ARNs (us-east-1), keyed by auth method.
|
||||||
// Used when an account cannot resolve its own profileArn. Builder ID and social
|
// Used when an account cannot resolve its own profileArn. Builder ID and social
|
||||||
@@ -40,6 +46,39 @@ export function resolveDefaultProfileArn(authMethod) {
|
|||||||
|
|
||||||
export const KIRO_THINKING_BUDGET_DEFAULT = 16000;
|
export const KIRO_THINKING_BUDGET_DEFAULT = 16000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a Kiro model after consuming the generic model(level) suffix.
|
||||||
|
* The suffix is a 9router request override, not part of Kiro's upstream model id.
|
||||||
|
*/
|
||||||
|
export function resolveKiroModelIntent(model) {
|
||||||
|
const { cleanModel, override } = parseSuffix(model);
|
||||||
|
return {
|
||||||
|
model: cleanModel,
|
||||||
|
...resolveKiroModel(cleanModel),
|
||||||
|
thinkingOverride: override,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a parsed model(level) override without mutating the caller's body. */
|
||||||
|
export function applyKiroThinkingOverride(body, override) {
|
||||||
|
if (!override) return body;
|
||||||
|
|
||||||
|
const next = { ...body };
|
||||||
|
if (override.mode === "budget") {
|
||||||
|
delete next.output_config;
|
||||||
|
delete next.reasoning_effort;
|
||||||
|
delete next.reasoning;
|
||||||
|
next.thinking = { type: "enabled", budget_tokens: override.budget };
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
next.output_config = {
|
||||||
|
...(body.output_config || {}),
|
||||||
|
effort: override.mode === "level" ? override.level : override.mode,
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
export const KIRO_AGENTIC_SYSTEM_PROMPT = `
|
export const KIRO_AGENTIC_SYSTEM_PROMPT = `
|
||||||
# CRITICAL: CHUNKED WRITE PROTOCOL (MANDATORY)
|
# CRITICAL: CHUNKED WRITE PROTOCOL (MANDATORY)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import REGISTRY from "../providers/registry/index.js";
|
|||||||
import { PROVIDER_MODELS } from "../providers/index.js";
|
import { PROVIDER_MODELS } from "../providers/index.js";
|
||||||
import { modelQuotaFamily, modelStrip, modelTargetFormat, normalizeModelId } from "../providers/models/schema.js";
|
import { modelQuotaFamily, modelStrip, modelTargetFormat, normalizeModelId } from "../providers/models/schema.js";
|
||||||
import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js";
|
import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js";
|
||||||
|
|
||||||
export { PROVIDER_MODELS };
|
export { PROVIDER_MODELS };
|
||||||
|
|
||||||
|
|
||||||
@@ -70,8 +69,13 @@ export function getModelUpstreamId(aliasOrId, modelId) {
|
|||||||
const baseId = suffix ? modelId.slice(0, sufMatch.index).trim() : modelId;
|
const baseId = suffix ? modelId.slice(0, sufMatch.index).trim() : modelId;
|
||||||
const models = PROVIDER_MODELS[aliasOrId];
|
const models = PROVIDER_MODELS[aliasOrId];
|
||||||
const found = findModel(models, baseId, aliasOrId);
|
const found = findModel(models, baseId, aliasOrId);
|
||||||
if (found?.upstreamModelId) return found.upstreamModelId + suffix;
|
const resolvedId = found?.upstreamModelId || found?.id;
|
||||||
if (found?.id) return found.id + suffix;
|
if (resolvedId) {
|
||||||
|
const presetMatch = resolvedId.match(/\([^()]+\)\s*$/);
|
||||||
|
const presetSuffix = presetMatch?.[0] || "";
|
||||||
|
const resolvedBase = presetSuffix ? resolvedId.slice(0, presetMatch.index).trim() : resolvedId;
|
||||||
|
return resolvedBase + (suffix || presetSuffix);
|
||||||
|
}
|
||||||
if (aliasOrId === "cx" && typeof baseId === "string" && baseId.endsWith(CODEX_REVIEW_SUFFIX)) {
|
if (aliasOrId === "cx" && typeof baseId === "string" && baseId.endsWith(CODEX_REVIEW_SUFFIX)) {
|
||||||
return baseId.slice(0, -CODEX_REVIEW_SUFFIX.length) + suffix;
|
return baseId.slice(0, -CODEX_REVIEW_SUFFIX.length) + suffix;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,10 @@ export class AntigravityExecutor extends BaseExecutor {
|
|||||||
transformRequest(model, body, stream, credentials) {
|
transformRequest(model, body, stream, credentials) {
|
||||||
const projectId = credentials?.projectId || this.generateProjectId();
|
const projectId = credentials?.projectId || this.generateProjectId();
|
||||||
|
|
||||||
|
// OpenAI clients may include stream_options even for non-streaming calls.
|
||||||
|
// Google generateContent rejects that combination before processing the request.
|
||||||
|
if (stream !== true) delete body.stream_options;
|
||||||
|
|
||||||
// ─── Image generation: completely different request structure ───
|
// ─── Image generation: completely different request structure ───
|
||||||
if (isImageModel(model)) {
|
if (isImageModel(model)) {
|
||||||
const imageConfig = parseImageConfig(model);
|
const imageConfig = parseImageConfig(model);
|
||||||
@@ -264,7 +268,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
|||||||
return {
|
return {
|
||||||
...body,
|
...body,
|
||||||
project: projectId,
|
project: projectId,
|
||||||
model: model,
|
model: body.model || model,
|
||||||
userAgent: "antigravity",
|
userAgent: "antigravity",
|
||||||
requestType: "agent",
|
requestType: "agent",
|
||||||
requestId: buildIdeRequestId({ body, request: transformedRequest, credentials, model, requestType: "agent" }),
|
requestId: buildIdeRequestId({ body, request: transformedRequest, credentials, model, requestType: "agent" }),
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export class BaseExecutor {
|
|||||||
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
||||||
const url = this.buildUrl(model, stream, urlIndex, credentials);
|
const url = this.buildUrl(model, stream, urlIndex, credentials);
|
||||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||||
const headers = this.buildHeaders(credentials, stream);
|
const headers = this.buildHeaders(credentials, stream, url);
|
||||||
|
|
||||||
if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0;
|
if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { buildCursorHeaders } from "../utils/cursorChecksum.js";
|
import { buildCursorHeaders } from "../utils/cursorChecksum.js";
|
||||||
import { estimateUsage } from "../utils/usageTracking.js";
|
import { estimateUsage } from "../utils/usageTracking.js";
|
||||||
import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js";
|
import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js";
|
||||||
import { chatChunkSse } from "../utils/sse.js";
|
import { chatChunkSse, sseChunk } from "../utils/sse.js";
|
||||||
import { FORMATS } from "../translator/formats.js";
|
import { FORMATS } from "../translator/formats.js";
|
||||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||||
import zlib from "zlib";
|
import zlib from "zlib";
|
||||||
@@ -543,6 +543,9 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
if (done) break;
|
if (done) break;
|
||||||
pending = Buffer.concat([pending, Buffer.from(value)]);
|
pending = Buffer.concat([pending, Buffer.from(value)]);
|
||||||
pending = decodeAgentFrames(pending, (payload) => {
|
pending = decodeAgentFrames(pending, (payload) => {
|
||||||
|
// A single read can carry several frames; once the turn is over the
|
||||||
|
// rest of the batch must not reach the already-closed controller.
|
||||||
|
if (finished) return;
|
||||||
const serverMessage = decodeMessage(payload);
|
const serverMessage = decodeMessage(payload);
|
||||||
|
|
||||||
// agent.v1.AgentServerMessage.interaction_update
|
// agent.v1.AgentServerMessage.interaction_update
|
||||||
@@ -570,9 +573,12 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
if (execRequest.has(10)) {
|
if (execRequest.has(10)) {
|
||||||
session.write(createRequestContextResponse());
|
session.write(createRequestContextResponse());
|
||||||
} else {
|
} else {
|
||||||
|
// Every other ExecServerMessage variant is an editor-backed tool
|
||||||
|
// (shell, read, write, …) that 9router cannot service. Fail the
|
||||||
|
// turn rather than narrating protocol state as assistant text.
|
||||||
|
debugLog(`[CURSOR AGENT] Unsupported exec request fields: ${[...execRequest.keys()].join(",")}`);
|
||||||
finished = true;
|
finished = true;
|
||||||
onEvent({ type: "error", value: "Cursor AgentService requested an unsupported IDE tool" });
|
onEvent({ type: "error", value: "Cursor AgentService requested an unsupported IDE tool" });
|
||||||
onEvent({ type: "done" });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -630,7 +636,12 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
} else if (event.type === "thinking") {
|
} else if (event.type === "thinking") {
|
||||||
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { reasoning_content: event.value } })));
|
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { reasoning_content: event.value } })));
|
||||||
} else if (event.type === "error") {
|
} else if (event.type === "error") {
|
||||||
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: { content: `\n[${event.value}]` } })));
|
// An SSE error frame, not a content delta: a protocol failure must not
|
||||||
|
// be rendered to the user as the assistant's reply, and downstream
|
||||||
|
// usage tracking must not record the turn as a success.
|
||||||
|
controller.enqueue(encoder.encode(sseChunk({ error: { message: event.value, type: "api_error" } })));
|
||||||
|
controller.enqueue(encoder.encode(SSE_DONE));
|
||||||
|
controller.close();
|
||||||
} else if (event.type === "done") {
|
} else if (event.type === "done") {
|
||||||
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: {}, finishReason: "stop" })));
|
controller.enqueue(encoder.encode(chatChunkSse({ id: responseId, created, model, delta: {}, finishReason: "stop" })));
|
||||||
controller.enqueue(encoder.encode(SSE_DONE));
|
controller.enqueue(encoder.encode(SSE_DONE));
|
||||||
|
|||||||
@@ -0,0 +1,847 @@
|
|||||||
|
/**
|
||||||
|
* DevinCliExecutor — routes completions through the official Devin CLI binary
|
||||||
|
* via the Agent Client Protocol (ACP) JSON-RPC 2.0 over stdio.
|
||||||
|
*
|
||||||
|
* Protocol flow:
|
||||||
|
* 1. Spawn `devin acp` (default agent = full built-in tools: fs/shell/search).
|
||||||
|
* Set CLI_DEVIN_AGENT_TYPE=summarizer for a tool-less, text-only mode.
|
||||||
|
* 2. Send: initialize → session/new (with model + cwd + mcpServers) → session/prompt.
|
||||||
|
* 3. Receive: session/update notifications (agent_message_chunk = reply text,
|
||||||
|
* tool_call/tool_call_update = built-in tool invocations, surfaced as text).
|
||||||
|
* When devin calls a client-tool from the exposed MCP ("Calling mcp_X from
|
||||||
|
* clientTools"), it is bridged to an OpenAI tool_use and the turn ends.
|
||||||
|
* 4. Emit deltas as OpenAI-compatible SSE chunks.
|
||||||
|
* 5. Kill subprocess on _cognition.ai/agent_stopped or error.
|
||||||
|
*
|
||||||
|
* Auth: noAuth — the subprocess inherits the parent env and uses credentials
|
||||||
|
* stored by `devin auth login` (~/.local/share/devin/credentials.toml).
|
||||||
|
*
|
||||||
|
* Binary discovery: CLI_DEVIN_BIN env → PATH lookup → platform installer paths.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { BaseExecutor } from "./base.js";
|
||||||
|
|
||||||
|
// ─── Binary discovery ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function resolveDevinBin() {
|
||||||
|
// 1. Explicit override
|
||||||
|
const envBin = process.env.CLI_DEVIN_BIN?.trim();
|
||||||
|
if (envBin) return envBin;
|
||||||
|
|
||||||
|
const isWin = process.platform === "win32";
|
||||||
|
const home = os.homedir();
|
||||||
|
|
||||||
|
// 2. Known installer / package-manager locations. spawn uses shell:false on
|
||||||
|
// macOS/Linux, so process.env.PATH alone may miss ~/.local/bin, Homebrew,
|
||||||
|
// Scoop, etc. when the server runs detached (tray/daemon/launchd) without
|
||||||
|
// a login shell — probe these explicitly before falling back to PATH.
|
||||||
|
const candidates = isWin
|
||||||
|
? [
|
||||||
|
// Official installer: %LOCALAPPDATA%\devin\cli\bin\devin.exe
|
||||||
|
path.join(process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "devin", "cli", "bin", "devin.exe"),
|
||||||
|
path.join(home, ".local", "bin", "devin.exe"),
|
||||||
|
path.join(home, "scoop", "shims", "devin.exe"),
|
||||||
|
path.join(process.env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Programs", "devin", "devin.exe"),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
path.join(home, ".local", "share", "devin", "bin", "devin"),
|
||||||
|
path.join(home, ".devin", "bin", "devin"),
|
||||||
|
path.join(home, ".local", "bin", "devin"), // pipx / user install
|
||||||
|
"/opt/homebrew/bin/devin", // Homebrew (Apple Silicon)
|
||||||
|
"/usr/local/bin/devin", // Homebrew (Intel) / manual
|
||||||
|
"/usr/bin/devin",
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback — rely on process.env.PATH
|
||||||
|
return isWin ? "devin.exe" : "devin";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ACP JSON-RPC helper ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function rpc(method, params, id) {
|
||||||
|
const msg = { jsonrpc: "2.0", method, params };
|
||||||
|
if (id !== undefined) msg.id = id;
|
||||||
|
return JSON.stringify(msg) + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Client-tools → MCP bridge ───────────────────────────────────────────────
|
||||||
|
// devin only invokes built-in + MCP tools, not OpenAI function-calling schemas.
|
||||||
|
// body.tools are exposed as a stdio MCP server "clientTools" so devin can call
|
||||||
|
// them. When devin calls one, we emit OpenAI tool_use and end the turn; the
|
||||||
|
// client executes and returns tool_result on the next request. That next request
|
||||||
|
// re-spawns with the full history (including tool_calls + tool results) and
|
||||||
|
// seeds the MCP server with those results so a re-call gets the real data.
|
||||||
|
// Tool schemas via DEVIN_MCP_TOOLS; prior results via DEVIN_MCP_RESULTS.
|
||||||
|
|
||||||
|
const CLIENT_TOOLS_MCP_SCRIPT = `
|
||||||
|
import readline from "node:readline";
|
||||||
|
const TOOLS = JSON.parse(process.env.DEVIN_MCP_TOOLS || "[]");
|
||||||
|
const RESULTS = JSON.parse(process.env.DEVIN_MCP_RESULTS || "{}");
|
||||||
|
const rl = readline.createInterface({ input: process.stdin });
|
||||||
|
function send(o){ process.stdout.write(JSON.stringify(o) + "\\n"); }
|
||||||
|
rl.on("line", (line) => {
|
||||||
|
let m; try { m = JSON.parse(line); } catch { return; }
|
||||||
|
if (m.method === "initialize") {
|
||||||
|
send({ jsonrpc: "2.0", id: m.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "clientTools", version: "1.0" } } });
|
||||||
|
} else if (m.method === "tools/list") {
|
||||||
|
send({ jsonrpc: "2.0", id: m.id, result: { tools: TOOLS } });
|
||||||
|
} else if (m.method === "tools/call") {
|
||||||
|
const name = m.params?.name || "";
|
||||||
|
const seeded = RESULTS[name];
|
||||||
|
const text = seeded !== undefined
|
||||||
|
? String(seeded)
|
||||||
|
: "(awaiting client tool_result)";
|
||||||
|
process.stderr.write("[client-tools] tool_call name=" + name + " seeded=" + (seeded !== undefined) + "\\n");
|
||||||
|
send({ jsonrpc: "2.0", id: m.id, result: { content: [{ type: "text", text }] } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
`.trimStart();
|
||||||
|
|
||||||
|
function ensureClientToolsScript() {
|
||||||
|
const scriptPath = path.join(os.tmpdir(), "9router-devin-client-tools.mjs");
|
||||||
|
// Always rewrite so script upgrades land without a process restart.
|
||||||
|
fs.writeFileSync(scriptPath, CLIENT_TOOLS_MCP_SCRIPT);
|
||||||
|
return scriptPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map OpenAI tools ([{type:"function",function:{name,description,parameters}}])
|
||||||
|
// to MCP tool declarations ([{name,description,inputSchema}]).
|
||||||
|
// devin only discovers MCP tools whose name carries the `mcp_` prefix, so we
|
||||||
|
// add it here and strip it back when bridging the call to the client.
|
||||||
|
const MCP_TOOL_PREFIX = "mcp_";
|
||||||
|
function toMcpToolName(name) {
|
||||||
|
return name.startsWith(MCP_TOOL_PREFIX) ? name : MCP_TOOL_PREFIX + name;
|
||||||
|
}
|
||||||
|
function fromMcpToolName(name) {
|
||||||
|
return name.startsWith(MCP_TOOL_PREFIX) ? name.slice(MCP_TOOL_PREFIX.length) : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClientToolsMcp(tools, resultMap) {
|
||||||
|
const mcpTools = [];
|
||||||
|
for (const t of tools) {
|
||||||
|
if (!t) continue;
|
||||||
|
const f = t.function || t;
|
||||||
|
if (!f?.name) continue;
|
||||||
|
mcpTools.push({
|
||||||
|
name: toMcpToolName(f.name),
|
||||||
|
description: f.description || "",
|
||||||
|
inputSchema: f.parameters || f.input_schema || { type: "object", properties: {} },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!mcpTools.length) return null;
|
||||||
|
const env = { DEVIN_MCP_TOOLS: JSON.stringify(mcpTools) };
|
||||||
|
if (resultMap && Object.keys(resultMap).length) {
|
||||||
|
env.DEVIN_MCP_RESULTS = JSON.stringify(resultMap);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
command: process.execPath,
|
||||||
|
args: [ensureClientToolsScript()],
|
||||||
|
env,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract tool_result content keyed by MCP tool name (mcp_<original>).
|
||||||
|
// Walks messages: assistant.tool_calls id→name, role=tool tool_call_id→content.
|
||||||
|
function extractClientToolResults(messages) {
|
||||||
|
const idToMcpName = new Map();
|
||||||
|
const results = {};
|
||||||
|
for (const m of messages) {
|
||||||
|
if (m?.role === "assistant" && Array.isArray(m.tool_calls)) {
|
||||||
|
for (const tc of m.tool_calls) {
|
||||||
|
const name = tc?.function?.name || tc?.name;
|
||||||
|
if (tc?.id && name) idToMcpName.set(tc.id, toMcpToolName(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Claude-style tool_use blocks in content
|
||||||
|
if (m?.role === "assistant" && Array.isArray(m.content)) {
|
||||||
|
for (const b of m.content) {
|
||||||
|
if (b?.type === "tool_use" && b.id && b.name) {
|
||||||
|
idToMcpName.set(b.id, toMcpToolName(b.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (m?.role === "tool" && m.tool_call_id) {
|
||||||
|
const mcpName = idToMcpName.get(m.tool_call_id);
|
||||||
|
if (mcpName) {
|
||||||
|
results[mcpName] =
|
||||||
|
typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Claude-style tool_result blocks in user content
|
||||||
|
if (m?.role === "user" && Array.isArray(m.content)) {
|
||||||
|
for (const b of m.content) {
|
||||||
|
if (b?.type === "tool_result" && b.tool_use_id) {
|
||||||
|
const mcpName = idToMcpName.get(b.tool_use_id);
|
||||||
|
if (mcpName) {
|
||||||
|
const c = b.content;
|
||||||
|
results[mcpName] =
|
||||||
|
typeof c === "string" ? c : JSON.stringify(c ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve workspace cwd from client request (Codex/CLI env context, body fields).
|
||||||
|
// Prefer an absolute existing path so agent file tools hit the user's project
|
||||||
|
// instead of os.tmpdir() (which made relative create/delete inconsistent).
|
||||||
|
function resolveWorkspaceCwd(body) {
|
||||||
|
const candidates = [];
|
||||||
|
const push = (v) => {
|
||||||
|
if (typeof v === "string" && v.trim()) candidates.push(v.trim());
|
||||||
|
};
|
||||||
|
push(body?.cwd);
|
||||||
|
push(body?.working_directory);
|
||||||
|
push(body?.workdir);
|
||||||
|
push(body?.workspace);
|
||||||
|
push(body?.metadata?.cwd);
|
||||||
|
push(body?.metadata?.working_directory);
|
||||||
|
|
||||||
|
const scanText = (text) => {
|
||||||
|
if (typeof text !== "string") return;
|
||||||
|
for (const m of text.matchAll(/<cwd>\s*([^<]+?)\s*<\/cwd>/gi)) push(m[1]);
|
||||||
|
};
|
||||||
|
const scanMessages = (msgs) => {
|
||||||
|
if (!Array.isArray(msgs)) return;
|
||||||
|
for (const msg of msgs) {
|
||||||
|
if (!msg) continue;
|
||||||
|
if (typeof msg.content === "string") scanText(msg.content);
|
||||||
|
else if (Array.isArray(msg.content)) {
|
||||||
|
for (const p of msg.content) {
|
||||||
|
if (typeof p === "string") scanText(p);
|
||||||
|
else if (p && typeof p === "object") {
|
||||||
|
scanText(p.text);
|
||||||
|
scanText(p.input_text);
|
||||||
|
scanText(p.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Responses API input items
|
||||||
|
if (typeof msg === "string") scanText(msg);
|
||||||
|
if (msg.type === "message" && Array.isArray(msg.content)) {
|
||||||
|
for (const p of msg.content) scanText(p?.text || p?.input_text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
scanMessages(body?.messages);
|
||||||
|
scanMessages(body?.input);
|
||||||
|
|
||||||
|
for (const c of candidates) {
|
||||||
|
try {
|
||||||
|
if (path.isAbsolute(c) && fs.existsSync(c) && fs.statSync(c).isDirectory()) {
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return os.tmpdir();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Multi-turn message → single prompt builder ─────────────────────────────
|
||||||
|
|
||||||
|
function buildPromptText(messages) {
|
||||||
|
// Inline the whole conversation so the model has full context, including
|
||||||
|
// prior tool_calls / tool_results so it can continue after a client round-trip.
|
||||||
|
const lines = [];
|
||||||
|
for (const m of messages) {
|
||||||
|
const role = String(m.role || "user");
|
||||||
|
let text = "";
|
||||||
|
if (typeof m.content === "string") {
|
||||||
|
text = m.content;
|
||||||
|
} else if (Array.isArray(m.content)) {
|
||||||
|
for (const p of m.content) {
|
||||||
|
if (!p || typeof p !== "object") continue;
|
||||||
|
if (p.type === "text") text += String(p.text || "");
|
||||||
|
else if (p.type === "tool_use") {
|
||||||
|
text += `\n[Tool call ${p.name} id=${p.id}]\n${JSON.stringify(p.input ?? {})}\n`;
|
||||||
|
} else if (p.type === "tool_result") {
|
||||||
|
const c =
|
||||||
|
typeof p.content === "string" ? p.content : JSON.stringify(p.content ?? "");
|
||||||
|
text += `\n[Tool result id=${p.tool_use_id}]\n${c}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// OpenAI tool_calls on assistant messages
|
||||||
|
if (role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length) {
|
||||||
|
const parts = m.tool_calls.map((tc) => {
|
||||||
|
const name = tc.function?.name || tc.name || "tool";
|
||||||
|
const args = tc.function?.arguments ?? tc.arguments ?? {};
|
||||||
|
const argStr = typeof args === "string" ? args : JSON.stringify(args);
|
||||||
|
return `[Tool call ${name} id=${tc.id}]\n${argStr}`;
|
||||||
|
});
|
||||||
|
text = [text, ...parts].filter(Boolean).join("\n\n");
|
||||||
|
}
|
||||||
|
// OpenAI role=tool messages
|
||||||
|
if (role === "tool") {
|
||||||
|
const c = typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? "");
|
||||||
|
text = `[Tool result id=${m.tool_call_id || ""}]\n${c}`;
|
||||||
|
}
|
||||||
|
if (!text.trim()) continue;
|
||||||
|
if (role === "system") {
|
||||||
|
lines.push(`[System]\n${text}`);
|
||||||
|
} else if (role === "assistant") {
|
||||||
|
lines.push(`[Assistant]\n${text}`);
|
||||||
|
} else if (role === "tool") {
|
||||||
|
lines.push(`[Tool]\n${text}`);
|
||||||
|
} else {
|
||||||
|
lines.push(`[User]\n${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join("\n\n") || "(empty)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DevinCliExecutor ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export class DevinCliExecutor extends BaseExecutor {
|
||||||
|
constructor() {
|
||||||
|
super("devin-cli", { id: "devin-cli", baseUrl: "devin://acp/stdio" });
|
||||||
|
}
|
||||||
|
|
||||||
|
buildUrl() {
|
||||||
|
return "devin://acp/stdio";
|
||||||
|
}
|
||||||
|
|
||||||
|
buildHeaders() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
transformRequest() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute({ model, body, credentials, signal, log }) {
|
||||||
|
const b = body ?? {};
|
||||||
|
const messages = Array.isArray(b.messages)
|
||||||
|
? b.messages
|
||||||
|
: Array.isArray(b.input)
|
||||||
|
? b.input
|
||||||
|
: [];
|
||||||
|
const promptText = buildPromptText(messages);
|
||||||
|
const workspaceCwd = resolveWorkspaceCwd(b);
|
||||||
|
const devinBin = resolveDevinBin();
|
||||||
|
|
||||||
|
log?.info?.(
|
||||||
|
"DEVIN",
|
||||||
|
`devin acp → model=${model}, bin=${devinBin}, cwd=${workspaceCwd}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optional MCP servers via DEVIN_MCP_SERVERS (JSON object, devin config format):
|
||||||
|
// {"echo":{"command":"/abs/node","args":["/srv/echo.js"],"env":{"K":"V"}}}
|
||||||
|
// Plus body.tools (OpenAI schema) → exposed as a "clientTools" MCP
|
||||||
|
// server so devin can invoke client-defined tools (bridged back in Phase 2).
|
||||||
|
// When any are present, a throwaway XDG_CONFIG_HOME holds devin/config.json so
|
||||||
|
// the agent auto-connects them (session/new mcpServers alone doesn't spawn
|
||||||
|
// them — see ACP mcp/connect, still unstable). Cleaned up on finish.
|
||||||
|
// NOTE: this replaces the user's global devin MCP config for the subprocess.
|
||||||
|
let mcpConfigDir = null;
|
||||||
|
const mcpServers = {};
|
||||||
|
const mcpJson = process.env.DEVIN_MCP_SERVERS?.trim();
|
||||||
|
if (mcpJson) {
|
||||||
|
try {
|
||||||
|
Object.assign(mcpServers, JSON.parse(mcpJson));
|
||||||
|
} catch (e) {
|
||||||
|
log?.info?.("DEVIN", `DEVIN_MCP_SERVERS parse failed: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const clientTools = Array.isArray(b.tools) ? b.tools.filter(Boolean) : [];
|
||||||
|
const clientToolResults = extractClientToolResults(messages);
|
||||||
|
const clientToolsMcp = buildClientToolsMcp(clientTools, clientToolResults);
|
||||||
|
const hasClientTools = !!clientToolsMcp;
|
||||||
|
if (clientToolsMcp) {
|
||||||
|
mcpServers["clientTools"] = clientToolsMcp;
|
||||||
|
const seeded = Object.keys(clientToolResults).length;
|
||||||
|
log?.info?.(
|
||||||
|
"DEVIN",
|
||||||
|
`exposing ${clientTools.length} client tool(s) as MCP` +
|
||||||
|
(seeded ? ` (seeded ${seeded} result(s))` : "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Object.keys(mcpServers).length) {
|
||||||
|
try {
|
||||||
|
mcpConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "devin-mcp-"));
|
||||||
|
const cfgDev = path.join(mcpConfigDir, "devin");
|
||||||
|
fs.mkdirSync(cfgDev, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(cfgDev, "config.json"),
|
||||||
|
JSON.stringify({ mcpServers })
|
||||||
|
);
|
||||||
|
log?.info?.("DEVIN", `mcp config written → ${mcpConfigDir}`);
|
||||||
|
} catch (e) {
|
||||||
|
log?.info?.("DEVIN", `mcp config write failed: ${e.message}`);
|
||||||
|
mcpConfigDir = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cleanupMcp = () => {
|
||||||
|
if (!mcpConfigDir) return;
|
||||||
|
try {
|
||||||
|
fs.rmSync(mcpConfigDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
mcpConfigDir = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sseStream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const emit = (data) => controller.enqueue(enc.encode(data));
|
||||||
|
|
||||||
|
// Inherit the parent environment so devin resolves stored CLI credentials
|
||||||
|
// (~/.local/share/devin/credentials.toml from `devin auth login`). Do NOT
|
||||||
|
// inject WINDSURF_API_KEY: this provider is noAuth, and a bogus/leaked key
|
||||||
|
// overrides stored creds and makes devin return -32000 "invalid api key".
|
||||||
|
const env = { ...process.env };
|
||||||
|
// Auto-approve tool execution so the agent doesn't block waiting for a
|
||||||
|
// session/request_permission response we never send (default mode would
|
||||||
|
// hang the stream on the first shell/exec tool call). Override via env.
|
||||||
|
// WARNING: bypass lets the agent run shell/modify FS unattended — local only.
|
||||||
|
env.DEVIN_PERMISSION_MODE = process.env.DEVIN_PERMISSION_MODE || "bypass";
|
||||||
|
if (mcpConfigDir) env.XDG_CONFIG_HOME = mcpConfigDir;
|
||||||
|
|
||||||
|
// Agent type: default (omitted) = full agent with built-in tools
|
||||||
|
// (fs/shell/search) so the model can actually perform tasks. Override to
|
||||||
|
// `summarizer` (no tools, text-only) via CLI_DEVIN_AGENT_TYPE for a safer,
|
||||||
|
// tool-less mode. WARNING: the default agent can run shell commands and
|
||||||
|
// modify the filesystem on the host running 9router — only expose locally.
|
||||||
|
const agentType = process.env.CLI_DEVIN_AGENT_TYPE?.trim();
|
||||||
|
const acpArgs = ["acp"];
|
||||||
|
if (agentType) acpArgs.push("--agent-type", agentType);
|
||||||
|
|
||||||
|
// Spawn in the client workspace cwd (from <cwd> env context) so built-in
|
||||||
|
// file tools create/delete relative paths in the user's project.
|
||||||
|
// MCP config still comes from XDG_CONFIG_HOME (throwaway), not project .devin/.
|
||||||
|
const child = spawn(devinBin, acpArgs, {
|
||||||
|
env,
|
||||||
|
cwd: workspaceCwd,
|
||||||
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
|
// On Windows, devin.exe may need shell resolution
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
});
|
||||||
|
|
||||||
|
let spawnError = null;
|
||||||
|
let stdinClosed = false;
|
||||||
|
|
||||||
|
child.on("error", (err) => {
|
||||||
|
spawnError = err;
|
||||||
|
const msg =
|
||||||
|
err.message.includes("ENOENT") || err.message.includes("not found")
|
||||||
|
? `Devin CLI not found: ${devinBin}. Install via https://cli.devin.ai or set CLI_DEVIN_BIN env var.`
|
||||||
|
: `Devin CLI spawn error: ${err.message}`;
|
||||||
|
emit(
|
||||||
|
`data: ${JSON.stringify({ error: { message: msg, type: "devin_cli_error", code: "spawn_failed" } })}\n\n`
|
||||||
|
);
|
||||||
|
emit("data: [DONE]\n\n");
|
||||||
|
controller.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener("abort", () => {
|
||||||
|
if (!child.killed) child.kill("SIGTERM");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON-RPC state machine ──────────────────────────────────────────
|
||||||
|
let idCounter = 1;
|
||||||
|
let sessionId = null;
|
||||||
|
let initDone = false;
|
||||||
|
let sessionCreated = false;
|
||||||
|
let promptSent = false;
|
||||||
|
const responseId = `chatcmpl-devin-${Date.now()}`;
|
||||||
|
const created = Math.floor(Date.now() / 1000);
|
||||||
|
let roleEmitted = false;
|
||||||
|
let totalText = "";
|
||||||
|
let finished = false;
|
||||||
|
|
||||||
|
const sendRpc = (method, params) => {
|
||||||
|
if (stdinClosed || child.stdin.destroyed) return;
|
||||||
|
const id = idCounter++;
|
||||||
|
try {
|
||||||
|
child.stdin.write(rpc(method, params, id));
|
||||||
|
} catch {
|
||||||
|
/* ignore write errors after close */
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emit a content delta as an OpenAI-compatible SSE chunk (handles the
|
||||||
|
// leading role chunk once).
|
||||||
|
const emitDelta = (delta) => {
|
||||||
|
if (!roleEmitted) {
|
||||||
|
emit(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
id: responseId,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created,
|
||||||
|
model,
|
||||||
|
choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }],
|
||||||
|
})}\n\n`
|
||||||
|
);
|
||||||
|
roleEmitted = true;
|
||||||
|
}
|
||||||
|
totalText += delta;
|
||||||
|
emit(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
id: responseId,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created,
|
||||||
|
model,
|
||||||
|
choices: [{ index: 0, delta: { content: delta }, finish_reason: null }],
|
||||||
|
})}\n\n`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Emit an OpenAI tool_call delta (function calling). Ends the turn with
|
||||||
|
// finish_reason "tool_calls" so the client executes and returns tool_result.
|
||||||
|
let toolUseEmitted = false;
|
||||||
|
// ACP tool_call is upsert-by-id: the first event has title, a later update
|
||||||
|
// may only carry rawInput (title omitted). Track pending client-tool calls.
|
||||||
|
const pendingClientTools = new Map(); // toolCallId → original tool name
|
||||||
|
const emitToolUse = (toolName, args, toolCallId) => {
|
||||||
|
const argsStr = typeof args === "string" ? args : JSON.stringify(args ?? {});
|
||||||
|
if (!roleEmitted) {
|
||||||
|
emit(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
id: responseId,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created,
|
||||||
|
model,
|
||||||
|
choices: [{ index: 0, delta: { role: "assistant", content: null }, finish_reason: null }],
|
||||||
|
})}\n\n`
|
||||||
|
);
|
||||||
|
roleEmitted = true;
|
||||||
|
}
|
||||||
|
emit(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
id: responseId,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created,
|
||||||
|
model,
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
delta: {
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
id: toolCallId,
|
||||||
|
type: "function",
|
||||||
|
function: { name: toolName, arguments: argsStr },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
finish_reason: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})}\n\n`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finish = (error, finishReason = "stop") => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
emit(
|
||||||
|
`data: ${JSON.stringify({ error: { message: error, type: "devin_cli_error" } })}\n\n`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Emit finish chunk
|
||||||
|
emit(
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
id: responseId,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created,
|
||||||
|
model,
|
||||||
|
choices: [{ index: 0, delta: {}, finish_reason: finishReason }],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: Math.ceil(promptText.length / 4),
|
||||||
|
completion_tokens: Math.ceil(totalText.length / 4),
|
||||||
|
total_tokens: Math.ceil((promptText.length + totalText.length) / 4),
|
||||||
|
estimated: true,
|
||||||
|
},
|
||||||
|
})}\n\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
emit("data: [DONE]\n\n");
|
||||||
|
|
||||||
|
// Gracefully close stdin → devin will exit
|
||||||
|
try {
|
||||||
|
if (!stdinClosed) {
|
||||||
|
stdinClosed = true;
|
||||||
|
child.stdin.end();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give it 2s to exit cleanly, then SIGKILL
|
||||||
|
const killTimer = setTimeout(() => {
|
||||||
|
if (!child.killed) child.kill("SIGKILL");
|
||||||
|
}, 2000);
|
||||||
|
killTimer.unref?.();
|
||||||
|
|
||||||
|
controller.close();
|
||||||
|
cleanupMcp();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── stdout reader (NDJSON) ──────────────────────────────────────────
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
buffer += chunk.toString("utf8");
|
||||||
|
let nl;
|
||||||
|
// Each ACP message is a newline-terminated JSON line
|
||||||
|
while ((nl = buffer.indexOf("\n")) !== -1) {
|
||||||
|
const line = buffer.slice(0, nl).trim();
|
||||||
|
buffer = buffer.slice(nl + 1);
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
let msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
continue; // ignore non-JSON lines (banner text, etc.)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Initialize response ───────────────────────────────────────
|
||||||
|
if (!initDone && msg.result !== undefined && !msg.method) {
|
||||||
|
initDone = true;
|
||||||
|
// Create session with the client workspace cwd so agent file tools
|
||||||
|
// resolve relative paths against the project (not /tmp).
|
||||||
|
// `mcpServers` is required by devin 3000.2.x (must be a sequence);
|
||||||
|
// omitting it returns -32602 "Invalid params: missing field mcpServers".
|
||||||
|
sendRpc("session/new", {
|
||||||
|
cwd: workspaceCwd,
|
||||||
|
mcpServers: [],
|
||||||
|
model: model || undefined,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── session/new response → get sessionId ──────────────────────
|
||||||
|
if (initDone && !sessionCreated && msg.result !== undefined && !msg.method) {
|
||||||
|
const res = msg.result || {};
|
||||||
|
sessionId = res.sessionId || null;
|
||||||
|
if (!sessionId) {
|
||||||
|
finish("Devin ACP: session/new returned no sessionId");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sessionCreated = true;
|
||||||
|
// Send the prompt. devin 3000.2.x expects `prompt` (a sequence),
|
||||||
|
// not `content` — using `content` returns -32602 "missing field prompt".
|
||||||
|
promptSent = true;
|
||||||
|
sendRpc("session/prompt", {
|
||||||
|
sessionId,
|
||||||
|
prompt: [{ type: "text", text: promptText }],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── session/prompt response (ack / final result) ────────────
|
||||||
|
if (sessionCreated && promptSent && msg.result !== undefined && !msg.method) {
|
||||||
|
// Devin 3000.2.x only resolves session/prompt with the final result
|
||||||
|
// (stopReason) after streaming completes. Streaming notifications are
|
||||||
|
// handled below; nothing to do here unless we never streamed.
|
||||||
|
if (!roleEmitted) {
|
||||||
|
const res = msg.result || undefined;
|
||||||
|
const content = extractResultText(res);
|
||||||
|
if (content) {
|
||||||
|
totalText = content;
|
||||||
|
emitDelta(content);
|
||||||
|
}
|
||||||
|
const stopReason = (res && res.stopReason) || "";
|
||||||
|
if (stopReason && stopReason !== "cancelled") {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Permission requests → auto-approve the first allow option ──
|
||||||
|
// Devi asks before running shell/exec tools; as a headless proxy we
|
||||||
|
// grant once. (DEVIN_PERMISSION_MODE=bypass usually prevents these,
|
||||||
|
// but some tool kinds still prompt, so handle them here too.)
|
||||||
|
if (msg.method === "session/request_permission" && msg.id !== undefined) {
|
||||||
|
const options = msg.params?.options || [];
|
||||||
|
const allow =
|
||||||
|
options.find((o) => /allow/i.test(String(o.kind || ""))) || options[0];
|
||||||
|
if (allow) {
|
||||||
|
child.stdin.write(
|
||||||
|
JSON.stringify({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: msg.id,
|
||||||
|
result: { outcome: { outcome: "selected", optionId: allow.optionId } },
|
||||||
|
}) + "\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Agent stopped notification (devin 3000.2.x stop signal) ───
|
||||||
|
if (msg.method === "_cognition.ai/agent_stopped" || msg.method === "$/agent_stopped") {
|
||||||
|
const cause = msg.params?.cause;
|
||||||
|
if (cause === "error") {
|
||||||
|
// devin uses errorMessage on this notification (not message/error).
|
||||||
|
const errText =
|
||||||
|
msg.params?.errorMessage ||
|
||||||
|
msg.params?.message ||
|
||||||
|
msg.params?.error ||
|
||||||
|
"Devin agent error";
|
||||||
|
finish(String(errText));
|
||||||
|
} else {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Streaming notifications (session/update) ──────────────────
|
||||||
|
if (msg.method === "session/update" || msg.method === "$/update") {
|
||||||
|
const params = msg.params;
|
||||||
|
if (!params) continue;
|
||||||
|
|
||||||
|
// devin 3000.2.x nests the payload under params.update.sessionUpdate;
|
||||||
|
// older devin used a flat params.type.
|
||||||
|
const update = params.update || {};
|
||||||
|
const type = update.sessionUpdate || params.type;
|
||||||
|
const contentField = update.content !== undefined ? update.content : params.content;
|
||||||
|
const deltaText =
|
||||||
|
typeof contentField === "string"
|
||||||
|
? contentField
|
||||||
|
: contentField?.text ?? params.delta ?? params.text ?? "";
|
||||||
|
|
||||||
|
// ── Client-tool bridge: devin calling a tool from our exposed MCP ──
|
||||||
|
// ACP title shape: "Calling mcp_<name> from clientTools".
|
||||||
|
// tool_call is upsert-by-id: title may only appear on the first event,
|
||||||
|
// rawInput on a later tool_call_update. Track pending ids so we don't
|
||||||
|
// require both fields on the same notification.
|
||||||
|
if (
|
||||||
|
hasClientTools &&
|
||||||
|
!toolUseEmitted &&
|
||||||
|
(type === "tool_call" || type === "tool_call_update")
|
||||||
|
) {
|
||||||
|
const tcId = update.toolCallId;
|
||||||
|
if (typeof update.title === "string" && update.title.startsWith("Calling mcp_") && /from clientTools\b/.test(update.title)) {
|
||||||
|
const nameMatch = update.title.match(/^Calling (mcp_\S+)\b/);
|
||||||
|
const mcpName = nameMatch ? nameMatch[1] : "";
|
||||||
|
const origName = fromMcpToolName(mcpName);
|
||||||
|
if (tcId && origName) pendingClientTools.set(tcId, origName);
|
||||||
|
}
|
||||||
|
const origName = tcId ? pendingClientTools.get(tcId) : null;
|
||||||
|
if (origName && update.rawInput) {
|
||||||
|
toolUseEmitted = true;
|
||||||
|
pendingClientTools.delete(tcId);
|
||||||
|
emitToolUse(origName, update.rawInput, tcId || `call_${Date.now()}`);
|
||||||
|
finish(null, "tool_calls");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "agent_message_chunk" || type === "message_delta" || type === "text_delta" || type === "content_delta") {
|
||||||
|
if (deltaText) emitDelta(deltaText);
|
||||||
|
} else if (type === "agent_thought_chunk") {
|
||||||
|
// Internal reasoning — not surfaced to the client.
|
||||||
|
} else if (type === "message_stop" || type === "stop" || type === "done") {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
} else if (type === "error") {
|
||||||
|
finish(String(params.message || params.error || "Devin ACP error"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Error responses ───────────────────────────────────────────
|
||||||
|
if (msg.error) {
|
||||||
|
finish(`Devin ACP error ${msg.error.code}: ${msg.error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
log?.debug?.("DEVIN", `stderr: ${chunk.toString("utf8").slice(0, 200)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
if (!finished) {
|
||||||
|
if (code !== 0 && !spawnError) {
|
||||||
|
finish(roleEmitted ? undefined : `Devin CLI exited with code ${code}`);
|
||||||
|
} else {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cleanupMcp();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Send initialize ───────────────────────────────────────────────
|
||||||
|
sendRpc("initialize", {
|
||||||
|
protocolVersion: "0.3",
|
||||||
|
clientInfo: { name: "9router", version: "1.0" },
|
||||||
|
capabilities: {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: new Response(sseStream, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
url: "devin://acp/stdio",
|
||||||
|
headers: {},
|
||||||
|
transformedBody: {
|
||||||
|
model,
|
||||||
|
cwd: workspaceCwd,
|
||||||
|
clientTools: clientTools.map((t) => t?.function?.name || t?.name).filter(Boolean),
|
||||||
|
clientToolResults: Object.keys(clientToolResults),
|
||||||
|
mcpServers: Object.keys(mcpServers),
|
||||||
|
promptLength: Array.isArray(body?.messages)
|
||||||
|
? body.messages.length
|
||||||
|
: Array.isArray(body?.input)
|
||||||
|
? body.input.length
|
||||||
|
: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Extract text from a final ACP session/prompt result object across common shapes.
|
||||||
|
function extractResultText(result) {
|
||||||
|
// { message: { content: "..." } }
|
||||||
|
// { messages: [{ content: "..." }] }
|
||||||
|
// { content: "..." }
|
||||||
|
// { text: "..." }
|
||||||
|
if (typeof result.content === "string") return result.content;
|
||||||
|
if (typeof result.text === "string") return result.text;
|
||||||
|
const msg = result.message;
|
||||||
|
if (msg && typeof msg.content === "string") return msg.content;
|
||||||
|
const msgs = result.messages;
|
||||||
|
if (Array.isArray(msgs)) {
|
||||||
|
return msgs
|
||||||
|
.filter((m) => m.role === "assistant")
|
||||||
|
.map((m) => String(m.content || ""))
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DevinCliExecutor;
|
||||||
@@ -20,7 +20,12 @@ import { CommandCodeExecutor } from "./commandcode.js";
|
|||||||
import { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js";
|
import { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js";
|
||||||
import { MimoFreeExecutor } from "./mimo-free.js";
|
import { MimoFreeExecutor } from "./mimo-free.js";
|
||||||
import { CodeBuddyExecutor } from "./codebuddy-cn.js";
|
import { CodeBuddyExecutor } from "./codebuddy-cn.js";
|
||||||
|
import { CodeBuddyIntlExecutor } from "./codebuddy-intl.js";
|
||||||
|
import TraeExecutor from "./trae.js";
|
||||||
|
import ZedExecutor from "./zed.js";
|
||||||
|
import WindsurfExecutor from "./windsurf.js";
|
||||||
import { DefaultExecutor } from "./default.js";
|
import { DefaultExecutor } from "./default.js";
|
||||||
|
import { DevinCliExecutor } from "./devin-cli.js";
|
||||||
|
|
||||||
const executors = {
|
const executors = {
|
||||||
antigravity: new AntigravityExecutor(),
|
antigravity: new AntigravityExecutor(),
|
||||||
@@ -50,6 +55,11 @@ const executors = {
|
|||||||
"mimo-free": new MimoFreeExecutor(),
|
"mimo-free": new MimoFreeExecutor(),
|
||||||
mmf: new MimoFreeExecutor(), // Alias for mimo-free
|
mmf: new MimoFreeExecutor(), // Alias for mimo-free
|
||||||
"codebuddy-cn": new CodeBuddyExecutor(),
|
"codebuddy-cn": new CodeBuddyExecutor(),
|
||||||
|
"codebuddy-intl": new CodeBuddyIntlExecutor(),
|
||||||
|
trae: new TraeExecutor(),
|
||||||
|
zed: new ZedExecutor(),
|
||||||
|
windsurf: new WindsurfExecutor(),
|
||||||
|
"devin-cli": new DevinCliExecutor(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultCache = new Map();
|
const defaultCache = new Map();
|
||||||
@@ -88,3 +98,8 @@ export { CommandCodeExecutor } from "./commandcode.js";
|
|||||||
export { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js";
|
export { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js";
|
||||||
export { MimoFreeExecutor } from "./mimo-free.js";
|
export { MimoFreeExecutor } from "./mimo-free.js";
|
||||||
export { CodeBuddyExecutor } from "./codebuddy-cn.js";
|
export { CodeBuddyExecutor } from "./codebuddy-cn.js";
|
||||||
|
export { CodeBuddyIntlExecutor } from "./codebuddy-intl.js";
|
||||||
|
export { default as TraeExecutor } from "./trae.js";
|
||||||
|
export { default as ZedExecutor } from "./zed.js";
|
||||||
|
export { default as WindsurfExecutor } from "./windsurf.js";
|
||||||
|
export { DevinCliExecutor } from "./devin-cli.js";
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { BaseExecutor } from "./base.js";
|
import { BaseExecutor } from "./base.js";
|
||||||
import { PROVIDERS } from "../config/providers.js";
|
import { PROVIDERS } from "../config/providers.js";
|
||||||
import { resolveKiroModel } from "../config/kiroConstants.js";
|
import {
|
||||||
|
KIRO_CODEWHISPERER_TARGET,
|
||||||
|
KIRO_ENDPOINT_FALLBACK_STATUSES,
|
||||||
|
resolveKiroModel,
|
||||||
|
} from "../config/kiroConstants.js";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { refreshKiroToken } from "../services/tokenRefresh.js";
|
import { refreshKiroToken } from "../services/tokenRefresh.js";
|
||||||
import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js";
|
import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js";
|
||||||
@@ -216,12 +220,17 @@ export class KiroExecutor extends BaseExecutor {
|
|||||||
super("kiro", PROVIDERS.kiro);
|
super("kiro", PROVIDERS.kiro);
|
||||||
}
|
}
|
||||||
|
|
||||||
buildHeaders(credentials, stream = true) {
|
buildHeaders(credentials, stream = true, url = "") {
|
||||||
const headers = {
|
const headers = {
|
||||||
...this.config.headers,
|
...this.config.headers,
|
||||||
"Amz-Sdk-Request": "attempt=1; max=3",
|
"Amz-Sdk-Request": "attempt=1; max=3",
|
||||||
"Amz-Sdk-Invocation-Id": uuidv4()
|
"Amz-Sdk-Invocation-Id": uuidv4()
|
||||||
};
|
};
|
||||||
|
if (url.includes("://codewhisperer.")) {
|
||||||
|
headers["X-Amz-Target"] = KIRO_CODEWHISPERER_TARGET;
|
||||||
|
} else {
|
||||||
|
delete headers["X-Amz-Target"];
|
||||||
|
}
|
||||||
|
|
||||||
// API-key auth: the key is stored as accessToken and sent as a bearer token
|
// API-key auth: the key is stored as accessToken and sent as a bearer token
|
||||||
// exactly like an OAuth access token, but with an extra `tokentype: API_KEY`
|
// exactly like an OAuth access token, but with an extra `tokentype: API_KEY`
|
||||||
@@ -236,8 +245,8 @@ export class KiroExecutor extends BaseExecutor {
|
|||||||
const apiKey = credentials?.apiKey || (isApiKey ? credentials?.accessToken : null);
|
const apiKey = credentials?.apiKey || (isApiKey ? credentials?.accessToken : null);
|
||||||
if (isApiKey && apiKey) {
|
if (isApiKey && apiKey) {
|
||||||
headers["Authorization"] = `Bearer ${apiKey}`;
|
headers["Authorization"] = `Bearer ${apiKey}`;
|
||||||
headers["tokentype"] = "API_KEY";
|
headers["TokenType"] = "API_KEY";
|
||||||
} else if (credentials.accessToken) {
|
} else if (credentials?.accessToken) {
|
||||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||||
if (isExternalIdp) {
|
if (isExternalIdp) {
|
||||||
headers["TokenType"] = "EXTERNAL_IDP";
|
headers["TokenType"] = "EXTERNAL_IDP";
|
||||||
@@ -250,14 +259,14 @@ export class KiroExecutor extends BaseExecutor {
|
|||||||
/**
|
/**
|
||||||
* Auth-aware endpoint ordering.
|
* Auth-aware endpoint ordering.
|
||||||
*
|
*
|
||||||
* API-key Kiro connections store a raw CodeWhisperer credential (validated
|
* API-key Kiro connections use the Amazon Q surface. The legacy
|
||||||
* against codewhisperer.us-east-1.amazonaws.com via ListAvailableProfiles).
|
* codewhisperer.* GenerateAssistantResponse endpoint can authenticate the key
|
||||||
|
* but rejects the same valid payload with REQUEST_BODY_INVALID. Since a 400
|
||||||
|
* is terminal in BaseExecutor, putting CodeWhisperer first prevents the working
|
||||||
|
* q.* endpoint from ever being tried. Keep q.* first only for api_key accounts.
|
||||||
|
*
|
||||||
* The Kiro IDE gateway (runtime.*.kiro.dev) expects Kiro OIDC/social tokens
|
* The Kiro IDE gateway (runtime.*.kiro.dev) expects Kiro OIDC/social tokens
|
||||||
* and rejects an `tokentype: API_KEY` token with 401/403 — which
|
* and rejects TokenType=API_KEY. External IdP enterprise tokens instead
|
||||||
* BaseExecutor.execute() returns immediately (only 429 / network errors fall
|
|
||||||
* through to the next host). So for api-key auth we must try the *.amazonaws.com
|
|
||||||
* CodeWhisperer hosts FIRST, mirroring the Kiro-Go reference fork which never
|
|
||||||
* routes api-key traffic through kiro.dev. External IdP enterprise tokens also
|
|
||||||
* use the CodeWhisperer surface, with the `TokenType: EXTERNAL_IDP` header.
|
* use the CodeWhisperer surface, with the `TokenType: EXTERNAL_IDP` header.
|
||||||
* Other OAuth methods keep the default order (kiro.dev first) since their
|
* Other OAuth methods keep the default order (kiro.dev first) since their
|
||||||
* tokens are what that gateway accepts.
|
* tokens are what that gateway accepts.
|
||||||
@@ -282,6 +291,14 @@ export class KiroExecutor extends BaseExecutor {
|
|||||||
|
|
||||||
const amazon = baseUrls.filter((u) => u.includes("amazonaws.com")).map(regionalize);
|
const amazon = baseUrls.filter((u) => u.includes("amazonaws.com")).map(regionalize);
|
||||||
const others = baseUrls.filter((u) => !u.includes("amazonaws.com"));
|
const others = baseUrls.filter((u) => !u.includes("amazonaws.com"));
|
||||||
|
if (authMethod === "api_key") {
|
||||||
|
const q = amazon.filter((u) => u.includes("://q."));
|
||||||
|
const remaining = amazon.filter((u) => !u.includes("://q."));
|
||||||
|
return q.length > 0
|
||||||
|
? [...q, ...remaining, ...others]
|
||||||
|
: [...amazon, ...others];
|
||||||
|
}
|
||||||
|
|
||||||
return amazon.length > 0 ? [...amazon, ...others] : baseUrls;
|
return amazon.length > 0 ? [...amazon, ...others] : baseUrls;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +307,14 @@ export class KiroExecutor extends BaseExecutor {
|
|||||||
return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl;
|
return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retry only endpoint/auth-surface failures. Payload-invalid HTTP 400 must be
|
||||||
|
// terminal: sending the same malformed body to every surface cannot repair it.
|
||||||
|
shouldRetry(status, urlIndex) {
|
||||||
|
const hasFallback = urlIndex + 1 < this.getFallbackCount();
|
||||||
|
return super.shouldRetry(status, urlIndex)
|
||||||
|
|| (hasFallback && KIRO_ENDPOINT_FALLBACK_STATUSES.has(status));
|
||||||
|
}
|
||||||
|
|
||||||
transformRequest(model, body, stream, credentials) {
|
transformRequest(model, body, stream, credentials) {
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ import { SSE_DONE } from "../utils/sseConstants.js";
|
|||||||
import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js";
|
import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js";
|
||||||
import {
|
import {
|
||||||
QODER_CHAT_URL_ENCODED,
|
QODER_CHAT_URL_ENCODED,
|
||||||
|
QODER_JOB_TOKEN_EXCHANGE_URL,
|
||||||
|
QODER_USERINFO_URL,
|
||||||
QODER_MODEL_MAP,
|
QODER_MODEL_MAP,
|
||||||
|
QODER_IDE_VERSION,
|
||||||
|
QODER_CLIENT_TYPE,
|
||||||
} from "../shared/qoder/constants.js";
|
} from "../shared/qoder/constants.js";
|
||||||
import { getQoderModelConfig, resolveQoderModels } from "../services/qoderModels.js";
|
import { getQoderModelConfig, resolveQoderModels } from "../services/qoderModels.js";
|
||||||
|
|
||||||
@@ -220,8 +224,13 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio
|
|||||||
* Each upstream line looks like:
|
* Each upstream line looks like:
|
||||||
* data: {"statusCodeValue":200,"body":"{\"choices\":[{\"delta\":{...}}]}"}
|
* data: {"statusCodeValue":200,"body":"{\"choices\":[{\"delta\":{...}}]}"}
|
||||||
* The inner body is an OpenAI streaming chunk (or "[DONE]"). We unwrap it
|
* The inner body is an OpenAI streaming chunk (or "[DONE]"). We unwrap it
|
||||||
* and re-emit as `data: <inner>\n\n`. Errors become `data: [DONE]\n\n` plus
|
* and re-emit as `data: <inner>\n\n`. Errors become a synthetic OpenAI error
|
||||||
* a synthetic OpenAI error chunk.
|
* chunk + [DONE].
|
||||||
|
*
|
||||||
|
* Critical: Qoder's SSE often keeps the socket open after the terminal
|
||||||
|
* [DONE]/error frame (agent keepalive). Non-streaming clients drain via
|
||||||
|
* response.text() which hangs until the socket closes — so on terminal
|
||||||
|
* events we cancel the upstream reader and close our stream immediately.
|
||||||
*/
|
*/
|
||||||
function wrapQoderSSE(response, model) {
|
function wrapQoderSSE(response, model) {
|
||||||
if (!response.ok || !response.body) return response;
|
if (!response.ok || !response.body) return response;
|
||||||
@@ -230,15 +239,14 @@ function wrapQoderSSE(response, model) {
|
|||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
let doneEmitted = false;
|
let doneEmitted = false;
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
|
||||||
// Process one already-extracted SSE line (no trailing newline). Returns
|
// Process one already-extracted SSE line (no trailing newline).
|
||||||
// false when the line indicated end-of-stream so the caller can stop
|
|
||||||
// forwarding any remaining chunks after [DONE].
|
|
||||||
const processLine = (line, controller) => {
|
const processLine = (line, controller) => {
|
||||||
const trimmed = line.replace(/\r$/, "").trim();
|
const trimmed = line.replace(/\r$/, "").trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
if (!trimmed.startsWith("data:")) return;
|
if (!trimmed.startsWith("data:")) return;
|
||||||
if (doneEmitted) return; // never forward chunks past stream end
|
if (doneEmitted) return;
|
||||||
|
|
||||||
const data = trimmed.slice(5).trimStart();
|
const data = trimmed.slice(5).trimStart();
|
||||||
if (data === "[DONE]") {
|
if (data === "[DONE]") {
|
||||||
@@ -271,47 +279,60 @@ function wrapQoderSSE(response, model) {
|
|||||||
doneEmitted = true;
|
doneEmitted = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Inner is an OpenAI-shaped chunk. Strip any embedded newlines so the
|
// Strip embedded newlines so the SSE frame stays a single event.
|
||||||
// SSE frame stays a single event (a literal "\n" inside `inner` would
|
|
||||||
// otherwise split the frame across multiple data: lines and downstream
|
|
||||||
// parsers would reassemble them as separate events).
|
|
||||||
const sanitized = inner.replace(/\r?\n/g, "");
|
const sanitized = inner.replace(/\r?\n/g, "");
|
||||||
controller.enqueue(encoder.encode(`data: ${sanitized}\n\n`));
|
controller.enqueue(encoder.encode(`data: ${sanitized}\n\n`));
|
||||||
};
|
};
|
||||||
|
|
||||||
const transform = new TransformStream({
|
const stream = new ReadableStream({
|
||||||
transform(chunk, controller) {
|
// Use start()+loop (not pull): a pull that buffers a partial line without
|
||||||
buffer += decoder.decode(chunk, { stream: true });
|
// enqueueing would never be re-invoked, hanging consumers like .text().
|
||||||
let nl;
|
async start(controller) {
|
||||||
while ((nl = buffer.indexOf("\n")) !== -1) {
|
try {
|
||||||
const line = buffer.slice(0, nl);
|
while (!doneEmitted) {
|
||||||
buffer = buffer.slice(nl + 1);
|
const { done, value } = await reader.read();
|
||||||
processLine(line, controller);
|
if (done) {
|
||||||
|
buffer += decoder.decode();
|
||||||
|
if (buffer.length > 0) {
|
||||||
|
processLine(buffer, controller);
|
||||||
|
buffer = "";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let nl;
|
||||||
|
while ((nl = buffer.indexOf("\n")) !== -1) {
|
||||||
|
const line = buffer.slice(0, nl);
|
||||||
|
buffer = buffer.slice(nl + 1);
|
||||||
|
processLine(line, controller);
|
||||||
|
if (doneEmitted) {
|
||||||
|
// Terminal frame received — drop upstream keepalive and end.
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to terminal [DONE] + close
|
||||||
|
} finally {
|
||||||
|
if (!doneEmitted) {
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(SSE_DONE));
|
||||||
|
doneEmitted = true;
|
||||||
|
} catch { /* already closed */ }
|
||||||
|
}
|
||||||
|
try { controller.close(); } catch { /* already closed */ }
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
flush(controller) {
|
cancel() {
|
||||||
// Finalize the decoder so any pending multi-byte sequence is
|
return reader.cancel().catch(() => {});
|
||||||
// released into `buffer` instead of being silently dropped.
|
|
||||||
buffer += decoder.decode();
|
|
||||||
// Drain any trailing line that arrived without a terminating newline
|
|
||||||
// (e.g. upstream closed the socket immediately after the last write,
|
|
||||||
// or a CDN stripped the final CRLF). Without this, the chunk that
|
|
||||||
// carries finish_reason is silently lost.
|
|
||||||
if (buffer.length > 0) {
|
|
||||||
processLine(buffer, controller);
|
|
||||||
buffer = "";
|
|
||||||
}
|
|
||||||
if (!doneEmitted) {
|
|
||||||
controller.enqueue(encoder.encode(SSE_DONE));
|
|
||||||
doneEmitted = true;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const transformed = response.body.pipeThrough(transform);
|
return new Response(stream, {
|
||||||
// Build a Response with passable headers; the streaming handler reads
|
|
||||||
// `.body` as a ReadableStream regardless of Content-Type.
|
|
||||||
return new Response(transformed, {
|
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -321,6 +342,92 @@ function wrapQoderSSE(response, model) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PAT (Personal Access Token) → job-token exchange ───────────────────────
|
||||||
|
// PATs (pt-...) cannot sign COSY requests directly. Exchange them for a
|
||||||
|
// short-lived job token (jt-...) via /api/v1/jobToken/exchange (plain JSON,
|
||||||
|
// not COSY-signed), then resolve the userId from userinfo. Mirrors the
|
||||||
|
// official qodercli flow. Cached per-PAT until near-expiry.
|
||||||
|
const PAT_PREFIX = "pt-";
|
||||||
|
const PAT_REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
||||||
|
const patJobCache = new Map();
|
||||||
|
|
||||||
|
export function isQoderPat(token) {
|
||||||
|
return typeof token === "string" && token.startsWith(PAT_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeJobToken(pat, proxyOptions = null, signal = null) {
|
||||||
|
const res = await proxyAwareFetch(
|
||||||
|
QODER_JOB_TOKEN_EXCHANGE_URL,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
"User-Agent": "qodercli/1.0.0",
|
||||||
|
"Cosy-Version": QODER_IDE_VERSION,
|
||||||
|
"Cosy-ClientType": QODER_CLIENT_TYPE,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ personal_token: pat }),
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
proxyOptions,
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(`qoder PAT exchange failed: ${res.status} ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.token) throw new Error("qoder PAT exchange returned no job token");
|
||||||
|
|
||||||
|
let expiresAt = Date.now() + 24 * 60 * 60 * 1000;
|
||||||
|
if (data.expires_at) {
|
||||||
|
const parsed = Date.parse(data.expires_at);
|
||||||
|
if (!Number.isNaN(parsed)) expiresAt = parsed;
|
||||||
|
} else if (typeof data.expires_in === "number" && data.expires_in > 0) {
|
||||||
|
expiresAt = Date.now() + data.expires_in;
|
||||||
|
}
|
||||||
|
return { jobToken: data.token, jobRefreshToken: data.refresh_token || "", expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUserIdForJobToken(jobToken, proxyOptions = null, signal = null) {
|
||||||
|
try {
|
||||||
|
const res = await proxyAwareFetch(
|
||||||
|
QODER_USERINFO_URL,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${jobToken}`,
|
||||||
|
Accept: "application/json",
|
||||||
|
"User-Agent": "qodercli/1.0.0",
|
||||||
|
},
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
proxyOptions,
|
||||||
|
);
|
||||||
|
if (!res.ok) return "";
|
||||||
|
const info = await res.json().catch(() => ({}));
|
||||||
|
return info.id || info.userId || info.user_id || "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchange a PAT for a job token + userId, caching until near-expiry so repeat
|
||||||
|
* chat requests don't re-exchange. Returns { accessToken, userId }.
|
||||||
|
*/
|
||||||
|
async function resolvePatCredential(pat, proxyOptions = null, signal = null) {
|
||||||
|
const cached = patJobCache.get(pat);
|
||||||
|
if (cached && cached.expiresAt - Date.now() > PAT_REFRESH_BUFFER_MS) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const { jobToken, expiresAt } = await exchangeJobToken(pat, proxyOptions, signal);
|
||||||
|
const userId = await fetchUserIdForJobToken(jobToken, proxyOptions, signal);
|
||||||
|
const entry = { accessToken: jobToken, userId, expiresAt };
|
||||||
|
patJobCache.set(pat, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
export class QoderExecutor extends BaseExecutor {
|
export class QoderExecutor extends BaseExecutor {
|
||||||
constructor() {
|
constructor() {
|
||||||
super("qoder", PROVIDERS.qoder);
|
super("qoder", PROVIDERS.qoder);
|
||||||
@@ -338,6 +445,34 @@ export class QoderExecutor extends BaseExecutor {
|
|||||||
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
|
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
|
||||||
const url = this.buildUrl();
|
const url = this.buildUrl();
|
||||||
|
|
||||||
|
// PAT (pt-...) → exchange for short-lived job token + resolve userId so
|
||||||
|
// downstream COSY signing + catalog fetch work. Device tokens (dt-...) and
|
||||||
|
// job tokens (jt-...) skip this and are used directly.
|
||||||
|
const rawToken = credentials?.apiKey || credentials?.accessToken;
|
||||||
|
if (isQoderPat(rawToken)) {
|
||||||
|
try {
|
||||||
|
const resolved = await resolvePatCredential(rawToken, proxyOptions, signal);
|
||||||
|
credentials = {
|
||||||
|
...credentials,
|
||||||
|
accessToken: resolved.accessToken,
|
||||||
|
apiKey: undefined,
|
||||||
|
providerSpecificData: {
|
||||||
|
authMethod: "pat",
|
||||||
|
...(credentials?.providerSpecificData || {}),
|
||||||
|
userId: resolved.userId || credentials?.providerSpecificData?.userId || "",
|
||||||
|
machineId: credentials?.providerSpecificData?.machineId || "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
log?.error?.("QODER", `PAT exchange failed: ${err.message}`);
|
||||||
|
const fakeResp = new Response(
|
||||||
|
JSON.stringify({ error: { message: `qoder PAT exchange failed: ${err.message}` } }),
|
||||||
|
{ status: 401, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
return { response: fakeResp, url, headers: {}, transformedBody: body };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const psd = credentials?.providerSpecificData || {};
|
const psd = credentials?.providerSpecificData || {};
|
||||||
if (!psd.userId) {
|
if (!psd.userId) {
|
||||||
// No user id → no way to sign. Surface a 401 so the dashboard nudges
|
// No user id → no way to sign. Surface a 401 so the dashboard nudges
|
||||||
@@ -455,4 +590,6 @@ export const __test__ = {
|
|||||||
normalizeMessages,
|
normalizeMessages,
|
||||||
wrapQoderSSE,
|
wrapQoderSSE,
|
||||||
buildQoderRequestBody,
|
buildQoderRequestBody,
|
||||||
|
isQoderPat,
|
||||||
|
resolvePatCredential,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { BaseExecutor } from "./base.js";
|
||||||
|
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||||
|
import { PROVIDERS } from "../config/providers.js";
|
||||||
|
|
||||||
|
// Trae executor — SOLO remote agent API.
|
||||||
|
//
|
||||||
|
// Flow:
|
||||||
|
// 1. POST {base}/chat_sessions → { code:0, data:{ chat_session_id, message_id } }
|
||||||
|
// 2. GET {base}/chat_sessions/{id}/events?reply_to_message_id={message_id}
|
||||||
|
// → text/event-stream. Assistant text streams in `plan_item` events under
|
||||||
|
// the `thought` field (cumulative per plan-item id). `token_usage` carries
|
||||||
|
// usage; `done` ends the turn; `error` carries upstream errors.
|
||||||
|
//
|
||||||
|
// Auth: header `Authorization: Cloud-IDE-JWT <jwt>` (RS256, ~14-day lifetime).
|
||||||
|
// Identity fields for common_params live in credentials.providerSpecificData.
|
||||||
|
|
||||||
|
const STREAM_TIMEOUT_MS = parseInt(process.env.TRAE_STREAM_TIMEOUT_MS || "300000", 10);
|
||||||
|
const TRAE_UA =
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
||||||
|
"(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
|
||||||
|
|
||||||
|
function flattenQuery(messages) {
|
||||||
|
const parts = [];
|
||||||
|
for (const m of messages) {
|
||||||
|
let content = "";
|
||||||
|
if (typeof m.content === "string") content = m.content;
|
||||||
|
else if (Array.isArray(m.content)) {
|
||||||
|
content = m.content
|
||||||
|
.map((p) => {
|
||||||
|
if (typeof p === "string") return p;
|
||||||
|
if (p && typeof p === "object") return String(p.text ?? "");
|
||||||
|
return "";
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
if (m.role === "system") parts.push(`[System]\n${content}`);
|
||||||
|
else if (m.role === "assistant") parts.push(`[Assistant]\n${content}`);
|
||||||
|
else parts.push(content);
|
||||||
|
}
|
||||||
|
// Trae expects query as a JSON-encoded string of typed content blocks.
|
||||||
|
return JSON.stringify([{ type: "text", data: { content: parts.join("\n\n") } }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class TraeExecutor extends BaseExecutor {
|
||||||
|
constructor() {
|
||||||
|
super("trae", PROVIDERS.trae);
|
||||||
|
}
|
||||||
|
|
||||||
|
base() {
|
||||||
|
return (this.config.baseUrl || "https://core-normal.trae.ai/api/remote/v1").replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
buildHeaders(credentials, stream = true) {
|
||||||
|
const token = credentials?.accessToken || "";
|
||||||
|
const psd = credentials?.providerSpecificData || {};
|
||||||
|
return {
|
||||||
|
Authorization: `Cloud-IDE-JWT ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Trae-Client-Type": "web",
|
||||||
|
"X-Preferenced-Language": psd.appLanguage || "en",
|
||||||
|
"x-user-region": psd.userRegion || "US",
|
||||||
|
Referer: "https://solo.trae.ai/",
|
||||||
|
"User-Agent": TRAE_UA,
|
||||||
|
Accept: stream ? "text/event-stream" : "application/json",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// SOLO session modes: "code" (model picker) vs "work" (fast auto lane).
|
||||||
|
resolveMode(model) {
|
||||||
|
const m = (model || "").trim().toLowerCase();
|
||||||
|
if (m === "work" || m === "auto-work" || m === "solo-work") {
|
||||||
|
return { mode: "work", strategy: "auto", modelName: "" };
|
||||||
|
}
|
||||||
|
const auto = !m || m === "auto";
|
||||||
|
return { mode: "code", strategy: auto ? "auto" : "manual", modelName: auto ? "" : model };
|
||||||
|
}
|
||||||
|
|
||||||
|
// common_params is a JSON-encoded string embedded inside initial_message.
|
||||||
|
commonParams(psd, mode, sessionId) {
|
||||||
|
const cp = {
|
||||||
|
language: "en-us",
|
||||||
|
app_language: psd.appLanguage || "en",
|
||||||
|
quality: "stable",
|
||||||
|
app_version: psd.appVersion || "1.0.0.1229",
|
||||||
|
web_id: psd.webId || "",
|
||||||
|
user_identity: psd.userIdentity || "Free",
|
||||||
|
is_freshman: "0",
|
||||||
|
biz_user_id: psd.bizUserId || "",
|
||||||
|
user_unique_id: psd.userUniqueId || "",
|
||||||
|
scope: psd.scope || "marscode-us",
|
||||||
|
tenant: psd.tenant || "marscode",
|
||||||
|
region: psd.region || "US-East",
|
||||||
|
aiRegion: psd.aiRegion || psd.region || "US-East",
|
||||||
|
is_privacy_mode: 0,
|
||||||
|
privacy_mode: "off",
|
||||||
|
solo_chat_mode: mode,
|
||||||
|
};
|
||||||
|
if (sessionId) cp.biz_session_id = sessionId;
|
||||||
|
return JSON.stringify(cp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /chat_sessions — creates a session and submits the first turn.
|
||||||
|
async createSession(headers, query, model, psd, signal) {
|
||||||
|
const { mode, strategy, modelName } = this.resolveMode(model);
|
||||||
|
const body = {
|
||||||
|
mode,
|
||||||
|
environment_id: "default",
|
||||||
|
initial_message: {
|
||||||
|
chat_session_id: "",
|
||||||
|
content: [],
|
||||||
|
query,
|
||||||
|
model_name: modelName,
|
||||||
|
agent_type: "solo_agent_remote",
|
||||||
|
model_selection_strategy: strategy,
|
||||||
|
common_params: this.commonParams(psd, mode),
|
||||||
|
},
|
||||||
|
env: "remote",
|
||||||
|
auto_create_project: false,
|
||||||
|
origin: "web",
|
||||||
|
};
|
||||||
|
const res = await proxyAwareFetch(`${this.base()}/chat_sessions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
|
}, null);
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) throw new Error(`[${res.status}] ${text}`);
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
if (json?.code !== 0) throw new Error(`Trae create_session: ${JSON.stringify(json)}`);
|
||||||
|
return { sessionId: json.data.chat_session_id, messageId: json.data.message_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /events SSE → invoke onEvent(eventType, dataObj) per frame.
|
||||||
|
// Resolves when `done`/`error` arrives, the stream ends, or timeout fires.
|
||||||
|
async streamEvents(headers, sessionId, replyTo, onEvent, signal) {
|
||||||
|
const url = `${this.base()}/chat_sessions/${sessionId}/events?reply_to_message_id=${encodeURIComponent(replyTo)}`;
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
if (signal?.aborted) ctrl.abort();
|
||||||
|
const timer = setTimeout(() => ctrl.abort(new Error("trae stream timeout")), STREAM_TIMEOUT_MS);
|
||||||
|
const onAbort = () => ctrl.abort();
|
||||||
|
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
try {
|
||||||
|
const res = await proxyAwareFetch(url, { method: "GET", headers, signal: ctrl.signal }, null);
|
||||||
|
if (!res.ok || !res.body) throw new Error(`[${res.status}] events stream failed`);
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = "";
|
||||||
|
let ev = null;
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
let nl;
|
||||||
|
while ((nl = buf.indexOf("\n")) >= 0) {
|
||||||
|
const line = buf.slice(0, nl).replace(/\r$/, "");
|
||||||
|
buf = buf.slice(nl + 1);
|
||||||
|
if (line.startsWith("event:")) ev = line.slice(6).trim();
|
||||||
|
else if (line.startsWith("data:")) {
|
||||||
|
const payload = line.slice(5).trim();
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(payload); } catch { data = { _raw: payload }; }
|
||||||
|
if (onEvent(ev, data)) {
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (line === "") ev = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (signal) signal.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute({ model, body, stream, credentials, signal }) {
|
||||||
|
const headers = this.buildHeaders(credentials, stream !== false);
|
||||||
|
const psd = credentials?.providerSpecificData || {};
|
||||||
|
const query = flattenQuery(body?.messages || []);
|
||||||
|
const responseId = `chatcmpl-trae-${Date.now()}`;
|
||||||
|
const created = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const errResponse = (status, message) => new Response(
|
||||||
|
JSON.stringify({ error: { message, type: "api_error", code: "" } }),
|
||||||
|
{ status, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
session = await this.createSession(headers, query, model, psd, signal);
|
||||||
|
} catch (err) {
|
||||||
|
return { response: errResponse(502, err?.message ? String(err.message) : String(err)), url: this.base(), headers, transformedBody: body };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared per-turn state: plan_item thoughts (cumulative, longest wins).
|
||||||
|
const order = [];
|
||||||
|
const thoughts = {};
|
||||||
|
let sent = 0;
|
||||||
|
let usage = null;
|
||||||
|
let errorEvent = null;
|
||||||
|
const renderNewText = (data) => {
|
||||||
|
const pid = data.id;
|
||||||
|
if (!pid) return "";
|
||||||
|
if (!(pid in thoughts)) order.push(pid);
|
||||||
|
const t = data.thought || "";
|
||||||
|
if (t.length >= (thoughts[pid] || "").length) thoughts[pid] = t;
|
||||||
|
const full = order.map((i) => thoughts[i]).join("");
|
||||||
|
const piece = full.slice(sent);
|
||||||
|
sent = full.length;
|
||||||
|
return piece;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (stream !== false) {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const sse = new ReadableStream({
|
||||||
|
start: async (controller) => {
|
||||||
|
const emit = (obj) => controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
|
||||||
|
emit({
|
||||||
|
id: responseId,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created,
|
||||||
|
model,
|
||||||
|
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await this.streamEvents(headers, session.sessionId, session.messageId, (ev, data) => {
|
||||||
|
if (ev === "error") { errorEvent = data; return true; }
|
||||||
|
if (ev === "token_usage") usage = data;
|
||||||
|
if (ev === "plan_item") {
|
||||||
|
const piece = renderNewText(data);
|
||||||
|
if (piece) {
|
||||||
|
emit({
|
||||||
|
id: responseId,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created,
|
||||||
|
model,
|
||||||
|
choices: [{ index: 0, delta: { content: piece }, finish_reason: null }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ev === "done";
|
||||||
|
}, signal);
|
||||||
|
if (errorEvent) {
|
||||||
|
emit({
|
||||||
|
id: responseId,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created,
|
||||||
|
model,
|
||||||
|
choices: [],
|
||||||
|
error: { message: `trae ${errorEvent.code || ""}: ${errorEvent.message || ""}`, type: "api_error" },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
emit({
|
||||||
|
id: responseId,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created,
|
||||||
|
model,
|
||||||
|
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||||
|
});
|
||||||
|
if (usage) {
|
||||||
|
emit({
|
||||||
|
id: responseId,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created,
|
||||||
|
model,
|
||||||
|
choices: [],
|
||||||
|
usage: {
|
||||||
|
prompt_tokens: usage.prompt_tokens || 0,
|
||||||
|
completion_tokens: usage.completion_tokens || 0,
|
||||||
|
total_tokens: usage.total_tokens || 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controller.enqueue(enc.encode("data: [DONE]\n\n"));
|
||||||
|
controller.close();
|
||||||
|
} catch (err) {
|
||||||
|
controller.error(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
response: new Response(sse, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
url: this.base(),
|
||||||
|
headers,
|
||||||
|
transformedBody: body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-streaming: drive to completion, return chat.completion JSON.
|
||||||
|
try {
|
||||||
|
await this.streamEvents(headers, session.sessionId, session.messageId, (ev, data) => {
|
||||||
|
if (ev === "error") { errorEvent = data; return true; }
|
||||||
|
if (ev === "token_usage") usage = data;
|
||||||
|
if (ev === "plan_item") renderNewText(data);
|
||||||
|
return ev === "done";
|
||||||
|
}, signal);
|
||||||
|
} catch (err) {
|
||||||
|
return { response: errResponse(502, err?.message ? String(err.message) : String(err)), url: this.base(), headers, transformedBody: body };
|
||||||
|
}
|
||||||
|
if (errorEvent) {
|
||||||
|
return { response: errResponse(502, `trae ${errorEvent.code || ""}: ${errorEvent.message || ""}`), url: this.base(), headers, transformedBody: body };
|
||||||
|
}
|
||||||
|
const content = order.map((i) => thoughts[i]).join("");
|
||||||
|
const out = {
|
||||||
|
id: responseId,
|
||||||
|
object: "chat.completion",
|
||||||
|
created,
|
||||||
|
model,
|
||||||
|
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
|
||||||
|
};
|
||||||
|
if (usage) {
|
||||||
|
out.usage = {
|
||||||
|
prompt_tokens: usage.prompt_tokens || 0,
|
||||||
|
completion_tokens: usage.completion_tokens || 0,
|
||||||
|
total_tokens: usage.total_tokens || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
response: new Response(JSON.stringify(out), { status: 200, headers: { "Content-Type": "application/json" } }),
|
||||||
|
url: this.base(),
|
||||||
|
headers,
|
||||||
|
transformedBody: body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh hook placeholder — Cloud-IDE-JWT is long-lived (~14d); refresh via
|
||||||
|
// ExchangeToken (refresh→access) is wired in services/tokenRefresh/providers.js.
|
||||||
|
async refreshCredentials() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
// ZedHostedExecutor — routes requests to Zed's hosted LLM aggregator
|
||||||
|
// (cloud.zed.dev/completions), a multi-format proxy fronting
|
||||||
|
// Anthropic/OpenAI/Google/xAI depending on the requested model.
|
||||||
|
//
|
||||||
|
// Wire protocol: POST /completions with an NDJSON/SSE-ish body-per-line
|
||||||
|
// response stream (`{"event": <provider-shaped-chunk>}` / `{"status": ...}` /
|
||||||
|
// `[DONE]`), authenticated with a short-lived LLM bearer token exchanged from
|
||||||
|
// the RSA-decrypted access_token (see open-sse/shared/zedAuth.js). The
|
||||||
|
// provider-shaped chunk is Claude/Gemini/OpenAI-Responses/xAI(OpenAI-shaped)
|
||||||
|
// depending on which upstream Zed fronts for the model — translated back to
|
||||||
|
// OpenAI Chat Completions by reusing the existing translators.
|
||||||
|
//
|
||||||
|
// Overrides execute() entirely (does NOT use DefaultExecutor's pipeline) because the Zed wire
|
||||||
|
// shape (thread envelope, LLM-token exchange, NDJSON status frames) doesn't
|
||||||
|
// fit the generic transformRequest/buildUrl contract.
|
||||||
|
|
||||||
|
import { BaseExecutor } from "./base.js";
|
||||||
|
import { FORMATS } from "../translator/formats.js";
|
||||||
|
import { initState } from "../translator/index.js";
|
||||||
|
import { openaiToClaudeRequest } from "../translator/request/openai-to-claude.js";
|
||||||
|
import { openaiToGeminiRequest } from "../translator/request/openai-to-gemini.js";
|
||||||
|
import { openaiToOpenAIResponsesRequest } from "../translator/request/openai-responses.js";
|
||||||
|
import { claudeToOpenAIResponse } from "../translator/response/claude-to-openai.js";
|
||||||
|
import { geminiToOpenAIResponse } from "../translator/response/gemini-to-openai.js";
|
||||||
|
import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js";
|
||||||
|
import {
|
||||||
|
ZED_HEADERS,
|
||||||
|
resolveZedModels,
|
||||||
|
zedLlmFetch,
|
||||||
|
} from "../shared/zedAuth.js";
|
||||||
|
|
||||||
|
const ZED_PROVIDER = {
|
||||||
|
anthropic: "Anthropic",
|
||||||
|
openai: "OpenAi",
|
||||||
|
google: "Google",
|
||||||
|
xai: "XAi",
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeZedProvider(value, model) {
|
||||||
|
const raw = String(value || "").toLowerCase();
|
||||||
|
if (raw === "anthropic") return ZED_PROVIDER.anthropic;
|
||||||
|
if (raw === "openai" || raw === "open_ai") return ZED_PROVIDER.openai;
|
||||||
|
if (raw === "google" || raw === "gemini") return ZED_PROVIDER.google;
|
||||||
|
if (raw === "xai" || raw === "x_ai" || raw === "x-ai") return ZED_PROVIDER.xai;
|
||||||
|
|
||||||
|
const m = String(model || "").toLowerCase();
|
||||||
|
if (m.includes("claude")) return ZED_PROVIDER.anthropic;
|
||||||
|
if (m.includes("gemini")) return ZED_PROVIDER.google;
|
||||||
|
if (m.includes("grok") || m.includes("xai")) return ZED_PROVIDER.xai;
|
||||||
|
return ZED_PROVIDER.openai;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProviderRequest(provider, model, body, stream, credentials) {
|
||||||
|
if (provider === ZED_PROVIDER.anthropic) {
|
||||||
|
return openaiToClaudeRequest(model, body, true);
|
||||||
|
}
|
||||||
|
if (provider === ZED_PROVIDER.google) {
|
||||||
|
return openaiToGeminiRequest(model, body, true);
|
||||||
|
}
|
||||||
|
if (provider === ZED_PROVIDER.openai) {
|
||||||
|
return openaiToOpenAIResponsesRequest(model, body, true, credentials);
|
||||||
|
}
|
||||||
|
// xAI is OpenAI-shaped — forward as-is.
|
||||||
|
return { ...(body || {}), model, stream: stream !== false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function initProviderState(provider, model) {
|
||||||
|
if (provider === ZED_PROVIDER.anthropic) return initState(FORMATS.CLAUDE);
|
||||||
|
if (provider === ZED_PROVIDER.google) return initState(FORMATS.GEMINI);
|
||||||
|
if (provider === ZED_PROVIDER.openai) return initState(FORMATS.OPENAI_RESPONSES);
|
||||||
|
const state = initState(FORMATS.OPENAI);
|
||||||
|
state.model = model;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertProviderEvent(provider, event, state) {
|
||||||
|
if (provider === ZED_PROVIDER.anthropic) return claudeToOpenAIResponse(event, state);
|
||||||
|
if (provider === ZED_PROVIDER.google) return geminiToOpenAIResponse(event, state);
|
||||||
|
if (provider === ZED_PROVIDER.openai) return openaiResponsesToOpenAIResponse(event, state);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createErrorChunk(model, message) {
|
||||||
|
return {
|
||||||
|
id: `chatcmpl-zed-error-${Date.now()}`,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model,
|
||||||
|
choices: [
|
||||||
|
{ index: 0, delta: { content: `[Zed error] ${message}` }, finish_reason: "stop" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueSseObject(controller, encoder, chunk) {
|
||||||
|
if (!chunk) return;
|
||||||
|
const items = Array.isArray(chunk) ? chunk : [chunk];
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item) continue;
|
||||||
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(item)}\n\n`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapZedLine(line) {
|
||||||
|
let text = line.replace(/\r$/, "").trim();
|
||||||
|
if (!text) return null;
|
||||||
|
if (text.startsWith("data:")) text = text.slice(5).trimStart();
|
||||||
|
if (text === "[DONE]") return { done: true };
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (parsed && Object.prototype.hasOwnProperty.call(parsed, "event")) {
|
||||||
|
return { event: parsed.event };
|
||||||
|
}
|
||||||
|
if (parsed && Object.prototype.hasOwnProperty.call(parsed, "status")) {
|
||||||
|
return { status: parsed.status };
|
||||||
|
}
|
||||||
|
return { event: parsed };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(status) {
|
||||||
|
if (!status) return null;
|
||||||
|
if (typeof status === "string") return { type: status };
|
||||||
|
if (typeof status === "object") {
|
||||||
|
const key = Object.keys(status)[0];
|
||||||
|
if (key && typeof status[key] === "object") return { type: key, ...status[key] };
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapZedCompletionStream(response, provider, model) {
|
||||||
|
if (!response.ok || !response.body) return response;
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const state = initProviderState(provider, model);
|
||||||
|
let buffer = "";
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
const finish = (controller) => {
|
||||||
|
if (done) return;
|
||||||
|
const finalChunk = convertProviderEvent(provider, null, state);
|
||||||
|
enqueueSseObject(controller, encoder, finalChunk);
|
||||||
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||||
|
done = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const processLine = (line, controller) => {
|
||||||
|
if (done) return;
|
||||||
|
const payload = unwrapZedLine(line);
|
||||||
|
if (!payload) return;
|
||||||
|
if (payload.done) {
|
||||||
|
finish(controller);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.status) {
|
||||||
|
const status = normalizeStatus(payload.status);
|
||||||
|
if (status?.type === "failed" || status?.failed) {
|
||||||
|
const failed = status.failed || status;
|
||||||
|
const message = String(failed.message || failed.error || failed.code || "request failed");
|
||||||
|
enqueueSseObject(controller, encoder, createErrorChunk(model, message));
|
||||||
|
finish(controller);
|
||||||
|
} else if (status?.type === "stream_ended" || status === "stream_ended") {
|
||||||
|
finish(controller);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const converted = convertProviderEvent(provider, payload.event, state);
|
||||||
|
enqueueSseObject(controller, encoder, converted);
|
||||||
|
};
|
||||||
|
|
||||||
|
const transformed = response.body.pipeThrough(
|
||||||
|
new TransformStream({
|
||||||
|
transform(chunk, controller) {
|
||||||
|
buffer += decoder.decode(chunk, { stream: true });
|
||||||
|
let nl;
|
||||||
|
while ((nl = buffer.indexOf("\n")) !== -1) {
|
||||||
|
const line = buffer.slice(0, nl);
|
||||||
|
buffer = buffer.slice(nl + 1);
|
||||||
|
processLine(line, controller);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
flush(controller) {
|
||||||
|
buffer += decoder.decode();
|
||||||
|
if (buffer) {
|
||||||
|
processLine(buffer, controller);
|
||||||
|
buffer = "";
|
||||||
|
}
|
||||||
|
finish(controller);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(transformed, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ZedExecutor extends BaseExecutor {
|
||||||
|
constructor() {
|
||||||
|
super("zed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveModel(model, credentials, signal, log) {
|
||||||
|
try {
|
||||||
|
const catalog = await resolveZedModels(credentials, { config: this.config, signal });
|
||||||
|
let raw = catalog?.rawById?.get(model) ?? null;
|
||||||
|
if (!raw) {
|
||||||
|
const refreshed = await resolveZedModels(credentials, {
|
||||||
|
config: this.config,
|
||||||
|
signal,
|
||||||
|
forceRefresh: true,
|
||||||
|
});
|
||||||
|
raw = refreshed?.rawById?.get(model) ?? null;
|
||||||
|
}
|
||||||
|
return { raw, provider: normalizeZedProvider(raw?.provider, model) };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
log?.warn?.("ZED", `model catalog unavailable, inferring provider for ${model}: ${message}`);
|
||||||
|
return { raw: null, provider: normalizeZedProvider(null, model) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
|
||||||
|
const { provider } = await this.resolveModel(model, credentials, signal, log);
|
||||||
|
const providerRequest = buildProviderRequest(provider, model, body, stream, credentials);
|
||||||
|
const bodyRecord = body || {};
|
||||||
|
const payload = {
|
||||||
|
thread_id: bodyRecord.thread_id || credentials?._clientSessionId,
|
||||||
|
prompt_id: bodyRecord.prompt_id,
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
provider_request: providerRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await zedLlmFetch(credentials, "/completions", {
|
||||||
|
config: this.config,
|
||||||
|
signal,
|
||||||
|
fetchOptions: {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/x-ndjson, text/event-stream, */*",
|
||||||
|
"User-Agent": "9router/zed",
|
||||||
|
"x-zed-version": this.config?.appVersion?.toString() || "0.200.0",
|
||||||
|
[ZED_HEADERS.clientSupportsStatus]: "true",
|
||||||
|
[ZED_HEADERS.clientSupportsStreamEnded]: "true",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapped = response.ok ? wrapZedCompletionStream(response, provider, model) : response;
|
||||||
|
return {
|
||||||
|
response: wrapped,
|
||||||
|
url: `${this.config?.llmBaseUrl || "https://cloud.zed.dev"}/completions`,
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: "Bearer <zed-llm-token>" },
|
||||||
|
transformedBody: payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
parseError(response, bodyText) {
|
||||||
|
let parsed = null;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(bodyText || "{}");
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorObj = parsed?.error || undefined;
|
||||||
|
const code = parsed?.code || errorObj?.code || "";
|
||||||
|
const rawMessage =
|
||||||
|
parsed?.message || errorObj?.message || bodyText || response.statusText;
|
||||||
|
if (code === "trial_blocked") {
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
message: `Zed trial access is blocked upstream. The account can list hosted models, but Zed is refusing completions until trial/billing access is enabled or unblocked. Zed says: ${rawMessage}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (code) {
|
||||||
|
return { status: response.status, message: `Zed ${code}: ${rawMessage}` };
|
||||||
|
}
|
||||||
|
return { status: response.status, message: rawMessage || `Zed upstream error: ${response.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshCredentials() {
|
||||||
|
// Zed uses a long-lived RSA-decrypted access_token — no OAuth refresh.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
needsRefresh() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ZedExecutor;
|
||||||
@@ -330,7 +330,18 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
// Handle 401/403 - try token refresh (skip for noAuth providers)
|
// Handle 401/403 - try token refresh (skip for noAuth providers)
|
||||||
if (!executor.noAuth && (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN)) {
|
if (!executor.noAuth && (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN)) {
|
||||||
try {
|
try {
|
||||||
const newCredentials = await refreshWithRetry(() => executor.refreshCredentials(credentials, log), 3, log);
|
// Mutate credentials after each successful refresh: rotating refresh_token
|
||||||
|
// providers (xAI/grok-cli) issue a new RT on every refresh; without this,
|
||||||
|
// refreshWithRetry's 2nd/3rd attempt reuses the already-consumed RT →
|
||||||
|
// invalid_grant → auth_failed retryable=false.
|
||||||
|
const newCredentials = await refreshWithRetry(async () => {
|
||||||
|
const result = await executor.refreshCredentials(credentials, log);
|
||||||
|
if (result?.refreshToken && result.refreshToken !== credentials.refreshToken) {
|
||||||
|
if (result.accessToken) credentials.accessToken = result.accessToken;
|
||||||
|
credentials.refreshToken = result.refreshToken;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, 3, log);
|
||||||
if (newCredentials?.accessToken || newCredentials?.copilotToken) {
|
if (newCredentials?.accessToken || newCredentials?.copilotToken) {
|
||||||
if (log?.line) log.line(reqTag, "🔑", `TOKEN REFRESHED · ${provider}/${model}`);
|
if (log?.line) log.line(reqTag, "🔑", `TOKEN REFRESHED · ${provider}/${model}`);
|
||||||
Object.assign(credentials, newCredentials);
|
Object.assign(credentials, newCredentials);
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export async function handleEmbeddingsCore({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
usage: normalized.usage || null,
|
||||||
response: new Response(JSON.stringify(normalized), {
|
response: new Response(JSON.stringify(normalized), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@@ -49,7 +49,10 @@ function truncate(text, max) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseJinaTitle(text) {
|
function parseJinaTitle(text) {
|
||||||
const m = String(text || "").match(/^\s*#\s+(.+)$/m);
|
const source = String(text || "");
|
||||||
|
const metadataTitle = source.match(/^\s*Title:\s*(.+)$/mi);
|
||||||
|
if (metadataTitle) return metadataTitle[1].trim();
|
||||||
|
const m = source.match(/^\s*#\s+(.+)$/m);
|
||||||
return m ? m[1].trim() : null;
|
return m ? m[1].trim() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,11 +154,14 @@ async function runFirecrawl({ url, fmt, timeoutMs, apiKey, maxCharacters, costPe
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runJina({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }) {
|
async function runJina({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }) {
|
||||||
const target = `https://r.jina.ai/${encodeURIComponent(url)}`;
|
|
||||||
const upstreamStart = Date.now();
|
const upstreamStart = Date.now();
|
||||||
const r = await tryFetch(target, {
|
const r = await tryFetch("https://r.jina.ai/", {
|
||||||
method: "GET",
|
method: "POST",
|
||||||
headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {}
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(apiKey ? { authorization: `Bearer ${apiKey}` } : {})
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ url })
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Search Provider Request Builders
|
* Search Provider Request Builders
|
||||||
*
|
*
|
||||||
* Ported from OmniRoute open-sse/handlers/search.ts (lines 223-610).
|
|
||||||
* Builds HTTP request `{ url, init }` for 10 search providers.
|
* Builds HTTP request `{ url, init }` for 10 search providers.
|
||||||
*
|
*
|
||||||
* @typedef {Object} SearchProviderConfig
|
* @typedef {Object} SearchProviderConfig
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Search Response Normalizers
|
* Search Response Normalizers
|
||||||
*
|
*
|
||||||
* Ported from OmniRoute open-sse/handlers/search.ts.
|
|
||||||
* Each normalizer maps a provider-specific response into the unified SearchResult shape.
|
* Each normalizer maps a provider-specific response into the unified SearchResult shape.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,11 @@ export function capabilitiesFromServiceKind(kind) {
|
|||||||
* otherwise mis-match. Only declare deltas vs DEFAULT.
|
* otherwise mis-match. Only declare deltas vs DEFAULT.
|
||||||
*/
|
*/
|
||||||
export const MODEL_CAPABILITIES = {
|
export const MODEL_CAPABILITIES = {
|
||||||
// Claude 4.6/4.7/4.8 and Kiro Sonnet 5 have 1M context + adaptive thinking (override generic claude pattern)
|
// Claude Opus 5, 4.6/4.7/4.8, and Kiro Sonnet 5 have 1M context + adaptive thinking (override generic claude pattern)
|
||||||
|
"claude-opus-5": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
||||||
|
"claude-opus-5-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
||||||
|
"claude-opus-5-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
||||||
|
"claude-opus-5-thinking-agentic": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
||||||
"claude-opus-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
"claude-opus-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
||||||
"claude-opus-4.7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
"claude-opus-4.7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
||||||
"claude-opus-4-7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
"claude-opus-4-7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
|
||||||
@@ -170,6 +174,11 @@ export const PROVIDER_CAPABILITIES = {
|
|||||||
"deepseek-v4-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 50000 },
|
"deepseek-v4-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 50000 },
|
||||||
"deepseek-v3-2-volc": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 96000, maxOutput: 32000 },
|
"deepseek-v3-2-volc": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 96000, maxOutput: 32000 },
|
||||||
},
|
},
|
||||||
|
// Poolside Laguna — OpenAI-compatible, all reasoning-capable (32K max output).
|
||||||
|
"poolside": {
|
||||||
|
"laguna-s-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 },
|
||||||
|
"laguna-xs-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -180,6 +189,7 @@ export const PROVIDER_CAPABILITIES = {
|
|||||||
*/
|
*/
|
||||||
export const PATTERN_CAPABILITIES = [
|
export const PATTERN_CAPABILITIES = [
|
||||||
// ── Claude (4.6+ = adaptive thinking; older/haiku = budget) ──────
|
// ── Claude (4.6+ = adaptive thinking; older/haiku = budget) ──────
|
||||||
|
{ pattern: "*claude*opus-5*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 } },
|
||||||
{ pattern: "*claude*opus-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
|
{ pattern: "*claude*opus-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
|
||||||
{ pattern: "*claude*opus-4.7*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
|
{ pattern: "*claude*opus-4.7*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
|
||||||
{ pattern: "*claude*opus-4.8*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
|
{ pattern: "*claude*opus-4.8*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
|
||||||
@@ -291,6 +301,13 @@ export const PATTERN_CAPABILITIES = [
|
|||||||
{ pattern: "*pplx*", caps: { search: true, contextWindow: 128000 } },
|
{ pattern: "*pplx*", caps: { search: true, contextWindow: 128000 } },
|
||||||
{ pattern: "*perplexity*", caps: { search: true, contextWindow: 128000 } },
|
{ pattern: "*perplexity*", caps: { search: true, contextWindow: 128000 } },
|
||||||
|
|
||||||
|
// ── Poolside Laguna (resellers: openrouter/nvidia/kilocode/vercel/...) ──
|
||||||
|
// Free tiers cap S 2.1 well below the paid 1M window → match the free suffix
|
||||||
|
// (":free" or "-free", depending on reseller) before the plain id.
|
||||||
|
{ pattern: "*laguna-s-2.1*free*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } },
|
||||||
|
{ pattern: "*laguna-s-2.1*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 } },
|
||||||
|
{ pattern: "*laguna*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } },
|
||||||
|
|
||||||
// ── Others ───────────────────────────────────────────────────────
|
// ── Others ───────────────────────────────────────────────────────
|
||||||
{ pattern: "*hunyuan*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } },
|
{ pattern: "*hunyuan*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } },
|
||||||
{ pattern: "hy3*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } },
|
{ pattern: "hy3*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } },
|
||||||
|
|||||||
@@ -57,7 +57,13 @@ export const MODEL_PRICING = {
|
|||||||
"o1-mini": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 },
|
"o1-mini": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 },
|
||||||
|
|
||||||
// === Gemini ===
|
// === Gemini ===
|
||||||
"gemini-3-flash-preview": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 },
|
"gemini-3.6-flash": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||||
|
"gemini-3.6-flash-high": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||||
|
"gemini-3.6-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||||
|
"gemini-3.6-flash-low": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||||
|
"gemini-3.5-flash-lite": { input: 0.30, output: 2.50, cached: 0.03, reasoning: 3.75, cache_creation: 0.375 },
|
||||||
|
"gemini-3.5-flash-high": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 },
|
||||||
|
"gemini-3-flash-preview": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 },
|
||||||
"gemini-3-pro-preview": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 },
|
"gemini-3-pro-preview": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 },
|
||||||
"gemini-3.1-pro-low": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 },
|
"gemini-3.1-pro-low": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 },
|
||||||
"gemini-3.1-pro-high": { input: 4.00, output: 18.00, cached: 0.50, reasoning: 27.00, cache_creation: 4.00 },
|
"gemini-3.1-pro-high": { input: 4.00, output: 18.00, cached: 0.50, reasoning: 27.00, cache_creation: 4.00 },
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
usage: {
|
usage: {
|
||||||
|
// Discovery (quota/project) on PROD; daily host rejects these.
|
||||||
quotaApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels",
|
quotaApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels",
|
||||||
loadProjectApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
loadProjectApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||||
tokenUrl: "https://oauth2.googleapis.com/token",
|
tokenUrl: "https://oauth2.googleapis.com/token",
|
||||||
@@ -44,6 +45,10 @@ export default {
|
|||||||
clientSecret: "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",
|
clientSecret: "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",
|
||||||
},
|
},
|
||||||
models: [
|
models: [
|
||||||
|
{ id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", upstreamModelId: "gemini-3.6-flash-tiered(high)" },
|
||||||
|
{ id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", upstreamModelId: "gemini-3.6-flash-tiered(medium)" },
|
||||||
|
{ id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)", upstreamModelId: "gemini-3.6-flash-tiered(low)" },
|
||||||
|
{ id: "gemini-3.5-flash-high", name: "Gemini 3.5 Flash (High)" },
|
||||||
{ id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)" },
|
{ id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)" },
|
||||||
{ id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium)" },
|
{ id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium)" },
|
||||||
{ id: "gemini-3.5-flash-extra-low", name: "Gemini 3.5 Flash (Low)" },
|
{ id: "gemini-3.5-flash-extra-low", name: "Gemini 3.5 Flash (Low)" },
|
||||||
@@ -67,7 +72,7 @@ export default {
|
|||||||
"https://www.googleapis.com/auth/cclog",
|
"https://www.googleapis.com/auth/cclog",
|
||||||
"https://www.googleapis.com/auth/experimentsandconfigs",
|
"https://www.googleapis.com/auth/experimentsandconfigs",
|
||||||
],
|
],
|
||||||
apiEndpoint: "https://cloudcode-pa.googleapis.com",
|
apiEndpoint: "https://daily-cloudcode-pa.googleapis.com",
|
||||||
apiVersion: "v1internal",
|
apiVersion: "v1internal",
|
||||||
loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||||
onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
|
onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export default {
|
||||||
|
id: "api-airforce",
|
||||||
|
alias: "af",
|
||||||
|
aliases: [
|
||||||
|
"airforce",
|
||||||
|
],
|
||||||
|
uiAlias: "af",
|
||||||
|
display: {
|
||||||
|
name: "API.airforce",
|
||||||
|
icon: "flight",
|
||||||
|
color: "#0EA5E9",
|
||||||
|
textIcon: "AF",
|
||||||
|
website: "https://api.airforce",
|
||||||
|
notice: {
|
||||||
|
apiKeyUrl: "https://api.airforce",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
category: "freeTier",
|
||||||
|
authType: "apikey",
|
||||||
|
authModes: [
|
||||||
|
"apikey",
|
||||||
|
],
|
||||||
|
transport: {
|
||||||
|
baseUrl: "https://api.airforce/v1/chat/completions",
|
||||||
|
validateUrl: "https://api.airforce/v1/models",
|
||||||
|
headers: {
|
||||||
|
"HTTP-Referer": "https://endpoint-proxy.local",
|
||||||
|
"X-Title": "Endpoint Proxy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
models: [
|
||||||
|
{ id: "anthropic/claude-3.7-sonnet", name: "Claude 3.7 Sonnet (Free)", contextLength: 200000 },
|
||||||
|
{ id: "moonshot/kimi-k2.6", name: "Kimi K2.6 (Free)", contextLength: 262144 },
|
||||||
|
{ id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash (Free)", contextLength: 1048576 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -60,10 +60,9 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
models: [
|
models: [
|
||||||
|
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
||||||
{ id: "claude-fable-5", name: "Claude Fable 5" },
|
{ id: "claude-fable-5", name: "Claude Fable 5" },
|
||||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
|
{ id: "claude-sonnet-5", name: "Claude Sonnet 5" },
|
||||||
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
|
|
||||||
{ id: "claude-opus-4-7", name: "Claude Opus 4.7" },
|
|
||||||
{ id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" },
|
{ id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" },
|
||||||
],
|
],
|
||||||
oauth: {
|
oauth: {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// CodeBuddy international (codebuddy.ai) — mirrors codebuddy-cn registry shape,
|
||||||
|
// swapping the Tencent CN domain for the .ai endpoint set. All OAuth/plugin URLs
|
||||||
|
// use the /v2/plugin prefix with platform=ide (CN uses platform=CLI).
|
||||||
|
export default {
|
||||||
|
id: "codebuddy-intl",
|
||||||
|
alias: "cbai",
|
||||||
|
uiAlias: "cbai",
|
||||||
|
hidden: false,
|
||||||
|
priority: 90,
|
||||||
|
display: {
|
||||||
|
name: "CodeBuddy",
|
||||||
|
icon: "smart_toy",
|
||||||
|
color: "#006EFF",
|
||||||
|
website: "https://www.codebuddy.ai",
|
||||||
|
notice: {
|
||||||
|
signupUrl: "https://www.codebuddy.ai",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
category: "oauth",
|
||||||
|
authModes: ["oauth", "apikey"],
|
||||||
|
hasOAuth: true,
|
||||||
|
transport: {
|
||||||
|
// Chat gateway is OpenAI-compatible SSE (same /v2/chat/completions path as CN).
|
||||||
|
baseUrl: "https://www.codebuddy.ai/v2/chat/completions",
|
||||||
|
forceStream: true,
|
||||||
|
// CodeBuddy intl speaks the same unified OpenAI reasoning_effort shape as CN.
|
||||||
|
thinkingFormat: "openai",
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1",
|
||||||
|
"X-Product": "SaaS",
|
||||||
|
"X-IDE-Type": "IDE",
|
||||||
|
"X-IDE-Name": "IDE",
|
||||||
|
"x-requested-with": "XMLHttpRequest",
|
||||||
|
"x-codebuddy-request": "1",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
combined: true,
|
||||||
|
header: "Authorization",
|
||||||
|
scheme: "bearer",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Same model lineup exposed by the CN gateway — intl backend is the same catalog.
|
||||||
|
models: [
|
||||||
|
{ id: "glm-5.2", name: "GLM-5.2" },
|
||||||
|
{ id: "glm-5.1", name: "GLM-5.1" },
|
||||||
|
{ id: "glm-5.0", name: "GLM-5.0" },
|
||||||
|
{ id: "glm-5.0-turbo", name: "GLM-5.0-Turbo" },
|
||||||
|
{ id: "glm-5v-turbo", name: "GLM-5v-Turbo" },
|
||||||
|
{ id: "glm-4.7", name: "GLM-4.7" },
|
||||||
|
{ id: "minimax-m3", name: "MiniMax-M3" },
|
||||||
|
{ id: "minimax-m2.7", name: "MiniMax-M2.7" },
|
||||||
|
{ id: "kimi-k2.7", name: "Kimi-K2.7-Code" },
|
||||||
|
{ id: "kimi-k2.6", name: "Kimi-K2.6" },
|
||||||
|
{ id: "kimi-k2.5", name: "Kimi-K2.5" },
|
||||||
|
{ id: "hy3-preview", name: "Hy3 Preview" },
|
||||||
|
{ id: "deepseek-v4-pro", name: "DeepSeek-V4-Pro" },
|
||||||
|
{ id: "deepseek-v4-flash", name: "DeepSeek-V4-Flash" },
|
||||||
|
{ id: "deepseek-v3-2-volc", name: "DeepSeek-V3.2" },
|
||||||
|
],
|
||||||
|
oauth: {
|
||||||
|
baseUrl: "https://www.codebuddy.ai",
|
||||||
|
stateUrl: "https://www.codebuddy.ai/v2/plugin/auth/state",
|
||||||
|
tokenUrl: "https://www.codebuddy.ai/v2/plugin/auth/token",
|
||||||
|
refreshUrl: "https://www.codebuddy.ai/v2/plugin/auth/token/refresh",
|
||||||
|
userAgent: "IDE/2.63.2 CodeBuddy/2.63.2",
|
||||||
|
platform: "ide",
|
||||||
|
pollInterval: 5000,
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
usage: true,
|
||||||
|
usageApikey: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -48,4 +48,8 @@ export default {
|
|||||||
{ id: "deepseek-chat", name: "DeepSeek V3.2 Chat" },
|
{ id: "deepseek-chat", name: "DeepSeek V3.2 Chat" },
|
||||||
{ id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" },
|
{ id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" },
|
||||||
],
|
],
|
||||||
|
features: {
|
||||||
|
usage: true,
|
||||||
|
usageApikey: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -16,6 +16,8 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
category: "freeTier",
|
category: "freeTier",
|
||||||
|
authType: "apikey",
|
||||||
|
authModes: ["apikey"],
|
||||||
mediaPriority: 1,
|
mediaPriority: 1,
|
||||||
transport: {
|
transport: {
|
||||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/models",
|
baseUrl: "https://generativelanguage.googleapis.com/v1beta/models",
|
||||||
@@ -34,6 +36,8 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
models: [
|
models: [
|
||||||
|
{ id: "gemini-3.6-flash", name: "Gemini 3.6 Flash" },
|
||||||
|
{ id: "gemini-3.5-flash-lite", name: "Gemini 3.5 Flash Lite" },
|
||||||
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
|
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
|
||||||
{ id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
|
{ id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
|
||||||
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
|
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
|
||||||
|
|||||||
@@ -1,106 +1,121 @@
|
|||||||
// Auto-generated: static imports for all registry entries
|
// Auto-generated by scripts/generate-provider-registry.mjs. Do not edit manually.
|
||||||
import p0 from "./alicode-intl.js";
|
import p0 from "./alicode.js";
|
||||||
import p1 from "./alicode.js";
|
import p1 from "./alicode-intl.js";
|
||||||
import p2 from "./anthropic.js";
|
import p2 from "./alims-intl.js";
|
||||||
import p3 from "./antigravity.js";
|
import p3 from "./anthropic.js";
|
||||||
import p4 from "./assemblyai.js";
|
import p4 from "./antigravity.js";
|
||||||
import p5 from "./aws-polly.js";
|
import p5 from "./api-airforce.js";
|
||||||
import p6 from "./azure.js";
|
import p6 from "./assemblyai.js";
|
||||||
import p7 from "./black-forest-labs.js";
|
import p7 from "./aws-polly.js";
|
||||||
import p8 from "./blackbox.js";
|
import p8 from "./azure.js";
|
||||||
import p9 from "./brave-search.js";
|
import p9 from "./baidu.js";
|
||||||
import p10 from "./byteplus.js";
|
import p10 from "./bazaarlink.js";
|
||||||
import p11 from "./cartesia.js";
|
import p11 from "./black-forest-labs.js";
|
||||||
import p12 from "./cerebras.js";
|
import p12 from "./blackbox.js";
|
||||||
import p13 from "./chutes.js";
|
import p13 from "./bluesminds.js";
|
||||||
import p14 from "./claude.js";
|
import p14 from "./brave-search.js";
|
||||||
import p15 from "./cline.js";
|
import p15 from "./byteplus.js";
|
||||||
import p16 from "./clinepass.js";
|
import p16 from "./cartesia.js";
|
||||||
import p17 from "./cloudflare-ai.js";
|
import p17 from "./cerebras.js";
|
||||||
import p18 from "./codebuddy-cn.js";
|
import p18 from "./chutes.js";
|
||||||
import p19 from "./codex.js";
|
import p19 from "./claude.js";
|
||||||
import p20 from "./cohere.js";
|
import p20 from "./cline.js";
|
||||||
import p21 from "./comfyui.js";
|
import p21 from "./clinepass.js";
|
||||||
import p22 from "./commandcode.js";
|
import p22 from "./cloudflare-ai.js";
|
||||||
import p23 from "./coqui.js";
|
import p23 from "./codebuddy-cn.js";
|
||||||
import p24 from "./cursor.js";
|
import p24 from "./codebuddy-intl.js";
|
||||||
import p25 from "./deepgram.js";
|
import p25 from "./codex.js";
|
||||||
import p26 from "./deepseek.js";
|
import p26 from "./cohere.js";
|
||||||
import p27 from "./edge-tts.js";
|
import p27 from "./comfyui.js";
|
||||||
import p28 from "./elevenlabs.js";
|
import p28 from "./commandcode.js";
|
||||||
import p29 from "./exa.js";
|
import p29 from "./coqui.js";
|
||||||
import p30 from "./fal-ai.js";
|
import p30 from "./cursor.js";
|
||||||
import p31 from "./featherless.js";
|
import p31 from "./deepgram.js";
|
||||||
import p32 from "./firecrawl.js";
|
import p32 from "./deepseek.js";
|
||||||
import p33 from "./fireworks.js";
|
import p33 from "./edge-tts.js";
|
||||||
import p34 from "./gemini-cli.js";
|
import p34 from "./elevenlabs.js";
|
||||||
import p35 from "./gemini.js";
|
import p35 from "./exa.js";
|
||||||
import p36 from "./github.js";
|
import p36 from "./fal-ai.js";
|
||||||
import p37 from "./gitlab.js";
|
import p37 from "./featherless.js";
|
||||||
import p38 from "./glm-cn.js";
|
import p38 from "./firecrawl.js";
|
||||||
import p39 from "./glm.js";
|
import p39 from "./fireworks.js";
|
||||||
import p40 from "./google-pse.js";
|
import p40 from "./gemini.js";
|
||||||
import p41 from "./google-tts.js";
|
import p41 from "./gemini-cli.js";
|
||||||
import p42 from "./grok-cli.js";
|
import p42 from "./github.js";
|
||||||
import p43 from "./grok-web.js";
|
import p43 from "./gitlab.js";
|
||||||
import p44 from "./groq.js";
|
import p44 from "./glm.js";
|
||||||
import p45 from "./huggingface.js";
|
import p45 from "./glm-cn.js";
|
||||||
import p46 from "./hyperbolic.js";
|
import p46 from "./google-pse.js";
|
||||||
import p47 from "./iflow.js";
|
import p47 from "./google-tts.js";
|
||||||
import p48 from "./inworld.js";
|
import p48 from "./grok-cli.js";
|
||||||
import p49 from "./jina-ai.js";
|
import p49 from "./grok-web.js";
|
||||||
import p50 from "./jina-reader.js";
|
import p50 from "./groq.js";
|
||||||
import p51 from "./kilocode.js";
|
import p51 from "./huggingface.js";
|
||||||
import p52 from "./kimchi.js";
|
import p52 from "./hyperbolic.js";
|
||||||
import p53 from "./kimi.js";
|
import p53 from "./iflow.js";
|
||||||
import p54 from "./kiro.js";
|
import p54 from "./inworld.js";
|
||||||
import p55 from "./linkup.js";
|
import p55 from "./jina-ai.js";
|
||||||
import p56 from "./local-device.js";
|
import p56 from "./jina-reader.js";
|
||||||
import p57 from "./mimo-free.js";
|
import p57 from "./kilo-gateway.js";
|
||||||
import p58 from "./minimax-cn.js";
|
import p58 from "./kilocode.js";
|
||||||
import p59 from "./minimax.js";
|
import p59 from "./kimchi.js";
|
||||||
import p60 from "./mistral.js";
|
import p60 from "./kimi.js";
|
||||||
import p61 from "./mmf.js";
|
import p61 from "./kiro.js";
|
||||||
import p62 from "./nanobanana.js";
|
import p62 from "./linkup.js";
|
||||||
import p63 from "./nebius.js";
|
import p63 from "./llm7.js";
|
||||||
import p64 from "./nvidia.js";
|
import p64 from "./local-device.js";
|
||||||
import p65 from "./ollama-local.js";
|
import p65 from "./mimo-free.js";
|
||||||
import p66 from "./ollama.js";
|
import p66 from "./minimax.js";
|
||||||
import p67 from "./openai.js";
|
import p67 from "./minimax-cn.js";
|
||||||
import p68 from "./opencode-go.js";
|
import p68 from "./mistral.js";
|
||||||
import p69 from "./opencode.js";
|
import p69 from "./mmf.js";
|
||||||
import p70 from "./openrouter.js";
|
import p70 from "./morph.js";
|
||||||
import p71 from "./perplexity-web.js";
|
import p71 from "./nanobanana.js";
|
||||||
import p72 from "./perplexity.js";
|
import p72 from "./nebius.js";
|
||||||
import p73 from "./perplexity-agent.js";
|
import p73 from "./nvidia.js";
|
||||||
import p74 from "./playht.js";
|
import p74 from "./ollama.js";
|
||||||
import p75 from "./qoder.js";
|
import p75 from "./ollama-local.js";
|
||||||
import p76 from "./qwen.js";
|
import p76 from "./openai.js";
|
||||||
import p77 from "./recraft.js";
|
import p77 from "./opencode.js";
|
||||||
import p78 from "./runwayml.js";
|
import p78 from "./opencode-go.js";
|
||||||
import p79 from "./sdwebui.js";
|
import p79 from "./openrouter.js";
|
||||||
import p80 from "./searchapi.js";
|
import p80 from "./orbit-provider.js";
|
||||||
import p81 from "./searxng.js";
|
import p81 from "./perplexity.js";
|
||||||
import p82 from "./serper.js";
|
import p82 from "./perplexity-agent.js";
|
||||||
import p83 from "./siliconflow.js";
|
import p83 from "./perplexity-web.js";
|
||||||
import p84 from "./stability-ai.js";
|
import p84 from "./playht.js";
|
||||||
import p85 from "./tavily.js";
|
import p85 from "./poolside.js";
|
||||||
import p86 from "./together.js";
|
import p86 from "./qoder.js";
|
||||||
import p87 from "./topaz.js";
|
import p87 from "./qwen.js";
|
||||||
import p88 from "./tortoise.js";
|
import p88 from "./recraft.js";
|
||||||
import p89 from "./venice.js";
|
import p89 from "./runwayml.js";
|
||||||
import p90 from "./vercel-ai-gateway.js";
|
import p90 from "./sambanova.js";
|
||||||
import p91 from "./vertex-partner.js";
|
import p91 from "./sdwebui.js";
|
||||||
import p92 from "./vertex.js";
|
import p92 from "./searchapi.js";
|
||||||
import p93 from "./volcengine-ark.js";
|
import p93 from "./searxng.js";
|
||||||
import p94 from "./voyage-ai.js";
|
import p94 from "./serper.js";
|
||||||
import p95 from "./xai.js";
|
import p95 from "./siliconflow.js";
|
||||||
import p96 from "./xiaomi-mimo.js";
|
import p96 from "./stability-ai.js";
|
||||||
import p97 from "./xiaomi-tokenplan.js";
|
import p97 from "./tavily.js";
|
||||||
import p98 from "./youcom.js";
|
import p98 from "./tencent.js";
|
||||||
import p99 from "./alims-intl.js";
|
import p99 from "./together.js";
|
||||||
import p100 from "./orbit-provider.js";
|
import p100 from "./topaz.js";
|
||||||
|
import p101 from "./tortoise.js";
|
||||||
|
import p102 from "./venice.js";
|
||||||
|
import p103 from "./vercel-ai-gateway.js";
|
||||||
|
import p104 from "./vertex.js";
|
||||||
|
import p105 from "./vertex-partner.js";
|
||||||
|
import p106 from "./volcengine-ark.js";
|
||||||
|
import p107 from "./voyage-ai.js";
|
||||||
|
import p108 from "./xai.js";
|
||||||
|
import p109 from "./xiaomi-mimo.js";
|
||||||
|
import p110 from "./xiaomi-tokenplan.js";
|
||||||
|
import p111 from "./youcom.js";
|
||||||
|
import p112 from "./zed.js";
|
||||||
|
|
||||||
|
// Hidden: devin-cli — spawns a local agent with shell/filesystem access.
|
||||||
|
// Hidden: trae — SOLO agent currently skips tool-call chunks.
|
||||||
|
// Hidden: windsurf — gRPC integration currently skips tool-call chunks.
|
||||||
export default [
|
export default [
|
||||||
p0,
|
p0,
|
||||||
p1,
|
p1,
|
||||||
@@ -203,4 +218,16 @@ export default [
|
|||||||
p98,
|
p98,
|
||||||
p99,
|
p99,
|
||||||
p100,
|
p100,
|
||||||
|
p101,
|
||||||
|
p102,
|
||||||
|
p103,
|
||||||
|
p104,
|
||||||
|
p105,
|
||||||
|
p106,
|
||||||
|
p107,
|
||||||
|
p108,
|
||||||
|
p109,
|
||||||
|
p110,
|
||||||
|
p111,
|
||||||
|
p112,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export default {
|
||||||
|
id: "kilo-gateway",
|
||||||
|
alias: "kgw",
|
||||||
|
aliases: [
|
||||||
|
"kilo-gateway",
|
||||||
|
"kilogateway",
|
||||||
|
],
|
||||||
|
uiAlias: "kgw",
|
||||||
|
category: "freeTier",
|
||||||
|
display: {
|
||||||
|
name: "Kilo Gateway",
|
||||||
|
icon: "login",
|
||||||
|
color: "#8B5CF6",
|
||||||
|
textIcon: "KG",
|
||||||
|
website: "https://kilo.ai",
|
||||||
|
notice: {
|
||||||
|
apiKeyUrl: "https://kilo.ai/dashboard?tab=apiKeys",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authType: "apikey",
|
||||||
|
authModes: ["apikey"],
|
||||||
|
transport: {
|
||||||
|
baseUrl: "https://api.kilo.ai/api/gateway/chat/completions",
|
||||||
|
validateUrl: "https://api.kilo.ai/api/gateway/models",
|
||||||
|
},
|
||||||
|
models: [
|
||||||
|
{ id: "kilo-auto/free", name: "Kilo Auto Free", contextLength: 256000 },
|
||||||
|
{ id: "nvidia/nemotron-3-super-120b-a12b:free", name: "Nemotron 3 Super 120B (Free)", contextLength: 262144 },
|
||||||
|
{ id: "nvidia/nemotron-3-ultra-550b-a55b:free", name: "Nemotron 3 Ultra 550B (Free)", contextLength: 1000000 },
|
||||||
|
{ id: "kwaipilot/kat-coder-pro-v2.5:free", name: "Kat Coder Pro v2.5 (Free)", contextLength: 256000 },
|
||||||
|
{ id: "kilo-auto/frontier", name: "Kilo Auto Frontier", contextLength: 1000000 },
|
||||||
|
{ id: "kilo-auto/balanced", name: "Kilo Auto Balanced", contextLength: 1000000 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -13,7 +13,7 @@ export default {
|
|||||||
signupUrl: "https://app.kimchi.dev",
|
signupUrl: "https://app.kimchi.dev",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
category: "oauth",
|
category: "freeTier",
|
||||||
authModes: ["oauth"],
|
authModes: ["oauth"],
|
||||||
hasOAuth: true,
|
hasOAuth: true,
|
||||||
transport: {
|
transport: {
|
||||||
|
|||||||
@@ -85,5 +85,8 @@ export default {
|
|||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
usage: true,
|
usage: true,
|
||||||
|
// API-key connections also hit /v1/usages (x-api-key) — need usageApikey
|
||||||
|
// so isUsageEligible + /api/usage allow non-oauth authType.
|
||||||
|
usageApikey: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export default {
|
|||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Accept: "application/vnd.amazon.eventstream",
|
Accept: "application/vnd.amazon.eventstream",
|
||||||
"X-Amz-Target": "AmazonCodeWhispererStreamingService.GenerateAssistantResponse",
|
|
||||||
"User-Agent": "AWS-SDK-JS/3.0.0 kiro-ide/1.0.0",
|
"User-Agent": "AWS-SDK-JS/3.0.0 kiro-ide/1.0.0",
|
||||||
"X-Amz-User-Agent": "aws-sdk-js/3.0.0 kiro-ide/1.0.0",
|
"X-Amz-User-Agent": "aws-sdk-js/3.0.0 kiro-ide/1.0.0",
|
||||||
},
|
},
|
||||||
@@ -43,6 +42,10 @@ export default {
|
|||||||
},
|
},
|
||||||
models: [
|
models: [
|
||||||
// Opus (added per kiro.dev/changelog/models and kiro.dev/docs/models)
|
// Opus (added per kiro.dev/changelog/models and kiro.dev/docs/models)
|
||||||
|
{ id: "claude-opus-5", name: "Claude Opus 5" },
|
||||||
|
{ id: "claude-opus-5-thinking", name: "Claude Opus 5 (Thinking)" },
|
||||||
|
{ id: "claude-opus-5-agentic", name: "Claude Opus 5 (Agentic)" },
|
||||||
|
{ id: "claude-opus-5-thinking-agentic", name: "Claude Opus 5 (Thinking + Agentic)" },
|
||||||
{ id: "claude-opus-4.8", name: "Claude Opus 4.8" },
|
{ id: "claude-opus-4.8", name: "Claude Opus 4.8" },
|
||||||
{ id: "claude-opus-4.8-thinking", name: "Claude Opus 4.8 (Thinking)" },
|
{ id: "claude-opus-4.8-thinking", name: "Claude Opus 4.8 (Thinking)" },
|
||||||
{ id: "claude-opus-4.8-agentic", name: "Claude Opus 4.8 (Agentic)" },
|
{ id: "claude-opus-4.8-agentic", name: "Claude Opus 4.8 (Agentic)" },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
// Xiaomi ended the free MiMo channel ("MiMo free API service has ended").
|
||||||
|
// Hidden until/unless a replacement (OAuth MiMo Platform) is wired.
|
||||||
export default {
|
export default {
|
||||||
id: "mimo-free",
|
id: "mimo-free",
|
||||||
|
hidden: true,
|
||||||
priority: 50,
|
priority: 50,
|
||||||
hasFree: true,
|
hasFree: true,
|
||||||
alias: "mmf",
|
alias: "mmf",
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -15,6 +15,8 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
category: "freeTier",
|
category: "freeTier",
|
||||||
|
authType: "apikey",
|
||||||
|
authModes: ["apikey"],
|
||||||
transport: {
|
transport: {
|
||||||
baseUrl: "https://integrate.api.nvidia.com/v1/chat/completions",
|
baseUrl: "https://integrate.api.nvidia.com/v1/chat/completions",
|
||||||
validateUrl: "https://integrate.api.nvidia.com/v1/models",
|
validateUrl: "https://integrate.api.nvidia.com/v1/models",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
category: "freeTier",
|
category: "freeTier",
|
||||||
|
authType: "apikey",
|
||||||
|
authModes: ["apikey"],
|
||||||
transport: {
|
transport: {
|
||||||
baseUrl: "https://openrouter.ai/api/v1/chat/completions",
|
baseUrl: "https://openrouter.ai/api/v1/chat/completions",
|
||||||
thinkingFormat: "openai",
|
thinkingFormat: "openai",
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -11,10 +11,11 @@ export default {
|
|||||||
notice: {
|
notice: {
|
||||||
signupUrl: "https://qoder.com",
|
signupUrl: "https://qoder.com",
|
||||||
},
|
},
|
||||||
deprecated: true,
|
|
||||||
deprecationNotice: "RISK_NOTICE",
|
|
||||||
},
|
},
|
||||||
category: "free",
|
category: "oauth",
|
||||||
|
authModes: ["oauth", "apikey"],
|
||||||
|
hasOAuth: true,
|
||||||
|
authHint: "Personal Access Token (pt-...) từ https://qoder.com/account/integrations",
|
||||||
transport: {
|
transport: {
|
||||||
baseUrl: "https://api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation",
|
baseUrl: "https://api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation",
|
||||||
headers: {},
|
headers: {},
|
||||||
@@ -25,18 +26,19 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
models: [
|
models: [
|
||||||
// { id: "auto", name: "Qoder Auto" },
|
{ id: "ultimate", name: "Ultimate" },
|
||||||
// { id: "ultimate", name: "Qoder Ultimate" },
|
{ id: "auto", name: "Auto" },
|
||||||
// { id: "performance", name: "Qoder Performance" },
|
{ id: "performance", name: "Performance" },
|
||||||
// { id: "efficient", name: "Qoder Efficient" },
|
{ id: "efficient", name: "Efficient" },
|
||||||
// { id: "lite", name: "Qoder Lite" },
|
{ id: "qmodel_preview", name: "Qwen3.8-Max-Preview" },
|
||||||
// { id: "qmodel", name: "Qwen 3.6 Plus (Qoder)" },
|
{ id: "qmodel_latest", name: "Qwen3.7-Max" },
|
||||||
{ id: "qmodel_latest", name: "Qoder Qwen 3.7 Max" },
|
{ id: "qmodel", name: "Qwen3.7-Plus" },
|
||||||
// { id: "dmodel", name: "DeepSeek V4 Pro (Qoder)" },
|
{ id: "kmodel_latest", name: "Kimi-K3" },
|
||||||
// { id: "dfmodel", name: "DeepSeek V4 Flash (Qoder)" },
|
{ id: "kmodel", name: "Kimi-K2.7-Code" },
|
||||||
// { id: "gm51model", name: "GLM 5.1 (Qoder)" },
|
{ id: "gm51model", name: "GLM-5.2" },
|
||||||
// { id: "kmodel", name: "Kimi K2.6 (Qoder)" },
|
{ id: "dmodel", name: "DeepSeek-V4-Pro" },
|
||||||
// { id: "mmodel", name: "MiniMax M2.7 (Qoder)" },
|
{ id: "dfmodel", name: "DeepSeek-V4-Flash" },
|
||||||
|
{ id: "mmodel", name: "MiniMax-M3" },
|
||||||
],
|
],
|
||||||
oauth: {
|
oauth: {
|
||||||
openApiBaseUrl: "https://openapi.qoder.sh",
|
openApiBaseUrl: "https://openapi.qoder.sh",
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Trae (ByteDance marscode) provider registry entry.
|
||||||
|
// Chat = SOLO remote agent API:
|
||||||
|
// POST {base}/chat_sessions → {data:{chat_session_id, message_id}}
|
||||||
|
// GET {base}/chat_sessions/{id}/events?reply_to_message_id=... → SSE
|
||||||
|
// Auth: Authorization: Cloud-IDE-JWT <jwt>
|
||||||
|
export default {
|
||||||
|
id: "trae",
|
||||||
|
alias: "tr",
|
||||||
|
uiAlias: "tr",
|
||||||
|
aliases: ["marscode"],
|
||||||
|
category: "oauth",
|
||||||
|
authType: "oauth",
|
||||||
|
hasOAuth: true,
|
||||||
|
authModes: ["oauth"],
|
||||||
|
display: {
|
||||||
|
name: "Trae",
|
||||||
|
icon: "bolt",
|
||||||
|
color: "#FF6A00",
|
||||||
|
textIcon: "TR",
|
||||||
|
website: "https://www.trae.ai",
|
||||||
|
notice: { signupUrl: "https://www.trae.ai" },
|
||||||
|
},
|
||||||
|
transport: {
|
||||||
|
// SOLO remote agent base — verified working chat endpoint.
|
||||||
|
baseUrl: "https://core-normal.trae.ai/api/remote/v1",
|
||||||
|
format: "openai",
|
||||||
|
headers: {
|
||||||
|
"X-Trae-Client-Type": "web",
|
||||||
|
"X-Preferenced-Language": "en",
|
||||||
|
"Referer": "https://solo.trae.ai/",
|
||||||
|
},
|
||||||
|
// Auth: Cloud-IDE-JWT scheme on Authorization — injected by executor buildHeaders.
|
||||||
|
auth: {
|
||||||
|
combined: true,
|
||||||
|
header: "Authorization",
|
||||||
|
scheme: "Cloud-IDE-JWT",
|
||||||
|
},
|
||||||
|
usage: {
|
||||||
|
url: "https://api.marscode.com/cloudide/api/v3/trae/GetUserInfo",
|
||||||
|
},
|
||||||
|
regions: {
|
||||||
|
cn: "https://api.marscode.com",
|
||||||
|
sg: "https://api.trae.ai",
|
||||||
|
us: "https://www.trae.ai",
|
||||||
|
},
|
||||||
|
defaultRegion: "cn",
|
||||||
|
},
|
||||||
|
oauth: {
|
||||||
|
clientId: "ono9krqynydwx5",
|
||||||
|
clientSecret: "-",
|
||||||
|
platform: "trae",
|
||||||
|
pollInterval: 1500,
|
||||||
|
// Login guidance returns LoginHost for browser open.
|
||||||
|
loginGuidanceUrl: "https://api.marscode.com/cloudide/api/v3/trae/GetLoginGuidance",
|
||||||
|
// ExchangeToken: refresh -> access (POST JSON, body below).
|
||||||
|
tokenUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken",
|
||||||
|
exchangeTokenUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken",
|
||||||
|
refreshUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken",
|
||||||
|
userInfoUrl: "https://api.marscode.com/cloudide/api/v3/trae/GetUserInfo",
|
||||||
|
// Trae refresh uses custom JSON body, not OAuth form — handled by refresh.js, not config-driven.
|
||||||
|
refresh: { encoding: "json" },
|
||||||
|
},
|
||||||
|
// Model catalog (IDE flow, core-normal.trae.ai).
|
||||||
|
models: [
|
||||||
|
{ id: "auto", name: "Auto (Server Picks)" },
|
||||||
|
{ id: "work", name: "Work (Fast)" },
|
||||||
|
{ id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
|
||||||
|
{ id: "gemini-3-flash-solo", name: "Gemini 3 Flash" },
|
||||||
|
{ id: "minimax-m3", name: "MiniMax M3" },
|
||||||
|
{ id: "minimax-m2.7", name: "MiniMax M2.7" },
|
||||||
|
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||||
|
{ id: "gpt-5.4", name: "GPT 5.4" },
|
||||||
|
{ id: "gpt-5.2", name: "GPT 5.2" },
|
||||||
|
],
|
||||||
|
features: { usage: true },
|
||||||
|
};
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// Windsurf provider registry — Firebase+Codeium+Devin auth chain.
|
||||||
|
// Chat = Codeium gRPC-web protobuf:
|
||||||
|
// POST {base} Content-Type: application/grpc-web+proto
|
||||||
|
// Service: exa.language_server_pb.LanguageServerService / GetChatMessage
|
||||||
|
export default {
|
||||||
|
id: "windsurf",
|
||||||
|
alias: "ws",
|
||||||
|
uiAlias: "ws",
|
||||||
|
display: {
|
||||||
|
name: "Windsurf",
|
||||||
|
icon: "surfing",
|
||||||
|
color: "#14B8A6",
|
||||||
|
website: "https://windsurf.com",
|
||||||
|
notice: { signupUrl: "https://windsurf.com" },
|
||||||
|
},
|
||||||
|
category: "oauth",
|
||||||
|
authType: "oauth",
|
||||||
|
hasOAuth: true,
|
||||||
|
authModes: ["oauth", "apikey"],
|
||||||
|
|
||||||
|
transport: {
|
||||||
|
baseUrl: "https://server.codeium.com/exa.language_server_pb.LanguageServerService/GetChatMessage",
|
||||||
|
format: "openai",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/grpc-web+proto",
|
||||||
|
"Accept": "application/grpc-web+proto",
|
||||||
|
"X-Grpc-Web": "1",
|
||||||
|
},
|
||||||
|
// apiKey (sk-ws-... or Firebase-derived) as Bearer + in protobuf Metadata.api_key.
|
||||||
|
auth: { combined: true, header: "Authorization", scheme: "Bearer" },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Auth chain (4 terminal paths, all yield apiKey):
|
||||||
|
// 1) OAuth web → Firebase JWT → POST register.windsurf.com/.../RegisterUser {firebase_id_token} → {apiKey, apiServerUrl, name}
|
||||||
|
// 2) sk-ws-... direct API key (apiKey used as metadata.apiKey on GetUserStatus)
|
||||||
|
// 3) Firebase JWT (eyJ...) → same RegisterUser exchange as #1
|
||||||
|
// 4) Devin auth1_... → self-serve chain → ide_token used as apiKey on server.self-serve.windsurf.com
|
||||||
|
oauth: {
|
||||||
|
clientId: "3GUryQ7ldAeKEuD2obYnppsnmj58eP5u",
|
||||||
|
firebaseApiKey: "AIzaSyDsOl-1XpT5err0Tcn0TFFod1H8gVGIycY",
|
||||||
|
firebaseSignInUrl: "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword",
|
||||||
|
registerUrl: "https://register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser",
|
||||||
|
apiServerUrl: "https://server.codeium.com",
|
||||||
|
auth1ApiServerUrl: "https://server.self-serve.windsurf.com",
|
||||||
|
platform: "windsurf",
|
||||||
|
// Quota (Connect RPC, protobuf): POST windsurf.com/_backend/.../GetPlanStatus,
|
||||||
|
// headers Content-Type:application/proto + Connect-Protocol-Version:1 + X-Auth-Token:<session>,
|
||||||
|
// body = field1:session_token, field2:varint 1.
|
||||||
|
quotaUrl: "https://windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/GetPlanStatus",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Catalog verified against model_configs_v2.bin from Devin CLI (2026.5.x).
|
||||||
|
// Dot-notation ids; the executor MODEL_ALIAS_MAP maps these to Windsurf modelUid.
|
||||||
|
// contextLength dropped — 9router schema uses id+name only.
|
||||||
|
models: [
|
||||||
|
// Cognition / SWE
|
||||||
|
{ id: "swe-1.6-fast", name: "SWE-1.6 Fast" },
|
||||||
|
{ id: "swe-1.6", name: "SWE-1.6" },
|
||||||
|
{ id: "swe-1.5-fast", name: "SWE-1.5 Fast" },
|
||||||
|
{ id: "swe-1.5", name: "SWE-1.5" },
|
||||||
|
// Claude Opus 4.7 — effort-tiered
|
||||||
|
{ id: "claude-opus-4.7-max", name: "Claude Opus 4.7 Max" },
|
||||||
|
{ id: "claude-opus-4.7-xhigh", name: "Claude Opus 4.7 XHigh" },
|
||||||
|
{ id: "claude-opus-4.7-high", name: "Claude Opus 4.7 High" },
|
||||||
|
{ id: "claude-opus-4.7-medium", name: "Claude Opus 4.7 Medium" },
|
||||||
|
{ id: "claude-opus-4.7-low", name: "Claude Opus 4.7 Low" },
|
||||||
|
{ id: "claude-opus-4.7-review", name: "Claude Opus 4.7 Review" },
|
||||||
|
// Claude Sonnet/Opus 4.6
|
||||||
|
{ id: "claude-sonnet-4.6-thinking-1m", name: "Claude Sonnet 4.6 Thinking 1M" },
|
||||||
|
{ id: "claude-sonnet-4.6-1m", name: "Claude Sonnet 4.6 1M" },
|
||||||
|
{ id: "claude-sonnet-4.6-thinking", name: "Claude Sonnet 4.6 Thinking" },
|
||||||
|
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" },
|
||||||
|
{ id: "claude-opus-4.6-thinking", name: "Claude Opus 4.6 Thinking" },
|
||||||
|
{ id: "claude-opus-4.6", name: "Claude Opus 4.6" },
|
||||||
|
// Claude 4.5
|
||||||
|
{ id: "claude-opus-4.5-thinking", name: "Claude Opus 4.5 Thinking" },
|
||||||
|
{ id: "claude-opus-4.5", name: "Claude Opus 4.5" },
|
||||||
|
{ id: "claude-sonnet-4.5-thinking", name: "Claude Sonnet 4.5 Thinking" },
|
||||||
|
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
|
||||||
|
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
|
||||||
|
// GPT-5.5 — effort-tiered
|
||||||
|
{ id: "gpt-5.5-xhigh-fast", name: "GPT-5.5 XHigh Fast" },
|
||||||
|
{ id: "gpt-5.5-xhigh", name: "GPT-5.5 XHigh" },
|
||||||
|
{ id: "gpt-5.5-high-fast", name: "GPT-5.5 High Fast" },
|
||||||
|
{ id: "gpt-5.5-high", name: "GPT-5.5 High" },
|
||||||
|
{ id: "gpt-5.5-medium-fast", name: "GPT-5.5 Medium Fast" },
|
||||||
|
{ id: "gpt-5.5-medium", name: "GPT-5.5 Medium" },
|
||||||
|
{ id: "gpt-5.5-low-fast", name: "GPT-5.5 Low Fast" },
|
||||||
|
{ id: "gpt-5.5-low", name: "GPT-5.5 Low" },
|
||||||
|
{ id: "gpt-5.5-none-fast", name: "GPT-5.5 None Fast" },
|
||||||
|
{ id: "gpt-5.5-none", name: "GPT-5.5 None" },
|
||||||
|
// GPT-5.4 — effort-tiered
|
||||||
|
{ id: "gpt-5.4-xhigh-fast", name: "GPT-5.4 XHigh Fast" },
|
||||||
|
{ id: "gpt-5.4-xhigh", name: "GPT-5.4 XHigh" },
|
||||||
|
{ id: "gpt-5.4-high-fast", name: "GPT-5.4 High Fast" },
|
||||||
|
{ id: "gpt-5.4-high", name: "GPT-5.4 High" },
|
||||||
|
{ id: "gpt-5.4-medium-fast", name: "GPT-5.4 Medium Fast" },
|
||||||
|
{ id: "gpt-5.4-medium", name: "GPT-5.4 Medium" },
|
||||||
|
{ id: "gpt-5.4-low-fast", name: "GPT-5.4 Low Fast" },
|
||||||
|
{ id: "gpt-5.4-low", name: "GPT-5.4 Low" },
|
||||||
|
{ id: "gpt-5.4-none-fast", name: "GPT-5.4 None Fast" },
|
||||||
|
{ id: "gpt-5.4-none", name: "GPT-5.4 None" },
|
||||||
|
{ id: "gpt-5.4-mini-xhigh", name: "GPT-5.4 Mini XHigh" },
|
||||||
|
{ id: "gpt-5.4-mini-high", name: "GPT-5.4 Mini High" },
|
||||||
|
{ id: "gpt-5.4-mini-medium", name: "GPT-5.4 Mini Medium" },
|
||||||
|
{ id: "gpt-5.4-mini-low", name: "GPT-5.4 Mini Low" },
|
||||||
|
// GPT-5.3 Codex
|
||||||
|
{ id: "gpt-5.3-codex-xhigh-fast", name: "GPT-5.3 Codex XHigh Fast" },
|
||||||
|
{ id: "gpt-5.3-codex-xhigh", name: "GPT-5.3 Codex XHigh" },
|
||||||
|
{ id: "gpt-5.3-codex-high-fast", name: "GPT-5.3 Codex High Fast" },
|
||||||
|
{ id: "gpt-5.3-codex-high", name: "GPT-5.3 Codex High" },
|
||||||
|
{ id: "gpt-5.3-codex-medium-fast", name: "GPT-5.3 Codex Medium Fast" },
|
||||||
|
{ id: "gpt-5.3-codex-medium", name: "GPT-5.3 Codex Medium" },
|
||||||
|
{ id: "gpt-5.3-codex-low-fast", name: "GPT-5.3 Codex Low Fast" },
|
||||||
|
{ id: "gpt-5.3-codex-low", name: "GPT-5.3 Codex Low" },
|
||||||
|
// GPT-5.2 / 5
|
||||||
|
{ id: "gpt-5.2-xhigh", name: "GPT-5.2 XHigh" },
|
||||||
|
{ id: "gpt-5.2-high", name: "GPT-5.2 High" },
|
||||||
|
{ id: "gpt-5.2-medium", name: "GPT-5.2 Medium" },
|
||||||
|
{ id: "gpt-5.2-low", name: "GPT-5.2 Low" },
|
||||||
|
{ id: "gpt-5.2-none", name: "GPT-5.2 None" },
|
||||||
|
{ id: "gpt-5", name: "GPT-5" },
|
||||||
|
// GPT-4.1 / 4o
|
||||||
|
{ id: "gpt-4.1", name: "GPT-4.1" },
|
||||||
|
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini" },
|
||||||
|
{ id: "gpt-4.1-nano", name: "GPT-4.1 Nano" },
|
||||||
|
{ id: "gpt-4o", name: "GPT-4o" },
|
||||||
|
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
|
||||||
|
// Gemini
|
||||||
|
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro High" },
|
||||||
|
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro Low" },
|
||||||
|
{ id: "gemini-3.0-flash-high", name: "Gemini 3 Flash High" },
|
||||||
|
{ id: "gemini-3.0-flash-medium", name: "Gemini 3 Flash Medium" },
|
||||||
|
{ id: "gemini-3.0-flash-low", name: "Gemini 3 Flash Low" },
|
||||||
|
{ id: "gemini-3.0-flash-minimal", name: "Gemini 3 Flash Minimal" },
|
||||||
|
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
|
||||||
|
// Others
|
||||||
|
{ id: "deepseek-v4", name: "DeepSeek V4" },
|
||||||
|
{ id: "kimi-k2.6", name: "Kimi K2.6" },
|
||||||
|
{ id: "kimi-k2.5", name: "Kimi K2.5" },
|
||||||
|
{ id: "glm-5.1", name: "GLM-5.1" },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Zed provider — RSA keypair callback auth (NOT standard OAuth).
|
||||||
|
export default {
|
||||||
|
id: "zed",
|
||||||
|
priority: 10,
|
||||||
|
alias: "zd",
|
||||||
|
uiAlias: "zd",
|
||||||
|
hidden: true,
|
||||||
|
display: {
|
||||||
|
name: "Zed",
|
||||||
|
icon: "code",
|
||||||
|
color: "#A855F7",
|
||||||
|
website: "https://zed.dev",
|
||||||
|
notice: {
|
||||||
|
signupUrl: "https://zed.dev/native_app_signin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
category: "oauth",
|
||||||
|
authType: "oauth",
|
||||||
|
hasOAuth: true,
|
||||||
|
|
||||||
|
transport: {
|
||||||
|
// Zed hosted LLM aggregator: cloud.zed.dev/completions is a
|
||||||
|
// multi-format proxy fronting Anthropic/OpenAI/Google/xAI depending on the model.
|
||||||
|
// Wire protocol = NDJSON/SSE-ish stream authenticated with a short-lived LLM bearer
|
||||||
|
// token exchanged from the RSA-decrypted access_token (see open-sse/shared/zedAuth).
|
||||||
|
baseUrl: "https://cloud.zed.dev/completions",
|
||||||
|
format: "openai",
|
||||||
|
forceStream: true,
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
// Auth scheme is non-standard: "Authorization: <user_id> <access_token>" plus a duplicate
|
||||||
|
// x-zed-cloud-token header (verified in zed_account.rs build_authorization_header +
|
||||||
|
// cloud fetch). Executor builds both; scheme here is a marker for config-driven tooling.
|
||||||
|
auth: {
|
||||||
|
combined: true,
|
||||||
|
header: "Authorization",
|
||||||
|
scheme: "<user_id> <access_token>", // placeholder — real value built in executor
|
||||||
|
},
|
||||||
|
usage: {
|
||||||
|
url: "https://cloud.zed.dev/client/users/me", // verified in zed_account.rs
|
||||||
|
},
|
||||||
|
// Live catalog discovery — Zed's hosted model list changes frequently and is fetched
|
||||||
|
// per-connection rather than hardcoded.
|
||||||
|
modelsUrl: "https://cloud.zed.dev/models",
|
||||||
|
},
|
||||||
|
|
||||||
|
// Empty static catalog + passthrough: Zed fronts a rotating set of upstream models
|
||||||
|
// (Claude/GPT/Gemini/Grok). Resolved live via modelsUrl; any client-sent model id is
|
||||||
|
// forwarded as-is rather than validated against a frozen list.
|
||||||
|
models: [],
|
||||||
|
passthroughModels: true,
|
||||||
|
|
||||||
|
oauth: {
|
||||||
|
// Zed auth flow is RSA-based, NOT OAuth2/PKCE:
|
||||||
|
// 1. App generates RSA-2048 keypair locally (PKCS#1 DER, URL-safe base64).
|
||||||
|
// 2. Bind random TCP port on 127.0.0.1.
|
||||||
|
// 3. Open https://zed.dev/native_app_signin?native_app_port={port}&native_app_public_key={pub}.
|
||||||
|
// 4. After login, browser redirects http://127.0.0.1:{port}/?user_id=...&access_token=...
|
||||||
|
// where access_token = base64(RSA-encrypted plaintext token).
|
||||||
|
// 5. Decrypt with private key (OAEP-SHA256, fallback PKCS1v15). Store user_id + plaintext token.
|
||||||
|
// No clientId/clientSecret/tokenUrl/refreshUrl — long-lived access_token, no refresh.
|
||||||
|
authorizeUrl: "https://zed.dev/native_app_signin",
|
||||||
|
platform: "zed",
|
||||||
|
rsaKeyExchange: true, // new flag: signals frontend/router this flow needs local RSA + TCP listener.
|
||||||
|
},
|
||||||
|
|
||||||
|
features: {
|
||||||
|
usage: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -58,7 +58,7 @@ export const ANTHROPIC_COMPAT_BASE = "https://api.anthropic.com/v1";
|
|||||||
// Keep this static even when 9router runs on Linux: the provider profile is
|
// Keep this static even when 9router runs on Linux: the provider profile is
|
||||||
// intentionally matching the IDE client, not the server host.
|
// intentionally matching the IDE client, not the server host.
|
||||||
export const ANTIGRAVITY_IDE_VERSION = "2.1.1";
|
export const ANTIGRAVITY_IDE_VERSION = "2.1.1";
|
||||||
export const ANTIGRAVITY_IDE_BASE_URL = "https://cloudcode-pa.googleapis.com";
|
export const ANTIGRAVITY_IDE_BASE_URL = "https://daily-cloudcode-pa.googleapis.com";
|
||||||
export const ANTIGRAVITY_IDE_USER_AGENT = `antigravity/ide/${ANTIGRAVITY_IDE_VERSION} darwin/arm64`;
|
export const ANTIGRAVITY_IDE_USER_AGENT = `antigravity/ide/${ANTIGRAVITY_IDE_VERSION} darwin/arm64`;
|
||||||
|
|
||||||
// Antigravity OAuth client credentials (public CLI client — duplicated in usage.js + src/lib/oauth)
|
// Antigravity OAuth client credentials (public CLI client — duplicated in usage.js + src/lib/oauth)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Reuses capabilities.js (thinkingFormat/canDisable) so this file only maps format→levels (DRY).
|
// Reuses capabilities.js (thinkingFormat/canDisable) so this file only maps format→levels (DRY).
|
||||||
import { getCapabilitiesForModel } from "./capabilities.js";
|
import { getCapabilitiesForModel } from "./capabilities.js";
|
||||||
import { matchPattern } from "./pricing.js";
|
import { matchPattern } from "./pricing.js";
|
||||||
|
import { resolveKiroEffortPath } from "../config/kiroConstants.js";
|
||||||
|
|
||||||
// Shared level sets (deduped) — verified against provider docs + wire in thinkingUnified.applyFormat.
|
// Shared level sets (deduped) — verified against provider docs + wire in thinkingUnified.applyFormat.
|
||||||
const L = {
|
const L = {
|
||||||
@@ -39,6 +40,7 @@ const PATTERN_THINKING = [
|
|||||||
|
|
||||||
// Returns valid thinking levels for a model, or null when the model has no reasoning.
|
// Returns valid thinking levels for a model, or null when the model has no reasoning.
|
||||||
export function getThinkingLevels(provider, model) {
|
export function getThinkingLevels(provider, model) {
|
||||||
|
if (provider === "kiro" && resolveKiroEffortPath(model) === null) return null;
|
||||||
const caps = getCapabilitiesForModel(provider, model);
|
const caps = getCapabilitiesForModel(provider, model);
|
||||||
if (!caps.reasoning) return null;
|
if (!caps.reasoning) return null;
|
||||||
const hit = PATTERN_THINKING.find((p) => matchPattern(p.pattern, model));
|
const hit = PATTERN_THINKING.find((p) => matchPattern(p.pattern, model));
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ startCacheCleanup();
|
|||||||
* @param {string} accessToken - Valid OAuth access token
|
* @param {string} accessToken - Valid OAuth access token
|
||||||
* @returns {Promise<string|null>} Real project ID or null
|
* @returns {Promise<string|null>} Real project ID or null
|
||||||
*/
|
*/
|
||||||
export async function getProjectIdForConnection(connectionId, accessToken) {
|
export async function getProjectIdForConnection(connectionId, accessToken, provider = "gemini-cli") {
|
||||||
if (!connectionId || !accessToken) return null;
|
if (!connectionId || !accessToken) return null;
|
||||||
|
|
||||||
// Return cached value if still fresh
|
// Return cached value if still fresh
|
||||||
@@ -102,7 +102,7 @@ export async function getProjectIdForConnection(connectionId, accessToken) {
|
|||||||
|
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
try {
|
try {
|
||||||
const projectId = await fetchProjectId(accessToken, controller.signal);
|
const projectId = await fetchProjectId(accessToken, controller.signal, provider);
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
projectIdCache.set(connectionId, {projectId, fetchedAt: Date.now()});
|
projectIdCache.set(connectionId, {projectId, fetchedAt: Date.now()});
|
||||||
return projectId;
|
return projectId;
|
||||||
@@ -155,8 +155,9 @@ export function removeConnection(connectionId) {
|
|||||||
* @param {AbortSignal} signal
|
* @param {AbortSignal} signal
|
||||||
* @returns {Promise<string|null>}
|
* @returns {Promise<string|null>}
|
||||||
*/
|
*/
|
||||||
async function fetchProjectId(accessToken, signal) {
|
async function fetchProjectId(accessToken, signal, provider) {
|
||||||
const response = await fetch(CLOUD_CODE_API.loadCodeAssist, {
|
const endpoints = CLOUD_CODE_API[provider] || CLOUD_CODE_API["gemini-cli"];
|
||||||
|
const response = await fetch(endpoints.loadCodeAssist, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` },
|
headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` },
|
||||||
body: JSON.stringify({ metadata: LOAD_CODE_ASSIST_METADATA }),
|
body: JSON.stringify({ metadata: LOAD_CODE_ASSIST_METADATA }),
|
||||||
@@ -185,7 +186,7 @@ async function fetchProjectId(accessToken, signal) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return onboardUser(accessToken, tierID, signal);
|
return onboardUser(accessToken, tierID, signal, endpoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -196,7 +197,7 @@ async function fetchProjectId(accessToken, signal) {
|
|||||||
* @param {AbortSignal} externalSignal – propagated from the connection's AbortController
|
* @param {AbortSignal} externalSignal – propagated from the connection's AbortController
|
||||||
* @returns {Promise<string|null>}
|
* @returns {Promise<string|null>}
|
||||||
*/
|
*/
|
||||||
async function onboardUser(accessToken, tierID, externalSignal) {
|
async function onboardUser(accessToken, tierID, externalSignal, endpoints) {
|
||||||
console.log(`[ProjectId] Onboarding user with tier: ${tierID}`);
|
console.log(`[ProjectId] Onboarding user with tier: ${tierID}`);
|
||||||
|
|
||||||
const reqBody = { tierId: tierID, metadata: LOAD_CODE_ASSIST_METADATA };
|
const reqBody = { tierId: tierID, metadata: LOAD_CODE_ASSIST_METADATA };
|
||||||
@@ -213,7 +214,7 @@ async function onboardUser(accessToken, tierID, externalSignal) {
|
|||||||
externalSignal?.addEventListener("abort", forwardAbort);
|
externalSignal?.addEventListener("abort", forwardAbort);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(CLOUD_CODE_API.onboardUser, {
|
const response = await fetch(endpoints.onboardUser, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` },
|
headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` },
|
||||||
body: JSON.stringify(reqBody),
|
body: JSON.stringify(reqBody),
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import {
|
|||||||
refreshGitHubToken,
|
refreshGitHubToken,
|
||||||
refreshCopilotToken,
|
refreshCopilotToken,
|
||||||
refreshCodebuddyToken,
|
refreshCodebuddyToken,
|
||||||
|
refreshCodebuddyIntlToken,
|
||||||
|
refreshTraeToken,
|
||||||
|
refreshZedToken,
|
||||||
|
refreshWindsurfToken,
|
||||||
classifyOAuthRefreshError,
|
classifyOAuthRefreshError,
|
||||||
} from "./tokenRefresh/providers.js";
|
} from "./tokenRefresh/providers.js";
|
||||||
|
|
||||||
@@ -29,6 +33,10 @@ export {
|
|||||||
refreshGitHubToken,
|
refreshGitHubToken,
|
||||||
refreshCopilotToken,
|
refreshCopilotToken,
|
||||||
refreshCodebuddyToken,
|
refreshCodebuddyToken,
|
||||||
|
refreshCodebuddyIntlToken,
|
||||||
|
refreshTraeToken,
|
||||||
|
refreshZedToken,
|
||||||
|
refreshWindsurfToken,
|
||||||
classifyOAuthRefreshError,
|
classifyOAuthRefreshError,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -138,6 +146,10 @@ const REFRESH_HANDLERS = {
|
|||||||
"grok-cli": (c, log) => refreshXaiToken(c.refreshToken, log),
|
"grok-cli": (c, log) => refreshXaiToken(c.refreshToken, log),
|
||||||
gcli: (c, log) => refreshXaiToken(c.refreshToken, log),
|
gcli: (c, log) => refreshXaiToken(c.refreshToken, log),
|
||||||
"codebuddy-cn": (c, log) => refreshCodebuddyToken(c.refreshToken, log),
|
"codebuddy-cn": (c, log) => refreshCodebuddyToken(c.refreshToken, log),
|
||||||
|
"codebuddy-intl": (c, log) => refreshCodebuddyIntlToken(c.refreshToken, log),
|
||||||
|
trae: (c, log) => refreshTraeToken(c.refreshToken, c, log),
|
||||||
|
zed: () => refreshZedToken(),
|
||||||
|
windsurf: (c, log) => refreshWindsurfToken(c, log),
|
||||||
// Kimi Code OAuth (merged into id `kimi`); legacy id still routes here
|
// Kimi Code OAuth (merged into id `kimi`); legacy id still routes here
|
||||||
kimi: (c, log) => refreshKimiToken(c.refreshToken, c, log),
|
kimi: (c, log) => refreshKimiToken(c.refreshToken, c, log),
|
||||||
"kimi-coding": (c, log) => refreshKimiToken(c.refreshToken, c, log),
|
"kimi-coding": (c, log) => refreshKimiToken(c.refreshToken, c, log),
|
||||||
|
|||||||
@@ -31,10 +31,68 @@ export async function refreshXaiToken(refreshToken, log) {
|
|||||||
}, log);
|
}, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-provider refresh variants for the generic path. Keys not listed fall back
|
||||||
|
// to the default form-encoded OAuth2 refresh with client_id + client_secret.
|
||||||
|
const REFRESH_PROFILES = {
|
||||||
|
claude: {
|
||||||
|
bodyFormat: "json",
|
||||||
|
includeClientSecret: false,
|
||||||
|
url: () => OAUTH_ENDPOINTS.anthropic.token,
|
||||||
|
dedupKey: "claude",
|
||||||
|
},
|
||||||
|
qwen: {
|
||||||
|
url: () => OAUTH_ENDPOINTS.qwen.token,
|
||||||
|
dedupKey: "qwen",
|
||||||
|
parse: (tokens) => tokens.resource_url ? { providerSpecificData: { resourceUrl: tokens.resource_url } } : {},
|
||||||
|
},
|
||||||
|
iflow: {
|
||||||
|
url: () => OAUTH_ENDPOINTS.iflow.token,
|
||||||
|
dedupKey: "iflow",
|
||||||
|
extraHeaders: (creds, cfg) => ({
|
||||||
|
Authorization: `Basic ${btoa(`${cfg.clientId}:${cfg.clientSecret}`)}`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
github: {
|
||||||
|
url: () => OAUTH_ENDPOINTS.github.token,
|
||||||
|
dedupKey: "github",
|
||||||
|
includeClientSecret: (cfg) => !!cfg?.clientSecret,
|
||||||
|
},
|
||||||
|
kimi: {
|
||||||
|
dedupKey: "kimi",
|
||||||
|
extraHeaders: (creds) => buildKimiHeaders(creds?.providerSpecificData?.deviceId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveRefreshUrl(provider, config, profile) {
|
||||||
|
if (profile?.url) {
|
||||||
|
try { return profile.url(); } catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
return config?.refreshUrl || PROVIDER_OAUTH[provider]?.tokenUrl || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRefreshBody(profile, config, refreshToken) {
|
||||||
|
const fmt = profile?.bodyFormat === "json" ? "json" : "form";
|
||||||
|
const includeSecret = profile?.includeClientSecret === undefined
|
||||||
|
? true
|
||||||
|
: typeof profile.includeClientSecret === "function"
|
||||||
|
? profile.includeClientSecret(config)
|
||||||
|
: profile.includeClientSecret;
|
||||||
|
const payload = {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
client_id: config.clientId,
|
||||||
|
};
|
||||||
|
if (includeSecret && config.clientSecret) payload.client_secret = config.clientSecret;
|
||||||
|
if (fmt === "json") return { format: "json", body: JSON.stringify(payload) };
|
||||||
|
return { format: "form", body: new URLSearchParams(payload) };
|
||||||
|
}
|
||||||
|
|
||||||
export async function refreshAccessToken(provider, refreshToken, credentials, log) {
|
export async function refreshAccessToken(provider, refreshToken, credentials, log) {
|
||||||
const config = PROVIDERS[provider];
|
const config = PROVIDERS[provider];
|
||||||
|
const profile = REFRESH_PROFILES[provider] || {};
|
||||||
|
const url = resolveRefreshUrl(provider, config, profile);
|
||||||
|
|
||||||
if (!config || !config.refreshUrl) {
|
if (!config || !url) {
|
||||||
log?.warn?.("TOKEN_REFRESH", `No refresh URL configured for provider: ${provider}`);
|
log?.warn?.("TOKEN_REFRESH", `No refresh URL configured for provider: ${provider}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -44,21 +102,17 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return dedupRefresh(provider, refreshToken, async () => {
|
const dedupKey = profile.dedupKey || provider;
|
||||||
|
|
||||||
|
return dedupRefresh(dedupKey, refreshToken, async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(config.refreshUrl, {
|
const { format: bodyFormat, body } = buildRefreshBody(profile, config, refreshToken);
|
||||||
method: "POST",
|
const headers = {
|
||||||
headers: {
|
"Content-Type": bodyFormat === "json" ? "application/json" : "application/x-www-form-urlencoded",
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
Accept: "application/json",
|
||||||
Accept: "application/json",
|
...(profile.extraHeaders ? (profile.extraHeaders(credentials, config) || {}) : {}),
|
||||||
},
|
};
|
||||||
body: new URLSearchParams({
|
const response = await fetch(url, { method: "POST", headers, body });
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
client_id: config.clientId,
|
|
||||||
client_secret: config.clientSecret,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
@@ -81,6 +135,7 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo
|
|||||||
accessToken: tokens.access_token,
|
accessToken: tokens.access_token,
|
||||||
refreshToken: tokens.refresh_token || refreshToken,
|
refreshToken: tokens.refresh_token || refreshToken,
|
||||||
expiresIn: tokens.expires_in,
|
expiresIn: tokens.expires_in,
|
||||||
|
...(profile.parse ? (profile.parse(tokens) || {}) : {}),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, {
|
log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, {
|
||||||
@@ -92,82 +147,14 @@ export async function refreshAccessToken(provider, refreshToken, credentials, lo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CLIProxyAPI DeviceFlowClient.RefreshToken: form body (no client_secret) + X-Msh-* headers
|
// CLIProxyAPI DeviceFlowClient.RefreshToken: form body (no client_secret) + X-Msh-* headers
|
||||||
|
// Delegate to refreshAccessToken("kimi", ...) — profile carries the X-Msh headers.
|
||||||
export async function refreshKimiToken(refreshToken, credentials, log) {
|
export async function refreshKimiToken(refreshToken, credentials, log) {
|
||||||
const config = PROVIDERS.kimi;
|
return refreshAccessToken("kimi", refreshToken, credentials, log);
|
||||||
if (!config?.refreshUrl || !config?.clientId) {
|
|
||||||
log?.warn?.("TOKEN_REFRESH", "No Kimi refresh URL/clientId configured");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!refreshToken) return null;
|
|
||||||
|
|
||||||
return dedupRefresh("kimi", refreshToken, async () => {
|
|
||||||
try {
|
|
||||||
const headers = {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
Accept: "application/json",
|
|
||||||
...buildKimiHeaders(credentials?.providerSpecificData?.deviceId),
|
|
||||||
};
|
|
||||||
const response = await fetch(config.refreshUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
client_id: config.clientId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
log?.error?.("TOKEN_REFRESH", `Failed to refresh token for kimi`, {
|
|
||||||
status: response.status,
|
|
||||||
error: errorText,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const tokens = await response.json();
|
|
||||||
return {
|
|
||||||
accessToken: tokens.access_token,
|
|
||||||
refreshToken: tokens.refresh_token || refreshToken,
|
|
||||||
expiresIn: tokens.expires_in,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
log?.error?.("TOKEN_REFRESH", `Error refreshing token for kimi`, { error: error.message });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, log);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Claude OAuth: JSON body, client_id only. Delegate to refreshAccessToken("claude", ...).
|
||||||
export async function refreshClaudeOAuthToken(refreshToken, log) {
|
export async function refreshClaudeOAuthToken(refreshToken, log) {
|
||||||
if (!refreshToken) return null;
|
return refreshAccessToken("claude", refreshToken, {}, log);
|
||||||
return dedupRefresh("claude", refreshToken, async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
client_id: PROVIDERS.claude.clientId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", { status: response.status, error: errorText });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = await response.json();
|
|
||||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in });
|
|
||||||
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in };
|
|
||||||
} catch (error) {
|
|
||||||
log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, log);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) {
|
export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) {
|
||||||
@@ -204,58 +191,9 @@ export async function refreshGoogleToken(refreshToken, clientId, clientSecret, l
|
|||||||
}, log);
|
}, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Qwen: form body + clientId, surfaces resource_url. Delegate to refreshAccessToken("qwen", ...).
|
||||||
export async function refreshQwenToken(refreshToken, log) {
|
export async function refreshQwenToken(refreshToken, log) {
|
||||||
if (!refreshToken) return null;
|
return refreshAccessToken("qwen", refreshToken, {}, log);
|
||||||
return dedupRefresh("qwen", refreshToken, async () => {
|
|
||||||
const endpoint = OAUTH_ENDPOINTS.qwen.token;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(endpoint, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
client_id: PROVIDERS.qwen.clientId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status === 200) {
|
|
||||||
const tokens = await response.json();
|
|
||||||
|
|
||||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qwen token", {
|
|
||||||
hasNewAccessToken: !!tokens.access_token,
|
|
||||||
hasNewRefreshToken: !!tokens.refresh_token,
|
|
||||||
expiresIn: tokens.expires_in,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
accessToken: tokens.access_token,
|
|
||||||
refreshToken: tokens.refresh_token || refreshToken,
|
|
||||||
expiresIn: tokens.expires_in,
|
|
||||||
providerSpecificData: tokens.resource_url
|
|
||||||
? { resourceUrl: tokens.resource_url }
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const errorText = await response.text().catch(() => "");
|
|
||||||
log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, {
|
|
||||||
status: response.status,
|
|
||||||
error: errorText,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, {
|
|
||||||
error: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token");
|
|
||||||
return null;
|
|
||||||
}, log);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function classifyOAuthRefreshError(errorText = "", status = 0) {
|
export function classifyOAuthRefreshError(errorText = "", status = 0) {
|
||||||
@@ -480,95 +418,14 @@ export async function refreshKiroToken(refreshToken, providerSpecificData, log,
|
|||||||
}, log);
|
}, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// iFlow: Basic Auth + client_id+client_secret in body. Delegate to refreshAccessToken("iflow", ...).
|
||||||
export async function refreshIflowToken(refreshToken, log) {
|
export async function refreshIflowToken(refreshToken, log) {
|
||||||
if (!refreshToken) return null;
|
return refreshAccessToken("iflow", refreshToken, {}, log);
|
||||||
return dedupRefresh("iflow", refreshToken, async () => {
|
|
||||||
const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`);
|
|
||||||
|
|
||||||
const response = await fetch(OAUTH_ENDPOINTS.iflow.token, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
Accept: "application/json",
|
|
||||||
Authorization: `Basic ${basicAuth}`,
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
client_id: PROVIDERS.iflow.clientId,
|
|
||||||
client_secret: PROVIDERS.iflow.clientSecret,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh iFlow token", {
|
|
||||||
status: response.status,
|
|
||||||
error: errorText,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = await response.json();
|
|
||||||
|
|
||||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed iFlow token", {
|
|
||||||
hasNewAccessToken: !!tokens.access_token,
|
|
||||||
hasNewRefreshToken: !!tokens.refresh_token,
|
|
||||||
expiresIn: tokens.expires_in,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
accessToken: tokens.access_token,
|
|
||||||
refreshToken: tokens.refresh_token || refreshToken,
|
|
||||||
expiresIn: tokens.expires_in,
|
|
||||||
};
|
|
||||||
}, log);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GitHub: optional client_secret. Delegate to refreshAccessToken("github", ...).
|
||||||
export async function refreshGitHubToken(refreshToken, log) {
|
export async function refreshGitHubToken(refreshToken, log) {
|
||||||
if (!refreshToken) return null;
|
return refreshAccessToken("github", refreshToken, {}, log);
|
||||||
return dedupRefresh("github", refreshToken, async () => {
|
|
||||||
const params = {
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
client_id: PROVIDERS.github.clientId,
|
|
||||||
};
|
|
||||||
if (PROVIDERS.github.clientSecret) {
|
|
||||||
params.client_secret = PROVIDERS.github.clientSecret;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(OAUTH_ENDPOINTS.github.token, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
body: new URLSearchParams(params),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh GitHub token", {
|
|
||||||
status: response.status,
|
|
||||||
error: errorText,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = await response.json();
|
|
||||||
|
|
||||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitHub token", {
|
|
||||||
hasNewAccessToken: !!tokens.access_token,
|
|
||||||
hasNewRefreshToken: !!tokens.refresh_token,
|
|
||||||
expiresIn: tokens.expires_in,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
accessToken: tokens.access_token,
|
|
||||||
refreshToken: tokens.refresh_token || refreshToken,
|
|
||||||
expiresIn: tokens.expires_in,
|
|
||||||
};
|
|
||||||
}, log);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshCopilotToken(githubAccessToken, log) {
|
export async function refreshCopilotToken(githubAccessToken, log) {
|
||||||
@@ -668,3 +525,146 @@ export async function refreshCodebuddyToken(refreshToken, log) {
|
|||||||
};
|
};
|
||||||
}, log);
|
}, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refreshCodebuddyIntlToken(refreshToken, log) {
|
||||||
|
if (!refreshToken) return null;
|
||||||
|
return dedupRefresh("codebuddy-intl", refreshToken, async () => {
|
||||||
|
const oauth = PROVIDER_OAUTH["codebuddy-intl"] || {};
|
||||||
|
const response = await fetch(oauth.refreshUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
"User-Agent": oauth.userAgent,
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
"X-Domain": "www.codebuddy.ai",
|
||||||
|
"X-Refresh-Token": refreshToken,
|
||||||
|
"X-Auth-Refresh-Source": "plugin",
|
||||||
|
"X-Product": "SaaS",
|
||||||
|
},
|
||||||
|
body: "{}",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh CodeBuddy intl token", {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.code !== 0 || !data.data?.accessToken) {
|
||||||
|
log?.error?.("TOKEN_REFRESH", "CodeBuddy intl token refresh returned no token", {
|
||||||
|
code: data.code,
|
||||||
|
msg: data.msg,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed CodeBuddy intl token", {
|
||||||
|
hasNewAccessToken: !!data.data.accessToken,
|
||||||
|
hasNewRefreshToken: !!data.data.refreshToken,
|
||||||
|
expiresIn: data.data.expiresIn,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: data.data.accessToken,
|
||||||
|
refreshToken: data.data.refreshToken || refreshToken,
|
||||||
|
expiresIn: data.data.expiresIn,
|
||||||
|
};
|
||||||
|
}, log);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trae refresh — POST ExchangeToken with JSON body {ClientID, RefreshToken, ClientSecret, UserID}.
|
||||||
|
// Response: {Result: {AccessToken, RefreshToken, TokenType, ExpiresAt}}.
|
||||||
|
export async function refreshTraeToken(refreshToken, credentials, log) {
|
||||||
|
if (!refreshToken) return null;
|
||||||
|
const oauth = PROVIDER_OAUTH.trae || {};
|
||||||
|
const url = oauth.exchangeTokenUrl || oauth.tokenUrl;
|
||||||
|
if (!url) {
|
||||||
|
log?.warn?.("TOKEN_REFRESH", "No Trae exchangeTokenUrl configured");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dedupRefresh("trae", refreshToken, async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
"User-Agent": "Trae/1.0.0 antigravity-cockpit-tools",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
ClientID: oauth.clientId || "ono9krqynydwx5",
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
ClientSecret: oauth.clientSecret || "-",
|
||||||
|
UserID: "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Trae token", {
|
||||||
|
status: response.status,
|
||||||
|
error: errorText,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
const result = payload?.Result || payload?.result || payload;
|
||||||
|
const accessToken = result?.AccessToken || result?.accessToken;
|
||||||
|
if (!accessToken) {
|
||||||
|
log?.error?.("TOKEN_REFRESH", "Trae refresh returned no AccessToken", { payload });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRefresh = result?.RefreshToken || result?.refreshToken || refreshToken;
|
||||||
|
const expiresAt = result?.ExpiresAt || result?.expiresAt;
|
||||||
|
let expiresIn;
|
||||||
|
if (typeof expiresAt === "number") {
|
||||||
|
expiresIn = Math.max(1, expiresAt - Math.floor(Date.now() / 1000));
|
||||||
|
} else if (typeof expiresAt === "string") {
|
||||||
|
const ms = new Date(expiresAt).getTime() - Date.now();
|
||||||
|
expiresIn = ms > 0 ? Math.floor(ms / 1000) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Trae token", {
|
||||||
|
hasNewAccessToken: !!accessToken,
|
||||||
|
hasNewRefreshToken: newRefresh !== refreshToken,
|
||||||
|
expiresIn,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken: newRefresh,
|
||||||
|
expiresIn,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
log?.error?.("TOKEN_REFRESH", `Error refreshing Trae token: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, log);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zed access_token is long-lived; auth flow returns no refresh_token.
|
||||||
|
// No refresh possible — re-login required when token expires/revoked.
|
||||||
|
// Mirrors cursor/kilocode null-refresh pattern.
|
||||||
|
export function refreshZedToken() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windsurf apiKey is the long-lived terminal credential (no OAuth2 refresh_token
|
||||||
|
// grant yields a fresh apiKey). Refresh handled out-of-band by the caller.
|
||||||
|
// TODO(firebase): if short-lived Firebase JWT credentials must be refreshed,
|
||||||
|
// re-run RegisterUser with the refreshed Firebase JWT (separate code path).
|
||||||
|
export async function refreshWindsurfToken(credentials, log) {
|
||||||
|
log?.info?.(
|
||||||
|
"TOKEN_REFRESH",
|
||||||
|
"windsurf: apiKey is long-lived (no refresh_token flow) — skipping"
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { getMiniMaxUsage } from "./usage/minimax.js";
|
|||||||
import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.js";
|
import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.js";
|
||||||
import { getGrokCliUsage } from "./usage/grok-cli.js";
|
import { getGrokCliUsage } from "./usage/grok-cli.js";
|
||||||
import { getOrbitUsage } from "./usage/orbit.js";
|
import { getOrbitUsage } from "./usage/orbit.js";
|
||||||
|
import { getKimiUsage } from "./usage/kimi.js";
|
||||||
|
import { getDeepseekUsage } from "./usage/deepseek.js";
|
||||||
import {
|
import {
|
||||||
getQwenUsage,
|
getQwenUsage,
|
||||||
getIflowUsage,
|
getIflowUsage,
|
||||||
@@ -47,6 +49,8 @@ const USAGE_HANDLERS = {
|
|||||||
"codebuddy-cn": (c) => getCodeBuddyCnUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions),
|
"codebuddy-cn": (c) => getCodeBuddyCnUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions),
|
||||||
"grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
"grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
||||||
"orbit-provider": (c) => getOrbitUsage(c.apiKey, c.proxyOptions),
|
"orbit-provider": (c) => getOrbitUsage(c.apiKey, c.proxyOptions),
|
||||||
|
kimi: (c) => getKimiUsage(c.accessToken, c.apiKey, c.proxyOptions, c.providerSpecificData),
|
||||||
|
deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions),
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getUsageForProvider(connection, proxyOptions = null) {
|
export async function getUsageForProvider(connection, proxyOptions = null) {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* DeepSeek usage — GET https://api.deepseek.com/user/balance
|
||||||
|
* Auth: Bearer <apiKey>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||||
|
import { toFiniteNumber } from "./shared.js";
|
||||||
|
|
||||||
|
const BALANCE_URL = "https://api.deepseek.com/user/balance";
|
||||||
|
|
||||||
|
function parseBalanceInfos(data) {
|
||||||
|
const list = Array.isArray(data?.balance_infos) ? data.balance_infos : [];
|
||||||
|
const results = [];
|
||||||
|
for (const item of list) {
|
||||||
|
if (!item || typeof item !== "object") continue;
|
||||||
|
const currency =
|
||||||
|
typeof item.currency === "string" ? item.currency.toUpperCase() : "";
|
||||||
|
if (!currency) continue;
|
||||||
|
const totalBalance = toFiniteNumber(
|
||||||
|
item.total_balance ?? item.totalBalance,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
results.push({
|
||||||
|
currency,
|
||||||
|
totalBalance,
|
||||||
|
grantedBalance: toFiniteNumber(
|
||||||
|
item.granted_balance ?? item.grantedBalance,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
toppedUpBalance: toFiniteNumber(
|
||||||
|
item.topped_up_balance ?? item.toppedUpBalance,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string|null|undefined} apiKey
|
||||||
|
* @param {object|null} proxyOptions
|
||||||
|
*/
|
||||||
|
export async function getDeepseekUsage(apiKey = null, proxyOptions = null) {
|
||||||
|
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
|
||||||
|
return { message: "DeepSeek API key not available. Add a key to view usage." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await proxyAwareFetch(
|
||||||
|
BALANCE_URL,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey.trim()}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
proxyOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
return {
|
||||||
|
plan: "DeepSeek",
|
||||||
|
message: "DeepSeek authentication failed. Check the API key.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errText = await response.text().catch(() => "");
|
||||||
|
return {
|
||||||
|
plan: "DeepSeek",
|
||||||
|
message: `DeepSeek balance API error (${response.status})${errText ? `: ${errText.slice(0, 120)}` : ""}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!data || typeof data !== "object") {
|
||||||
|
return { message: "DeepSeek balance response was not JSON." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const balances = parseBalanceInfos(data);
|
||||||
|
if (balances.length === 0) {
|
||||||
|
return {
|
||||||
|
plan: "DeepSeek",
|
||||||
|
message: "DeepSeek connected. No balance data returned.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAvailable = data.is_available === true || data.isAvailable === true;
|
||||||
|
const quotas = {};
|
||||||
|
for (const b of balances) {
|
||||||
|
const total = Math.max(0, b.totalBalance);
|
||||||
|
// Credit pot: show full remaining against current balance; never set absolute
|
||||||
|
// `remaining` — QuotaTable treats it as a 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}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,11 +29,19 @@ import {
|
|||||||
GROK_CLI_USER_AGENT,
|
GROK_CLI_USER_AGENT,
|
||||||
GROK_CLI_VERSION,
|
GROK_CLI_VERSION,
|
||||||
} from "../../config/grokCli.js";
|
} from "../../config/grokCli.js";
|
||||||
|
import { decodeGrokCreditsFrame } from "./grokCliQuotaFrame.js";
|
||||||
|
|
||||||
const USAGE = U("grok-cli");
|
const USAGE = U("grok-cli");
|
||||||
const BILLING_URL = USAGE.url || "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
|
const BILLING_URL = USAGE.url || "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
|
||||||
const USER_URL = USAGE.userUrl || "https://cli-chat-proxy.grok.com/v1/user?include=subscription";
|
const USER_URL = USAGE.userUrl || "https://cli-chat-proxy.grok.com/v1/user?include=subscription";
|
||||||
|
|
||||||
|
// SuperGrok weekly pool.
|
||||||
|
const GRPC_CREDITS_URL =
|
||||||
|
"https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig";
|
||||||
|
// Empty gRPC-web request frame (flag 0 + length 0). Without it upstream returns
|
||||||
|
// grpc-status 13 "Missing request message." with a 0-byte body.
|
||||||
|
const GRPC_WEB_EMPTY_REQUEST_FRAME = Buffer.from([0, 0, 0, 0, 0]);
|
||||||
|
|
||||||
/** Unwrap protobuf-json `{ val: n }` or plain numbers/strings. */
|
/** Unwrap protobuf-json `{ val: n }` or plain numbers/strings. */
|
||||||
function unwrapVal(value, fallback = 0) {
|
function unwrapVal(value, fallback = 0) {
|
||||||
if (value == null) return fallback;
|
if (value == null) return fallback;
|
||||||
@@ -198,6 +206,21 @@ export function parseGrokCliBilling(billing, user = null) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SuperGrok weekly shared-pool usage (subscription tier). creditUsagePercent is
|
||||||
|
// the single total used %; productUsage is a breakdown legend, NOT independent
|
||||||
|
// quotas — never split it into separate bars.
|
||||||
|
const usedPct = unwrapVal(
|
||||||
|
config.creditUsagePercent ?? config.credit_usage_percent ?? root.creditUsagePercent,
|
||||||
|
NaN,
|
||||||
|
);
|
||||||
|
if (Number.isFinite(usedPct) && usedPct >= 0) {
|
||||||
|
quotas["Weekly SuperGrok"] = makeQuota({
|
||||||
|
used: Math.max(0, Math.min(100, usedPct)),
|
||||||
|
total: 100,
|
||||||
|
resetAt: periodEnd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Opportunistic richer credit envelopes (future / other account types)
|
// Opportunistic richer credit envelopes (future / other account types)
|
||||||
const creditBags = [
|
const creditBags = [
|
||||||
root.credits,
|
root.credits,
|
||||||
@@ -256,6 +279,50 @@ export function parseGrokCliBilling(billing, user = null) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live SuperGrok weekly pool via gRPC-web GetGrokCreditsConfig.
|
||||||
|
* Fail-open: any network/auth/parse failure returns null.
|
||||||
|
* @returns {{ percentUsed: number, resetAt: string|null } | null}
|
||||||
|
*/
|
||||||
|
export async function fetchGrokCliCreditsConfig(accessToken, proxyOptions = null) {
|
||||||
|
if (!accessToken) return null;
|
||||||
|
try {
|
||||||
|
const res = await proxyAwareFetch(
|
||||||
|
GRPC_CREDITS_URL,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
"Content-Type": "application/grpc-web+proto",
|
||||||
|
"X-Grpc-Web": "1",
|
||||||
|
Accept: "application/grpc-web+proto",
|
||||||
|
},
|
||||||
|
body: GRPC_WEB_EMPTY_REQUEST_FRAME,
|
||||||
|
},
|
||||||
|
proxyOptions,
|
||||||
|
);
|
||||||
|
if (!res?.ok) return null;
|
||||||
|
const arrayBuffer = await res.arrayBuffer().catch(() => null);
|
||||||
|
if (!arrayBuffer) return null;
|
||||||
|
return decodeGrokCreditsFrame(Buffer.from(arrayBuffer));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotasFromGrpcCredits(decoded) {
|
||||||
|
if (!decoded || !Number.isFinite(decoded.percentUsed)) return null;
|
||||||
|
// Round for bar display (fixed32 ratio * 100 can be 34.999… for 0.35)
|
||||||
|
const used = Math.round(Math.max(0, Math.min(100, decoded.percentUsed)));
|
||||||
|
return {
|
||||||
|
"Weekly SuperGrok": makeQuota({
|
||||||
|
used,
|
||||||
|
total: 100,
|
||||||
|
resetAt: decoded.resetAt || null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} accessToken
|
* @param {string} accessToken
|
||||||
* @param {object|null} providerSpecificData
|
* @param {object|null} providerSpecificData
|
||||||
@@ -306,6 +373,16 @@ export async function getGrokCliUsage(accessToken, providerSpecificData = null,
|
|||||||
const parsed = parseGrokCliBilling(billing, user);
|
const parsed = parseGrokCliBilling(billing, user);
|
||||||
|
|
||||||
if (!parsed.quotas || Object.keys(parsed.quotas).length === 0) {
|
if (!parsed.quotas || Object.keys(parsed.quotas).length === 0) {
|
||||||
|
// Paid SuperGrok often returns cap=0 over REST but exposes the shared
|
||||||
|
// weekly pool on GetGrokCreditsConfig — try that before giving up.
|
||||||
|
const grpc = await fetchGrokCliCreditsConfig(accessToken, proxyOptions);
|
||||||
|
const grpcQuotas = quotasFromGrpcCredits(grpc);
|
||||||
|
if (grpcQuotas) {
|
||||||
|
return {
|
||||||
|
plan: parsed.plan,
|
||||||
|
quotas: grpcQuotas,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
plan: parsed.plan,
|
plan: parsed.plan,
|
||||||
message: parsed.subscriptionAccess
|
message: parsed.subscriptionAccess
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* gRPC-web frame decoder for xAI GetGrokCreditsConfig
|
||||||
|
* (grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig).
|
||||||
|
*
|
||||||
|
* Real response shape (live capture 2026-07-20):
|
||||||
|
* top-level field 1 (length-delimited) — nested credits info
|
||||||
|
* subfield 1 (fixed32 float) — usage ratio 0..1
|
||||||
|
* subfield 5 (Timestamp{seconds,nanos}) — credit-pool reset time
|
||||||
|
*
|
||||||
|
* Fail-open: any malformed buffer returns null, never throws.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FIELD_CREDITS_INFO = 1;
|
||||||
|
const CREDITS_FIELD_USAGE_RATIO = 1;
|
||||||
|
const CREDITS_FIELD_RESET_TIMESTAMP = 5;
|
||||||
|
const TIMESTAMP_FIELD_SECONDS = 1;
|
||||||
|
const TIMESTAMP_FIELD_NANOS = 2;
|
||||||
|
|
||||||
|
const WIRE_TYPE_VARINT = 0;
|
||||||
|
const WIRE_TYPE_FIXED64 = 1;
|
||||||
|
const WIRE_TYPE_LENGTH_DELIMITED = 2;
|
||||||
|
const WIRE_TYPE_FIXED32 = 5;
|
||||||
|
|
||||||
|
const GRPC_WEB_TRAILER_FLAG_BIT = 0x80;
|
||||||
|
const MAX_VARINT_SHIFT_BITS = 70n;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a gRPC-web frame header at `offset`.
|
||||||
|
* @returns {{ flag: number, payloadStart: number, payloadLength: number } | null}
|
||||||
|
*/
|
||||||
|
export function probeFrameHeader(buffer, offset = 0) {
|
||||||
|
if (!Buffer.isBuffer(buffer) || offset < 0 || buffer.length - offset < 5) return null;
|
||||||
|
const flag = buffer[offset];
|
||||||
|
if (flag !== 0x00 && flag !== 0x01 && flag !== 0x80 && flag !== 0x81) return null;
|
||||||
|
const payloadStart = offset + 5;
|
||||||
|
const payloadLength = buffer.readUInt32BE(offset + 1);
|
||||||
|
if (payloadLength > buffer.length - payloadStart) return null;
|
||||||
|
return { flag, payloadStart, payloadLength };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readVarint(buffer, offset) {
|
||||||
|
let result = 0n;
|
||||||
|
let shift = 0n;
|
||||||
|
let pos = offset;
|
||||||
|
for (;;) {
|
||||||
|
if (pos >= buffer.length) return null;
|
||||||
|
const byte = buffer[pos];
|
||||||
|
result |= BigInt(byte & 0x7f) << shift;
|
||||||
|
pos += 1;
|
||||||
|
if ((byte & 0x80) === 0) break;
|
||||||
|
shift += 7n;
|
||||||
|
if (shift > MAX_VARINT_SHIFT_BITS) return null;
|
||||||
|
}
|
||||||
|
return { value: Number(result), next: pos };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLengthDelimitedField(buffer, offset) {
|
||||||
|
const lengthResult = readVarint(buffer, offset);
|
||||||
|
if (!lengthResult) return null;
|
||||||
|
const { value: length, next: bodyStart } = lengthResult;
|
||||||
|
if (length < 0 || bodyStart + length > buffer.length) return null;
|
||||||
|
return {
|
||||||
|
field: { wireType: WIRE_TYPE_LENGTH_DELIMITED, bytes: buffer.subarray(bodyStart, bodyStart + length) },
|
||||||
|
next: bodyStart + length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFixedWidthField(buffer, offset, width, wireType) {
|
||||||
|
if (offset + width > buffer.length) return null;
|
||||||
|
return {
|
||||||
|
field: { wireType, bytes: buffer.subarray(offset, offset + width) },
|
||||||
|
next: offset + width,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readField(buffer, offset) {
|
||||||
|
const tagResult = readVarint(buffer, offset);
|
||||||
|
if (!tagResult) return null;
|
||||||
|
const fieldNumber = tagResult.value >>> 3;
|
||||||
|
const wireType = tagResult.value & 0x7;
|
||||||
|
if (fieldNumber === 0) return null;
|
||||||
|
|
||||||
|
if (wireType === WIRE_TYPE_VARINT) {
|
||||||
|
const valueResult = readVarint(buffer, tagResult.next);
|
||||||
|
if (!valueResult) return null;
|
||||||
|
return {
|
||||||
|
fieldNumber,
|
||||||
|
field: { wireType: WIRE_TYPE_VARINT, value: valueResult.value },
|
||||||
|
next: valueResult.next,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (wireType === WIRE_TYPE_LENGTH_DELIMITED) {
|
||||||
|
const result = readLengthDelimitedField(buffer, tagResult.next);
|
||||||
|
return result ? { fieldNumber, field: result.field, next: result.next } : null;
|
||||||
|
}
|
||||||
|
if (wireType === WIRE_TYPE_FIXED64) {
|
||||||
|
const result = readFixedWidthField(buffer, tagResult.next, 8, WIRE_TYPE_FIXED64);
|
||||||
|
return result ? { fieldNumber, field: result.field, next: result.next } : null;
|
||||||
|
}
|
||||||
|
if (wireType === WIRE_TYPE_FIXED32) {
|
||||||
|
const result = readFixedWidthField(buffer, tagResult.next, 4, WIRE_TYPE_FIXED32);
|
||||||
|
return result ? { fieldNumber, field: result.field, next: result.next } : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeFields(buffer) {
|
||||||
|
const fields = new Map();
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < buffer.length) {
|
||||||
|
const result = readField(buffer, offset);
|
||||||
|
if (!result) return null;
|
||||||
|
fields.set(result.fieldNumber, result.field);
|
||||||
|
offset = result.next;
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDataFramePayload(buffer) {
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < buffer.length) {
|
||||||
|
const frame = probeFrameHeader(buffer, offset);
|
||||||
|
if (!frame) return null;
|
||||||
|
const frameEnd = frame.payloadStart + frame.payloadLength;
|
||||||
|
const isTrailer = (frame.flag & GRPC_WEB_TRAILER_FLAG_BIT) !== 0;
|
||||||
|
if (!isTrailer) {
|
||||||
|
return buffer.subarray(frame.payloadStart, frameEnd);
|
||||||
|
}
|
||||||
|
offset = frameEnd;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNestedMessage(field) {
|
||||||
|
if (!field || field.wireType !== WIRE_TYPE_LENGTH_DELIMITED) return null;
|
||||||
|
return decodeFields(field.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUsageRatio(field) {
|
||||||
|
if (!field) return 0; // proto3 omission = 0% used
|
||||||
|
if (field.wireType === WIRE_TYPE_FIXED32) return field.bytes.readFloatLE(0);
|
||||||
|
if (field.wireType === WIRE_TYPE_FIXED64) return field.bytes.readDoubleLE(0);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractResetAt(field) {
|
||||||
|
if (!field || field.wireType !== WIRE_TYPE_LENGTH_DELIMITED) return null;
|
||||||
|
|
||||||
|
const timestampFields = decodeFields(field.bytes);
|
||||||
|
if (!timestampFields) return null;
|
||||||
|
|
||||||
|
const secondsField = timestampFields.get(TIMESTAMP_FIELD_SECONDS);
|
||||||
|
const nanosField = timestampFields.get(TIMESTAMP_FIELD_NANOS);
|
||||||
|
const seconds = secondsField?.wireType === WIRE_TYPE_VARINT ? secondsField.value : 0;
|
||||||
|
const nanos = nanosField?.wireType === WIRE_TYPE_VARINT ? nanosField.value : 0;
|
||||||
|
|
||||||
|
const millis = seconds * 1000 + Math.round(nanos / 1_000_000);
|
||||||
|
const parsed = new Date(millis);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode GetGrokCreditsConfig response → `{ percentUsed: 0-100, resetAt }` or null.
|
||||||
|
* @param {Buffer} buffer
|
||||||
|
* @returns {{ percentUsed: number, resetAt: string|null } | null}
|
||||||
|
*/
|
||||||
|
export function decodeGrokCreditsFrame(buffer) {
|
||||||
|
if (!buffer || !Buffer.isBuffer(buffer) || buffer.length === 0) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const framed = probeFrameHeader(buffer, 0) !== null;
|
||||||
|
const payload = framed ? findDataFramePayload(buffer) : buffer;
|
||||||
|
if (!payload) return null;
|
||||||
|
|
||||||
|
const topLevelFields = decodeFields(payload);
|
||||||
|
if (!topLevelFields) return null;
|
||||||
|
|
||||||
|
const creditsInfo = extractNestedMessage(topLevelFields.get(FIELD_CREDITS_INFO));
|
||||||
|
if (!creditsInfo) return null;
|
||||||
|
|
||||||
|
const usageRatio = extractUsageRatio(creditsInfo.get(CREDITS_FIELD_USAGE_RATIO));
|
||||||
|
if (usageRatio === null || !Number.isFinite(usageRatio) || usageRatio < 0) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
percentUsed: Math.min(100, usageRatio * 100),
|
||||||
|
resetAt: extractResetAt(creditsInfo.get(CREDITS_FIELD_RESET_TIMESTAMP)),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,11 @@ export const QODER_USERINFO_URL = `${QODER_OPENAPI_BASE}/api/v1/userinfo`;
|
|||||||
export const QODER_QUOTA_USAGE_URL = `${QODER_OPENAPI_BASE}/api/v2/quota/usage`;
|
export const QODER_QUOTA_USAGE_URL = `${QODER_OPENAPI_BASE}/api/v2/quota/usage`;
|
||||||
export const QODER_REFRESH_TOKEN_URL = `${QODER_CENTER_BASE}/algo/api/v3/user/refresh_token`;
|
export const QODER_REFRESH_TOKEN_URL = `${QODER_CENTER_BASE}/algo/api/v3/user/refresh_token`;
|
||||||
|
|
||||||
|
// PAT (Personal Access Token, pt-...) → short-lived job token (jt-...) exchange.
|
||||||
|
// PATs cannot sign COSY requests directly — they must be exchanged first.
|
||||||
|
// This endpoint is NOT COSY-signed (plain JSON POST).
|
||||||
|
export const QODER_JOB_TOKEN_EXCHANGE_URL = `${QODER_OPENAPI_BASE}/api/v1/jobToken/exchange`;
|
||||||
|
|
||||||
// Inference endpoints (under /algo on api3.qoder.sh, all COSY-signed)
|
// Inference endpoints (under /algo on api3.qoder.sh, all COSY-signed)
|
||||||
export const QODER_CHAT_SIG_PATH = "/api/v2/service/pro/sse/agent_chat_generation";
|
export const QODER_CHAT_SIG_PATH = "/api/v2/service/pro/sse/agent_chat_generation";
|
||||||
export const QODER_CHAT_URL = `${QODER_CHAT_BASE}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common`;
|
export const QODER_CHAT_URL = `${QODER_CHAT_BASE}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common`;
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
// Zed hosted LLM aggregator — auth + model-catalog helpers.
|
||||||
|
//
|
||||||
|
// Zed's cloud (cloud.zed.dev) authenticates native apps with a self-generated RSA
|
||||||
|
// keypair instead of a registered OAuth client_id/secret:
|
||||||
|
// 1. Client generates an ephemeral RSA keypair.
|
||||||
|
// 2. Sends the public key to zed.dev/native_app_signin.
|
||||||
|
// 3. User signs in via browser; Zed redirects to a local callback with the
|
||||||
|
// access token RSA-encrypted against the public key.
|
||||||
|
// 4. Client decrypts locally with the private key that never left the host.
|
||||||
|
// No embedded client_id/secret — the credential is a per-login keypair.
|
||||||
|
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||||
|
|
||||||
|
export const ZED_WEB_BASE_URL = "https://zed.dev";
|
||||||
|
export const ZED_CLOUD_BASE_URL = "https://cloud.zed.dev";
|
||||||
|
export const ZED_LLM_BASE_URL = "https://cloud.zed.dev";
|
||||||
|
|
||||||
|
export const ZED_HEADERS = {
|
||||||
|
expiredToken: "x-zed-expired-token",
|
||||||
|
outdatedToken: "x-zed-outdated-token",
|
||||||
|
clientSupportsStatus: "x-zed-client-supports-status-messages",
|
||||||
|
clientSupportsStreamEnded:
|
||||||
|
"x-zed-client-supports-stream-ended-request-completion-status",
|
||||||
|
serverSupportsStatus: "x-zed-server-supports-status-messages",
|
||||||
|
clientSupportsXai: "x-zed-client-supports-x-ai",
|
||||||
|
systemId: "x-zed-system-id",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIVATE_KEY_PREFIX = "zed-rsa-pkcs1:";
|
||||||
|
const LLM_TOKEN_TTL_MS = 50 * 60 * 1000;
|
||||||
|
const MODEL_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const llmTokenCache = new Map();
|
||||||
|
const modelCache = new Map();
|
||||||
|
const modelInflight = new Map();
|
||||||
|
|
||||||
|
function b64url(value) {
|
||||||
|
return Buffer.from(value).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64urlPadded(buf) {
|
||||||
|
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromB64url(value) {
|
||||||
|
return Buffer.from(String(value || ""), "base64url").toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBaseUrl(baseUrl, fallback) {
|
||||||
|
return String(baseUrl || fallback).replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function zedUrl(config, key, path, fallbackBase) {
|
||||||
|
const base = normalizeBaseUrl(config?.[key], fallbackBase);
|
||||||
|
return `${base}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encode a PEM private key as an opaque verifier (flows through the OAuth codeVerifier slot). */
|
||||||
|
export function encodeZedPrivateKeyVerifier(privateKeyPem) {
|
||||||
|
return `${PRIVATE_KEY_PREFIX}${b64url(privateKeyPem)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeZedPrivateKeyVerifier(verifier) {
|
||||||
|
const value = String(verifier || "");
|
||||||
|
if (!value.startsWith(PRIVATE_KEY_PREFIX)) {
|
||||||
|
throw new Error("Missing Zed private key verifier; restart the login flow");
|
||||||
|
}
|
||||||
|
return fromB64url(value.slice(PRIVATE_KEY_PREFIX.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a fresh RSA keypair + the zed.dev native_app_signin URL for it. */
|
||||||
|
export function createZedNativeAuthData(config = {}, options = {}) {
|
||||||
|
const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
|
||||||
|
modulusLength: 2048,
|
||||||
|
publicKeyEncoding: { type: "pkcs1", format: "der" },
|
||||||
|
privateKeyEncoding: { type: "pkcs1", format: "pem" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const nativeAppPort = Number(
|
||||||
|
options.nativeAppPort || config.defaultNativeAppPort || 58443,
|
||||||
|
);
|
||||||
|
const systemId = options.systemId || crypto.randomUUID();
|
||||||
|
const publicKeyString = b64urlPadded(publicKey);
|
||||||
|
const signInUrl = new URL(
|
||||||
|
`${normalizeBaseUrl(config.webBaseUrl, ZED_WEB_BASE_URL)}/native_app_signin`,
|
||||||
|
);
|
||||||
|
signInUrl.searchParams.set("native_app_port", String(nativeAppPort));
|
||||||
|
signInUrl.searchParams.set("native_app_public_key", publicKeyString);
|
||||||
|
if (systemId) signInUrl.searchParams.set("system_id", systemId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
authUrl: signInUrl.toString(),
|
||||||
|
privateKeyVerifier: encodeZedPrivateKeyVerifier(privateKey),
|
||||||
|
nativeAppPort,
|
||||||
|
systemId,
|
||||||
|
publicKey: publicKeyString,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the pasted native-app callback URL/JSON/query into userId + encrypted token. */
|
||||||
|
export function parseZedCallbackPayload(input) {
|
||||||
|
const raw = String(input || "").trim();
|
||||||
|
if (!raw) throw new Error("Missing Zed callback URL");
|
||||||
|
|
||||||
|
let data = {};
|
||||||
|
try {
|
||||||
|
data = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
url = new URL(`http://127.0.0.1/?${raw.replace(/^\?/, "")}`);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid Zed callback URL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
url.searchParams.forEach((value, key) => {
|
||||||
|
data[key] = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = data.user_id || data.userId;
|
||||||
|
const encryptedAccessToken = data.access_token || data.accessToken || data.token;
|
||||||
|
if (!userId || !encryptedAccessToken) {
|
||||||
|
throw new Error("Zed callback must include user_id and access_token");
|
||||||
|
}
|
||||||
|
return { userId: String(userId), encryptedAccessToken: String(encryptedAccessToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrypt the RSA-encrypted access token using the stored private key. */
|
||||||
|
export function decryptZedAccessToken(encryptedAccessToken, privateKeyVerifier) {
|
||||||
|
const privateKey = decodeZedPrivateKeyVerifier(privateKeyVerifier);
|
||||||
|
const encrypted = Buffer.from(String(encryptedAccessToken), "base64url");
|
||||||
|
try {
|
||||||
|
return crypto
|
||||||
|
.privateDecrypt(
|
||||||
|
{ key: privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" },
|
||||||
|
encrypted,
|
||||||
|
)
|
||||||
|
.toString("utf8");
|
||||||
|
} catch (oaepError) {
|
||||||
|
try {
|
||||||
|
return crypto
|
||||||
|
.privateDecrypt(
|
||||||
|
{ key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING },
|
||||||
|
encrypted,
|
||||||
|
)
|
||||||
|
.toString("utf8");
|
||||||
|
} catch {
|
||||||
|
const message = oaepError instanceof Error ? oaepError.message : String(oaepError);
|
||||||
|
throw new Error(`Failed to decrypt Zed access token: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildZedUserAuthHeader(credentials) {
|
||||||
|
const psd = credentials?.providerSpecificData || {};
|
||||||
|
const userId = psd.userId || credentials?.userId;
|
||||||
|
const accessToken = credentials?.accessToken || credentials?.apiKey;
|
||||||
|
if (!userId || !accessToken) {
|
||||||
|
throw new Error("Zed credential is missing userId or accessToken");
|
||||||
|
}
|
||||||
|
return `${userId} ${accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSystemId(credentials) {
|
||||||
|
return String(
|
||||||
|
credentials?.providerSpecificData?.systemId || credentials?.systemId || "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url, options) {
|
||||||
|
const res = await proxyAwareFetch(url, options);
|
||||||
|
const text = await res.text();
|
||||||
|
let data = null;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
data = { raw: text };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const message =
|
||||||
|
data?.message || data?.error?.message || data?.error || text || `HTTP ${res.status}`;
|
||||||
|
const err = new Error(String(message));
|
||||||
|
err.status = res.status;
|
||||||
|
err.body = data;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchZedAuthenticatedUser(credentials, options = {}) {
|
||||||
|
const config = options.config || {};
|
||||||
|
const headers = {
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: buildZedUserAuthHeader(credentials),
|
||||||
|
};
|
||||||
|
const systemId = getSystemId(credentials);
|
||||||
|
if (systemId) headers[ZED_HEADERS.systemId] = systemId;
|
||||||
|
|
||||||
|
return fetchJson(zedUrl(config, "cloudBaseUrl", "/client/users/me", ZED_CLOUD_BASE_URL), {
|
||||||
|
method: "GET",
|
||||||
|
headers,
|
||||||
|
signal: options.signal ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOrganizationId(value) {
|
||||||
|
if (!value) return "";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "object" && value !== null) {
|
||||||
|
if (typeof value[0] === "string") return value[0];
|
||||||
|
if (typeof value.id === "string") return value.id;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveZedOrganizationId(credentials, userInfo = null) {
|
||||||
|
const psd = credentials?.providerSpecificData || {};
|
||||||
|
const explicit = normalizeOrganizationId(psd.organizationId || psd.defaultOrganizationId);
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const fromUser = normalizeOrganizationId(
|
||||||
|
userInfo?.default_organization_id || userInfo?.defaultOrganizationId,
|
||||||
|
);
|
||||||
|
if (fromUser) return fromUser;
|
||||||
|
const orgs = userInfo?.organizations || [];
|
||||||
|
const org = orgs.find((item) => item?.is_personal) || orgs[0];
|
||||||
|
return normalizeOrganizationId(org?.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zedUserCacheKey(credentials, organizationId) {
|
||||||
|
const psd = credentials?.providerSpecificData || {};
|
||||||
|
const userId = psd.userId || credentials?.userId || "unknown";
|
||||||
|
const token = credentials?.accessToken || credentials?.apiKey || "";
|
||||||
|
return `${userId}:${organizationId || "default"}:${token.slice(-16)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zedModelCacheKey(credentials) {
|
||||||
|
const psd = credentials?.providerSpecificData || {};
|
||||||
|
const org = psd.organizationId || psd.defaultOrganizationId || "default";
|
||||||
|
const token = credentials?.accessToken || credentials?.apiKey || "";
|
||||||
|
return `${psd.userId || "unknown"}:${org}:${token.slice(-16)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchZedLlmToken(credentials, options = {}) {
|
||||||
|
const config = options.config || {};
|
||||||
|
let organizationId = options.organizationId || resolveZedOrganizationId(credentials);
|
||||||
|
if (!organizationId) {
|
||||||
|
const userInfo = await fetchZedAuthenticatedUser(credentials, options);
|
||||||
|
organizationId = resolveZedOrganizationId(credentials, userInfo);
|
||||||
|
}
|
||||||
|
if (!organizationId) throw new Error("No Zed organization selected");
|
||||||
|
|
||||||
|
const cacheKey = zedUserCacheKey(credentials, organizationId);
|
||||||
|
const cached = llmTokenCache.get(cacheKey);
|
||||||
|
if (!options.forceRefresh && cached && cached.expiresAt > Date.now()) return cached.token;
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: buildZedUserAuthHeader(credentials),
|
||||||
|
};
|
||||||
|
const systemId = getSystemId(credentials);
|
||||||
|
if (systemId) headers[ZED_HEADERS.systemId] = systemId;
|
||||||
|
|
||||||
|
const data = await fetchJson(
|
||||||
|
zedUrl(config, "cloudBaseUrl", "/client/llm_tokens", ZED_CLOUD_BASE_URL),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ organization_id: organizationId }),
|
||||||
|
signal: options.signal ?? undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const token =
|
||||||
|
typeof data?.token === "string" ? data.token : data?.token?.[0] || data?.token?.value;
|
||||||
|
if (!token) throw new Error("Zed did not return an LLM token");
|
||||||
|
llmTokenCache.set(cacheKey, { token, expiresAt: Date.now() + LLM_TOKEN_TTL_MS });
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRefreshZedLlmToken(response) {
|
||||||
|
return (
|
||||||
|
response?.status === 401 ||
|
||||||
|
!!response?.headers?.has?.(ZED_HEADERS.expiredToken) ||
|
||||||
|
!!response?.headers?.has?.(ZED_HEADERS.outdatedToken)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function zedLlmFetch(credentials, path, options = {}) {
|
||||||
|
const config = options.config || {};
|
||||||
|
const url = zedUrl(config, "llmBaseUrl", path, ZED_LLM_BASE_URL);
|
||||||
|
const buildRequest = async (forceRefresh) => {
|
||||||
|
const token = await fetchZedLlmToken(credentials, { ...options, forceRefresh });
|
||||||
|
return proxyAwareFetch(url, {
|
||||||
|
...options.fetchOptions,
|
||||||
|
headers: {
|
||||||
|
...(options.fetchOptions?.headers || {}),
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
signal: options.signal ?? undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = await buildRequest(false);
|
||||||
|
if (shouldRefreshZedLlmToken(response)) {
|
||||||
|
response = await buildRequest(true);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeZedModelId(id) {
|
||||||
|
if (!id) return "";
|
||||||
|
if (typeof id === "string") return id;
|
||||||
|
if (typeof id === "object" && id !== null) {
|
||||||
|
if (typeof id[0] === "string") return id[0];
|
||||||
|
if (typeof id.id === "string") return id.id;
|
||||||
|
}
|
||||||
|
return String(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapZedModel(model) {
|
||||||
|
const id = normalizeZedModelId(model?.id);
|
||||||
|
if (!id) return null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: model.display_name || model.displayName || id,
|
||||||
|
provider: model.provider,
|
||||||
|
isLatest: !!model.is_latest,
|
||||||
|
contextLength: model.max_token_count ?? model.maxTokenCount,
|
||||||
|
contextLengthInMaxMode: model.max_token_count_in_max_mode ?? model.maxTokenCountInMaxMode,
|
||||||
|
maxOutputTokens: model.max_output_tokens ?? model.maxOutputTokens,
|
||||||
|
supportsTools: !!model.supports_tools,
|
||||||
|
supportsImages: !!model.supports_images,
|
||||||
|
supportsThinking: !!model.supports_thinking,
|
||||||
|
supportsDisablingThinking: !!model.supports_disabling_thinking,
|
||||||
|
supportsFastMode: !!model.supports_fast_mode,
|
||||||
|
supportsServerSideCompaction: !!model.supports_server_side_compaction,
|
||||||
|
supportedEffortLevels: model.supported_effort_levels ?? model.supportedEffortLevels ?? [],
|
||||||
|
supportsStreamingTools: !!model.supports_streaming_tools,
|
||||||
|
supportsParallelToolCalls: !!model.supports_parallel_tool_calls,
|
||||||
|
isDisabled: !!model.is_disabled,
|
||||||
|
disabledReason: model.disabled_reason ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve (and cache) the live Zed model catalog. Never hardcoded — always a live fetch. */
|
||||||
|
export async function resolveZedModels(credentials, options = {}) {
|
||||||
|
if (!credentials?.accessToken) return null;
|
||||||
|
const key = zedModelCacheKey(credentials);
|
||||||
|
const cached = modelCache.get(key);
|
||||||
|
if (!options.forceRefresh && cached && cached.expiresAt > Date.now()) return cached;
|
||||||
|
|
||||||
|
const existing = modelInflight.get(key);
|
||||||
|
if (existing && !options.forceRefresh) return existing;
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
const response = await zedLlmFetch(credentials, "/models", {
|
||||||
|
...options,
|
||||||
|
fetchOptions: {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
[ZED_HEADERS.clientSupportsXai]: "true",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => "");
|
||||||
|
throw new Error(`Zed models failed: ${response.status} ${text}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const rawModels = Array.isArray(data?.models) ? data.models : [];
|
||||||
|
const models = rawModels
|
||||||
|
.map(mapZedModel)
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((model) => !model.isDisabled);
|
||||||
|
const rawById = new Map();
|
||||||
|
for (const raw of rawModels) {
|
||||||
|
const id = normalizeZedModelId(raw?.id);
|
||||||
|
if (id) rawById.set(id, raw);
|
||||||
|
}
|
||||||
|
const entry = {
|
||||||
|
expiresAt: Date.now() + MODEL_CACHE_TTL_MS,
|
||||||
|
models,
|
||||||
|
rawModels,
|
||||||
|
rawById,
|
||||||
|
defaultModel: normalizeZedModelId(data?.default_model ?? data?.defaultModel),
|
||||||
|
defaultFastModel: normalizeZedModelId(data?.default_fast_model ?? data?.defaultFastModel),
|
||||||
|
recommendedModels: (data?.recommended_models || data?.recommendedModels || [])
|
||||||
|
.map(normalizeZedModelId)
|
||||||
|
.filter(Boolean),
|
||||||
|
};
|
||||||
|
modelCache.set(key, entry);
|
||||||
|
return entry;
|
||||||
|
})();
|
||||||
|
|
||||||
|
modelInflight.set(key, promise);
|
||||||
|
try {
|
||||||
|
return await promise;
|
||||||
|
} finally {
|
||||||
|
if (modelInflight.get(key) === promise) modelInflight.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearZedCaches() {
|
||||||
|
llmTokenCache.clear();
|
||||||
|
modelCache.clear();
|
||||||
|
modelInflight.clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
import {
|
||||||
|
KIRO_TOOL_DESCRIPTION_MAX_LENGTH,
|
||||||
|
KIRO_TOOL_ID_MAX_LENGTH,
|
||||||
|
KIRO_TOOL_NAME_MAX_LENGTH,
|
||||||
|
} from "../../config/kiroConstants.js";
|
||||||
|
|
||||||
|
const TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
||||||
|
const TOOL_NAME_PATTERN = /[^a-zA-Z0-9_-]/g;
|
||||||
|
|
||||||
|
function clone(value) {
|
||||||
|
return value == null ? value : JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(value) {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (value == null) return "";
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendText(target, extra) {
|
||||||
|
if (!extra) return;
|
||||||
|
target.content = target.content ? `${target.content}\n\n${extra}` : extra;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimCodePoints(value, limit) {
|
||||||
|
return [...String(value || "")].slice(0, limit).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueName(rawName, index, usedNames) {
|
||||||
|
const cleaned = String(rawName || "")
|
||||||
|
.trim()
|
||||||
|
.replace(TOOL_NAME_PATTERN, "_")
|
||||||
|
.replace(/_+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "");
|
||||||
|
const base = trimCodePoints(cleaned || `tool_${index + 1}`, KIRO_TOOL_NAME_MAX_LENGTH);
|
||||||
|
let candidate = base;
|
||||||
|
let suffix = 2;
|
||||||
|
while (usedNames.has(candidate)) {
|
||||||
|
const tail = `_${suffix++}`;
|
||||||
|
candidate = `${base.slice(0, KIRO_TOOL_NAME_MAX_LENGTH - tail.length)}${tail}`;
|
||||||
|
}
|
||||||
|
usedNames.add(candidate);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanSchemaValue(value) {
|
||||||
|
if (Array.isArray(value)) return value.map(cleanSchemaValue);
|
||||||
|
if (!value || typeof value !== "object") return value;
|
||||||
|
|
||||||
|
const cleaned = {};
|
||||||
|
for (const [key, child] of Object.entries(value)) {
|
||||||
|
if (key === "additionalProperties") continue;
|
||||||
|
if (key === "required" && Array.isArray(child) && child.length === 0) continue;
|
||||||
|
cleaned[key] = cleanSchemaValue(child);
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRootSchema(schema) {
|
||||||
|
const cleaned = cleanSchemaValue(schema && typeof schema === "object" ? clone(schema) : {});
|
||||||
|
cleaned.type = "object";
|
||||||
|
if (!cleaned.properties || typeof cleaned.properties !== "object" || Array.isArray(cleaned.properties)) {
|
||||||
|
cleaned.properties = {};
|
||||||
|
}
|
||||||
|
if (Array.isArray(cleaned.required)) {
|
||||||
|
cleaned.required = [...new Set(cleaned.required.filter(
|
||||||
|
(name) => typeof name === "string" && Object.hasOwn(cleaned.properties, name)
|
||||||
|
))];
|
||||||
|
if (cleaned.required.length === 0) delete cleaned.required;
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize OpenAI- or Claude-shaped tool definitions into Kiro tool specs. */
|
||||||
|
export function normalizeKiroToolSpecs(tools) {
|
||||||
|
const specs = [];
|
||||||
|
const nameMap = new Map();
|
||||||
|
const usedNames = new Set();
|
||||||
|
|
||||||
|
for (const [index, tool] of (Array.isArray(tools) ? tools : []).entries()) {
|
||||||
|
if (!tool || typeof tool !== "object") continue;
|
||||||
|
const rawName = tool.function?.name ?? tool.name;
|
||||||
|
if (typeof rawName !== "string" || !rawName.trim()) continue;
|
||||||
|
|
||||||
|
// A repeated definition with the same source name describes the same tool.
|
||||||
|
if (nameMap.has(rawName)) continue;
|
||||||
|
const name = uniqueName(rawName, index, usedNames);
|
||||||
|
nameMap.set(rawName, name);
|
||||||
|
|
||||||
|
const rawDescription = tool.function?.description ?? tool.description ?? `Tool: ${rawName}`;
|
||||||
|
const description = trimCodePoints(
|
||||||
|
String(rawDescription || `Tool: ${rawName}`),
|
||||||
|
KIRO_TOOL_DESCRIPTION_MAX_LENGTH
|
||||||
|
);
|
||||||
|
const schema = tool.function?.parameters ?? tool.parameters ?? tool.input_schema ?? {};
|
||||||
|
specs.push({
|
||||||
|
toolSpecification: {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
inputSchema: { json: normalizeRootSchema(schema) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { specs, nameMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolCallText(toolUse) {
|
||||||
|
return `[Tool call: ${toolUse?.name || "unknown"}(${text(toolUse?.input || {})})]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolResultText(toolResult) {
|
||||||
|
const content = Array.isArray(toolResult?.content)
|
||||||
|
? toolResult.content.map((part) => text(part?.text ?? part)).filter(Boolean).join("\n")
|
||||||
|
: text(toolResult?.content);
|
||||||
|
return `[Tool result${toolResult?.status === "error" ? " (error)" : ""}: ${content}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeUser(target, source) {
|
||||||
|
appendText(target, source.content);
|
||||||
|
if (Array.isArray(source.images) && source.images.length > 0) {
|
||||||
|
target.images = [...(target.images || []), ...source.images];
|
||||||
|
}
|
||||||
|
const results = source.userInputMessageContext?.toolResults;
|
||||||
|
if (Array.isArray(results) && results.length > 0) {
|
||||||
|
target.userInputMessageContext ||= {};
|
||||||
|
target.userInputMessageContext.toolResults = [
|
||||||
|
...(target.userInputMessageContext.toolResults || []),
|
||||||
|
...results,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeAssistant(target, source) {
|
||||||
|
appendText(target, source.content);
|
||||||
|
if (Array.isArray(source.toolUses) && source.toolUses.length > 0) {
|
||||||
|
target.toolUses = [...(target.toolUses || []), ...source.toolUses];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTurns(history, currentMessage, modelId) {
|
||||||
|
const rawTurns = [...(Array.isArray(history) ? history : [])];
|
||||||
|
if (currentMessage) rawTurns.push(currentMessage);
|
||||||
|
const turns = [];
|
||||||
|
|
||||||
|
for (const raw of rawTurns) {
|
||||||
|
const isUser = !!raw?.userInputMessage;
|
||||||
|
const isAssistant = !!raw?.assistantResponseMessage;
|
||||||
|
if (isUser === isAssistant) continue;
|
||||||
|
|
||||||
|
const turn = isUser
|
||||||
|
? { userInputMessage: clone(raw.userInputMessage) }
|
||||||
|
: { assistantResponseMessage: clone(raw.assistantResponseMessage) };
|
||||||
|
const previous = turns[turns.length - 1];
|
||||||
|
if (turn.userInputMessage && previous?.userInputMessage) {
|
||||||
|
mergeUser(previous.userInputMessage, turn.userInputMessage);
|
||||||
|
} else if (turn.assistantResponseMessage && previous?.assistantResponseMessage) {
|
||||||
|
mergeAssistant(previous.assistantResponseMessage, turn.assistantResponseMessage);
|
||||||
|
} else {
|
||||||
|
turns.push(turn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (turns[0]?.assistantResponseMessage) {
|
||||||
|
turns.unshift({ userInputMessage: { content: "continue", modelId } });
|
||||||
|
}
|
||||||
|
if (turns.length === 0 || turns[turns.length - 1]?.assistantResponseMessage) {
|
||||||
|
turns.push({ userInputMessage: { content: "continue", modelId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const turn of turns) {
|
||||||
|
if (turn.userInputMessage) {
|
||||||
|
turn.userInputMessage.content = text(turn.userInputMessage.content).trim() || "continue";
|
||||||
|
turn.userInputMessage.modelId ||= modelId;
|
||||||
|
if (turn.userInputMessage.userInputMessageContext?.tools) {
|
||||||
|
delete turn.userInputMessage.userInputMessageContext.tools;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
turn.assistantResponseMessage.content =
|
||||||
|
text(turn.assistantResponseMessage.content).trim() || "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return turns;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawId(value) {
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function reserveToolId(value, turnIndex, callIndex, name, usedIds) {
|
||||||
|
const sanitized = rawId(value).replace(/[^a-zA-Z0-9_-]/g, "");
|
||||||
|
const generated = `call_msg${turnIndex}_tc${callIndex}_${name || "tool"}`;
|
||||||
|
const base = trimCodePoints(
|
||||||
|
TOOL_ID_PATTERN.test(sanitized) && sanitized ? sanitized : generated,
|
||||||
|
KIRO_TOOL_ID_MAX_LENGTH
|
||||||
|
);
|
||||||
|
let candidate = base;
|
||||||
|
let suffix = 2;
|
||||||
|
while (usedIds.has(candidate)) {
|
||||||
|
const tail = `_${suffix++}`;
|
||||||
|
candidate = `${base.slice(0, KIRO_TOOL_ID_MAX_LENGTH - tail.length)}${tail}`;
|
||||||
|
}
|
||||||
|
usedIds.add(candidate);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolInput(input) {
|
||||||
|
if (input && typeof input === "object" && !Array.isArray(input)) return clone(input);
|
||||||
|
if (typeof input === "string") {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(input);
|
||||||
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return input == null ? {} : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolResult(result) {
|
||||||
|
const content = Array.isArray(result?.content)
|
||||||
|
? result.content.map((part) => ({ text: text(part?.text ?? part) }))
|
||||||
|
: [{ text: text(result?.content) }];
|
||||||
|
return {
|
||||||
|
toolUseId: rawId(result?.toolUseId),
|
||||||
|
status: result?.status === "error" ? "error" : "success",
|
||||||
|
content: content.length > 0 ? content : [{ text: "" }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenResults(userMessage, results) {
|
||||||
|
for (const result of results) appendText(userMessage, toolResultText(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanUserContext(userMessage) {
|
||||||
|
const context = userMessage.userInputMessageContext;
|
||||||
|
if (!context) return;
|
||||||
|
if (!context.toolResults?.length) delete context.toolResults;
|
||||||
|
if (!context.tools?.length) delete context.tools;
|
||||||
|
if (Object.keys(context).length === 0) delete userMessage.userInputMessageContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcileToolPair(assistant, user, turnIndex, nameMap, specNames, usedIds, repairs) {
|
||||||
|
const calls = Array.isArray(assistant.toolUses) ? assistant.toolUses : [];
|
||||||
|
const results = Array.isArray(user.userInputMessageContext?.toolResults)
|
||||||
|
? user.userInputMessageContext.toolResults.map(normalizeToolResult)
|
||||||
|
: [];
|
||||||
|
if (calls.length === 0) {
|
||||||
|
if (results.length > 0) {
|
||||||
|
flattenResults(user, results);
|
||||||
|
repairs.orphanResults += results.length;
|
||||||
|
}
|
||||||
|
if (user.userInputMessageContext) delete user.userInputMessageContext.toolResults;
|
||||||
|
cleanUserContext(user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callQueues = new Map();
|
||||||
|
const callRecords = calls.map((call, callIndex) => {
|
||||||
|
const key = rawId(call?.toolUseId);
|
||||||
|
const mappedName = nameMap.get(call?.name) || call?.name;
|
||||||
|
const input = normalizeToolInput(call?.input);
|
||||||
|
const record = { call, callIndex, key, mappedName, input, result: null };
|
||||||
|
const queue = callQueues.get(key) || [];
|
||||||
|
queue.push(record);
|
||||||
|
callQueues.set(key, queue);
|
||||||
|
return record;
|
||||||
|
});
|
||||||
|
|
||||||
|
const orphanResults = [];
|
||||||
|
for (const result of results) {
|
||||||
|
const queue = callQueues.get(rawId(result.toolUseId));
|
||||||
|
const record = queue?.find((candidate) => !candidate.result);
|
||||||
|
if (record) record.result = result;
|
||||||
|
else orphanResults.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keptCalls = [];
|
||||||
|
const keptResults = [];
|
||||||
|
for (const record of callRecords) {
|
||||||
|
const hasSpec = typeof record.mappedName === "string" && specNames.has(record.mappedName);
|
||||||
|
const valid = !!record.result && hasSpec && record.input !== null;
|
||||||
|
if (!valid) {
|
||||||
|
appendText(assistant, toolCallText({ name: record.mappedName, input: record.call?.input }));
|
||||||
|
repairs.missingResults += record.result ? 0 : 1;
|
||||||
|
repairs.invalidToolUses += hasSpec && record.input !== null ? 0 : 1;
|
||||||
|
if (record.result) {
|
||||||
|
flattenResults(user, [record.result]);
|
||||||
|
repairs.orphanResults++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolUseId = reserveToolId(
|
||||||
|
record.key,
|
||||||
|
turnIndex,
|
||||||
|
record.callIndex,
|
||||||
|
record.mappedName,
|
||||||
|
usedIds
|
||||||
|
);
|
||||||
|
keptCalls.push({
|
||||||
|
toolUseId,
|
||||||
|
name: record.mappedName,
|
||||||
|
input: record.input,
|
||||||
|
});
|
||||||
|
keptResults.push({ ...record.result, toolUseId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orphanResults.length > 0) {
|
||||||
|
flattenResults(user, orphanResults);
|
||||||
|
repairs.orphanResults += orphanResults.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keptCalls.length > 0) assistant.toolUses = keptCalls;
|
||||||
|
else delete assistant.toolUses;
|
||||||
|
user.userInputMessageContext ||= {};
|
||||||
|
if (keptResults.length > 0) user.userInputMessageContext.toolResults = keptResults;
|
||||||
|
else delete user.userInputMessageContext.toolResults;
|
||||||
|
cleanUserContext(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate the final Kiro wire conversation without mutating it. */
|
||||||
|
export function validateKiroConversation(history, currentMessage, toolSpecs = []) {
|
||||||
|
const errors = [];
|
||||||
|
const turns = [...(history || []), currentMessage].filter(Boolean);
|
||||||
|
const specNames = new Set(toolSpecs.map((spec) => spec?.toolSpecification?.name).filter(Boolean));
|
||||||
|
const usedIds = new Set();
|
||||||
|
|
||||||
|
for (let index = 0; index < turns.length; index++) {
|
||||||
|
const expectedUser = index % 2 === 0;
|
||||||
|
const isUser = !!turns[index]?.userInputMessage;
|
||||||
|
if (isUser !== expectedUser) errors.push(`role:${index}`);
|
||||||
|
if (!isUser) {
|
||||||
|
const calls = turns[index].assistantResponseMessage?.toolUses || [];
|
||||||
|
const results = turns[index + 1]?.userInputMessage?.userInputMessageContext?.toolResults || [];
|
||||||
|
const callIds = calls.map((call) => call.toolUseId);
|
||||||
|
const resultIds = results.map((result) => result.toolUseId);
|
||||||
|
if (calls.length !== results.length || callIds.some((id) => !resultIds.includes(id))) {
|
||||||
|
errors.push(`pair:${index}`);
|
||||||
|
}
|
||||||
|
for (const call of calls) {
|
||||||
|
if (!call.toolUseId || usedIds.has(call.toolUseId)) errors.push(`id:${index}`);
|
||||||
|
usedIds.add(call.toolUseId);
|
||||||
|
if (!specNames.has(call.name)) errors.push(`spec:${index}`);
|
||||||
|
}
|
||||||
|
} else if (index === 0) {
|
||||||
|
const results = turns[index].userInputMessage?.userInputMessageContext?.toolResults;
|
||||||
|
if (results?.length) errors.push("orphan:0");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!currentMessage?.userInputMessage?.content) errors.push("current");
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenAllStructuredTools(turns, repairs) {
|
||||||
|
for (const turn of turns) {
|
||||||
|
if (turn.assistantResponseMessage?.toolUses?.length) {
|
||||||
|
for (const call of turn.assistantResponseMessage.toolUses) {
|
||||||
|
appendText(turn.assistantResponseMessage, toolCallText(call));
|
||||||
|
}
|
||||||
|
repairs.invalidToolUses += turn.assistantResponseMessage.toolUses.length;
|
||||||
|
delete turn.assistantResponseMessage.toolUses;
|
||||||
|
}
|
||||||
|
const user = turn.userInputMessage;
|
||||||
|
const results = user?.userInputMessageContext?.toolResults;
|
||||||
|
if (results?.length) {
|
||||||
|
flattenResults(user, results);
|
||||||
|
repairs.orphanResults += results.length;
|
||||||
|
delete user.userInputMessageContext.toolResults;
|
||||||
|
cleanUserContext(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produce a strict Kiro conversation: alternating turns, current user message,
|
||||||
|
* adjacent one-to-one tool use/result pairs, and tool specs only on currentMessage.
|
||||||
|
*/
|
||||||
|
export function canonicalizeKiroConversation({
|
||||||
|
history,
|
||||||
|
currentMessage,
|
||||||
|
modelId,
|
||||||
|
toolSpecs = [],
|
||||||
|
nameMap = new Map(),
|
||||||
|
} = {}) {
|
||||||
|
const turns = normalizeTurns(history, currentMessage, modelId);
|
||||||
|
const repairs = { missingResults: 0, orphanResults: 0, invalidToolUses: 0 };
|
||||||
|
const specNames = new Set(toolSpecs.map((spec) => spec?.toolSpecification?.name).filter(Boolean));
|
||||||
|
const usedIds = new Set();
|
||||||
|
|
||||||
|
for (let index = 0; index < turns.length; index += 2) {
|
||||||
|
const user = turns[index].userInputMessage;
|
||||||
|
if (index === 0) {
|
||||||
|
const leadingResults = user.userInputMessageContext?.toolResults || [];
|
||||||
|
if (leadingResults.length > 0) {
|
||||||
|
flattenResults(user, leadingResults);
|
||||||
|
repairs.orphanResults += leadingResults.length;
|
||||||
|
delete user.userInputMessageContext.toolResults;
|
||||||
|
cleanUserContext(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const assistant = turns[index + 1]?.assistantResponseMessage;
|
||||||
|
const nextUser = turns[index + 2]?.userInputMessage;
|
||||||
|
if (assistant && nextUser) {
|
||||||
|
reconcileToolPair(assistant, nextUser, index + 1, nameMap, specNames, usedIds, repairs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalCurrent = turns[turns.length - 1];
|
||||||
|
finalCurrent.userInputMessage.userInputMessageContext ||= {};
|
||||||
|
if (toolSpecs.length > 0) {
|
||||||
|
finalCurrent.userInputMessage.userInputMessageContext.tools = clone(toolSpecs);
|
||||||
|
}
|
||||||
|
cleanUserContext(finalCurrent.userInputMessage);
|
||||||
|
|
||||||
|
let finalHistory = turns.slice(0, -1);
|
||||||
|
let validation = validateKiroConversation(finalHistory, finalCurrent, toolSpecs);
|
||||||
|
if (!validation.valid) {
|
||||||
|
flattenAllStructuredTools(turns, repairs);
|
||||||
|
finalHistory = turns.slice(0, -1);
|
||||||
|
validation = validateKiroConversation(finalHistory, finalCurrent, toolSpecs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
history: finalHistory,
|
||||||
|
currentMessage: finalCurrent,
|
||||||
|
repairs,
|
||||||
|
valid: validation.valid,
|
||||||
|
errors: validation.errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -353,6 +353,19 @@ export function cleanJSONSchemaForAntigravity(schema) {
|
|||||||
function addPlaceholders(obj) {
|
function addPlaceholders(obj) {
|
||||||
if (!obj || typeof obj !== "object") return;
|
if (!obj || typeof obj !== "object") return;
|
||||||
|
|
||||||
|
// Empty schema {} (no type, no properties) after $ref removal — treat as object with placeholder
|
||||||
|
if (Object.keys(obj).length === 0) {
|
||||||
|
obj.type = "object";
|
||||||
|
obj.properties = {
|
||||||
|
reason: {
|
||||||
|
type: "string",
|
||||||
|
description: "Brief explanation of why you are calling this tool"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
obj.required = ["reason"];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (obj.type === "object") {
|
if (obj.type === "object") {
|
||||||
if (!obj.properties || Object.keys(obj.properties).length === 0) {
|
if (!obj.properties || Object.keys(obj.properties).length === 0) {
|
||||||
obj.properties = {
|
obj.properties = {
|
||||||
|
|||||||
@@ -62,8 +62,13 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
|
|||||||
// Always ensure tool_calls have id (some providers require it)
|
// Always ensure tool_calls have id (some providers require it)
|
||||||
ensureToolCallIds(result);
|
ensureToolCallIds(result);
|
||||||
|
|
||||||
// Fix missing tool responses (insert empty tool_result if needed)
|
// Kiro performs stricter source-aware reconciliation after session replay.
|
||||||
fixMissingToolResponses(result);
|
// The generic helper inserts OpenAI `role: tool` messages, which a direct
|
||||||
|
// Claude→Kiro translator cannot consume and which cannot repair partial
|
||||||
|
// parallel tool results.
|
||||||
|
if (targetFormat !== FORMATS.KIRO) {
|
||||||
|
fixMissingToolResponses(result);
|
||||||
|
}
|
||||||
|
|
||||||
// Capture thinking intent from the original (pre-translation) body, before any
|
// Capture thinking intent from the original (pre-translation) body, before any
|
||||||
// format conversion strips/renames the fields. Applied after translation.
|
// format conversion strips/renames the fields. Applied after translation.
|
||||||
|
|||||||
@@ -6,17 +6,10 @@
|
|||||||
* direct `claude:kiro` route in ../index.js uses; it is NOT reached through the
|
* direct `claude:kiro` route in ../index.js uses; it is NOT reached through the
|
||||||
* claude→openai→kiro pivot.
|
* claude→openai→kiro pivot.
|
||||||
*
|
*
|
||||||
* It reproduces the two 400-guards that live in openai-to-kiro.js so that a
|
* After session replay it delegates to the shared Kiro conversation
|
||||||
* Claude client which omits the `tools` array on a follow-up turn (typical
|
* canonicalizer. That layer enforces adjacent one-to-one tool use/results,
|
||||||
* after client-side compaction) does not trip Kiro's schema validator and get
|
* repairs partial parallel calls, and flattens compacted structured references
|
||||||
* "Improperly formed request" (HTTP 400):
|
* that can no longer be represented safely.
|
||||||
*
|
|
||||||
* 1. flattenClaudeToolInteractions — when the client sent NO tools, collapse
|
|
||||||
* every tool_use / tool_result block to plain text so no structured tool
|
|
||||||
* reference survives to trigger the "tools required" rule.
|
|
||||||
* 2. reconcileOrphanedToolResults — when tools ARE present, fold any
|
|
||||||
* tool_result whose tool_use_id has no matching tool_use back into the
|
|
||||||
* user text instead of leaving a dangling structured reference.
|
|
||||||
*
|
*
|
||||||
* It also handles the 9router-synthetic `-agentic` / `-thinking` suffixes and
|
* It also handles the 9router-synthetic `-agentic` / `-thinking` suffixes and
|
||||||
* the `<thinking_mode>enabled</thinking_mode>` reasoning trigger, matching
|
* the `<thinking_mode>enabled</thinking_mode>` reasoning trigger, matching
|
||||||
@@ -27,7 +20,8 @@ import { FORMATS } from "../formats.js";
|
|||||||
import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js";
|
import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js";
|
||||||
import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js";
|
import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js";
|
||||||
import {
|
import {
|
||||||
resolveKiroModel,
|
resolveKiroModelIntent,
|
||||||
|
applyKiroThinkingOverride,
|
||||||
resolveKiroThinkingBudget,
|
resolveKiroThinkingBudget,
|
||||||
buildThinkingSystemPrefix,
|
buildThinkingSystemPrefix,
|
||||||
KIRO_AGENTIC_SYSTEM_PROMPT,
|
KIRO_AGENTIC_SYSTEM_PROMPT,
|
||||||
@@ -37,82 +31,17 @@ import {
|
|||||||
} from "../../config/kiroConstants.js";
|
} from "../../config/kiroConstants.js";
|
||||||
import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
|
import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
|
||||||
import { ROLE, CLAUDE_BLOCK } from "../schema/index.js";
|
import { ROLE, CLAUDE_BLOCK } from "../schema/index.js";
|
||||||
|
import {
|
||||||
/** Stringify a tool_use input as a readable line. */
|
canonicalizeKiroConversation,
|
||||||
function toolUseToText(name, input) {
|
normalizeKiroToolSpecs,
|
||||||
let argStr;
|
} from "../concerns/kiroConversation.js";
|
||||||
try {
|
|
||||||
argStr = typeof input === "string" ? input : JSON.stringify(input ?? {});
|
|
||||||
} catch {
|
|
||||||
argStr = "{}";
|
|
||||||
}
|
|
||||||
return `[Tool call: ${name || "unknown"}(${argStr})]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render a Claude tool_result block's content as a readable line. */
|
|
||||||
function toolResultBlockToText(content) {
|
|
||||||
let text = "";
|
|
||||||
if (typeof content === "string") {
|
|
||||||
text = content;
|
|
||||||
} else if (Array.isArray(content)) {
|
|
||||||
text = content
|
|
||||||
.map((c) => (typeof c === "string" ? c : c?.text || ""))
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n");
|
|
||||||
} else if (content) {
|
|
||||||
try {
|
|
||||||
text = JSON.stringify(content);
|
|
||||||
} catch {
|
|
||||||
text = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `[Tool result: ${text}]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When the client sent no tools, rewrite every tool_use (assistant) and
|
|
||||||
* tool_result (user) content block into plain text. Keeps text + images.
|
|
||||||
* Returns a new messages array; never mutates the input.
|
|
||||||
*/
|
|
||||||
function flattenClaudeToolInteractions(messages) {
|
|
||||||
const out = [];
|
|
||||||
for (const msg of messages) {
|
|
||||||
if (!msg) continue;
|
|
||||||
|
|
||||||
if (msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) {
|
|
||||||
const parts = [];
|
|
||||||
for (const block of msg.content) {
|
|
||||||
if (block.type === CLAUDE_BLOCK.TEXT && block.text) {
|
|
||||||
parts.push(block.text);
|
|
||||||
} else if (block.type === CLAUDE_BLOCK.TOOL_USE) {
|
|
||||||
parts.push(toolUseToText(block.name, block.input));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.push({ ...msg, content: parts.join("\n") });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.role === ROLE.USER && Array.isArray(msg.content)) {
|
|
||||||
const newContent = msg.content.map((block) =>
|
|
||||||
block.type === CLAUDE_BLOCK.TOOL_RESULT
|
|
||||||
? { type: CLAUDE_BLOCK.TEXT, text: toolResultBlockToText(block.content) }
|
|
||||||
: block
|
|
||||||
);
|
|
||||||
out.push({ ...msg, content: newContent });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
out.push(msg);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert Claude messages to Kiro history + currentMessage.
|
* Convert Claude messages to Kiro history + currentMessage.
|
||||||
* Kiro requires alternating user/assistant turns; consecutive same-role
|
* Kiro requires alternating user/assistant turns; consecutive same-role
|
||||||
* messages are merged.
|
* messages are merged.
|
||||||
*/
|
*/
|
||||||
function convertClaudeMessagesToKiro(messages, tools, model) {
|
function convertClaudeMessagesToKiro(messages, model) {
|
||||||
const history = [];
|
const history = [];
|
||||||
let currentMessage = null;
|
let currentMessage = null;
|
||||||
|
|
||||||
@@ -121,27 +50,6 @@ function convertClaudeMessagesToKiro(messages, tools, model) {
|
|||||||
let pendingToolResults = [];
|
let pendingToolResults = [];
|
||||||
let pendingImages = [];
|
let pendingImages = [];
|
||||||
let currentRole = null;
|
let currentRole = null;
|
||||||
let toolsInjected = false;
|
|
||||||
|
|
||||||
const clientProvidedTools = Array.isArray(tools) && tools.length > 0;
|
|
||||||
|
|
||||||
const buildToolSpecs = () =>
|
|
||||||
tools.map((t) => {
|
|
||||||
const name = t.name;
|
|
||||||
const description = t.description || `Tool: ${name}`;
|
|
||||||
const schema = t.input_schema || {};
|
|
||||||
const normalizedSchema =
|
|
||||||
Object.keys(schema).length === 0
|
|
||||||
? { type: "object", properties: {}, required: [] }
|
|
||||||
: { ...schema, required: schema.required ?? [] };
|
|
||||||
return {
|
|
||||||
toolSpecification: {
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
inputSchema: { json: normalizedSchema },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const flushPending = () => {
|
const flushPending = () => {
|
||||||
if (currentRole === ROLE.USER) {
|
if (currentRole === ROLE.USER) {
|
||||||
@@ -156,15 +64,6 @@ function convertClaudeMessagesToKiro(messages, tools, model) {
|
|||||||
toolResults: pendingToolResults,
|
toolResults: pendingToolResults,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Attach tools to the first user turn only.
|
|
||||||
if (clientProvidedTools && !toolsInjected) {
|
|
||||||
if (!userMsg.userInputMessage.userInputMessageContext) {
|
|
||||||
userMsg.userInputMessage.userInputMessageContext = {};
|
|
||||||
}
|
|
||||||
userMsg.userInputMessage.userInputMessageContext.tools = buildToolSpecs();
|
|
||||||
toolsInjected = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
history.push(userMsg);
|
history.push(userMsg);
|
||||||
currentMessage = userMsg;
|
currentMessage = userMsg;
|
||||||
pendingUserContent = [];
|
pendingUserContent = [];
|
||||||
@@ -208,7 +107,7 @@ function convertClaudeMessagesToKiro(messages, tools, model) {
|
|||||||
}
|
}
|
||||||
pendingToolResults.push({
|
pendingToolResults.push({
|
||||||
toolUseId: block.tool_use_id,
|
toolUseId: block.tool_use_id,
|
||||||
status: "success",
|
status: block.is_error ? "error" : "success",
|
||||||
content: [{ text: resultContent }],
|
content: [{ text: resultContent }],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -255,14 +154,7 @@ function convertClaudeMessagesToKiro(messages, tools, model) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grab tools from the first history user turn before cleanup strips them.
|
|
||||||
const firstHistoryTools =
|
|
||||||
history[0]?.userInputMessage?.userInputMessageContext?.tools;
|
|
||||||
|
|
||||||
history.forEach((item) => {
|
history.forEach((item) => {
|
||||||
if (item.userInputMessage?.userInputMessageContext?.tools) {
|
|
||||||
delete item.userInputMessage.userInputMessageContext.tools;
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
item.userInputMessage?.userInputMessageContext &&
|
item.userInputMessage?.userInputMessageContext &&
|
||||||
Object.keys(item.userInputMessage.userInputMessageContext).length === 0
|
Object.keys(item.userInputMessage.userInputMessageContext).length === 0
|
||||||
@@ -306,66 +198,9 @@ function convertClaudeMessagesToKiro(messages, tools, model) {
|
|||||||
currentMessage = { userInputMessage: { content: "", modelId: model } };
|
currentMessage = { userInputMessage: { content: "", modelId: model } };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject tools into currentMessage after cleanup if not already present.
|
|
||||||
if (
|
|
||||||
firstHistoryTools?.length > 0 &&
|
|
||||||
!currentMessage.userInputMessage.userInputMessageContext?.tools
|
|
||||||
) {
|
|
||||||
if (!currentMessage.userInputMessage.userInputMessageContext) {
|
|
||||||
currentMessage.userInputMessage.userInputMessageContext = {};
|
|
||||||
}
|
|
||||||
currentMessage.userInputMessage.userInputMessageContext.tools =
|
|
||||||
firstHistoryTools;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { history: mergedHistory, currentMessage };
|
return { history: mergedHistory, currentMessage };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fold orphaned toolResults (those whose toolUseId has no matching toolUse in
|
|
||||||
* any assistant turn) back into the user text, removing the dangling
|
|
||||||
* structured reference that makes Kiro 400.
|
|
||||||
*/
|
|
||||||
function reconcileOrphanedToolResults(history, currentMessage) {
|
|
||||||
const validIds = new Set();
|
|
||||||
for (const h of history) {
|
|
||||||
const arm = h.assistantResponseMessage;
|
|
||||||
if (!arm) continue;
|
|
||||||
for (const tu of arm.toolUses || []) {
|
|
||||||
if (tu.toolUseId) validIds.add(tu.toolUseId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const carriers = currentMessage ? [...history, currentMessage] : history;
|
|
||||||
for (const item of carriers) {
|
|
||||||
const uim = item.userInputMessage;
|
|
||||||
const ctx = uim?.userInputMessageContext;
|
|
||||||
if (!ctx?.toolResults?.length) continue;
|
|
||||||
|
|
||||||
const kept = [];
|
|
||||||
const salvaged = [];
|
|
||||||
for (const tr of ctx.toolResults) {
|
|
||||||
if (validIds.has(tr.toolUseId)) {
|
|
||||||
kept.push(tr);
|
|
||||||
} else {
|
|
||||||
const text = Array.isArray(tr.content)
|
|
||||||
? tr.content.map((c) => c?.text || "").join("\n")
|
|
||||||
: "";
|
|
||||||
salvaged.push(`[Tool result: ${text}]`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (salvaged.length === 0) continue;
|
|
||||||
|
|
||||||
const extra = salvaged.join("\n");
|
|
||||||
uim.content = uim.content ? `${uim.content}\n\n${extra}` : extra;
|
|
||||||
ctx.toolResults = kept;
|
|
||||||
if (kept.length === 0 && !ctx.tools?.length) {
|
|
||||||
delete uim.userInputMessageContext;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractClaudeSystemText(system) {
|
function extractClaudeSystemText(system) {
|
||||||
if (!system) return "";
|
if (!system) return "";
|
||||||
if (typeof system === "string") return system;
|
if (typeof system === "string") return system;
|
||||||
@@ -382,33 +217,21 @@ function extractClaudeSystemText(system) {
|
|||||||
* Build a Kiro payload directly from a Claude Messages API request body.
|
* Build a Kiro payload directly from a Claude Messages API request body.
|
||||||
*/
|
*/
|
||||||
export function claudeToKiroRequest(model, body, stream, credentials) {
|
export function claudeToKiroRequest(model, body, stream, credentials) {
|
||||||
let messages = Array.isArray(body.messages) ? body.messages : [];
|
const messages = Array.isArray(body.messages) ? body.messages : [];
|
||||||
const tools = Array.isArray(body.tools) ? body.tools : [];
|
const tools = Array.isArray(body.tools) ? body.tools : [];
|
||||||
const clientProvidedTools = tools.length > 0;
|
|
||||||
const maxTokens = body.max_tokens || 32000;
|
const maxTokens = body.max_tokens || 32000;
|
||||||
const temperature = body.temperature;
|
const temperature = body.temperature;
|
||||||
const topP = body.top_p;
|
const topP = body.top_p;
|
||||||
|
|
||||||
const { upstream: upstreamModel, agentic } = resolveKiroModel(model);
|
const modelIntent = resolveKiroModelIntent(model);
|
||||||
const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model);
|
const { upstream: upstreamModel, agentic } = modelIntent;
|
||||||
const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel);
|
const thinkingBody = applyKiroThinkingOverride(body, modelIntent.thinkingOverride);
|
||||||
const usesNativeGptEffort = usesKiroNativeGptEffort(body, upstreamModel);
|
const thinkingBudget = resolveKiroThinkingBudget(thinkingBody, credentials?.rawHeaders, modelIntent.model);
|
||||||
|
const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(thinkingBody, upstreamModel);
|
||||||
|
const usesNativeGptEffort = usesKiroNativeGptEffort(thinkingBody, upstreamModel);
|
||||||
|
|
||||||
// Guard 1: no client tools → flatten all tool interactions to text.
|
const { specs: toolSpecs, nameMap } = normalizeKiroToolSpecs(tools);
|
||||||
if (!clientProvidedTools) {
|
const { history, currentMessage } = convertClaudeMessagesToKiro(messages, upstreamModel);
|
||||||
messages = flattenClaudeToolInteractions(messages);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { history, currentMessage } = convertClaudeMessagesToKiro(
|
|
||||||
messages,
|
|
||||||
tools,
|
|
||||||
upstreamModel
|
|
||||||
);
|
|
||||||
|
|
||||||
// Guard 2: tools present → reconcile dangling tool_results.
|
|
||||||
if (clientProvidedTools) {
|
|
||||||
reconcileOrphanedToolResults(history, currentMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// api_key / idc / external_idp must never use the shared default ARN (belongs
|
// api_key / idc / external_idp must never use the shared default ARN (belongs
|
||||||
// to another account → 403 "bearer token invalid"); OAuth/social fall back to it.
|
// to another account → 403 "bearer token invalid"); OAuth/social fall back to it.
|
||||||
@@ -457,7 +280,14 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
|
|||||||
history,
|
history,
|
||||||
currentMessage,
|
currentMessage,
|
||||||
});
|
});
|
||||||
const replayCurrent = replay.currentMessage?.userInputMessage || {};
|
const canonical = canonicalizeKiroConversation({
|
||||||
|
history: replay.history,
|
||||||
|
currentMessage: replay.currentMessage,
|
||||||
|
modelId: upstreamModel,
|
||||||
|
toolSpecs,
|
||||||
|
nameMap,
|
||||||
|
});
|
||||||
|
const replayCurrent = canonical.currentMessage.userInputMessage;
|
||||||
const userInputMessage = {
|
const userInputMessage = {
|
||||||
content: replayCurrent.content || "",
|
content: replayCurrent.content || "",
|
||||||
modelId: upstreamModel,
|
modelId: upstreamModel,
|
||||||
@@ -479,7 +309,7 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
|
|||||||
currentMessage: {
|
currentMessage: {
|
||||||
userInputMessage,
|
userInputMessage,
|
||||||
},
|
},
|
||||||
history: replay.history,
|
history: canonical.history,
|
||||||
},
|
},
|
||||||
agentMode: "vibe",
|
agentMode: "vibe",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -253,6 +253,10 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (msg.role === ROLE.ASSISTANT) {
|
} else if (msg.role === ROLE.ASSISTANT) {
|
||||||
|
if (typeof msg.reasoning_content === "string" && msg.reasoning_content) {
|
||||||
|
blocks.push({ type: CLAUDE_BLOCK.THINKING, thinking: msg.reasoning_content });
|
||||||
|
}
|
||||||
|
|
||||||
if (Array.isArray(msg.content)) {
|
if (Array.isArray(msg.content)) {
|
||||||
for (const part of msg.content) {
|
for (const part of msg.content) {
|
||||||
if (part.type === OPENAI_BLOCK.TEXT && part.text) {
|
if (part.type === OPENAI_BLOCK.TEXT && part.text) {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { v4 as uuidv4 } from "uuid";
|
|||||||
import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js";
|
import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js";
|
||||||
import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js";
|
import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js";
|
||||||
import {
|
import {
|
||||||
resolveKiroModel,
|
resolveKiroModelIntent,
|
||||||
|
applyKiroThinkingOverride,
|
||||||
resolveKiroThinkingBudget,
|
resolveKiroThinkingBudget,
|
||||||
buildThinkingSystemPrefix,
|
buildThinkingSystemPrefix,
|
||||||
KIRO_AGENTIC_SYSTEM_PROMPT,
|
KIRO_AGENTIC_SYSTEM_PROMPT,
|
||||||
@@ -19,148 +20,10 @@ import {
|
|||||||
import { parseDataUri } from "../concerns/image.js";
|
import { parseDataUri } from "../concerns/image.js";
|
||||||
import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
|
import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
|
||||||
import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js";
|
import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js";
|
||||||
|
import {
|
||||||
/** Render a single tool call as a readable text line. */
|
canonicalizeKiroConversation,
|
||||||
function toolCallToText(name, input) {
|
normalizeKiroToolSpecs,
|
||||||
let argStr;
|
} from "../concerns/kiroConversation.js";
|
||||||
try {
|
|
||||||
argStr = typeof input === "string" ? input : JSON.stringify(input ?? {});
|
|
||||||
} catch {
|
|
||||||
argStr = "{}";
|
|
||||||
}
|
|
||||||
return `[Tool call: ${name || "unknown"}(${argStr})]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render a tool result (string or content-block array) as a text line. */
|
|
||||||
function toolResultToText(content) {
|
|
||||||
const text = Array.isArray(content)
|
|
||||||
? content.map(c => (typeof c === "string" ? c : c.text || "")).join("\n")
|
|
||||||
: (typeof content === "string" ? content : "");
|
|
||||||
return `[Tool result: ${text}]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flatten all tool calls/results in a conversation into plain text.
|
|
||||||
*
|
|
||||||
* Kiro's schema validator requires a non-empty
|
|
||||||
* currentMessage.userInputMessageContext.tools array whenever the history
|
|
||||||
* references any tool use; otherwise it returns "Improperly formed request"
|
|
||||||
* (HTTP 400). A client can hit this by omitting the `tools` array on a
|
|
||||||
* follow-up request — typically after client-side compaction (e.g. OpenCode).
|
|
||||||
*
|
|
||||||
* Rather than fabricate stub tool specs — which would advertise tool-calling
|
|
||||||
* capability the client never requested and may not handle, risking a phantom
|
|
||||||
* tool call on an otherwise plain turn — we collapse the tool interaction into
|
|
||||||
* text. The request stays honest, and since no structured tool content
|
|
||||||
* remains, the validator's "tools required" rule never fires.
|
|
||||||
*
|
|
||||||
* Only invoked when the client did NOT send tools; when tools are present the
|
|
||||||
* structured form is preserved.
|
|
||||||
*/
|
|
||||||
function flattenToolInteractions(messages) {
|
|
||||||
const out = [];
|
|
||||||
|
|
||||||
for (const msg of messages) {
|
|
||||||
// OpenAI tool-result message → user text line
|
|
||||||
if (msg.role === ROLE.TOOL) {
|
|
||||||
out.push({ role: ROLE.USER, content: toolResultToText(msg.content) });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.role === ROLE.ASSISTANT) {
|
|
||||||
const parts = [];
|
|
||||||
if (Array.isArray(msg.content)) {
|
|
||||||
for (const c of msg.content) {
|
|
||||||
if (c.type === CLAUDE_BLOCK.TOOL_USE) {
|
|
||||||
parts.push(toolCallToText(c.name, c.input));
|
|
||||||
} else if (c.type === OPENAI_BLOCK.TEXT || c.text) {
|
|
||||||
parts.push(c.text || "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (typeof msg.content === "string") {
|
|
||||||
parts.push(msg.content);
|
|
||||||
}
|
|
||||||
for (const tc of msg.tool_calls || []) {
|
|
||||||
parts.push(toolCallToText(tc.function?.name, tc.function?.arguments));
|
|
||||||
}
|
|
||||||
out.push({ role: ROLE.ASSISTANT, content: parts.filter(Boolean).join("\n") });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// User messages: replace tool_result blocks with text, keep text + images.
|
|
||||||
if (msg.role === ROLE.USER && Array.isArray(msg.content)) {
|
|
||||||
const newContent = msg.content.map(c =>
|
|
||||||
c.type === CLAUDE_BLOCK.TOOL_RESULT
|
|
||||||
? { type: OPENAI_BLOCK.TEXT, text: toolResultToText(c.content) }
|
|
||||||
: c
|
|
||||||
);
|
|
||||||
out.push({ ...msg, content: newContent });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
out.push(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reconcile orphaned toolResults — those whose toolUseId has no matching
|
|
||||||
* toolUse in any assistant message. This happens when client-side compaction
|
|
||||||
* truncates the conversation and removes the assistant message containing the
|
|
||||||
* tool_use, but keeps the user message with the corresponding tool_result.
|
|
||||||
*
|
|
||||||
* A dangling structured reference makes Kiro return 400, so it must be removed.
|
|
||||||
* But the client deliberately kept the result content through compaction, so
|
|
||||||
* rather than discard it we fold it back into the user message as text — the
|
|
||||||
* same shape flattenToolInteractions() produces. The 400 trigger (the
|
|
||||||
* structured reference) is gone; the content survives.
|
|
||||||
*
|
|
||||||
* `messages` is every carrier that can hold toolResults — both history items
|
|
||||||
* and the popped-out currentMessage (orphans can land on either).
|
|
||||||
*/
|
|
||||||
function reconcileOrphanedToolResults(history, currentMessage) {
|
|
||||||
// Phase 1: collect all valid toolUseIds from assistant messages in history.
|
|
||||||
// (currentMessage is always a user turn, so it carries no toolUses.)
|
|
||||||
const validIds = new Set();
|
|
||||||
for (const h of history) {
|
|
||||||
const arm = h.assistantResponseMessage;
|
|
||||||
if (!arm) continue;
|
|
||||||
for (const tu of arm.toolUses || []) {
|
|
||||||
if (tu.toolUseId) validIds.add(tu.toolUseId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: across history + currentMessage, keep results with a matching
|
|
||||||
// toolUse and salvage the rest as text.
|
|
||||||
const carriers = currentMessage ? [...history, currentMessage] : history;
|
|
||||||
for (const item of carriers) {
|
|
||||||
const uim = item.userInputMessage;
|
|
||||||
const ctx = uim?.userInputMessageContext;
|
|
||||||
if (!ctx?.toolResults?.length) continue;
|
|
||||||
|
|
||||||
const kept = [];
|
|
||||||
const salvaged = [];
|
|
||||||
for (const tr of ctx.toolResults) {
|
|
||||||
if (validIds.has(tr.toolUseId)) {
|
|
||||||
kept.push(tr);
|
|
||||||
} else {
|
|
||||||
salvaged.push(toolResultToText(tr.content));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (salvaged.length === 0) continue; // no orphans — leave untouched
|
|
||||||
|
|
||||||
// Fold orphaned result content into the user text so it is not lost
|
|
||||||
const extra = salvaged.join("\n");
|
|
||||||
uim.content = uim.content ? `${uim.content}\n\n${extra}` : extra;
|
|
||||||
|
|
||||||
ctx.toolResults = kept;
|
|
||||||
if (kept.length === 0 && !ctx.tools?.length) {
|
|
||||||
delete uim.userInputMessageContext;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely parse JSON string, returning fallback on failure.
|
* Safely parse JSON string, returning fallback on failure.
|
||||||
@@ -176,26 +39,15 @@ function safeJSONParse(str, fallback) {
|
|||||||
*
|
*
|
||||||
* Returns { history, currentMessage }.
|
* Returns { history, currentMessage }.
|
||||||
*/
|
*/
|
||||||
function convertMessages(messages, tools, model) {
|
function convertMessages(messages, model) {
|
||||||
let history = [];
|
let history = [];
|
||||||
let currentMessage = null;
|
let currentMessage = null;
|
||||||
|
|
||||||
const clientProvidedTools = tools && tools.length > 0;
|
|
||||||
|
|
||||||
// When the client did not send tools, flatten any tool calls/results in the
|
|
||||||
// history into plain text (see flattenToolInteractions). This keeps the
|
|
||||||
// request honest and sidesteps Kiro's "tools required" 400, since no
|
|
||||||
// structured tool content survives to trigger it.
|
|
||||||
if (!clientProvidedTools) {
|
|
||||||
messages = flattenToolInteractions(messages);
|
|
||||||
}
|
|
||||||
|
|
||||||
let pendingUserContent = [];
|
let pendingUserContent = [];
|
||||||
let pendingAssistantContent = [];
|
let pendingAssistantContent = [];
|
||||||
let pendingToolResults = [];
|
let pendingToolResults = [];
|
||||||
let pendingImages = [];
|
let pendingImages = [];
|
||||||
let currentRole = null;
|
let currentRole = null;
|
||||||
let toolsInjectedToFirstUserMsg = false;
|
|
||||||
|
|
||||||
const flushPending = () => {
|
const flushPending = () => {
|
||||||
if (currentRole === "user") {
|
if (currentRole === "user") {
|
||||||
@@ -218,39 +70,6 @@ function convertMessages(messages, tools, model) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add tools to the user message that has no preceding assistant messages,
|
|
||||||
// OR the first user message (whichever comes first after any opening
|
|
||||||
// assistant messages). We track whether any user message has already
|
|
||||||
// received tools via a flag on the history array.
|
|
||||||
if (clientProvidedTools && !toolsInjectedToFirstUserMsg) {
|
|
||||||
if (!userMsg.userInputMessage.userInputMessageContext) {
|
|
||||||
userMsg.userInputMessage.userInputMessageContext = {};
|
|
||||||
}
|
|
||||||
userMsg.userInputMessage.userInputMessageContext.tools = tools.map(t => {
|
|
||||||
const name = t.function?.name || t.name;
|
|
||||||
let description = t.function?.description || t.description || "";
|
|
||||||
|
|
||||||
if (!description.trim()) {
|
|
||||||
description = `Tool: ${name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const schema = t.function?.parameters || t.parameters || t.input_schema || {};
|
|
||||||
// Normalize schema: Kiro requires required[] and proper type/properties
|
|
||||||
const normalizedSchema = Object.keys(schema).length === 0
|
|
||||||
? { type: "object", properties: {}, required: [] }
|
|
||||||
: { ...schema, required: schema.required ?? [] };
|
|
||||||
|
|
||||||
return {
|
|
||||||
toolSpecification: {
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
inputSchema: { json: normalizedSchema }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
toolsInjectedToFirstUserMsg = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
history.push(userMsg);
|
history.push(userMsg);
|
||||||
currentMessage = userMsg;
|
currentMessage = userMsg;
|
||||||
pendingUserContent = [];
|
pendingUserContent = [];
|
||||||
@@ -326,7 +145,7 @@ function convertMessages(messages, tools, model) {
|
|||||||
|
|
||||||
pendingToolResults.push({
|
pendingToolResults.push({
|
||||||
toolUseId: block.tool_use_id,
|
toolUseId: block.tool_use_id,
|
||||||
status: "success",
|
status: block.is_error ? "error" : "success",
|
||||||
content: [{ text: text }]
|
content: [{ text: text }]
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -338,7 +157,7 @@ function convertMessages(messages, tools, model) {
|
|||||||
const toolContent = typeof msg.content === "string" ? msg.content : "";
|
const toolContent = typeof msg.content === "string" ? msg.content : "";
|
||||||
pendingToolResults.push({
|
pendingToolResults.push({
|
||||||
toolUseId: msg.tool_call_id,
|
toolUseId: msg.tool_call_id,
|
||||||
status: "success",
|
status: msg.is_error || msg.status === "error" ? "error" : "success",
|
||||||
content: [{ text: toolContent }]
|
content: [{ text: toolContent }]
|
||||||
});
|
});
|
||||||
} else if (content) {
|
} else if (content) {
|
||||||
@@ -412,14 +231,8 @@ function convertMessages(messages, tools, model) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grab tools from first history item BEFORE cleanup removes them
|
|
||||||
const firstHistoryTools = history[0]?.userInputMessage?.userInputMessageContext?.tools;
|
|
||||||
|
|
||||||
// Clean up history for Kiro API compatibility
|
// Clean up history for Kiro API compatibility
|
||||||
history.forEach(item => {
|
history.forEach(item => {
|
||||||
if (item.userInputMessage?.userInputMessageContext?.tools) {
|
|
||||||
delete item.userInputMessage.userInputMessageContext.tools;
|
|
||||||
}
|
|
||||||
if (item.userInputMessage?.userInputMessageContext &&
|
if (item.userInputMessage?.userInputMessageContext &&
|
||||||
Object.keys(item.userInputMessage.userInputMessageContext).length === 0) {
|
Object.keys(item.userInputMessage.userInputMessageContext).length === 0) {
|
||||||
delete item.userInputMessage.userInputMessageContext;
|
delete item.userInputMessage.userInputMessageContext;
|
||||||
@@ -472,33 +285,6 @@ function convertMessages(messages, tools, model) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconcile orphaned toolResults across history AND currentMessage — when
|
|
||||||
// client-side compaction removes assistant messages containing tool_use but
|
|
||||||
// keeps the tool_result, the dangling reference triggers a Kiro 400. Fold the
|
|
||||||
// content back into the user text instead of discarding it. Run after
|
|
||||||
// currentMessage is finalized (an orphan can be merged into it) and before
|
|
||||||
// tool injection (which may re-add userInputMessageContext).
|
|
||||||
//
|
|
||||||
// Only needed on the tools-present path: when the client sent no tools,
|
|
||||||
// flattenToolInteractions already collapsed every toolResult to text, so
|
|
||||||
// there is nothing structured left to orphan.
|
|
||||||
if (clientProvidedTools) {
|
|
||||||
reconcileOrphanedToolResults(mergedHistory, currentMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inject tools into currentMessage AFTER cleanup. Tools only exist here when
|
|
||||||
// the client explicitly sent them (otherwise flattenToolInteractions already
|
|
||||||
// collapsed all tool content to text upstream, so there is nothing to carry).
|
|
||||||
const resolvedTools = firstHistoryTools;
|
|
||||||
|
|
||||||
if (resolvedTools?.length > 0 &&
|
|
||||||
!currentMessage.userInputMessage.userInputMessageContext?.tools) {
|
|
||||||
if (!currentMessage.userInputMessage.userInputMessageContext) {
|
|
||||||
currentMessage.userInputMessage.userInputMessageContext = {};
|
|
||||||
}
|
|
||||||
currentMessage.userInputMessage.userInputMessageContext.tools = resolvedTools;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { history: mergedHistory, currentMessage };
|
return { history: mergedHistory, currentMessage };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,12 +310,15 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
|
|||||||
const temperature = body.temperature;
|
const temperature = body.temperature;
|
||||||
const topP = body.top_p;
|
const topP = body.top_p;
|
||||||
|
|
||||||
const { upstream: upstreamModel, agentic } = resolveKiroModel(model);
|
const modelIntent = resolveKiroModelIntent(model);
|
||||||
const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model);
|
const { upstream: upstreamModel, agentic } = modelIntent;
|
||||||
const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel);
|
const thinkingBody = applyKiroThinkingOverride(body, modelIntent.thinkingOverride);
|
||||||
const usesNativeGptEffort = usesKiroNativeGptEffort(body, upstreamModel);
|
const thinkingBudget = resolveKiroThinkingBudget(thinkingBody, credentials?.rawHeaders, modelIntent.model);
|
||||||
|
const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(thinkingBody, upstreamModel);
|
||||||
|
const usesNativeGptEffort = usesKiroNativeGptEffort(thinkingBody, upstreamModel);
|
||||||
|
|
||||||
const { history, currentMessage } = convertMessages(messages, tools, upstreamModel);
|
const { specs: toolSpecs, nameMap } = normalizeKiroToolSpecs(tools);
|
||||||
|
const { history, currentMessage } = convertMessages(messages, upstreamModel);
|
||||||
|
|
||||||
// API-key (headless) auth uses a raw CodeWhisperer credential whose profile is
|
// API-key (headless) auth uses a raw CodeWhisperer credential whose profile is
|
||||||
// account-specific. Injecting the shared builder-id/social *default* placeholder
|
// account-specific. Injecting the shared builder-id/social *default* placeholder
|
||||||
@@ -583,7 +372,14 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
|
|||||||
history,
|
history,
|
||||||
currentMessage,
|
currentMessage,
|
||||||
});
|
});
|
||||||
const replayCurrent = replay.currentMessage?.userInputMessage || {};
|
const canonical = canonicalizeKiroConversation({
|
||||||
|
history: replay.history,
|
||||||
|
currentMessage: replay.currentMessage,
|
||||||
|
modelId: upstreamModel,
|
||||||
|
toolSpecs,
|
||||||
|
nameMap,
|
||||||
|
});
|
||||||
|
const replayCurrent = canonical.currentMessage.userInputMessage;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
conversationState: {
|
conversationState: {
|
||||||
@@ -604,7 +400,7 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
history: replay.history
|
history: canonical.history
|
||||||
},
|
},
|
||||||
agentMode: "vibe",
|
agentMode: "vibe",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ function findFirstUserIndex(history) {
|
|||||||
return history.findIndex((item) => item?.userInputMessage);
|
return history.findIndex((item) => item?.userInputMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasToolResults(message) {
|
||||||
|
return !!message?.userInputMessage?.userInputMessageContext?.toolResults?.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canReplaceSessionStart(history, firstUserIndex) {
|
||||||
|
return firstUserIndex === 0 && !hasToolResults(history[firstUserIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
function rememberSessionStart(key, entry) {
|
function rememberSessionStart(key, entry) {
|
||||||
if (sessionStartStore.size >= MAX_SESSION_STARTS) {
|
if (sessionStartStore.size >= MAX_SESSION_STARTS) {
|
||||||
sessionStartStore.delete(sessionStartStore.keys().next().value);
|
sessionStartStore.delete(sessionStartStore.keys().next().value);
|
||||||
@@ -73,10 +81,13 @@ export function applyKiroSessionReplay({
|
|||||||
existing.lastUsed = Date.now();
|
existing.lastUsed = Date.now();
|
||||||
const firstUserIndex = findFirstUserIndex(baseHistory);
|
const firstUserIndex = findFirstUserIndex(baseHistory);
|
||||||
const sessionStart = ensureUserMessageModelId(clone(existing.sessionStart), modelId);
|
const sessionStart = ensureUserMessageModelId(clone(existing.sessionStart), modelId);
|
||||||
if (firstUserIndex >= 0) {
|
if (canReplaceSessionStart(baseHistory, firstUserIndex)) {
|
||||||
baseHistory[firstUserIndex] = sessionStart;
|
baseHistory[firstUserIndex] = sessionStart;
|
||||||
} else {
|
} else {
|
||||||
baseHistory.unshift(sessionStart);
|
baseHistory.unshift(sessionStart);
|
||||||
|
if (baseHistory.length === 1) {
|
||||||
|
baseHistory.push({ assistantResponseMessage: { content: "..." } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
history: ensureHistoryModelIds(baseHistory, modelId),
|
history: ensureHistoryModelIds(baseHistory, modelId),
|
||||||
@@ -88,10 +99,18 @@ export function applyKiroSessionReplay({
|
|||||||
const firstUserIndex = findFirstUserIndex(baseHistory);
|
const firstUserIndex = findFirstUserIndex(baseHistory);
|
||||||
let sessionStart;
|
let sessionStart;
|
||||||
let nextCurrent = ensureUserMessageModelId(baseCurrent, modelId);
|
let nextCurrent = ensureUserMessageModelId(baseCurrent, modelId);
|
||||||
if (firstUserIndex >= 0) {
|
if (canReplaceSessionStart(baseHistory, firstUserIndex)) {
|
||||||
sessionStart = prefixUserMessage(baseHistory[firstUserIndex], contentPrefix, modelId);
|
sessionStart = prefixUserMessage(baseHistory[firstUserIndex], contentPrefix, modelId);
|
||||||
baseHistory[firstUserIndex] = clone(sessionStart);
|
baseHistory[firstUserIndex] = clone(sessionStart);
|
||||||
nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId);
|
nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId);
|
||||||
|
} else if (firstUserIndex >= 0) {
|
||||||
|
sessionStart = prefixUserMessage(
|
||||||
|
{ userInputMessage: { content: "", modelId } },
|
||||||
|
contentPrefix,
|
||||||
|
modelId
|
||||||
|
);
|
||||||
|
baseHistory.unshift(clone(sessionStart));
|
||||||
|
nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId);
|
||||||
} else {
|
} else {
|
||||||
sessionStart = prefixUserMessage(baseCurrent, contentPrefix, modelId);
|
sessionStart = prefixUserMessage(baseCurrent, contentPrefix, modelId);
|
||||||
nextCurrent = clone(sessionStart);
|
nextCurrent = clone(sessionStart);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "9router-app",
|
"name": "9router-app",
|
||||||
"version": "0.5.40",
|
"version": "0.5.45",
|
||||||
"description": "9Router web dashboard",
|
"description": "9Router web dashboard",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -276,6 +276,16 @@ export default function ProvidersPage() {
|
|||||||
}))
|
}))
|
||||||
.filter((p) => matchSearch(p.name));
|
.filter((p) => matchSearch(p.name));
|
||||||
|
|
||||||
|
// Dual-auth providers (oauth + apikey) store API keys as authType "apikey"
|
||||||
|
// (and sometimes "api_key"). Card stats must count both so totals match detail.
|
||||||
|
// kiro has no authModes in registry but accepts both (headless uses "api_key").
|
||||||
|
const dualAuthTypes = (info, key) => {
|
||||||
|
if (key === "kiro") return ["oauth", "apikey", "api_key"];
|
||||||
|
const modes = info?.authModes;
|
||||||
|
if (!Array.isArray(modes) || !modes.includes("apikey")) return "oauth";
|
||||||
|
return ["oauth", "apikey", "api_key"];
|
||||||
|
};
|
||||||
|
|
||||||
const oauthEntries = sortByPriority(
|
const oauthEntries = sortByPriority(
|
||||||
Object.entries(OAUTH_PROVIDERS).filter(([, info]) => !info.hidden && matchSearch(info.name)),
|
Object.entries(OAUTH_PROVIDERS).filter(([, info]) => !info.hidden && matchSearch(info.name)),
|
||||||
"oauth",
|
"oauth",
|
||||||
@@ -287,15 +297,27 @@ export default function ProvidersPage() {
|
|||||||
matchSearch(info.name),
|
matchSearch(info.name),
|
||||||
)
|
)
|
||||||
.sort(([, a], [, b]) => (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0));
|
.sort(([, a], [, b]) => (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0));
|
||||||
const freeTierEntries = sortByPriority(
|
// Free Tier cards may be oauth-only (e.g. kimchi) or dual-auth, so count via
|
||||||
Object.entries(FREE_TIER_PROVIDERS).filter(
|
// dualAuthTypes per provider instead of a fixed "apikey" — otherwise oauth
|
||||||
|
// connections are invisible here (mismatch with the detail page).
|
||||||
|
const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS)
|
||||||
|
.filter(
|
||||||
([, info]) =>
|
([, info]) =>
|
||||||
!info.hidden &&
|
!info.hidden &&
|
||||||
matchSearch(info.name) &&
|
matchSearch(info.name) &&
|
||||||
(info.serviceKinds ?? ["llm"]).includes("llm"),
|
(info.serviceKinds ?? ["llm"]).includes("llm"),
|
||||||
),
|
)
|
||||||
"freeTier",
|
.sort(([ka, a], [kb, b]) => {
|
||||||
).sort(([, a], [, b]) => (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0));
|
const pa = a.priority ?? 999;
|
||||||
|
const pb = b.priority ?? 999;
|
||||||
|
if (pa !== pb) return pa - pb;
|
||||||
|
const noAuthDiff = (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0);
|
||||||
|
if (noAuthDiff !== 0) return noAuthDiff;
|
||||||
|
const ca = getProviderStats(ka, dualAuthTypes(a, ka)).connected > 0 ? 0 : 1;
|
||||||
|
const cb = getProviderStats(kb, dualAuthTypes(b, kb)).connected > 0 ? 0 : 1;
|
||||||
|
if (ca !== cb) return ca - cb;
|
||||||
|
return (a.name || "").localeCompare(b.name || "");
|
||||||
|
});
|
||||||
// API Key: connected providers first, then alphabetical by name
|
// API Key: connected providers first, then alphabetical by name
|
||||||
const apikeyEntries = Object.entries(APIKEY_PROVIDERS)
|
const apikeyEntries = Object.entries(APIKEY_PROVIDERS)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -429,16 +451,19 @@ export default function ProvidersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{oauthEntries.map(([key, info]) => (
|
{oauthEntries.map(([key, info]) => {
|
||||||
<ProviderCard
|
const authTypes = dualAuthTypes(info, key);
|
||||||
key={key}
|
return (
|
||||||
providerId={key}
|
<ProviderCard
|
||||||
provider={info}
|
key={key}
|
||||||
stats={getProviderStats(key, "oauth")}
|
providerId={key}
|
||||||
authType="oauth"
|
provider={info}
|
||||||
onToggle={(active) => handleToggleProvider(key, "oauth", active)}
|
stats={getProviderStats(key, authTypes)}
|
||||||
/>
|
authType="oauth"
|
||||||
))}
|
onToggle={(active) => handleToggleProvider(key, authTypes, active)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -471,12 +496,9 @@ export default function ProvidersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{freeEntries.map(([key, info]) => {
|
{freeEntries.map(([key, info]) => {
|
||||||
// Kiro accepts both OAuth and api-key connections; count/toggle both
|
// Dual-auth (e.g. kiro): count/toggle oauth + apikey/api_key so the
|
||||||
// so the card total matches the provider detail page (#kiro-apikey).
|
// card total matches the provider detail page.
|
||||||
// Kiro's headless api-key flow persists authType "api_key" (underscore),
|
const freeAuthTypes = dualAuthTypes(info, key);
|
||||||
// while generic apikey providers use "apikey" — include both spellings.
|
|
||||||
const freeAuthTypes =
|
|
||||||
key === "kiro" ? ["oauth", "apikey", "api_key"] : "oauth";
|
|
||||||
return (
|
return (
|
||||||
<ProviderCard
|
<ProviderCard
|
||||||
key={key}
|
key={key}
|
||||||
@@ -490,16 +512,19 @@ export default function ProvidersPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{freeTierEntries.map(([key, info]) => (
|
{freeTierEntries.map(([key, info]) => {
|
||||||
<ApiKeyProviderCard
|
const freeAuthTypes = dualAuthTypes(info, key);
|
||||||
key={key}
|
return (
|
||||||
providerId={key}
|
<ApiKeyProviderCard
|
||||||
provider={info}
|
key={key}
|
||||||
stats={getProviderStats(key, "apikey")}
|
providerId={key}
|
||||||
authType="apikey"
|
provider={info}
|
||||||
onToggle={(active) => handleToggleProvider(key, "apikey", active)}
|
stats={getProviderStats(key, freeAuthTypes)}
|
||||||
/>
|
authType={Array.isArray(freeAuthTypes) ? (freeAuthTypes[0] ?? "apikey") : freeAuthTypes}
|
||||||
))}
|
onToggle={(active) => handleToggleProvider(key, freeAuthTypes, active)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -146,103 +146,100 @@ export default function QuotaTable({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="space-y-px">
|
||||||
<table className="w-full table-fixed text-left">
|
{currentPageRows.map((quota) => {
|
||||||
<tbody>
|
const colors = getColorClasses(quota.remaining);
|
||||||
{currentPageRows.map((quota) => {
|
const countdown = formatResetTime(quota.resetAt);
|
||||||
const colors = getColorClasses(quota.remaining);
|
const resetDisplay = formatResetTimeDisplay(quota.resetAt);
|
||||||
const countdown = formatResetTime(quota.resetAt);
|
// recurring defaults true: a missing flag means the quota
|
||||||
const resetDisplay = formatResetTimeDisplay(quota.resetAt);
|
// refreshes at resetAt. Bonus/one-shot packs set recurring:false
|
||||||
// recurring defaults true: a missing flag means the quota
|
// and their resetAt is a hard expiry, so word it as "expires".
|
||||||
// refreshes at resetAt. Bonus/one-shot packs set recurring:false
|
const recurring = quota.recurring !== false;
|
||||||
// and their resetAt is a hard expiry, so word it as "expires".
|
const countdownLabel = recurring ? `in ${countdown}` : `expires in ${countdown}`;
|
||||||
const recurring = quota.recurring !== false;
|
|
||||||
const countdownLabel = recurring ? `in ${countdown}` : `expires in ${countdown}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<div
|
||||||
key={`${quota.name}-${quota.index}`}
|
key={`${quota.name}-${quota.index}`}
|
||||||
className="border-b border-black/5 dark:border-white/5 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
|
className={`flex items-center gap-2 border-b border-black/5 dark:border-white/5 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors ${cellPad}`}
|
||||||
|
>
|
||||||
|
{/* Name */}
|
||||||
|
<div className="flex w-36 min-w-0 items-center gap-1.5">
|
||||||
|
<span className="text-[10px] shrink-0">{colors.emoji}</span>
|
||||||
|
<span className={`${nameText} font-medium text-text-primary truncate`}>
|
||||||
|
{quota.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress + used/total */}
|
||||||
|
<div className={`min-w-0 flex-1 ${compact ? "space-y-1" : "space-y-1.5"}`}>
|
||||||
|
<div className={`${compact ? "h-1" : "h-1.5"} rounded-full overflow-hidden border ${colors.bgLight} ${
|
||||||
|
quota.remaining === 0 ? "border-black/10 dark:border-white/10" : "border-transparent"
|
||||||
|
}`}>
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all duration-300 ${colors.bg}`}
|
||||||
|
style={{ width: `${Math.min(quota.remaining, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex items-center justify-between gap-1 min-w-0 ${compact ? "text-[10px]" : "text-xs"}`}>
|
||||||
|
<span
|
||||||
|
className="text-text-muted truncate"
|
||||||
|
title={`${quota.used.toLocaleString()} / ${quota.total > 0 ? quota.total.toLocaleString() : "∞"}`}
|
||||||
|
>
|
||||||
|
{quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"}
|
||||||
|
</span>
|
||||||
|
<span className={`font-medium ${colors.text} shrink-0`}>
|
||||||
|
{quota.remaining}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset time */}
|
||||||
|
<div className="min-w-0 shrink">
|
||||||
|
{countdown !== "-" || resetDisplay ? (
|
||||||
|
compact ? (
|
||||||
|
<div
|
||||||
|
className={`${resetPrimary} text-text-primary font-medium truncate`}
|
||||||
|
title={resetDisplay || ""}
|
||||||
|
>
|
||||||
|
{countdown !== "-" ? countdownLabel : resetDisplay}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="min-w-0 space-y-0.5">
|
||||||
|
{countdown !== "-" && (
|
||||||
|
<div className={`${resetPrimary} text-text-primary font-medium truncate`}>
|
||||||
|
{countdownLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{resetDisplay && (
|
||||||
|
<div className={`${resetSecondary} text-text-muted truncate`}>
|
||||||
|
{resetDisplay}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className={`${resetPrimary} text-text-muted italic`}>N/A</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hide action */}
|
||||||
|
{hasHideAction && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onHideQuota(quota)}
|
||||||
|
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-text-muted transition-colors hover:bg-black/5 hover:text-text-primary dark:hover:bg-white/5"
|
||||||
|
title="Hide this quota row"
|
||||||
|
aria-label={`Hide quota ${quota.name}`}
|
||||||
>
|
>
|
||||||
<td className={`${cellPad} w-[30%]`}>
|
<span className="material-symbols-outlined text-[15px]">
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
visibility_off
|
||||||
<span className="text-[10px] shrink-0">{colors.emoji}</span>
|
</span>
|
||||||
<span className={`${nameText} font-medium text-text-primary truncate`}>
|
</button>
|
||||||
{quota.name}
|
)}
|
||||||
</span>
|
</div>
|
||||||
</div>
|
);
|
||||||
</td>
|
})}
|
||||||
|
|
||||||
<td className={`${cellPad} w-[45%]`}>
|
|
||||||
<div className={compact ? "space-y-1" : "space-y-1.5"}>
|
|
||||||
<div className={`${compact ? "h-1" : "h-1.5"} rounded-full overflow-hidden border ${colors.bgLight} ${
|
|
||||||
quota.remaining === 0 ? "border-black/10 dark:border-white/10" : "border-transparent"
|
|
||||||
}`}>
|
|
||||||
<div
|
|
||||||
className={`h-full transition-all duration-300 ${colors.bg}`}
|
|
||||||
style={{ width: `${Math.min(quota.remaining, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`flex items-center justify-between ${compact ? "text-[10px]" : "text-xs"}`}>
|
|
||||||
<span className="text-text-muted">
|
|
||||||
{quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"}
|
|
||||||
</span>
|
|
||||||
<span className={`font-medium ${colors.text}`}>
|
|
||||||
{quota.remaining}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className={`${cellPad} ${hasHideAction ? "w-[20%]" : "w-[25%]"}`}>
|
|
||||||
{countdown !== "-" || resetDisplay ? (
|
|
||||||
compact ? (
|
|
||||||
<div
|
|
||||||
className={`${resetPrimary} text-text-primary font-medium truncate`}
|
|
||||||
title={resetDisplay || ""}
|
|
||||||
>
|
|
||||||
{countdown !== "-" ? countdownLabel : resetDisplay}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{countdown !== "-" && (
|
|
||||||
<div className={`${resetPrimary} text-text-primary font-medium`}>
|
|
||||||
{countdownLabel}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{resetDisplay && (
|
|
||||||
<div className={`${resetSecondary} text-text-muted`}>
|
|
||||||
{resetDisplay}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div className={`${resetPrimary} text-text-muted italic`}>N/A</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{hasHideAction && (
|
|
||||||
<td className={`${cellPad} w-[5%] text-right`}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onHideQuota(quota)}
|
|
||||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-text-muted transition-colors hover:bg-black/5 hover:text-text-primary dark:hover:bg-white/5"
|
|
||||||
title="Hide this quota row"
|
|
||||||
aria-label={`Hide quota ${quota.name}`}
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined text-[15px]">
|
|
||||||
visibility_off
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
|
|||||||
@@ -1241,7 +1241,7 @@ export default function ProviderLimits() {
|
|||||||
visibility_off
|
visibility_off
|
||||||
</span>
|
</span>
|
||||||
<span className="shrink-0">Hidden:</span>
|
<span className="shrink-0">Hidden:</span>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto whitespace-nowrap">
|
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto whitespace-nowrap pb-2">
|
||||||
{hiddenQuotaRows.map((quotaRow) => (
|
{hiddenQuotaRows.map((quotaRow) => (
|
||||||
<button
|
<button
|
||||||
key={getQuotaVisibilityKey(quotaRow)}
|
key={getQuotaVisibilityKey(quotaRow)}
|
||||||
|
|||||||
@@ -512,6 +512,36 @@ export function parseQuotaData(provider, data) {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "kimi":
|
||||||
|
// Weekly / Ratelimit from /v1/usages. Prefer remainingPercentage only.
|
||||||
|
if (data.quotas) {
|
||||||
|
Object.entries(data.quotas).forEach(([name, quota]) => {
|
||||||
|
normalizedQuotas.push({
|
||||||
|
name,
|
||||||
|
used: quota.used || 0,
|
||||||
|
total: quota.total || 0,
|
||||||
|
resetAt: quota.resetAt || null,
|
||||||
|
remainingPercentage: quota.remainingPercentage,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "deepseek":
|
||||||
|
// Credit balance — remainingPercentage only (no absolute remaining).
|
||||||
|
if (data.quotas) {
|
||||||
|
Object.entries(data.quotas).forEach(([name, quota]) => {
|
||||||
|
normalizedQuotas.push({
|
||||||
|
name,
|
||||||
|
used: quota.used || 0,
|
||||||
|
total: quota.total || 0,
|
||||||
|
resetAt: quota.resetAt || null,
|
||||||
|
remainingPercentage: quota.remainingPercentage,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Generic fallback for unknown providers
|
// Generic fallback for unknown providers
|
||||||
if (data.quotas) {
|
if (data.quotas) {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import fs from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import os from "os";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
// Mirror the executor's resolveDevinBin discovery so the dashboard's status
|
||||||
|
// matches what the runtime actually spawns.
|
||||||
|
const candidateDevinPaths = () => {
|
||||||
|
const home = os.homedir();
|
||||||
|
const isWin = os.platform() === "win32";
|
||||||
|
const localAppData = process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
|
||||||
|
// Mirror resolveDevinBin in the executor — cover installer + common
|
||||||
|
// package-manager locations so detection matches runtime resolution.
|
||||||
|
return isWin
|
||||||
|
? [
|
||||||
|
path.join(localAppData, "devin", "cli", "bin", "devin.exe"),
|
||||||
|
path.join(home, ".local", "bin", "devin.exe"),
|
||||||
|
path.join(home, "scoop", "shims", "devin.exe"),
|
||||||
|
path.join(localAppData, "Programs", "devin", "devin.exe"),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
path.join(home, ".local", "share", "devin", "bin", "devin"),
|
||||||
|
path.join(home, ".devin", "bin", "devin"),
|
||||||
|
path.join(home, ".local", "bin", "devin"),
|
||||||
|
"/opt/homebrew/bin/devin",
|
||||||
|
"/usr/local/bin/devin",
|
||||||
|
"/usr/bin/devin",
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkDevinInstalled = async () => {
|
||||||
|
// 1. PATH lookup
|
||||||
|
try {
|
||||||
|
const isWindows = os.platform() === "win32";
|
||||||
|
const command = isWindows ? "where devin" : "which devin";
|
||||||
|
await execAsync(command, { windowsHide: true });
|
||||||
|
return { installed: true, source: "path" };
|
||||||
|
} catch {
|
||||||
|
// fall through to filesystem probes
|
||||||
|
}
|
||||||
|
// 2. Known installer paths
|
||||||
|
for (const candidate of candidateDevinPaths()) {
|
||||||
|
try {
|
||||||
|
await fs.access(candidate);
|
||||||
|
return { installed: true, source: candidate };
|
||||||
|
} catch { /* keep probing */ }
|
||||||
|
}
|
||||||
|
return { installed: false, source: null };
|
||||||
|
};
|
||||||
|
|
||||||
|
const readDevinVersion = async () => {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execAsync("devin --version", { windowsHide: true });
|
||||||
|
return stdout.trim().split("\n")[0] || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// GET — install detection only. No config to write: the binary handles its own auth.
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const { installed, source } = await checkDevinInstalled();
|
||||||
|
if (!installed) {
|
||||||
|
return NextResponse.json({
|
||||||
|
installed: false,
|
||||||
|
message: "Devin CLI is not installed. Install it from https://cli.devin.ai and run `devin auth login`.",
|
||||||
|
installUrl: "https://cli.devin.ai",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const version = await readDevinVersion();
|
||||||
|
return NextResponse.json({
|
||||||
|
installed: true,
|
||||||
|
source,
|
||||||
|
version,
|
||||||
|
message: "Devin CLI detected. Make sure `devin auth login` has been run.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error checking devin settings:", error);
|
||||||
|
return NextResponse.json({ error: "Failed to check devin settings" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,24 @@ import {
|
|||||||
registerXaiSession,
|
registerXaiSession,
|
||||||
getXaiSessionStatus,
|
getXaiSessionStatus,
|
||||||
clearXaiSession,
|
clearXaiSession,
|
||||||
|
startTraeProxy,
|
||||||
|
stopTraeProxy,
|
||||||
|
registerTraeSession,
|
||||||
|
getTraeSessionStatus,
|
||||||
|
clearTraeSession,
|
||||||
|
startWindsurfProxy,
|
||||||
|
stopWindsurfProxy,
|
||||||
|
registerWindsurfSession,
|
||||||
|
getWindsurfSessionStatus,
|
||||||
|
clearWindsurfSession,
|
||||||
|
startZedProxy,
|
||||||
|
stopZedProxy,
|
||||||
|
registerZedSession,
|
||||||
|
getZedSessionStatus,
|
||||||
|
clearZedSession,
|
||||||
} from "@/lib/oauth/utils/server";
|
} from "@/lib/oauth/utils/server";
|
||||||
|
import { detectIdeInstalled } from "@/lib/oauth/utils/ideDetect";
|
||||||
|
import { ZED_HOSTED_CONFIG } from "@/lib/oauth/constants/oauth";
|
||||||
|
|
||||||
async function completeXaiManualCode(code, state, ownerId) {
|
async function completeXaiManualCode(code, state, ownerId) {
|
||||||
const session = state ? getXaiSessionStatus(state) : null;
|
const session = state ? getXaiSessionStatus(state) : null;
|
||||||
@@ -80,13 +97,34 @@ export async function GET(request, { params }) {
|
|||||||
const reservedParams = new Set(["redirect_uri"]);
|
const reservedParams = new Set(["redirect_uri"]);
|
||||||
const meta = {};
|
const meta = {};
|
||||||
searchParams.forEach((value, key) => { if (!reservedParams.has(key)) meta[key] = value; });
|
searchParams.forEach((value, key) => { if (!reservedParams.has(key)) meta[key] = value; });
|
||||||
|
// Zed: derive native_app_port from the local callback URL so the RSA keypair
|
||||||
|
// is bound to the port the proxy is actually listening on.
|
||||||
|
if (provider === "zed") {
|
||||||
|
try { const p = new URL(redirectUri).port; if (p) meta.nativeAppPort = p; } catch { /* ignore */ }
|
||||||
|
}
|
||||||
const authData = await generateAuthData(provider, redirectUri, Object.keys(meta).length ? meta : undefined);
|
const authData = await generateAuthData(provider, redirectUri, Object.keys(meta).length ? meta : undefined);
|
||||||
return NextResponse.json(authData);
|
return NextResponse.json(authData);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "start-proxy") {
|
if (action === "start-proxy") {
|
||||||
|
// Trae/Windsurf/Zed use a dynamic-port local callback server (singleton session,
|
||||||
|
// state is registered separately via /register-session after /authorize).
|
||||||
|
if (provider === "trae") {
|
||||||
|
const result = await startTraeProxy();
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
|
if (provider === "windsurf") {
|
||||||
|
const result = await startWindsurfProxy();
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
|
if (provider === "zed") {
|
||||||
|
// Prefer ZED_HOSTED_CONFIG.defaultNativeAppPort (58443) so the browser redirect
|
||||||
|
// matches what Zed expects; falls back to a random port if it's busy.
|
||||||
|
const result = await startZedProxy(searchParams.get("native_app_port") || ZED_HOSTED_CONFIG.defaultNativeAppPort);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
if (!["codex", "xai"].includes(provider)) {
|
if (!["codex", "xai"].includes(provider)) {
|
||||||
return NextResponse.json({ error: "Proxy only supported for codex/xai" }, { status: 400 });
|
return NextResponse.json({ error: "Proxy only supported for codex/xai/trae/windsurf/zed" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const appPort = searchParams.get("app_port");
|
const appPort = searchParams.get("app_port");
|
||||||
if (!appPort) {
|
if (!appPort) {
|
||||||
@@ -108,18 +146,24 @@ export async function GET(request, { params }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === "poll-status") {
|
if (action === "poll-status") {
|
||||||
if (!["codex", "xai"].includes(provider)) {
|
|
||||||
return NextResponse.json({ error: "Poll only supported for codex/xai" }, { status: 400 });
|
|
||||||
}
|
|
||||||
const state = searchParams.get("state");
|
const state = searchParams.get("state");
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return NextResponse.json({ error: "Missing state" }, { status: 400 });
|
return NextResponse.json({ error: "Missing state" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const session = provider === "xai" ? getXaiSessionStatus(state) : getCodexSessionStatus(state);
|
let session;
|
||||||
|
if (provider === "trae") session = getTraeSessionStatus(state, user.id);
|
||||||
|
else if (provider === "windsurf") session = getWindsurfSessionStatus(state, user.id);
|
||||||
|
else if (provider === "zed") session = getZedSessionStatus(state, user.id);
|
||||||
|
else if (provider === "xai") session = getXaiSessionStatus(state);
|
||||||
|
else if (provider === "codex") session = getCodexSessionStatus(state);
|
||||||
|
else return NextResponse.json({ error: "Poll only supported for codex/xai/trae/windsurf/zed" }, { status: 400 });
|
||||||
if (!session) return NextResponse.json({ status: "unknown" });
|
if (!session) return NextResponse.json({ status: "unknown" });
|
||||||
if (session.status === "done" || session.status === "error") {
|
if (session.status === "done" || session.status === "error") {
|
||||||
const payload = { ...session };
|
const payload = { ...session };
|
||||||
if (provider === "xai") clearXaiSession(state);
|
if (provider === "trae") clearTraeSession(state);
|
||||||
|
else if (provider === "windsurf") clearWindsurfSession(state);
|
||||||
|
else if (provider === "zed") clearZedSession(state);
|
||||||
|
else if (provider === "xai") clearXaiSession(state);
|
||||||
else clearCodexSession(state);
|
else clearCodexSession(state);
|
||||||
return NextResponse.json(payload);
|
return NextResponse.json(payload);
|
||||||
}
|
}
|
||||||
@@ -127,14 +171,24 @@ export async function GET(request, { params }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === "stop-proxy") {
|
if (action === "stop-proxy") {
|
||||||
if (!["codex", "xai"].includes(provider)) {
|
if (provider === "trae") stopTraeProxy();
|
||||||
return NextResponse.json({ error: "Proxy only supported for codex/xai" }, { status: 400 });
|
else if (provider === "windsurf") stopWindsurfProxy();
|
||||||
}
|
else if (provider === "zed") stopZedProxy();
|
||||||
if (provider === "xai") stopXaiProxy();
|
else if (provider === "xai") stopXaiProxy();
|
||||||
else stopCodexProxy();
|
else if (provider === "codex") stopCodexProxy();
|
||||||
|
else return NextResponse.json({ error: "Proxy only supported for codex/xai/trae/windsurf/zed" }, { status: 400 });
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === "ide-status") {
|
||||||
|
// Detect whether the IDE is installed locally (used by import-token UX).
|
||||||
|
if (provider !== "trae" && provider !== "windsurf") {
|
||||||
|
return NextResponse.json({ error: "ide-status only supported for trae/windsurf" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const status = await detectIdeInstalled(provider);
|
||||||
|
return NextResponse.json(status);
|
||||||
|
}
|
||||||
|
|
||||||
if (action === "device-code") {
|
if (action === "device-code") {
|
||||||
const providerData = getProvider(provider);
|
const providerData = getProvider(provider);
|
||||||
if (providerData.flowType !== "device_code") {
|
if (providerData.flowType !== "device_code") {
|
||||||
@@ -161,6 +215,7 @@ export async function GET(request, { params }) {
|
|||||||
"kimi-coding",
|
"kimi-coding",
|
||||||
"kilocode",
|
"kilocode",
|
||||||
"codebuddy-cn",
|
"codebuddy-cn",
|
||||||
|
"codebuddy-intl",
|
||||||
"qoder",
|
"qoder",
|
||||||
"grok-cli",
|
"grok-cli",
|
||||||
];
|
];
|
||||||
@@ -206,9 +261,55 @@ export async function POST(request, { params }) {
|
|||||||
return NextResponse.json({ error: "Invalid or empty request body" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid or empty request body" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === "register-session") {
|
||||||
|
// Register proxy session out of URL query (state) + body (codeVerifier).
|
||||||
|
// Zed's codeVerifier encodes the RSA private key — must stay out of URL/logs.
|
||||||
|
const state = body?.state;
|
||||||
|
if (!state) return NextResponse.json({ error: "Missing state" }, { status: 400 });
|
||||||
|
let ok = false;
|
||||||
|
if (provider === "trae") ok = registerTraeSession({ state, ownerId: user.id });
|
||||||
|
else if (provider === "windsurf") ok = registerWindsurfSession({ state, ownerId: user.id });
|
||||||
|
else if (provider === "zed") ok = registerZedSession({ state, codeVerifier: body?.codeVerifier, ownerId: user.id });
|
||||||
|
else return NextResponse.json({ error: "register-session only supported for trae/windsurf/zed" }, { status: 400 });
|
||||||
|
return NextResponse.json({ success: ok });
|
||||||
|
}
|
||||||
|
|
||||||
if (action === "exchange") {
|
if (action === "exchange") {
|
||||||
const { code, redirectUri, codeVerifier, state, meta } = body;
|
const { code, redirectUri, codeVerifier, state, meta } = body;
|
||||||
|
|
||||||
|
// Trae/Windsurf: code is either a raw callback URL or a pasted token.
|
||||||
|
// exchangeTokens() handles both paths; no PKCE, skip codex JWT extraction.
|
||||||
|
if (provider === "trae" || provider === "windsurf") {
|
||||||
|
const token = typeof code === "string" ? code.trim() : "";
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: "Missing token or callback URL" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const tokenData = await exchangeTokens(provider, token, null, null, state);
|
||||||
|
const connection = await createProviderConnection({
|
||||||
|
provider,
|
||||||
|
authType: provider === "windsurf" ? "api_key" : "oauth",
|
||||||
|
ownerId: user.id,
|
||||||
|
...tokenData,
|
||||||
|
expiresAt: tokenData.expiresIn
|
||||||
|
? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString()
|
||||||
|
: null,
|
||||||
|
testStatus: "active",
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
connection: {
|
||||||
|
id: connection.id,
|
||||||
|
provider: connection.provider,
|
||||||
|
email: connection.email,
|
||||||
|
displayName: connection.displayName,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Detect if "code" is actually a raw JWT access token (starts with eyJ)
|
// Detect if "code" is actually a raw JWT access token (starts with eyJ)
|
||||||
if (code && code.startsWith("eyJ") && code.includes(".")) {
|
if (code && code.startsWith("eyJ") && code.includes(".")) {
|
||||||
const { extractCodexAccountInfo } = await import("@/lib/oauth/providers");
|
const { extractCodexAccountInfo } = await import("@/lib/oauth/providers");
|
||||||
@@ -292,7 +393,7 @@ export async function POST(request, { params }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Providers that don't use PKCE for device code
|
// Providers that don't use PKCE for device code
|
||||||
const noPkceProviders = ["github", "kimi", "kimi-coding", "kilocode", "codebuddy-cn"];
|
const noPkceProviders = ["github", "kimi", "kimi-coding", "kilocode", "codebuddy-cn", "codebuddy-intl"];
|
||||||
let result;
|
let result;
|
||||||
if (noPkceProviders.includes(provider)) {
|
if (noPkceProviders.includes(provider)) {
|
||||||
// kimi needs extraData._kimiDeviceId for stable X-Msh-Device-Id (CLIProxyAPI parity)
|
// kimi needs extraData._kimiDeviceId for stable X-Msh-Device-Id (CLIProxyAPI parity)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { getProviderConnectionAccess } from "@/lib/providers/connectionAccess";
|
|||||||
/**
|
/**
|
||||||
* POST /api/oauth/kiro/api-key
|
* POST /api/oauth/kiro/api-key
|
||||||
* Import a Kiro API key (headless auth). The key is a long-lived bearer
|
* Import a Kiro API key (headless auth). The key is a long-lived bearer
|
||||||
* credential — there is no refresh token. It is validated by listing
|
* credential — there is no refresh token. It is validated against the Amazon
|
||||||
* CodeWhisperer profiles, then stored with authMethod="api_key".
|
* Q model catalog, then stored with authMethod="api_key".
|
||||||
*/
|
*/
|
||||||
export async function POST(request) {
|
export async function POST(request) {
|
||||||
try {
|
try {
|
||||||
@@ -23,7 +23,7 @@ export async function POST(request) {
|
|||||||
|
|
||||||
const kiroService = new KiroService();
|
const kiroService = new KiroService();
|
||||||
|
|
||||||
// Validate the key and resolve its profileArn via ListAvailableProfiles
|
// Validate the key against the same Amazon Q surface used for inference.
|
||||||
const credential = await kiroService.validateApiKey(
|
const credential = await kiroService.validateApiKey(
|
||||||
apiKey,
|
apiKey,
|
||||||
region || "us-east-1"
|
region || "us-east-1"
|
||||||
@@ -43,7 +43,7 @@ export async function POST(request) {
|
|||||||
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
|
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
email: email || null,
|
email: email || null,
|
||||||
providerSpecificData: {
|
providerSpecificData: {
|
||||||
profileArn: credential.profileArn,
|
...(credential.profileArn ? { profileArn: credential.profileArn } : {}),
|
||||||
region: credential.region,
|
region: credential.region,
|
||||||
authMethod: "api_key",
|
authMethod: "api_key",
|
||||||
provider: "API Key",
|
provider: "API Key",
|
||||||
|
|||||||
@@ -793,6 +793,26 @@ async function testApiKeyConnection(connection, effectiveProxy = null) {
|
|||||||
}, effectiveProxy);
|
}, effectiveProxy);
|
||||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||||
}
|
}
|
||||||
|
case "qoder": {
|
||||||
|
// PAT (pt-...) exchange → job token. A successful exchange proves the PAT.
|
||||||
|
const raw = connection.apiKey || "";
|
||||||
|
const pat = raw.startsWith("pt-") ? raw : `pt-${raw}`;
|
||||||
|
const exRes = await fetchWithConnectionProxy(
|
||||||
|
"https://openapi.qoder.sh/api/v1/jobToken/exchange",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
"Cosy-Version": "1.0.1",
|
||||||
|
"Cosy-ClientType": "5",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ personal_token: pat }),
|
||||||
|
},
|
||||||
|
effectiveProxy,
|
||||||
|
);
|
||||||
|
return { valid: exRes.ok, error: exRes.ok ? null : "Invalid Personal Access Token" };
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return { valid: false, error: "Provider test not supported" };
|
return { valid: false, error: "Provider test not supported" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,8 +83,9 @@ export async function GET(request) {
|
|||||||
return new Response(stream, {
|
return new Response(stream, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache, no-transform",
|
||||||
"Connection": "keep-alive",
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { resolveCopilotModels } from "open-sse/services/copilotModels.js";
|
|||||||
import { resolveClinepassModels } from "open-sse/services/clinepassModels.js";
|
import { resolveClinepassModels } from "open-sse/services/clinepassModels.js";
|
||||||
import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js";
|
import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js";
|
||||||
import { resolveCursorModels } from "open-sse/services/cursorModels.js";
|
import { resolveCursorModels } from "open-sse/services/cursorModels.js";
|
||||||
|
import { resolveZedModels } from "open-sse/shared/zedAuth.js";
|
||||||
import { updateProviderCredentials } from "@/sse/services/tokenRefresh";
|
import { updateProviderCredentials } from "@/sse/services/tokenRefresh";
|
||||||
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
||||||
import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
|
import { capabilitiesFromServiceKind, getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
|
||||||
@@ -104,7 +105,23 @@ const LIVE_MODEL_RESOLVERS = {
|
|||||||
providerSpecificData: conn.providerSpecificData || {},
|
providerSpecificData: conn.providerSpecificData || {},
|
||||||
}, { log: console });
|
}, { log: console });
|
||||||
return result?.models?.length ? { models: result.models } : null;
|
return result?.models?.length ? { models: result.models } : null;
|
||||||
}
|
},
|
||||||
|
zed: async (conn) => {
|
||||||
|
const result = await resolveZedModels({
|
||||||
|
accessToken: conn.accessToken,
|
||||||
|
providerSpecificData: conn.providerSpecificData || {},
|
||||||
|
});
|
||||||
|
if (!result?.models?.length) return null;
|
||||||
|
return {
|
||||||
|
models: result.models
|
||||||
|
.filter((m) => !m.isDisabled)
|
||||||
|
.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
capabilities: m.supportsTools ? { tools: true } : undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseOpenAIStyleModels = (data) => {
|
const parseOpenAIStyleModels = (data) => {
|
||||||
|
|||||||