Merge branch 'master' of https://github.com/decolua/9router
Some checks failed
Deploy GitBook to 9router.github.io / build-deploy (push) Has been cancelled

# Conflicts:
#	open-sse/utils/usageTracking.js
This commit is contained in:
2026-06-29 10:10:13 +07:00
466 changed files with 28067 additions and 11945 deletions

3
.gitignore vendored
View File

@@ -72,3 +72,6 @@ ecosystem.config.*
scripts/agSniffer/* scripts/agSniffer/*
gitbooks/* gitbooks/*
gitbook/README.md gitbook/README.md
# Refactor backup reference (do not bundle/lint)
open-sse.old/

View File

@@ -1,3 +1,104 @@
# v0.5.12 (2026-06-26)
## Features
- Add token-saver dashboard page — decolua
- Add bulk delete for provider connections — teddytkz
- Resolve GitHub Copilot model catalog from upstream — caiqinzhou
- Add Venice AI provider — Brokenc0de
- Add Kiro external_idp import for Microsoft SSO (CLIProxyAPI) — Stevanus Pangau
- Overhaul Blackbox provider catalog + WebUI test support — suryacagur
## Fixes
- Provider thinking compatibility (DeepSeek/Gemini) — Mink Nguyen
- Stop double-counting streaming usage at source — decolua
- Usage logging dedupe to reduce stats churn — Mink Nguyen
- Prevent non-JSON SSE lines / duplicate [DONE] from breaking clients (PR #2046) — qianze
- Resolve Gemini TTS models from catalog — nguyenha935
- Support Kiro IDC (organization) token import — quanturbo
- Preserve forced streaming for JSON clients (#2031) — Joseph Yaksich
- Preserve Responses text format (Codex) — tenglong
- Support Gemini native TTS generateContent endpoint — nguyenha935
- Add missing zh-CN endpoint key label (i18n) — weimaozhen
- CodeBuddy: only send reasoning params when client requests reasoning (#2071) — Rex
- Show custom provider models in combo picker — Sapto
- Docker: add docker-compose.yml with headroom enabled by default — nitsuahlabs
- Clarify token diagnostics vs provider billing (headroom, #1998) — Sutarto Jordan Chrisfivo
- Translate openai-responses input through OpenAI for compression (#1998) — Ankit
- Kiro: report 1M context window for claude-opus-4.8 — EdisonPVE
- Avoid stale redirects after auth changes (#2100) — Emirhan
- Mark Claude Opus 4.7 (dashed id) as 1M context — Brokenc0de
- Preserve reasoning effort through Codex translations — ntdung6868
- Token-saver: full width card layout — decolua
- Antigravity: retry transient upstream failures — Sutarto Jordan Chrisfivo
- Param-support: handle strip rules without match/drop (#1960) — Joseph Yaksich
- Translator: resolve custom provider prefix in debug endpoint (#1083) — hamsa0x7
# v0.5.8 (2026-06-21)
## Features
- **Antigravity**: native image generation support (image models tagged kind:image, hiển thị trong media-providers UI)
- **CodeBuddy CN**: API key auth + credit quota tracker
- **CodeBuddy CN**: short model prefix alias "cbcn"
## Fixes
- **MiniMax-M3**: enable vision capability
- **Headroom**: support Docker sidecar proxy
- **Antigravity**: image executor fixes
- **mimo-free**: Chrome User-Agent rotation to bypass anti-abuse gate
- **cloudflare-ai**: flatten content-part arrays to string to avoid oneOf 400 (#1926)
- **Translator**: normalize tools to Anthropic-native shape for non-Anthropic providers
- **CLI**: handle Next.js 16 nested standalone output path (#1940)
- **Codex**: preserve custom tools during request normalization
- **next.config**: add new route for responses endpoint to API
# v0.5.6 (2026-06-20)
## Features
- **Ponytail**: minimalist code generation feature
- **Headroom**: proxy lifecycle management + dashboard UI (one-click start/stop, install detection, status probing, token saver, claude↔openai shape conversion)
- **CodeBuddy CN**: new OAuth provider (copilot.tencent.com) — 15-model catalog, /v2 inference, forced streaming, OpenAI-style reasoning
- **OpenCode-Go**: align models with official endpoints; route Qwen 3.7 MiniMax via /v1/messages, GLM/Kimi/DeepSeek/MiMo via /chat/completions
## Fixes
- **Anthropic-compatible validation**: use POST /v1/messages (GET /models not spec, false "invalid" for valid keys)
- **CLI tools**: tolerate JSONC configs in all 8 settings routes (opencode, openclaw, kilo, droid, cowork, copilot, claude, cline)
- **Gemini/Antigravity**: preserve 'pattern' in tool schema translation (glob/grep)
- **Combo/Fusion**: flatten Anthropic-style tool messages in panel calls (prevent 503)
- **Models**: store provider custom models by provider scope
- **Perplexity**: use /v1/models endpoint for key validation
# v0.5.4 (2026-06-18)
## Fixes
- **Kiro**: honor thinking effort budgets
- **AG/Kiro/Xiaomi**: provider fixes
- **Combo/Fusion**: flatten tool history in panel calls to prevent 503
- **LLM selector**: show custom vision models in selector and model list
- **Image**: prevent compatible nodes from shadowing provider aliases
# v0.5.2 (2026-06-17)
## Features
- **Combo Fusion strategy** — fans the prompt out to all member models in parallel, then a configurable judge model synthesizes one final answer (quorum-grace, anonymized sources, graceful degradation)
- **Per-combo strategy selector** — pick `fallback` / `round-robin` / `fusion` / `capacity` per combo (replaces the old round-robin toggle), with a judge picker for fusion
- **Capacity auto-switch** — reorders models per request so images/PDFs route to capable models first
- **Kiro headless API-key auth** (`ksk_`) + direct `claude↔kiro` route that avoids the lossy OpenAI two-hop pivot
- **Claude auto-ping** — warms the 5h quota window right after reset so a fresh window starts immediately (per-connection toggle)
## Fixes
- **Claude 429**: stop hammering the OAuth usage endpoint — cache resetAt, throttle quota refresh to 3 min, cool down after a 429 (chat unaffected)
- **Usage logs always empty**: missing `await` on `getAdapter()` in `getRecentLogs` made `/api/usage/logs` & `/api/usage/request-logs` return nothing
- **Executors**: strip params unsupported by the provider/model (drops deprecated `temperature` for claude-opus-4 → Anthropic 400)
- **Translator**: derive deterministic tool_call ids for gemini/antigravity → OpenAI so function call/response pair correctly (fixes tool-pairing 400s)
- **Antigravity**: strip `optional` from tool schemas before sending to Gemini
- **Claude-to-OpenAI**: handle OpenAI-format responses in the non-streaming path (e.g. xiaomi-tokenplan)
- **Usage views**: show edited connection names consistently across Providers & Quota Tracker
- **Security**: hardened reverse-proxy local-access trust
- **Security**: SSRF hardening on web fetch
## Internal
- Large **open-sse / translator refactor** (~40 commits): unified provider/model registry (LiteLLM-style `models[]` + `kind` field, 100 co-located registry files), single-sourced media/OAuth/refresh/token URLs, registry-based dispatch for usage & token-refresh, DRY translator concerns (buildUsage, encodeDataUri, finishReasonMap, chunkBuilder, reasoningDelta…), ESM-safe registry init, large-file splits, dead-code removal, and golden/no-regression test gates
# v0.4.80 (2026-06-13) # v0.4.80 (2026-06-13)
## Features ## Features
@@ -220,34 +321,3 @@
## Fixes ## Fixes
- fix(docker): restore `/app/server.js` (v0.4.38 regression) - fix(docker): restore `/app/server.js` (v0.4.38 regression)
# v0.4.38 (2026-05-13)
## Features
- Add DeepSeek TUI as CLI tool in dashboard (#1088)
## Fixes
- Fix broken Docker image in v0.4.36/v0.4.37 (#1096, #1097)
## Improvements
- Clean Docker tags + clearer pulls badge
# v0.4.37 (2026-05-13)
## Improvements
- Security hardening — upgrade recommended
# v0.4.36 (2026-05-13)
## Features
- Add MiniMax TTS provider support (#1043)
- Docker images now published on both Docker Hub (`decolua/9router`) and GHCR — pull from your preferred registry
## Improvements
- Replace browser confirm dialogs with custom ConfirmModal (#1060)
## Fixes
- Fix Docker `Cannot find module 'next'` error in standalone build
- Restore /app/server.js in Docker standalone build (#1064, #1067)
- Fix CLI TUI menu arrow-key escape sequences leaking (^[[A^[[B)
- Switch macOS/Linux tray to systray2 fork (fixes Kaspersky AV false-positive) (#1080)
- Fix zoom controls contrast in topology view (#1066)

View File

@@ -64,6 +64,34 @@ docker run -d \
decolua/9router:latest decolua/9router:latest
``` ```
## Optional Headroom sidecar
The 9Router image does not bundle Python or Headroom. To use Headroom in Docker, run it as a separate service and point 9Router at that proxy:
```yaml
services:
9router:
image: decolua/9router:latest
ports:
- "20128:20128"
volumes:
- "$HOME/.9router:/app/data"
environment:
DATA_DIR: /app/data
HEADROOM_URL: http://headroom:8787
depends_on:
- headroom
headroom:
image: ghcr.io/chopratejas/headroom:latest
ports:
- "8787:8787"
```
In the dashboard, open `Endpoint` → `Token Saver` → `Headroom`, confirm the URL is `http://headroom:8787`, recheck status, then enable Headroom.
If Headroom runs on the Docker host instead of as a sidecar, use `http://host.docker.internal:8787` on macOS/Windows. On Linux, add `--add-host=host.docker.internal:host-gateway` or the equivalent compose `extra_hosts` entry.
## Update to latest ## Update to latest
```bash ```bash

View File

@@ -170,7 +170,7 @@ Default URLs:
<sub>FREE OpenClaw + Claude Opus 4.6<br/>by <a href="https://www.youtube.com/@BuildAIWithHamid">Build AI With Hamid</a></sub> <sub>FREE OpenClaw + Claude Opus 4.6<br/>by <a href="https://www.youtube.com/@BuildAIWithHamid">Build AI With Hamid</a></sub>
</td> </td>
</tr> </tr>
<tr> <tr>
<td align="center" width="320"> <td align="center" width="320">
<a href="https://www.youtube.com/watch?v=CkVZZUSTXAI"> <a href="https://www.youtube.com/watch?v=CkVZZUSTXAI">
<img src="https://img.youtube.com/vi/CkVZZUSTXAI/mqdefault.jpg" alt="Claude CLI Free Setup" width="300"/> <img src="https://img.youtube.com/vi/CkVZZUSTXAI/mqdefault.jpg" alt="Claude CLI Free Setup" width="300"/>
@@ -178,6 +178,13 @@ Default URLs:
<b>🇮🇩 Indonesia</b><br/> <b>🇮🇩 Indonesia</b><br/>
<sub>Koding 24 Jam Anti Rate Limit! Hemat Token AI 65% | Tutorial Quick Setup 9Router 🚀<br/>by <a href="https://www.youtube.com/@krisswuh">Krisswuh</a></sub> <sub>Koding 24 Jam Anti Rate Limit! Hemat Token AI 65% | Tutorial Quick Setup 9Router 🚀<br/>by <a href="https://www.youtube.com/@krisswuh">Krisswuh</a></sub>
</td> </td>
<td align="center" width="320">
<a href="https://www.youtube.com/watch?v=TXGv4eofe1I">
<img src="https://img.youtube.com/vi/TXGv4eofe1I/mqdefault.jpg" alt="Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB" width="300"/>
</a><br/>
<b>🇮🇩 Indonesia</b><br/>
<sub>Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB<br/>by <a href="https://www.youtube.com/@krisswuh">Krisswuh</a></sub>
</td>
</tr> </tr>
</table> </table>
@@ -400,7 +407,9 @@ Default URLs:
| Feature | What It Does | Why It Matters | | Feature | What It Does | Why It Matters |
|---------|--------------|----------------| |---------|--------------|----------------|
| 🚀 **RTK Token Saver** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | Compress tool outputs (`git diff`, `grep`, `ls`, `tree`...) before sending to LLM | Save **20-40% input tokens** per request | | 🚀 **RTK Token Saver** ([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | Compress tool outputs (`git diff`, `grep`, `ls`, `tree`...) before sending to LLM | Save **20-40% input tokens** per request |
| 🧠 **Headroom Token Saver** ([Headroom](https://github.com/chopratejas/headroom)) | Optional external `/v1/compress` proxy before provider routing | Save more context tokens without changing clients |
| 🪨 **Caveman Mode** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | Inject caveman-speak prompt → LLM replies terse, technical substance preserved | Save **up to 65% output tokens** | | 🪨 **Caveman Mode** ([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | Inject caveman-speak prompt → LLM replies terse, technical substance preserved | Save **up to 65% output tokens** |
| 🐴 **Ponytail** ([Ponytail](https://github.com/DietrichGebert/ponytail)) | Inject "lazy senior dev" prompt → LLM writes minimal, YAGNI-first code (Lite/Full/Ultra) | **Fewer output tokens, less refactoring** |
| 🎯 **Smart 3-Tier Fallback** | Auto-route: Subscription → Cheap → Free | Never stop coding, zero downtime | | 🎯 **Smart 3-Tier Fallback** | Auto-route: Subscription → Cheap → Free | Never stop coding, zero downtime |
| 📊 **Real-Time Quota Tracking** | Live token count + reset countdown | Maximize subscription value | | 📊 **Real-Time Quota Tracking** | Live token count + reset countdown | Maximize subscription value |
| 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | Works with any CLI tool | | 🔄 **Format Translation** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | Works with any CLI tool |
@@ -430,6 +439,50 @@ Without RTK: 47K tokens sent to LLM
With RTK: 28K tokens sent to LLM (40% saved · same context · same answer) With RTK: 28K tokens sent to LLM (40% saved · same context · same answer)
``` ```
### 🧠 Headroom Token Saver
Headroom is optional and runs separately. 9Router calls Headroom's local `/v1/compress` endpoint, then keeps normal routing, fallback, auth, and usage tracking:
```
Client → 9Router → Headroom /v1/compress → 9Router → provider
```
Local setup:
```bash
pip install "headroom-ai[proxy]"
headroom proxy --port 8787
```
Enable in Dashboard → Endpoint → Token Saver → Headroom. Default URL: `http://localhost:8787`.
Docker examples:
```bash
# Headroom service in same Docker network
http://headroom:8787
# Headroom running on host machine
http://host.docker.internal:8787
```
If Headroom is down or returns an error, 9Router fails open and sends the original request.
### 🐴 Ponytail (Lazy Senior Dev)
Ponytail injects a *"lazy senior dev"* system prompt into every request, biasing the LLM toward minimal, YAGNI-first code — deletion over addition, stdlib over new deps, one-liners over abstractions. Adapted from [DietrichGebert/ponytail](https://github.com/DietrichGebert/ponytail).
- **Lite** — Build what's asked, name the lazier alternative.
- **Full** — YAGNI ladder enforced: stdlib → native → existing deps → one-liner → minimal code.
- **Ultra** — YAGNI extremist: deletion first, ship the one-liner, challenge the rest of the requirement in the same response.
```
Without Ponytail: verbose code, extra abstractions, "just in case" scaffolding
With Ponytail: shortest working diff, no unrequested abstractions, fewer tokens
```
Never trades away: input validation, error handling that prevents data loss, security, accessibility, or anything explicitly requested. Enable in Dashboard → Endpoint → Ponytail. Stacks with Caveman (output terseness) and RTK (input compression).
### 🎯 Smart 3-Tier Fallback ### 🎯 Smart 3-Tier Fallback
Create combos with automatic fallback: Create combos with automatic fallback:
@@ -1311,6 +1364,7 @@ Built on the shoulders of giants:
- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — original Go implementation that inspired this JavaScript port. - **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** — original Go implementation that inspired this JavaScript port.
- **[RTK](https://github.com/rtk-ai/rtk)** ![Stars](https://img.shields.io/github/stars/rtk-ai/rtk?style=flat&color=yellow) — Rust token-saver. 9Router ports its compression pipeline to JS → **−20-40% input tokens** on every request. - **[RTK](https://github.com/rtk-ai/rtk)** ![Stars](https://img.shields.io/github/stars/rtk-ai/rtk?style=flat&color=yellow) — Rust token-saver. 9Router ports its compression pipeline to JS → **−20-40% input tokens** on every request.
- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) by **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral *"why use many token when few token do trick"*. 9Router adapts its prompt → **−65% output tokens**. - **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) by **[@JuliusBrussee](https://github.com/JuliusBrussee)** — viral *"why use many token when few token do trick"*. 9Router adapts its prompt → **−65% output tokens**.
- **[Ponytail](https://github.com/DietrichGebert/ponytail)** ![Stars](https://img.shields.io/github/stars/DietrichGebert/ponytail?style=flat&color=yellow) by **[@DietrichGebert](https://github.com/DietrichGebert)** — *"lazy senior dev"* skill. 9Router injects its YAGNI-first ladder → **fewer tokens, less code, shorter diffs**.
Huge thanks to these authors — without their work, 9Router's token-saving features wouldn't exist. ⭐ them on GitHub! Huge thanks to these authors — without their work, 9Router's token-saving features wouldn't exist. ⭐ them on GitHub!

View File

@@ -61,6 +61,21 @@ const INSTALL_CMD_LATEST = `npm i -g ${APP_NAME}@latest --prefer-online`;
const DEFAULT_PORT = 20128; const DEFAULT_PORT = 20128;
const DEFAULT_HOST = "0.0.0.0"; const DEFAULT_HOST = "0.0.0.0";
// First non-internal IPv4 — the address remote peers actually reach when bound to 0.0.0.0.
function getLanIp() {
for (const ifaces of Object.values(os.networkInterfaces())) {
for (const i of ifaces || []) {
if (i.family === "IPv4" && !i.internal) return i.address;
}
}
return null;
}
// Local URL stays "localhost"; warn separately when bound to all interfaces (network-exposed).
function getDisplayHost() {
return host === DEFAULT_HOST ? "localhost" : host;
}
const MAX_PORT_ATTEMPTS = 10; const MAX_PORT_ATTEMPTS = 10;
// Identifiers for killAllAppProcesses - only kill 9router specifically // Identifiers for killAllAppProcesses - only kill 9router specifically
const PROCESS_IDENTIFIERS = [ const PROCESS_IDENTIFIERS = [
@@ -501,7 +516,7 @@ async function showInterfaceMenu(latestVersion) {
clearScreen(); clearScreen();
const displayHost = host === DEFAULT_HOST ? "localhost" : host; const displayHost = getDisplayHost();
// Detect tunnel/local mode for server URL display // Detect tunnel/local mode for server URL display
let serverUrl; let serverUrl;
@@ -542,8 +557,13 @@ const MAX_RESTARTS = 2;
const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s
function startServer(latestVersion) { function startServer(latestVersion) {
const displayHost = host === DEFAULT_HOST ? "localhost" : host; const displayHost = getDisplayHost();
const url = `http://${displayHost}:${port}/dashboard`; const url = `http://${displayHost}:${port}/dashboard`;
// Surface real network exposure when bound to all interfaces (default 0.0.0.0).
if (host === DEFAULT_HOST) {
const lanIp = getLanIp();
if (lanIp) console.log(`\x1b[33m⚠ Network-exposed: reachable at http://${lanIp}:${port} (bound 0.0.0.0). Use --host 127.0.0.1 for local-only.\x1b[0m`);
}
let restartCount = 0; let restartCount = 0;
let serverStartTime = Date.now(); let serverStartTime = Date.now();

View File

@@ -7,6 +7,7 @@ const os = require("os");
const path = require("path"); const path = require("path");
const BETTER_SQLITE3_VERSION = "12.6.2"; const BETTER_SQLITE3_VERSION = "12.6.2";
const SQL_JS_VERSION = "1.14.1";
function getDataDir() { function getDataDir() {
if (process.env.DATA_DIR) return process.env.DATA_DIR; if (process.env.DATA_DIR) return process.env.DATA_DIR;
@@ -102,20 +103,36 @@ function npmInstall(pkgs, opts = {}) {
} }
// Public: ensure better-sqlite3 native module is installed in user-writable // Public: ensure better-sqlite3 native module is installed in user-writable
// runtime dir. sql.js is bundled in bin/app already; node:sqlite is built-in. // runtime dir. sql.js may be bundled in bin/app, but npm publish strips .wasm
// This is purely a *speed optimization* — app works without it via fallbacks. // from nested node_modules — verify and reinstall if missing. node:sqlite is
// built-in. This is purely a *speed optimization* — app works without
// better-sqlite3 via fallbacks.
function isSqlJsWasmValid() {
const bundledWasm = path.join(__dirname, "..", "app", "node_modules", "sql.js", "dist", "sql-wasm.wasm");
if (fs.existsSync(bundledWasm)) return true;
const runtimeWasm = path.join(getRuntimeNodeModules(), "sql.js", "dist", "sql-wasm.wasm");
return fs.existsSync(runtimeWasm);
}
function ensureSqliteRuntime({ silent = false } = {}) { function ensureSqliteRuntime({ silent = false } = {}) {
ensureRuntimeDir(); ensureRuntimeDir();
let sqlJsOk = isSqlJsWasmValid();
if (!sqlJsOk) {
sqlJsOk = npmInstall([`sql.js@${SQL_JS_VERSION}`], { silent });
if (sqlJsOk) sqlJsOk = isSqlJsWasmValid();
}
const needBetterSqlite = !hasModule("better-sqlite3") || !isBetterSqliteBinaryValid(); const needBetterSqlite = !hasModule("better-sqlite3") || !isBetterSqliteBinaryValid();
if (!needBetterSqlite) { if (!needBetterSqlite) {
if (!silent) console.log("✅ SQLite engine ready"); if (!silent) console.log("✅ SQLite engine ready");
return { betterSqlite: true }; return { betterSqlite: true, sqlJs: sqlJsOk };
} }
const ok = npmInstall([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { optional: true, silent }); const ok = npmInstall([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { optional: true, silent });
return { return {
betterSqlite: ok && hasModule("better-sqlite3") && isBetterSqliteBinaryValid(), betterSqlite: ok && hasModule("better-sqlite3") && isBetterSqliteBinaryValid(),
sqlJs: sqlJsOk,
}; };
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "9router", "name": "9router",
"version": "0.4.80", "version": "0.5.12",
"description": "9Router CLI - Start and manage 9Router server", "description": "9Router CLI - Start and manage 9Router server",
"bin": { "bin": {
"9router": "./cli.js" "9router": "./cli.js"

View File

@@ -17,7 +17,6 @@ const EXCLUDE_PATTERNS = [
"@img", // Sharp image processing (not needed with unoptimized images) "@img", // Sharp image processing (not needed with unoptimized images)
"sharp", // Sharp core lib (not needed with unoptimized images) "sharp", // Sharp core lib (not needed with unoptimized images)
"detect-libc", // Sharp dependency "detect-libc", // Sharp dependency
"logs", // Runtime logs
".env", // Environment files ".env", // Environment files
".env.local", ".env.local",
".env.*.local", ".env.*.local",
@@ -136,7 +135,15 @@ console.log("✅ Cleaned\n");
console.log("3️⃣ Copying Next.js standalone build to app/cli/app..."); console.log("3️⃣ Copying Next.js standalone build to app/cli/app...");
const standaloneRoot = path.join(appDir, ".next", "standalone"); const standaloneRoot = path.join(appDir, ".next", "standalone");
const standaloneRootResolved = path.join(buildDistDir, "standalone"); const standaloneRootResolved = path.join(buildDistDir, "standalone");
const standaloneRootToUse = fs.existsSync(standaloneRootResolved) ? standaloneRootResolved : standaloneRoot; let standaloneRootToUse = fs.existsSync(standaloneRootResolved) ? standaloneRootResolved : standaloneRoot;
// Next.js 16 nests standalone output under the project name when NEXT_TRACING_ROOT_MODE=workspace
// e.g. .next-cli-build/standalone/9router/server.js
const pkgName = path.basename(appDir);
const nestedRoot = path.join(standaloneRootToUse, pkgName);
if (fs.existsSync(path.join(nestedRoot, "server.js")) && !fs.existsSync(path.join(standaloneRootToUse, "server.js"))) {
console.log(`ℹ️ Detected nested standalone output: ${pkgName}/`);
standaloneRootToUse = nestedRoot;
}
const standaloneApp = fs.existsSync(path.join(standaloneRootToUse, "server.js")) const standaloneApp = fs.existsSync(path.join(standaloneRootToUse, "server.js"))
? standaloneRootToUse ? standaloneRootToUse
: path.join(standaloneRootToUse, "app"); : path.join(standaloneRootToUse, "app");

View File

@@ -39,6 +39,8 @@ async function showSettingsMenu(breadcrumb = []) {
// RTK section // RTK section
const rtkOn = data?.settings?.rtkEnabled !== false; const rtkOn = data?.settings?.rtkEnabled !== false;
lines.push(` RTK: ${rtkOn ? `${COLORS.green}ON${COLORS.reset}` : `${COLORS.red}OFF${COLORS.reset}`} ${COLORS.dim}(Token Saver)${COLORS.reset}`); lines.push(` RTK: ${rtkOn ? `${COLORS.green}ON${COLORS.reset}` : `${COLORS.red}OFF${COLORS.reset}`} ${COLORS.dim}(Token Saver)${COLORS.reset}`);
const headroomOn = data?.settings?.headroomEnabled === true;
lines.push(` Headroom: ${headroomOn ? `${COLORS.green}ON${COLORS.reset}` : `${COLORS.red}OFF${COLORS.reset}`} ${COLORS.dim}(${data?.settings?.headroomUrl || "http://localhost:8787"})${COLORS.reset}`);
// Auth mode section // Auth mode section
const authMode = data?.settings?.authMode || "password"; const authMode = data?.settings?.authMode || "password";
@@ -73,6 +75,13 @@ async function showSettingsMenu(breadcrumb = []) {
}, },
action: async (d) => { await toggleRtk(d?.settings?.rtkEnabled !== false); return true; } action: async (d) => { await toggleRtk(d?.settings?.rtkEnabled !== false); return true; }
}, },
{
label: (d) => {
const on = d?.settings?.headroomEnabled === true;
return `Token Saver (Headroom): ${on ? "ON" : "OFF"} → toggle`;
},
action: async (d) => { await toggleHeadroom(d?.settings?.headroomEnabled === true); return true; }
},
{ {
label: "🔑 Reset Password to Default", label: "🔑 Reset Password to Default",
action: async () => { await resetPassword(); return true; } action: async () => { await resetPassword(); return true; }
@@ -160,6 +169,17 @@ async function toggleRtk(currentlyOn) {
await pause(); await pause();
} }
async function toggleHeadroom(currentlyOn) {
const next = !currentlyOn;
const result = await api.updateSettings({ headroomEnabled: next });
if (result.success) {
showStatus(`Headroom ${next ? "enabled" : "disabled"}`, "success");
} else {
showStatus(`Failed: ${result.error}`, "error");
}
await pause();
}
/** /**
* Reset dashboard password to default via server API (writes the live SQLite DB). * Reset dashboard password to default via server API (writes the live SQLite DB).
* After reset, user can log in with the default password "123456". * After reset, user can log in with the default password "123456".

View File

@@ -10,10 +10,20 @@ http.createServer = (...args) => {
const rest = args.filter((a) => typeof a !== "function"); const rest = args.filter((a) => typeof a !== "function");
if (!handler) return origCreate(...args); if (!handler) return origCreate(...args);
const wrapped = (req, res) => { const wrapped = (req, res) => {
const ip = req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : ""; const socketIp = req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : "";
const xff = req.headers["x-forwarded-for"];
const xRealIp = req.headers["x-real-ip"];
const viaProxy = !!(xff || xRealIp);
const isLoopbackProxy = socketIp === "127.0.0.1" || socketIp === "::1" || socketIp === "::ffff:127.0.0.1";
// Trust forwarding headers only when the TCP peer is a local reverse proxy.
// Direct/public sockets remain keyed by the unspoofable peer address.
const proxyIp = xRealIp || (xff ? String(xff).split(",")[0].trim() : "");
const ip = isLoopbackProxy && proxyIp ? proxyIp : socketIp;
delete req.headers["x-9r-real-ip"]; delete req.headers["x-9r-real-ip"];
delete req.headers["x-forwarded-for"]; delete req.headers["x-forwarded-for"];
delete req.headers["x-9r-via-proxy"];
req.headers["x-9r-real-ip"] = ip; req.headers["x-9r-real-ip"] = ip;
if (viaProxy) req.headers["x-9r-via-proxy"] = "1";
return handler(req, res); return handler(req, res);
}; };
return origCreate(...rest, wrapped); return origCreate(...rest, wrapped);

30
docker-compose.yml Normal file
View File

@@ -0,0 +1,30 @@
services:
9router:
image: decolua/9router:latest
container_name: 9router
restart: always
ports:
- "20128:20128"
volumes:
- 9router-data:/app/data
env_file:
- .env
environment:
DATA_DIR: /app/data
PORT: "20128"
HOSTNAME: "0.0.0.0"
NODE_ENV: production
HEADROOM_URL: http://headroom:8787
depends_on:
- headroom
headroom:
image: ghcr.io/chopratejas/headroom:latest
container_name: headroom
restart: always
ports:
- "8787:8787"
volumes:
9router-data:
name: 9router-data

BIN
images/fusion-combo-ui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -28,6 +28,8 @@ const nextConfig = {
experimental: { experimental: {
// #1529/#1572: LLM clients can send long context or base64 image payloads through /v1 rewrites. // #1529/#1572: LLM clients can send long context or base64 image payloads through /v1 rewrites.
proxyClientMaxBodySize, proxyClientMaxBodySize,
// Cache fetch responses across HMR refreshes for faster dev reloads.
serverComponentsHmrCache: true,
}, },
webpack: (config, { isServer }) => { webpack: (config, { isServer }) => {
// Ignore fs/path modules in browser bundle // Ignore fs/path modules in browser bundle
@@ -38,8 +40,12 @@ const nextConfig = {
path: false, path: false,
}; };
} }
// Exclude logs, .next, gitbook subapp from watcher // Exclude non-source dirs from watcher to reduce inotify load
config.watchOptions = { ...config.watchOptions, ignored: /[\\/](logs|\.next|gitbook|cli)[\\/]/ }; config.watchOptions = {
...config.watchOptions,
aggregateTimeout: 300,
ignored: /[\\/](node_modules|\.git|logs|\.next|\.next-cli-build|gitbook|cli|open-sse\.old|tests|docs)[\\/]/,
};
return config; return config;
}, },
async rewrites() { async rewrites() {
@@ -56,6 +62,18 @@ const nextConfig = {
source: "/codex/:path*", source: "/codex/:path*",
destination: "/api/v1/responses" destination: "/api/v1/responses"
}, },
{
source: "/responses",
destination: "/api/v1/responses"
},
{
source: "/v1beta/:path*",
destination: "/api/v1beta/:path*"
},
{
source: "/v1beta",
destination: "/api/v1beta"
},
{ {
source: "/v1/:path*", source: "/v1/:path*",
destination: "/api/v1/:path*" destination: "/api/v1/:path*"

39
open-sse/AGENTS.md Normal file
View File

@@ -0,0 +1,39 @@
# open-sse
Provider-agnostic SSE engine: one OpenAI-style request → any provider (LLM chat, image, embedding, tts, stt, search), streamed back in the client's format.
## Request lifecycle (chat)
`handlers/chatCore.js` → `services/model.js` `parseModel` (resolve `provider/model`) → **pre-translate hooks** (`rtk/` tool_result compress, `rtk/headroom.js` proxy compress, `rtk/caveman.js` system inject — all fail-open) → `executors/index.js` `getExecutor(provider)` → `translator/index.js` `translateRequest` (client format → provider format) → `executor.execute()` (streams upstream) → `translateResponse` (provider chunks → client format) → SSE out.
## Directory map
- `config/` — ALL constants/config (no hardcode elsewhere). `providers.js`/`registry/` (provider defs), `providerModels.js` (alias→models matrix), `runtimeConfig.js` (timeouts, token limits), `*Constants.js`.
- `translator/` — format conversion. `request/<from>-to-<to>.js`, `response/<from>-to-<to>.js`, `schema/` (enums: ROLE, CLAUDE_BLOCK…), `concerns/` (shared logic), `formats.js`+`formats/` (per-format). `index.js` is the registry/entry.
- `executors/` — per-provider upstream call. `base.js` (BaseExecutor), one file per special provider, `index.js` map.
- `providers/` — registry build + `capabilities.js` + `pricing.js`. Entry: `index.js` (PROVIDERS).
- `handlers/` — per-modality cores (chat/image/embedding/tts/stt/search) + sub-provider folders. `chatCore/` has the streaming/non-streaming/sse-to-json handlers.
- `rtk/` — request token-killer. `index.js` compresses `tool_result` content in-place (OpenAI/Claude/Kiro shapes); `filters/` per-tool compressors + `autodetect.js`; `headroom.js` external compress proxy; `caveman.js` system-prompt injector.
- `transformer/` — `responsesTransformer.js` (Chat Completions SSE → Codex Responses API SSE), `streamToJsonConverter.js`.
- `shared/` — cross-provider auth/identity: `clineAuth.js`, `machineId.js`, `qoder/`.
- `services/` — `model.js`, `provider.js`, `accountFallback.js`, `combo.js`, `compact.js`, `tokenRefresh/`+`tokenRefresh.js`, `oauthCredentialManager.js`, `usage/`, `projectId.js`, `kiroModels.js`/`qoderModels.js`.
- `utils/` — streamHandler, stream, sse, error, sessionManager, claudeCloaking, clientDetector, proxyFetch (patches global fetch), cursorProtobuf/cursorChecksum, ollamaTransform.
## Conventions
- Config-driven, DRY, camelCase. NEVER hardcode values, models, or block/role strings — use `config/` + `schema/` constants.
- Translator pipeline pivots through OpenAI as the intermediate format. A translator registered on the exact `source:target` pair (e.g. `claude:kiro`) runs as a **direct route**, skipping the lossy double-hop.
- Translators self-register via `register(from, to, reqFn, resFn)` as an import side-effect — new files MUST be imported in `translator/index.js`.
## How to add
- **Provider**: copy `providers/REGISTRY_TEMPLATE.js` → `providers/registry/{id}.js`; add models to `config/providerModels.js`. Generic providers need no executor (DefaultExecutor handles OpenAI-compatible APIs).
- **Executor** (only for non-standard upstream): subclass `BaseExecutor` (override `getBaseUrls`/`buildHeaders`/`buildUrl`/`execute`), register in `executors/index.js` map. `getExecutor` falls back to `DefaultExecutor` when absent.
- **Translator**: add `request|response/<from>-to-<to>.js` calling `register(...)`, then import it in `translator/index.js`. Reuse `schema/` + `concerns/` — don't re-implement parsing.
## Pitfalls
- OpenAI bridge is lossy (thinking, non-base64 images, tool ids, is_error) — prefer a direct route for fragile pairs.
- `registry/index.js` is an auto-generated static import list; regenerate it (don't hand-edit) after adding a `registry/{id}.js`. REGISTRY_TEMPLATE is excluded by design.
- Special binary/protobuf formats (kiro EventStream, cursor protobuf, commandcode NDJSON) don't round-trip through OpenAI — handle in their executor.
- `rtk/` + `headroom.js` mutate the request body in-place and are **fail-open**: any error returns null and leaves the body untouched — never throw out of them. RTK skips `is_error`/`status:"error"` tool results to preserve traces.

View File

@@ -1,8 +1,9 @@
import { platform, arch } from "os"; import { platform, arch } from "os";
import { PROVIDERS, PROVIDER_OAUTH } from "./providers.js";
// === Gemini CLI === // === Gemini CLI === derive từ registry gemini-cli.transport
export const GEMINI_CLI_VERSION = "0.34.0"; export const GEMINI_CLI_VERSION = PROVIDERS["gemini-cli"]?.cliVersion;
export const GEMINI_CLI_API_CLIENT = "google-genai-sdk/1.41.0 gl-node/v22.19.0"; export const GEMINI_CLI_API_CLIENT = PROVIDERS["gemini-cli"]?.apiClient;
// Map Node arch to Gemini CLI arch string (x64/x86/arm64/...) // Map Node arch to Gemini CLI arch string (x64/x86/arm64/...)
function geminiCLIArch() { function geminiCLIArch() {
@@ -16,11 +17,13 @@ export function geminiCLIUserAgent(model = "unknown") {
} }
// === GitHub Copilot === // === GitHub Copilot ===
// Derive từ registry github.transport.copilot
const _ghCopilot = PROVIDERS.github?.copilot || {};
export const GITHUB_COPILOT = { export const GITHUB_COPILOT = {
VSCODE_VERSION: "1.110.0", VSCODE_VERSION: _ghCopilot.vscodeVersion,
COPILOT_CHAT_VERSION: "0.38.0", COPILOT_CHAT_VERSION: _ghCopilot.chatVersion,
USER_AGENT: "GitHubCopilotChat/0.38.0", USER_AGENT: _ghCopilot.userAgent,
API_VERSION: "2025-04-01", API_VERSION: _ghCopilot.apiVersion,
}; };
// === Antigravity enums === // === Antigravity enums ===
@@ -152,43 +155,19 @@ export const LOAD_CODE_ASSIST_METADATA = {
export const CLAUDE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude."; export const CLAUDE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude.";
export const ANTIGRAVITY_DEFAULT_SYSTEM = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**"; export const ANTIGRAVITY_DEFAULT_SYSTEM = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**";
// Proactive token refresh lead times per provider (ms) // Derive từ registry oauth.refreshLeadMs
export const REFRESH_LEAD_MS = { export const REFRESH_LEAD_MS = Object.fromEntries(
codex: 5 * 24 * 60 * 60 * 1000, // 5 days Object.entries(PROVIDER_OAUTH).filter(([, o]) => o.refreshLeadMs).map(([id, o]) => [id, o.refreshLeadMs])
claude: 4 * 60 * 60 * 1000, // 4 hours );
iflow: 24 * 60 * 60 * 1000, // 24 hours
qwen: 20 * 60 * 1000, // 20 minutes
"kimi-coding": 5 * 60 * 1000, // 5 minutes
antigravity: 5 * 60 * 1000, // 5 minutes
};
// OAuth endpoints // OAuth endpoints
export const OAUTH_ENDPOINTS = { export const OAUTH_ENDPOINTS = {
google: { google: { token: "https://oauth2.googleapis.com/token", auth: "https://accounts.google.com/o/oauth2/auth" },
token: "https://oauth2.googleapis.com/token", openai: { token: PROVIDER_OAUTH["codex"]?.tokenUrl, auth: PROVIDER_OAUTH["codex"]?.authorizeUrl },
auth: "https://accounts.google.com/o/oauth2/auth" anthropic: { token: PROVIDER_OAUTH["claude"]?.tokenUrl, auth: "https://api.anthropic.com/v1/oauth/authorize" }, // ≠ claude.authorizeUrl (claude.ai login) — keep
}, qwen: { token: PROVIDER_OAUTH["qwen"]?.tokenUrl, auth: PROVIDER_OAUTH["qwen"]?.deviceCodeUrl },
openai: { iflow: { token: PROVIDER_OAUTH["iflow"]?.tokenUrl, auth: PROVIDER_OAUTH["iflow"]?.authorizeUrl },
token: "https://auth.openai.com/oauth/token", github: { token: PROVIDER_OAUTH["github"]?.tokenUrl, auth: PROVIDER_OAUTH["github"]?.authorizeUrl, deviceCode: PROVIDER_OAUTH["github"]?.deviceCodeUrl },
auth: "https://auth.openai.com/oauth/authorize"
},
anthropic: {
token: "https://api.anthropic.com/v1/oauth/token",
auth: "https://api.anthropic.com/v1/oauth/authorize"
},
qwen: {
token: "https://qwen.ai/api/v1/oauth2/token",
auth: "https://qwen.ai/api/v1/oauth2/device/code"
},
iflow: {
token: "https://iflow.cn/oauth/token",
auth: "https://iflow.cn/oauth"
},
github: {
token: "https://github.com/login/oauth/access_token",
auth: "https://github.com/login/oauth/authorize",
deviceCode: "https://github.com/login/device/code"
}
}; };
// Generate Kimi OAuth custom headers // Generate Kimi OAuth custom headers

View File

@@ -15,6 +15,9 @@
* 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 { 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";
@@ -89,16 +92,48 @@ REMEMBER: When in doubt, write LESS per operation. Multiple small operations > o
`.trim(); `.trim();
/** /**
* Detect whether an inbound request is asking for reasoning / thinking output. * Resolve the Kiro thinking budget requested by a client.
* *
* Sources of intent (any one is enough): * Reuses the shared thinkingUnified parser (extractThinking) so every client
* - HTTP header `Anthropic-Beta: ...interleaved-thinking...` * shape (Claude output_config.effort / thinking.budget_tokens, OpenAI
* - JSON `thinking.type === "enabled"` (Claude Messages API) * reasoning_effort / reasoning.effort, Gemini, Qwen) maps consistently. Explicit
* - JSON `reasoning_effort` in {low, medium, high, auto} (OpenAI o1/o3) * `none`/`off`/disabled wins and returns null (no prefix injected).
* - JSON `reasoning.effort` in {low, medium, high, auto} (OpenAI Responses) * buildThinkingSystemPrefix performs Kiro's final 1..32000 clamp.
* - System prompt contains `<thinking_mode>enabled</thinking_mode>` or *
* `<thinking_mode>interleaved</thinking_mode>` (AMP / Cursor) * @param {object} body OpenAI/Claude-shaped request body
* - Model name contains `thinking` or `-reason` * @param {object} [headers] Original inbound HTTP headers (case-insensitive)
* @param {string} [model] Model id the caller asked for
* @returns {number|null} budget to inject, or null when thinking is disabled
*/
export function resolveKiroThinkingBudget(body, headers, model) {
const cfg = extractThinking(body);
if (cfg) {
if (cfg.mode === "none") return null;
if (cfg.mode === "budget") return cfg.budget;
if (cfg.mode === "level") return effortToBudget(cfg.level) ?? KIRO_THINKING_BUDGET_DEFAULT;
return KIRO_THINKING_BUDGET_DEFAULT;
}
if (headers) {
const beta = pickHeader(headers, "anthropic-beta");
if (typeof beta === "string" && beta.toLowerCase().includes("interleaved-thinking")) {
return KIRO_THINKING_BUDGET_DEFAULT;
}
}
if (containsThinkingModeTag(body)) return KIRO_THINKING_BUDGET_DEFAULT;
if (typeof model === "string" && model) {
const m = model.toLowerCase();
if (m.includes("thinking") || m.includes("-reason")) return KIRO_THINKING_BUDGET_DEFAULT;
}
return null;
}
/**
* Detect whether an inbound request is asking for reasoning / thinking output.
* Thin wrapper over resolveKiroThinkingBudget (single source of truth).
* *
* @param {object} body OpenAI-shaped request body (post-translation) * @param {object} body OpenAI-shaped request body (post-translation)
* @param {object} [headers] Original inbound HTTP headers (case-insensitive) * @param {object} [headers] Original inbound HTTP headers (case-insensitive)
@@ -106,44 +141,7 @@ REMEMBER: When in doubt, write LESS per operation. Multiple small operations > o
* @returns {boolean} * @returns {boolean}
*/ */
export function isThinkingEnabled(body, headers, model) { export function isThinkingEnabled(body, headers, model) {
if (headers) { return resolveKiroThinkingBudget(body, headers, model) !== null;
const beta = pickHeader(headers, "anthropic-beta");
if (typeof beta === "string" && beta.toLowerCase().includes("interleaved-thinking")) {
return true;
}
}
if (body && typeof body === "object") {
const thinking = body.thinking;
if (thinking && typeof thinking === "object" && thinking.type === "enabled") {
const budget = Number(thinking.budget_tokens);
if (!Number.isFinite(budget) || budget > 0) {
return true;
}
}
const effort = body.reasoning_effort
?? (body.reasoning && typeof body.reasoning === "object" ? body.reasoning.effort : null);
if (typeof effort === "string") {
const v = effort.toLowerCase();
if (v && v !== "none" && (v === "low" || v === "medium" || v === "high" || v === "auto")) {
return true;
}
}
if (containsThinkingModeTag(body)) {
return true;
}
}
if (typeof model === "string" && model) {
const m = model.toLowerCase();
if (m.includes("thinking") || m.includes("-reason")) {
return true;
}
}
return false;
} }
/** /**

View File

@@ -0,0 +1,27 @@
// Central config for remote-media fetching security limits.
// Max bytes accepted from a remote image fetch (reject larger to prevent memory DoS).
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10MB
// Fetch timeout for remote media.
export const FETCH_TIMEOUT_MS = 10000;
// Magic-byte signatures -> mime. Each entry: { sig:[bytes], offset, mime }.
// offset>0 for containers where the signature is not at byte 0 (e.g. webp).
export const IMAGE_SIGNATURES = [
{ sig: [0x89, 0x50, 0x4e, 0x47], offset: 0, mime: "image/png" },
{ sig: [0xff, 0xd8, 0xff], offset: 0, mime: "image/jpeg" },
{ sig: [0x47, 0x49, 0x46, 0x38], offset: 0, mime: "image/gif" },
{ sig: [0x52, 0x49, 0x46, 0x46], offset: 0, mime: "image/webp", verifyWebp: true },
{ sig: [0x42, 0x4d], offset: 0, mime: "image/bmp" },
];
// Hostnames/IPs that must never be fetched (SSRF guard for loopback + cloud metadata).
export const BLOCKED_HOSTS = new Set([
"localhost",
"127.0.0.1",
"0.0.0.0",
"::1",
"169.254.169.254", // AWS/GCP/Azure IMDS
"metadata.google.internal",
]);

View File

@@ -1,845 +1,12 @@
import { PROVIDERS } from "./providers.js"; import { PROVIDERS } from "./providers.js";
import { buildTtsProviderModels } from "./ttsModels.js"; import REGISTRY from "../providers/registry/index.js";
// PROVIDER_MODELS now built from providers/registry (transport + models co-located)
import { PROVIDER_MODELS } from "../providers/index.js";
import { modelQuotaFamily, modelStrip, modelTargetFormat } from "../providers/models/schema.js";
import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js";
// Provider models - Single source of truth export { PROVIDER_MODELS };
// Key = alias (cc, cx, gc, qw, if, ag, gh for OAuth; id for API Key)
// Field "provider" for special cases (e.g. AntiGravity models that call different backends)
const CODEX_REVIEW_SUFFIX = "-review";
function withCodexReviewModels(models) {
return models.flatMap((model) => {
if ((model.type || "llm") !== "llm" || model.id.endsWith(CODEX_REVIEW_SUFFIX)) {
return [model];
}
return [
model,
{
...model,
id: `${model.id}${CODEX_REVIEW_SUFFIX}`,
name: `${model.name} Review`,
upstreamModelId: model.upstreamModelId || model.id,
quotaFamily: "review",
},
];
});
}
export const PROVIDER_MODELS = {
// OAuth Providers (using alias)
cc: [ // Claude Code
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
{ id: "claude-opus-4-7", name: "Claude Opus 4.7" },
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ id: "claude-opus-4-5-20251101", name: "Claude 4.5 Opus" },
{ id: "claude-sonnet-4-5-20250929", name: "Claude 4.5 Sonnet" },
{ id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" },
],
cx: withCodexReviewModels([ // OpenAI Codex
{ id: "gpt-5.5", name: "GPT 5.5" },
{ id: "gpt-5.4", name: "GPT 5.4" },
{ id: "gpt-5.4-mini", name: "GPT 5.4 Mini" },
// GPT 5.3 Codex - all thinking levels
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex" },
{ id: "gpt-5.3-codex-xhigh", name: "GPT 5.3 Codex (xHigh)" },
{ id: "gpt-5.3-codex-high", name: "GPT 5.3 Codex (High)" },
{ id: "gpt-5.3-codex-low", name: "GPT 5.3 Codex (Low)" },
{ id: "gpt-5.3-codex-none", name: "GPT 5.3 Codex (None)" },
{ id: "gpt-5.3-codex-spark", name: "GPT 5.3 Codex Spark" },
// Image models (uses image_generation tool, requires Plus/Pro plan)
{ id: "gpt-5.5-image", name: "GPT 5.5 Image", type: "image", capabilities: ["text2img", "edit"], params: ["size", "quality", "background", "image_detail", "output_format"] },
{ id: "gpt-5.4-image", name: "GPT 5.4 Image", type: "image", capabilities: ["text2img", "edit"], params: ["size", "quality", "background", "image_detail", "output_format"] },
{ id: "gpt-5.3-image", name: "GPT 5.3 Image", type: "image", capabilities: ["text2img", "edit"], params: ["size", "quality", "background", "image_detail", "output_format"] },
]),
gc: [ // Gemini CLI
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
{ id: "gemini-3-pro-preview", name: "Gemini 3 Pro Preview" },
],
qw: [ // Qwen Code
// { id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "qwen3-coder-flash", name: "Qwen3 Coder Flash" },
{ id: "vision-model", name: "Qwen3 Vision Model" },
{ id: "coder-model", name: "Qwen3.6 Coder Model" },
],
if: [ // iFlow AI
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "qwen3-max", name: "Qwen3 Max" },
{ id: "qwen3-vl-plus", name: "Qwen3 VL Plus" },
{ id: "qwen3-max-preview", name: "Qwen3 Max Preview" },
{ id: "qwen3-235b", name: "Qwen3 235B A22B" },
{ id: "qwen3-235b-a22b-instruct", name: "Qwen3 235B A22B Instruct" },
{ id: "qwen3-235b-a22b-thinking-2507", name: "Qwen3 235B A22B Thinking" },
{ id: "qwen3-32b", name: "Qwen3 32B" },
{ id: "kimi-k2", name: "Kimi K2" },
{ id: "deepseek-v3.2", name: "DeepSeek V3.2 Exp" },
{ id: "deepseek-v3.1", name: "DeepSeek V3.1 Terminus" },
{ id: "deepseek-v3", name: "DeepSeek V3 671B" },
{ id: "deepseek-r1", name: "DeepSeek R1" },
{ id: "glm-4.7", name: "GLM 4.7" },
{ id: "iflow-rome-30ba3b", name: "iFlow ROME" },
],
ag: [ // Antigravity - special case: models call different backends
{ id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)" },
{ id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium)" },
{ id: "gemini-3.5-flash-extra-low", name: "Gemini 3.5 Flash (Low)" },
{ id: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)" },
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Thinking)" },
{ id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 (Thinking)" },
{ id: "gpt-oss-120b-medium", name: "GPT-OSS 120B (Medium)" },
{ id: "gemini-3-flash", name: "Gemini 3 Flash", thinking: false }, // command model; AG strips thinking
],
gh: [ // GitHub Copilot - OpenAI models
{ id: "gpt-3.5-turbo", name: "GPT-3.5 Turbo" },
{ id: "gpt-4", name: "GPT-4" },
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "gpt-4o-mini", name: "GPT-4o mini" },
{ id: "gpt-4.1", name: "GPT-4.1" },
{ id: "gpt-5-mini", name: "GPT-5 Mini" },
{ id: "gpt-5.2", name: "GPT-5.2" },
{ id: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
{ id: "gpt-5.4", name: "GPT-5.4" },
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini" },
// GitHub Copilot - Anthropic models
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
{ id: "claude-opus-4.5", name: "Claude Opus 4.5" },
{ id: "claude-sonnet-4", name: "Claude Sonnet 4" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" },
{ id: "claude-opus-4.6", name: "Claude Opus 4.6" },
{ id: "claude-opus-4.7", name: "Claude Opus 4.7" },
// GitHub Copilot - Google models
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash" },
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro" },
// GitHub Copilot - Other models
{ id: "grok-code-fast-1", name: "Grok Code Fast 1" },
{ id: "oswe-vscode-prime", name: "Raptor Mini" },
{ id: "goldeneye-free-auto", name: "GoldenEye" },
// GitHub Copilot - Embedding models
{ id: "text-embedding-3-small", name: "Text Embedding 3 Small (GitHub)", type: "embedding" },
{ id: "text-embedding-3-large", name: "Text Embedding 3 Large (GitHub)", type: "embedding" },
],
kr: [ // Kiro AI
// --- Base Claude variants ---
// { id: "claude-opus-4.5", name: "Claude Opus 4.5" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
{ id: "deepseek-3.2", name: "DeepSeek 3.2", strip: ["image", "audio"] },
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next", strip: ["image", "audio"] },
{ id: "glm-5", name: "GLM 5" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
// --- Thinking variants (alias to base; thinking is enabled at request time
// via <thinking_mode>enabled</thinking_mode> system-prompt injection) ---
{ id: "claude-sonnet-4.5-thinking", name: "Claude Sonnet 4.5 (Thinking)" },
{ id: "claude-haiku-4.5-thinking", name: "Claude Haiku 4.5 (Thinking)" },
// --- Agentic variants (synthetic; same upstream model + chunked-write
// system prompt to dodge Kiro's 2-3 min server timeout on big writes) ---
{ id: "claude-sonnet-4.5-agentic", name: "Claude Sonnet 4.5 (Agentic)" },
{ id: "claude-haiku-4.5-agentic", name: "Claude Haiku 4.5 (Agentic)" },
{ id: "claude-sonnet-4.5-thinking-agentic", name: "Claude Sonnet 4.5 (Thinking + Agentic)" },
{ id: "claude-haiku-4.5-thinking-agentic", name: "Claude Haiku 4.5 (Thinking + Agentic)" },
],
qd: [ // Qoder - tier + frontier models (server-published catalog)
// Tier models — pick a quality/cost tradeoff
{ id: "auto", name: "Qoder Auto" },
{ id: "ultimate", name: "Qoder Ultimate" },
{ id: "performance", name: "Qoder Performance" },
{ id: "efficient", name: "Qoder Efficient" },
{ id: "lite", name: "Qoder Lite" },
// Frontier models — pin a specific backing model
{ id: "qmodel", name: "Qwen 3.6 Plus (Qoder)" },
{ id: "qmodel_latest", name: "Qoder Qwen 3.7 Max" },
{ id: "dmodel", name: "DeepSeek V4 Pro (Qoder)" },
{ id: "dfmodel", name: "DeepSeek V4 Flash (Qoder)" },
{ id: "gm51model", name: "GLM 5.1 (Qoder)" },
{ id: "kmodel", name: "Kimi K2.6 (Qoder)" },
{ id: "mmodel", name: "MiniMax M2.7 (Qoder)" },
],
cu: [ // Cursor IDE
{ id: "default", name: "Auto (Server Picks)" },
{ id: "claude-4.5-opus-high-thinking", name: "Claude 4.5 Opus High Thinking" },
{ id: "claude-4.5-opus-high", name: "Claude 4.5 Opus High" },
{ id: "claude-4.5-sonnet-thinking", name: "Claude 4.5 Sonnet Thinking" },
{ id: "claude-4.5-sonnet", name: "Claude 4.5 Sonnet" },
{ id: "claude-4.5-haiku", name: "Claude 4.5 Haiku" },
{ id: "claude-4.5-opus", name: "Claude 4.5 Opus" },
{ id: "gpt-5.2-codex", name: "GPT 5.2 Codex" },
{ id: "claude-4.6-opus-max", name: "Claude 4.6 Opus Max" },
{ id: "claude-4.6-sonnet-medium-thinking", name: "Claude 4.6 Sonnet Medium Thinking" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
{ id: "gpt-5.2", name: "GPT 5.2" },
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex" },
],
kmc: [ // Kimi Coding
{ id: "kimi-k2.6", name: "Kimi K2.6" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" },
{ id: "kimi-latest", name: "Kimi Latest" },
],
kc: [ // KiloCode
{ id: "anthropic/claude-sonnet-4-20250514", name: "Claude Sonnet 4" },
{ id: "anthropic/claude-opus-4-20250514", name: "Claude Opus 4" },
{ id: "google/gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "google/gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "openai/gpt-4.1", name: "GPT-4.1" },
{ id: "openai/o3", name: "o3" },
{ id: "deepseek/deepseek-chat", name: "DeepSeek Chat" },
{ id: "deepseek/deepseek-reasoner", name: "DeepSeek Reasoner" },
],
"opencode-go": [ // OpenCode Go subscription (API key)
{ id: "kimi-k2.6", name: "Kimi K2.6" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "glm-5.1", name: "GLM 5.1" },
{ id: "glm-5", name: "GLM 5" },
{ id: "qwen3.5-plus", name: "Qwen 3.5 Plus" },
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus" },
{ id: "mimo-v2-pro", name: "MiMo V2 Pro" },
{ id: "mimo-v2-omni", name: "MiMo V2 Omni" },
{ id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" },
{ id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" },
],
oc: [ // OpenCode
// { id: "nemotron-3-super-free", name: "Nemotron 3 Super" },
// { id: "qwen3.6-plus-free", name: "Qwen 3.6 Plus" },
// { id: "big-pickle", name: "Big Pickle", targetFormat: "claude" },
// { id: "minimax-m2.5-free", name: "MiniMax M2.5", targetFormat: "claude" },
// { id: "trinity-large-preview-free", name: "Trinity Large Preview" },
],
mmf: [ // MiMo Free — free channel only serves mimo-auto
{ id: "mimo-auto", name: "MiMo Auto" },
],
cl: [ // Cline
{ id: "anthropic/claude-opus-4.7", name: "Claude Opus 4.7" },
{ id: "anthropic/claude-sonnet-4.6", name: "Claude Sonnet 4.6" },
{ id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" },
{ id: "openai/gpt-5.3-codex", name: "GPT-5.3 Codex" },
{ id: "openai/gpt-5.4", name: "GPT-5.4" },
{ id: "google/gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
{ id: "google/gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
{ id: "kwaipilot/kat-coder-pro", name: "KAT Coder Pro" },
],
// API Key Providers (alias = id)
openai: [
// Flagship models
{ id: "gpt-5.4", name: "GPT-5.4" },
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini" },
{ id: "gpt-5.4-nano", name: "GPT-5.4 Nano" },
{ id: "gpt-5.2", name: "GPT-5.2" },
{ id: "gpt-5.1", name: "GPT-5.1" },
{ id: "gpt-5", name: "GPT-5" },
{ id: "gpt-5-mini", name: "GPT-5 Mini" },
{ id: "gpt-5-nano", name: "GPT-5 Nano" },
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
{ id: "gpt-4-turbo", name: "GPT-4 Turbo" },
{ 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" },
// Reasoning models
{ id: "o3", name: "O3" },
{ id: "o3-mini", name: "O3 Mini" },
{ id: "o3-pro", name: "O3 Pro" },
{ id: "o4-mini", name: "O4 Mini" },
{ id: "o1", name: "O1" },
{ id: "o1-mini", name: "O1 Mini" },
// Embedding models
{ id: "text-embedding-3-large", name: "Text Embedding 3 Large", type: "embedding" },
{ id: "text-embedding-3-small", name: "Text Embedding 3 Small", type: "embedding" },
{ id: "text-embedding-ada-002", name: "Text Embedding Ada 002", type: "embedding" },
// TTS models
{ id: "tts-1", name: "TTS-1", type: "tts" },
{ id: "tts-1-hd", name: "TTS-1 HD", type: "tts" },
{ id: "gpt-4o-mini-tts", name: "GPT-4o Mini TTS", type: "tts" },
// STT models
{ id: "whisper-1", name: "Whisper 1", type: "stt", params: ["language", "response_format", "temperature", "prompt"] },
{ id: "gpt-4o-transcribe", name: "GPT-4o Transcribe", type: "stt", params: ["language", "response_format", "temperature", "prompt"] },
{ id: "gpt-4o-mini-transcribe", name: "GPT-4o Mini Transcribe", type: "stt", params: ["language", "response_format", "temperature", "prompt"] },
// Image models
{ id: "gpt-image-1", name: "GPT Image 1", type: "image", params: ["n", "size", "quality", "response_format"] },
{ id: "dall-e-3", name: "DALL-E 3", type: "image", params: ["size", "quality", "style", "response_format"] },
{ id: "dall-e-2", name: "DALL-E 2", type: "image", params: ["n", "size", "response_format"] },
],
anthropic: [
{ id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4" },
{ id: "claude-opus-4-20250514", name: "Claude Opus 4" },
{ id: "claude-3-5-sonnet-20241022", name: "Claude 3.5 Sonnet" },
],
gemini: [
// Gemini 3.1 series
{ 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" },
// Gemini 3 series
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
// Gemini 2.5 series
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
// Gemini 2.0 series (retiring June 1, 2026)
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" },
{ id: "gemini-2.0-flash-lite", name: "Gemini 2.0 Flash Lite" },
{ id: "gemma-4-31b-it", name: "Gemma 4 31B IT" },
// Embedding models
{ id: "gemini-embedding-2-preview", name: "Gemini Embedding 2 Preview", type: "embedding" },
{ id: "gemini-embedding-001", name: "Gemini Embedding 001", type: "embedding" },
{ id: "text-embedding-005", name: "Text Embedding 005", type: "embedding" },
{ id: "text-embedding-004", name: "Text Embedding 004 (Legacy)", type: "embedding" },
// Image models (Nano Banana)
{ id: "gemini-3.1-flash-image-preview", name: "Gemini 3.1 Flash Image (Nano Banana 2)", type: "image", params: [] },
{ id: "gemini-3-pro-image-preview", name: "Gemini 3 Pro Image (Nano Banana Pro)", type: "image", params: [] },
{ id: "gemini-2.5-flash-image", name: "Gemini 2.5 Flash Image (Nano Banana)", type: "image", params: [] },
// STT models (multimodal generateContent)
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (Best)", type: "stt", params: ["language", "prompt"] },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", type: "stt", params: ["language", "prompt"] },
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite (Cheapest)", type: "stt", params: ["language", "prompt"] },
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", type: "stt", params: ["language", "prompt"] },
],
openrouter: [
// Embedding models
{ id: "openai/text-embedding-3-large", name: "OpenAI Text Embedding 3 Large", type: "embedding" },
{ id: "openai/text-embedding-3-small", name: "OpenAI Text Embedding 3 Small", type: "embedding" },
{ id: "openai/text-embedding-ada-002", name: "OpenAI Text Embedding Ada 002", type: "embedding" },
{ id: "qwen/qwen3-embedding-8b", name: "Qwen3 Embedding 8B", type: "embedding" },
{ id: "perplexity/pplx-embed-v1-4b", name: "Perplexity Embed V1 4B", type: "embedding" },
{ id: "perplexity/pplx-embed-v1-0.6b", name: "Perplexity Embed V1 0.6B", type: "embedding" },
{ id: "nvidia/llama-nemotron-embed-vl-1b-v2:free", name: "NVIDIA Nemotron Embed VL 1B V2 (Free)", type: "embedding" },
// TTS models
{ id: "openai/gpt-4o-mini-tts", name: "GPT-4o Mini TTS", type: "tts" },
{ id: "openai/tts-1-hd", name: "TTS-1 HD", type: "tts" },
{ id: "openai/tts-1", name: "TTS-1", type: "tts" },
// Image models
{ id: "openai/dall-e-3", name: "DALL-E 3 (via OpenRouter)", type: "image", params: ["size", "quality", "style", "response_format"] },
{ id: "openai/gpt-image-1", name: "GPT Image 1 (via OpenRouter)", type: "image", params: ["n", "size", "quality", "response_format"] },
{ id: "google/imagen-3.0-generate-002", name: "Imagen 3 (via OpenRouter)", type: "image", params: ["n", "size"] },
{ id: "black-forest-labs/FLUX.1-schnell", name: "FLUX.1 Schnell (via OpenRouter)", type: "image", params: ["n", "size"] },
],
glm: [
{ id: "glm-5.1", name: "GLM 5.1" },
{ id: "glm-5", name: "GLM 5" },
{ id: "glm-4.7", name: "GLM 4.7" },
{ id: "glm-4.6v", name: "GLM 4.6V (Vision)" },
],
"glm-cn": [
{ id: "glm-5.1", name: "GLM 5.1" },
{ id: "glm-5", name: "GLM 5" },
{ id: "glm-4.7", name: "GLM-4.7" },
{ id: "glm-4.6", name: "GLM-4.6" },
{ id: "glm-4.5-air", name: "GLM-4.5-Air" },
],
kimi: [
{ id: "kimi-k2.6", name: "Kimi K2.6" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" },
{ id: "kimi-latest", name: "Kimi Latest" },
],
minimax: [
{ id: "MiniMax-M3", name: "MiniMax M3", targetFormat: "claude" },
{ id: "MiniMax-M2.7", name: "MiniMax M2.7" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "MiniMax-M2.1", name: "MiniMax M2.1" },
// Image models
{ id: "minimax-image-01", name: "MiniMax Image 01", type: "image", params: ["n", "size", "response_format"] },
],
blackbox: [
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "gpt-4o-mini", name: "GPT-4o mini" },
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
{ id: "claude-opus-4.6", name: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Legacy)" },
{ id: "claude-opus-4-6", name: "Claude Opus 4.6 (Legacy)" },
{ id: "deepseek-chat", name: "DeepSeek Chat" },
{ id: "deepseek-v3-671b", name: "DeepSeek V3 671B" },
{ id: "deepseek-r1", name: "DeepSeek R1" },
{ id: "o1", name: "OpenAI o1" },
{ id: "o3-mini", name: "OpenAI o3-mini" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "qwen3-max", name: "Qwen3 Max" },
{ id: "qwen3-vl-plus", name: "Qwen3 VL Plus" },
],
"minimax-cn": [
{ id: "MiniMax-M3", name: "MiniMax M3", targetFormat: "claude" },
{ id: "MiniMax-M2.7", name: "MiniMax M2.7" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "MiniMax-M2.1", name: "MiniMax M2.1" },
],
alicode: [
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "glm-5", name: "GLM 5" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "qwen3-max-2026-01-23", name: "Qwen3 Max" },
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "glm-4.7", name: "GLM 4.7" },
],
"alicode-intl": [
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "glm-5", name: "GLM 5" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "glm-4.7", name: "GLM 4.7" },
],
"volcengine-ark": [
{ id: "Doubao-Seed-2.0-Code", name: "Doubao-Seed-2.0-Code" },
{ id: "Doubao-Seed-2.0-pro", name: "Doubao-Seed-2.0-pro" },
{ id: "Doubao-Seed-2.0-lite", name: "Doubao-Seed-2.0-lite" },
{ id: "Doubao-Seed-Code", name: "Doubao-Seed-Code" },
{ id: "DeepSeek-V4-Flash", name: "DeepSeek-V4-Flash" },
{ id: "DeepSeek-V4-Pro", name: "DeepSeek-V4-Pro" },
{ id: "GLM-5.1", name: "GLM-5.1" },
{ id: "MiniMax-M2.7", name: "MiniMax-M2.7" },
{ id: "Kimi-K2.6", name: "Kimi-K2.6" },
],
"cloudflare-ai": [
{ id: "@cf/meta/llama-3.2-1b-instruct", name: "Llama 3.2 1B Instruct" },
{ id: "@cf/meta/llama-3.2-3b-instruct", name: "Llama 3.2 3B Instruct" },
{ id: "@cf/meta/llama-3.1-8b-instruct-fp8-fast", name: "Llama 3.1 8B Instruct FP8 Fast" },
{ id: "@cf/meta/llama-3.1-8b-instruct-awq", name: "Llama 3.1 8B Instruct AWQ" },
{ id: "@cf/mistralai/mistral-small-3.1-24b-instruct", name: "Mistral Small 3.1 24B Instruct" },
{ id: "@cf/meta/llama-3.1-70b-instruct-fp8-fast", name: "Llama 3.1 70B Instruct FP8 Fast" },
{ id: "@cf/meta/llama-3.3-70b-instruct-fp8-fast", name: "Llama 3.3 70B Instruct FP8 Fast" },
{ id: "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b", name: "DeepSeek R1 Distill Qwen 32B" },
{ id: "@cf/moonshotai/kimi-k2.5", name: "Kimi K2.5" },
{ id: "@cf/moonshotai/kimi-k2.6", name: "Kimi K2.6" },
{ id: "@cf/zai-org/glm-4.7-flash", name: "GLM 4.7 Flash" },
{ id: "@cf/qwen/qwq-32b", name: "QwQ 32B" },
{ id: "@cf/qwen/qwen2.5-coder-32b-instruct", name: "Qwen 2.5 Coder 32B Instruct" },
{ id: "@cf/black-forest-labs/flux-2-klein-9b", name: "FLUX.2 Klein 9B", type: "image", params: ["size"] },
{ id: "@cf/black-forest-labs/flux-2-klein-4b", name: "FLUX.2 Klein 4B", type: "image", params: ["size"] },
{ id: "@cf/black-forest-labs/flux-2-dev", name: "FLUX.2 Dev", type: "image", params: ["size"] },
{ id: "@cf/leonardo/lucid-origin", name: "Lucid Origin", type: "image", params: ["size"] },
{ id: "@cf/leonardo/phoenix-1.0", name: "Phoenix 1.0", type: "image", params: ["size"] },
{ id: "@cf/black-forest-labs/flux-1-schnell", name: "FLUX.1 Schnell", type: "image", params: ["size"] },
{ id: "@cf/bytedance/stable-diffusion-xl-lightning", name: "SDXL Lightning", type: "image", params: ["size"] },
{ id: "@cf/lykon/dreamshaper-8-lcm", name: "DreamShaper 8 LCM", type: "image", params: ["size"] },
{ id: "@cf/runwayml/stable-diffusion-v1-5-img2img", name: "Stable Diffusion v1.5 Img2Img", type: "image", params: ["size"], capabilities: ["edit"] },
{ id: "@cf/runwayml/stable-diffusion-v1-5-inpainting", name: "Stable Diffusion v1.5 Inpainting", type: "image", params: ["size"], capabilities: ["edit", "mask"] },
{ id: "@cf/stabilityai/stable-diffusion-xl-base-1.0", name: "SDXL Base 1.0", type: "image", params: ["size"] },
],
byteplus: [
{ id: "seed-2-0-pro-260328", name: "Seed 2.0 Pro" },
{ id: "seed-2-0-code-preview-260328", name: "Seed 2.0 Code Preview" },
{ id: "seed-2-0-mini-260215", name: "Seed 2.0 Mini" },
{ id: "seed-2-0-lite-260228", name: "Seed 2.0 Lite" },
{ id: "kimi-k2-thinking-251104", name: "Kimi K2 Thinking" },
{ id: "glm-4-7-251222", name: "GLM 4.7" },
{ id: "gpt-oss-120b-250805", name: "GPT-OSS-120B" },
],
deepseek: [
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
{ id: "deepseek-v4-pro-max", name: "DeepSeek V4 Pro Max", upstreamModelId: "deepseek-v4-pro" },
{ id: "deepseek-v4-pro-none", name: "DeepSeek V4 Pro No Thinking", upstreamModelId: "deepseek-v4-pro" },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" },
{ id: "deepseek-chat", name: "DeepSeek V3.2 Chat" },
{ id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" },
],
commandcode: [
{ id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro" },
{ id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash" },
{ id: "moonshotai/Kimi-K2.6", name: "Kimi K2.6" },
{ id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5" },
{ id: "zai-org/GLM-5.1", name: "GLM 5.1" },
{ id: "zai-org/GLM-5", name: "GLM 5" },
{ id: "MiniMaxAI/MiniMax-M2.7", name: "MiniMax M2.7" },
{ id: "MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "Qwen/Qwen3.6-Max-Preview", name: "Qwen 3.6 Max Preview" },
{ id: "Qwen/Qwen3.6-Plus", name: "Qwen 3.6 Plus" },
{ id: "stepfun/Step-3.5-Flash", name: "Step 3.5 Flash" },
],
groq: [
{ id: "llama-3.3-70b-versatile", name: "Llama 3.3 70B" },
{ id: "meta-llama/llama-4-maverick-17b-128e-instruct", name: "Llama 4 Maverick" },
{ id: "qwen/qwen3-32b", name: "Qwen3 32B" },
{ id: "openai/gpt-oss-120b", name: "GPT-OSS 120B" },
// STT models
{ id: "whisper-large-v3", name: "Whisper Large v3", type: "stt", params: ["language", "response_format", "temperature", "prompt"] },
{ id: "whisper-large-v3-turbo", name: "Whisper Large v3 Turbo", type: "stt", params: ["language", "response_format", "temperature", "prompt"] },
{ id: "distil-whisper-large-v3-en", name: "Distil Whisper Large v3 EN", type: "stt", params: ["language", "response_format", "temperature", "prompt"] },
],
xai: [
{ id: "grok-4", name: "Grok 4" },
{ id: "grok-4-fast-reasoning", name: "Grok 4 Fast Reasoning" },
{ id: "grok-code-fast-1", name: "Grok Code Fast" },
{ id: "grok-3", name: "Grok 3" },
{ id: "grok-2-image-1212", name: "Grok 2 Image", type: "image", params: ["n", "response_format"] },
],
mistral: [
{ id: "mistral-large-latest", name: "Mistral Large 3" },
{ id: "codestral-latest", name: "Codestral" },
{ id: "mistral-medium-latest", name: "Mistral Medium 3" },
{ id: "mistral-embed", name: "Mistral Embed", type: "embedding" },
],
perplexity: [
{ id: "sonar-pro", name: "Sonar Pro" },
{ id: "sonar", name: "Sonar" },
],
together: [
{ id: "meta-llama/Llama-3.3-70B-Instruct-Turbo", name: "Llama 3.3 70B Turbo" },
{ id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" },
{ id: "Qwen/Qwen3-235B-A22B", name: "Qwen3 235B" },
{ id: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", name: "Llama 4 Maverick" },
{ id: "BAAI/bge-large-en-v1.5", name: "BGE Large EN v1.5", type: "embedding" },
{ id: "togethercomputer/m2-bert-80M-8k-retrieval", name: "M2 BERT 80M 8K", type: "embedding" },
],
fireworks: [
{ id: "accounts/fireworks/models/deepseek-v3p1", name: "DeepSeek V3.1" },
{ id: "accounts/fireworks/models/llama-v3p3-70b-instruct", name: "Llama 3.3 70B" },
{ id: "accounts/fireworks/models/qwen3-235b-a22b", name: "Qwen3 235B" },
{ id: "nomic-ai/nomic-embed-text-v1.5", name: "Nomic Embed Text v1.5", type: "embedding" },
],
cerebras: [
{ id: "gpt-oss-120b", name: "GPT OSS 120B" },
{ id: "zai-glm-4.7", name: "ZAI GLM 4.7" },
{ id: "llama-3.3-70b", name: "Llama 3.3 70B" },
{ id: "llama-4-scout-17b-16e-instruct", name: "Llama 4 Scout" },
{ id: "qwen-3-235b-a22b-instruct-2507", name: "Qwen3 235B A22B" },
{ id: "qwen-3-32b", name: "Qwen3 32B" },
],
cohere: [
{ id: "command-r-plus-08-2024", name: "Command R+ (Aug 2024)" },
{ id: "command-r-08-2024", name: "Command R (Aug 2024)" },
{ id: "command-a-03-2025", name: "Command A (Mar 2025)" },
],
nvidia: [
{ id: "minimaxai/minimax-m2.7", name: "Minimax M2.7" },
{ id: "z-ai/glm4.7", name: "GLM 4.7" },
{ id: "nvidia/nv-embedqa-e5-v5", name: "NV EmbedQA E5 v5", type: "embedding" },
// STT models
{ id: "nvidia/parakeet-ctc-1.1b-asr", name: "Parakeet CTC 1.1B", type: "stt", params: ["language"] },
],
nebius: [
{ id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B Instruct" },
{ id: "Qwen/Qwen3-Embedding-8B", name: "Qwen3 Embedding 8B", type: "embedding" },
],
"voyage-ai": [
{ id: "voyage-3-large", name: "Voyage 3 Large", type: "embedding" },
{ id: "voyage-3.5", name: "Voyage 3.5", type: "embedding" },
{ id: "voyage-3.5-lite", name: "Voyage 3.5 Lite", type: "embedding" },
{ id: "voyage-code-3", name: "Voyage Code 3", type: "embedding" },
{ id: "voyage-finance-2", name: "Voyage Finance 2", type: "embedding" },
{ id: "voyage-law-2", name: "Voyage Law 2", type: "embedding" },
{ id: "voyage-multilingual-2", name: "Voyage Multilingual 2", type: "embedding" },
],
siliconflow: [
// DeepSeek models
{ id: "deepseek-ai/DeepSeek-V4-Pro", name: "DeepSeek V4 Pro" },
{ id: "deepseek-ai/DeepSeek-V4-Flash", name: "DeepSeek V4 Flash" },
{ id: "deepseek-ai/DeepSeek-V3.2", name: "DeepSeek V3.2" },
{ id: "deepseek-ai/DeepSeek-V3.2-Exp", name: "DeepSeek V3.2 Exp" },
{ id: "deepseek-ai/DeepSeek-V3.1", name: "DeepSeek V3.1" },
{ id: "deepseek-ai/DeepSeek-V3.1-Terminus", name: "DeepSeek V3.1 Terminus" },
{ id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" },
// Qwen models
{ id: "Qwen/Qwen3.5-397B-A17B", name: "Qwen 3.5 397B A17B" },
{ id: "Qwen/Qwen3.5-122B-A10B", name: "Qwen 3.5 122B A10B" },
// GLM models
{ id: "zai-org/GLM-5.1", name: "GLM 5.1" },
{ id: "zai-org/GLM-5", name: "GLM 5" },
// Kimi models
{ id: "moonshotai/Kimi-K2.6", name: "Kimi K2.6" },
{ id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5" },
// Other models
{ id: "openai/gpt-oss-120b", name: "GPT OSS 120B" },
{ id: "MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "inclusionAI/Ling-flash-2.0", name: "Ling Flash 2.0" },
],
"xiaomi-mimo": [
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" },
{ id: "mimo-v2.5", name: "MiMo V2.5" },
{ id: "mimo-v2-omni", name: "MiMo V2 Omni" },
{ id: "mimo-v2-flash", name: "MiMo V2 Flash" },
],
"xiaomi-tokenplan": [
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" },
{ id: "mimo-v2.5-pro-claude", name: "MiMo V2.5 Pro (Claude Native)", targetFormat: "claude", upstreamModelId: "mimo-v2.5-pro" },
{ id: "mimo-v2.5", name: "MiMo V2.5" },
{ id: "mimo-v2-pro", name: "MiMo V2 Pro" },
{ id: "mimo-v2-omni", name: "MiMo V2 Omni" },
{ id: "mimo-v2-tts", name: "MiMo V2 TTS" },
{ id: "mimo-v2.5-tts", name: "MiMo V2.5 TTS" },
{ id: "mimo-v2.5-tts-voiceclone", name: "MiMo V2.5 TTS Voice Clone" },
{ id: "mimo-v2.5-tts-voicedesign", name: "MiMo V2.5 TTS Voice Design" },
],
hyperbolic: [
{ id: "Qwen/QwQ-32B", name: "QwQ 32B" },
{ id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" },
{ id: "deepseek-ai/DeepSeek-V3", name: "DeepSeek V3" },
{ id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B" },
{ id: "meta-llama/Llama-3.2-3B-Instruct", name: "Llama 3.2 3B" },
{ id: "Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B" },
{ id: "Qwen/Qwen2.5-Coder-32B-Instruct", name: "Qwen 2.5 Coder 32B" },
{ id: "NousResearch/Hermes-3-Llama-3.1-70B", name: "Hermes 3 70B" },
],
ollama: [
{ id: "gpt-oss:120b", name: "GPT OSS 120B" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "glm-5", name: "GLM 5" },
{ id: "minimax-m2.5", name: "MiniMax M2.5" },
{ id: "glm-4.7-flash", name: "GLM 4.7 Flash" },
{ id: "qwen3.5", name: "Qwen3.5" },
],
vertex: [
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
{ id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
],
"vertex-partner": [
{ id: "deepseek-ai/deepseek-v3.2-maas", name: "DeepSeek V3.2 (Vertex)" },
{ id: "qwen/qwen3-next-80b-a3b-thinking-maas", name: "Qwen3 Next 80B Thinking (Vertex)" },
{ id: "qwen/qwen3-next-80b-a3b-instruct-maas", name: "Qwen3 Next 80B Instruct (Vertex)" },
{ id: "zai-org/glm-5-maas", name: "GLM-5 (Vertex)" },
],
"grok-web": [
{ id: "grok-3", name: "Grok 3" },
{ id: "grok-3-mini", name: "Grok 3 Mini (Thinking)" },
{ id: "grok-3-thinking", name: "Grok 3 Thinking" },
{ id: "grok-4", name: "Grok 4" },
{ id: "grok-4-mini", name: "Grok 4 Mini (Thinking)" },
{ id: "grok-4-thinking", name: "Grok 4 Thinking" },
{ id: "grok-4-heavy", name: "Grok 4 Heavy (SuperGrok)" },
{ id: "grok-4.1-mini", name: "Grok 4.1 Mini (Thinking)" },
{ id: "grok-4.1-fast", name: "Grok 4.1 Fast" },
{ id: "grok-4.1-expert", name: "Grok 4.1 Expert" },
{ id: "grok-4.1-thinking", name: "Grok 4.1 Thinking" },
{ id: "grok-4.2", name: "Grok 4.2 (4.20 Beta)" },
],
"perplexity-web": [
{ id: "pplx-auto", name: "Perplexity Auto (Free)" },
{ id: "pplx-sonar", name: "Perplexity Sonar" },
{ id: "pplx-gpt", name: "GPT-5.4 (via Perplexity)" },
{ id: "pplx-gemini", name: "Gemini 3.1 Pro (via Perplexity)" },
{ id: "pplx-sonnet", name: "Claude Sonnet 4.6 (via Perplexity)" },
{ id: "pplx-opus", name: "Claude Opus 4.6 (via Perplexity)" },
{ id: "pplx-nemotron", name: "Nemotron 3 Super (via Perplexity)" },
],
// TTS entries are loaded from ttsModels.js via buildTtsProviderModels()
...buildTtsProviderModels(),
// Image providers
nanobanana: [
{ id: "nanobanana-flash", name: "NanoBanana Flash", type: "image", params: ["n", "size"] },
{ id: "nanobanana-pro", name: "NanoBanana Pro", type: "image", params: ["n", "size"] },
],
sdwebui: [
{ id: "stable-diffusion-v1-5", name: "Stable Diffusion v1.5", type: "image", params: ["n", "size"] },
{ id: "sdxl-base-1.0", name: "SDXL Base 1.0", type: "image", params: ["n", "size"] },
],
comfyui: [
{ id: "flux-dev", name: "FLUX Dev", type: "image", params: ["n", "size"] },
{ id: "sdxl", name: "SDXL", type: "image", params: ["n", "size"] },
],
huggingface: [
{ id: "black-forest-labs/FLUX.1-schnell", name: "FLUX.1 Schnell", type: "image", params: [] },
{ id: "stabilityai/stable-diffusion-xl-base-1.0", name: "SDXL Base 1.0", type: "image", params: [] },
// STT models
{ id: "openai/whisper-large-v3", name: "Whisper Large v3 (HF)", type: "stt", params: ["language"] },
{ id: "openai/whisper-small", name: "Whisper Small (HF)", type: "stt", params: ["language"] },
],
// === Free-tier providers (synced from OmniRoute) ===
agentrouter: [
{ id: "claude-opus-4-6", name: "Claude 4.6 Opus" },
{ id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" },
{ id: "glm-5.1", name: "GLM 5.1" },
{ id: "deepseek-v3.2", name: "DeepSeek V3.2" },
],
aimlapi: [
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
{ id: "claude-3-5-sonnet-20241022", name: "Claude 3.5 Sonnet" },
{ id: "gemini-2.0-flash-exp", name: "Gemini 2.0 Flash" },
{ id: "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo", name: "Llama 3.1 70B" },
],
novita: [
{ id: "deepseek/deepseek-r1", name: "DeepSeek R1" },
{ id: "deepseek/deepseek-v3", name: "DeepSeek V3" },
{ id: "meta-llama/llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
{ id: "qwen/qwen-2.5-72b-instruct", name: "Qwen 2.5 72B" },
],
modal: [
{ id: "auto", name: "Auto (User-hosted)" },
],
reka: [
{ id: "reka-flash-3", name: "Reka Flash 3" },
{ id: "reka-edge-2603", name: "Reka Edge 2603" },
],
nlpcloud: [
{ id: "chatdolphin", name: "ChatDolphin" },
{ id: "dolphin", name: "Dolphin" },
{ id: "finetuned-llama-3-70b", name: "Llama 3 70B (Finetuned)" },
],
bazaarlink: [
{ id: "auto:free", name: "Auto Free (Zero Cost)" },
{ id: "auto", name: "Auto (Best Model)" },
],
completions: [
{ id: "claude-opus-4", name: "Claude Opus 4" },
{ id: "claude-sonnet-4", name: "Claude Sonnet 4" },
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" },
],
enally: [
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet" },
],
freetheai: [
{ id: "gpt-4o", name: "GPT-4o" },
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet" },
{ id: "gemini-1.5-pro", name: "Gemini 1.5 Pro" },
{ id: "deepseek-chat", name: "DeepSeek Chat" },
],
llm7: [
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini" },
{ id: "gemini-1.5-flash", name: "Gemini 1.5 Flash" },
],
lepton: [
{ id: "llama3-1-405b", name: "Llama 3.1 405B" },
{ id: "llama3-1-70b", name: "Llama 3.1 70B" },
{ id: "llama3-1-8b", name: "Llama 3.1 8B" },
{ id: "mixtral-8x7b", name: "Mixtral 8x7B" },
],
kluster: [
{ id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" },
{ id: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", name: "Llama 4 Maverick" },
{ id: "meta-llama/Llama-4-Scout-17B-16E-Instruct", name: "Llama 4 Scout" },
{ id: "Qwen/Qwen3-235B-A22B-Instruct", name: "Qwen3 235B" },
],
ai21: [
{ id: "jamba-large", name: "Jamba 1.5 Large" },
{ id: "jamba-mini", name: "Jamba 1.5 Mini" },
],
"inference-net": [
{ id: "meta-llama/llama-3.3-70b-instruct/fp-16", name: "Llama 3.3 70B" },
{ id: "deepseek/deepseek-v3-0324", name: "DeepSeek V3" },
{ id: "mistralai/mistral-nemo-12b-instruct/fp-16", name: "Mistral Nemo 12B" },
],
predibase: [
{ id: "llama-3-2-3b-instruct", name: "Llama 3.2 3B" },
{ id: "llama-3-1-8b-instruct", name: "Llama 3.1 8B" },
{ id: "qwen2-5-7b-instruct", name: "Qwen 2.5 7B" },
],
bytez: [
{ id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B" },
{ id: "mistralai/Mistral-7B-Instruct-v0.3", name: "Mistral 7B v0.3" },
{ id: "Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B" },
],
morph: [
{ id: "morph-v3-large", name: "Morph V3 Large" },
{ id: "morph-v3-fast", name: "Morph V3 Fast" },
],
longcat: [
{ id: "LongCat-Flash-Chat", name: "LongCat Flash Chat" },
{ id: "LongCat-Flash-Thinking", name: "LongCat Flash Thinking" },
{ id: "LongCat-Flash-Lite", name: "LongCat Flash Lite" },
],
puter: [
{ id: "gpt-5", name: "GPT-5" },
{ id: "claude-opus-4", name: "Claude Opus 4" },
{ id: "gemini-3-pro-preview", name: "Gemini 3 Pro" },
{ id: "grok-4", name: "Grok 4" },
{ id: "deepseek-chat", name: "DeepSeek V3" },
],
uncloseai: [
{ id: "auto", name: "Auto (Free)" },
{ id: "gpt-4o-mini", name: "GPT-4o Mini" },
],
scaleway: [
{ id: "qwen3-235b-a22b-instruct-2507", name: "Qwen3 235B" },
{ id: "llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
{ id: "mistral-small-3.1-24b-instruct-2503", name: "Mistral Small 3.1" },
],
deepinfra: [
{ id: "meta-llama/Meta-Llama-3.1-70B-Instruct", name: "Llama 3.1 70B" },
{ id: "deepseek-ai/DeepSeek-V3", name: "DeepSeek V3" },
{ id: "Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B" },
],
sambanova: [
{ id: "Meta-Llama-3.1-405B-Instruct", name: "Llama 3.1 405B" },
{ id: "Meta-Llama-3.1-70B-Instruct", name: "Llama 3.1 70B" },
{ id: "Meta-Llama-3.1-8B-Instruct", name: "Llama 3.1 8B" },
],
nscale: [
{ id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B" },
{ id: "Qwen/Qwen2.5-Coder-32B-Instruct", name: "Qwen 2.5 Coder 32B" },
],
baseten: [
{ id: "deepseek-ai/DeepSeek-R1", name: "DeepSeek R1" },
{ id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B" },
],
publicai: [
{ id: "auto", name: "Auto (Community)" },
],
"nous-research": [
{ id: "Hermes-4-405B", name: "Hermes 4 405B" },
{ id: "Hermes-4-70B", name: "Hermes 4 70B" },
],
glhf: [
{ id: "hf:meta-llama/Meta-Llama-3.1-405B-Instruct", name: "Llama 3.1 405B" },
{ id: "hf:meta-llama/Meta-Llama-3.1-70B-Instruct", name: "Llama 3.1 70B" },
{ id: "hf:Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B" },
],
deepgram: [
{ id: "nova-3", name: "Nova 3", type: "stt", params: ["language"] },
{ id: "nova-2", name: "Nova 2", type: "stt", params: ["language"] },
{ id: "whisper-large", name: "Whisper Large", type: "stt", params: ["language"] },
],
assemblyai: [
{ id: "universal-3-pro", name: "Universal 3 Pro", type: "stt", params: ["language"] },
{ id: "universal-2", name: "Universal 2", type: "stt", params: ["language"] },
],
"fal-ai": [
{ id: "fal-ai/flux/schnell", name: "FLUX Schnell", type: "image", params: ["n", "size"] },
{ id: "fal-ai/flux/dev", name: "FLUX Dev", type: "image", params: ["n", "size"] },
{ id: "fal-ai/flux-pro/v1.1", name: "FLUX Pro v1.1", type: "image", params: ["n", "size"] },
{ id: "fal-ai/flux-pro/v1.1-ultra", name: "FLUX Pro v1.1 Ultra", type: "image", params: ["n", "size"] },
{ id: "fal-ai/recraft-v3", name: "Recraft V3", type: "image", params: ["n", "size", "style"] },
{ id: "fal-ai/ideogram/v2", name: "Ideogram V2", type: "image", params: ["n", "size", "style"] },
{ id: "fal-ai/stable-diffusion-v35-large", name: "SD 3.5 Large", type: "image", params: ["n", "size"] },
],
"stability-ai": [
{ id: "stable-image-ultra", name: "Stable Image Ultra", type: "image", params: ["size"] },
{ id: "stable-image-core", name: "Stable Image Core", type: "image", params: ["size", "style"] },
{ id: "sd3.5-large", name: "Stable Diffusion 3.5 Large", type: "image", params: ["size"] },
{ id: "sd3.5-large-turbo", name: "Stable Diffusion 3.5 Large Turbo", type: "image", params: ["size"] },
{ id: "sd3.5-medium", name: "Stable Diffusion 3.5 Medium", type: "image", params: ["size"] },
],
"black-forest-labs": [
{ id: "flux-pro-1.1", name: "FLUX Pro 1.1", type: "image", params: ["n", "size"] },
{ id: "flux-pro-1.1-ultra", name: "FLUX Pro 1.1 Ultra", type: "image", params: ["size"] },
{ id: "flux-pro", name: "FLUX Pro", type: "image", params: ["n", "size"] },
{ id: "flux-dev", name: "FLUX Dev", type: "image", params: ["n", "size"] },
{ id: "flux-kontext-pro", name: "FLUX Kontext Pro (Edit)", type: "image", params: ["size"], capabilities: ["edit"] },
{ id: "flux-kontext-max", name: "FLUX Kontext Max (Edit)", type: "image", params: ["size"], capabilities: ["edit"] },
],
recraft: [
{ id: "recraftv3", name: "Recraft V3", type: "image", params: ["n", "size", "style"] },
{ id: "recraftv2", name: "Recraft V2", type: "image", params: ["n", "size", "style"] },
],
runwayml: [
{ id: "gen4_image", name: "Gen-4 Image", type: "image", params: ["size"] },
{ id: "gen4_image_turbo", name: "Gen-4 Image Turbo", type: "image", params: ["size"] },
{ id: "gen4_turbo", name: "Gen-4 Turbo", type: "video", params: [] },
{ id: "gen3a_turbo", name: "Gen-3 Alpha Turbo", type: "video", params: [] },
],
};
// Helper functions // Helper functions
export function getProviderModels(aliasOrId) { export function getProviderModels(aliasOrId) {
@@ -868,15 +35,14 @@ export function findModelName(aliasOrId, modelId) {
export function getModelTargetFormat(aliasOrId, modelId) { export function getModelTargetFormat(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId]; const models = PROVIDER_MODELS[aliasOrId];
if (!models) return null; if (!models) return null;
const found = models.find(m => m.id === modelId); return modelTargetFormat(models.find(m => m.id === modelId));
return found?.targetFormat || null;
} }
export function getModelType(aliasOrId, modelId) { export function getModelType(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId]; const models = PROVIDER_MODELS[aliasOrId];
if (!models) return null; if (!models) return null;
const found = models.find(m => m.id === modelId); const found = models.find(m => m.id === modelId);
return found?.type || null; return found?.kind || found?.type || null;
} }
export function getModelUpstreamId(aliasOrId, modelId) { export function getModelUpstreamId(aliasOrId, modelId) {
@@ -891,30 +57,14 @@ export function getModelUpstreamId(aliasOrId, modelId) {
export function getModelQuotaFamily(aliasOrId, modelId) { export function getModelQuotaFamily(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId]; const models = PROVIDER_MODELS[aliasOrId];
const found = models?.find(m => m.id === modelId); return modelQuotaFamily(models?.find(m => m.id === modelId));
return found?.quotaFamily || "normal";
} }
// OAuth providers that use short aliases (everything else: alias = id) // OAuth short aliases — derived from registry `alias` (single source). everything else: alias = id.
const OAUTH_ALIASES = { // vertex/vertex-partner keep alias=id (kept via the `|| id` fallback in consumers).
claude: "cc", export const OAUTH_ALIASES = Object.fromEntries(
codex: "cx", REGISTRY.filter(r => r.alias && r.alias !== r.id).map(r => [r.id, r.alias])
"gemini-cli": "gc", );
qwen: "qw",
iflow: "if",
antigravity: "ag",
github: "gh",
kiro: "kr",
cursor: "cu",
"kimi-coding": "kmc",
kilocode: "kc",
cline: "cl",
opencode: "oc",
qoder: "qd",
"mimo-free": "mmf",
vertex: "vertex",
"vertex-partner": "vertex-partner",
};
// Derived from PROVIDERS — no need to maintain manually // Derived from PROVIDERS — no need to maintain manually
export const PROVIDER_ID_TO_ALIAS = Object.fromEntries( export const PROVIDER_ID_TO_ALIAS = Object.fromEntries(
@@ -929,6 +79,5 @@ export function getModelsByProviderId(providerId) {
// Get strip list for a model entry (explicit opt-in only) // Get strip list for a model entry (explicit opt-in only)
// Returns array of content types to strip, e.g. ["image", "audio"] // Returns array of content types to strip, e.g. ["image", "audio"]
export function getModelStrip(alias, modelId) { export function getModelStrip(alias, modelId) {
const entry = PROVIDER_MODELS[alias]?.find(m => m.id === modelId); return modelStrip(PROVIDER_MODELS[alias]?.find(m => m.id === modelId));
return entry?.strip || [];
} }

View File

@@ -1,461 +1,6 @@
import { platform, arch } from "os"; // Barrel: PROVIDERS now built from providers/registry (transport co-located with models)
import { PROVIDERS } from "../providers/index.js";
// === OS/Arch helpers === export { PROVIDERS, PROVIDER_OAUTH } from "../providers/index.js";
function mapStainlessOs() {
switch (platform()) {
case "darwin": return "MacOS";
case "win32": return "Windows";
case "linux": return "Linux";
case "freebsd": return "FreeBSD";
default: return `Other::${platform()}`;
}
}
function mapStainlessArch() {
switch (arch()) {
case "x64": return "x64";
case "arm64": return "arm64";
case "ia32": return "x86";
default: return `other::${arch()}`;
}
}
// Shared Claude-compatible API headers (reused across claude-format providers)
const CLAUDE_API_HEADERS = {
"Anthropic-Version": "2023-06-01",
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14"
};
// Full Claude CLI fingerprint — required by providers that gate on client identity (e.g. agentrouter)
const CLAUDE_CLI_SPOOF_HEADERS = {
"Anthropic-Version": "2023-06-01",
"Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28",
"Anthropic-Dangerous-Direct-Browser-Access": "true",
"User-Agent": "claude-cli/2.1.92 (external, sdk-cli)",
"X-App": "cli",
"X-Stainless-Helper-Method": "stream",
"X-Stainless-Retry-Count": "0",
"X-Stainless-Runtime-Version": "v24.14.0",
"X-Stainless-Package-Version": "0.80.0",
"X-Stainless-Runtime": "node",
"X-Stainless-Lang": "js",
"X-Stainless-Arch": mapStainlessArch(),
"X-Stainless-Os": mapStainlessOs(),
"X-Stainless-Timeout": "600"
};
// Shared baseUrls
const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/v1/messages";
export const PROVIDERS = {
claude: {
baseUrl: "https://api.anthropic.com/v1/messages",
format: "claude",
headers: { ...CLAUDE_CLI_SPOOF_HEADERS },
clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
tokenUrl: "https://api.anthropic.com/v1/oauth/token"
},
gemini: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta/models",
format: "gemini",
clientId: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
clientSecret: "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"
},
"gemini-cli": {
baseUrl: "https://cloudcode-pa.googleapis.com/v1internal",
format: "gemini-cli",
clientId: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
clientSecret: "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"
},
codex: {
baseUrl: "https://chatgpt.com/backend-api/codex/responses",
format: "openai-responses",
headers: {
"originator": "codex_cli_rs",
"User-Agent": "codex_cli_rs/0.136.0"
},
clientId: "app_EMoamEEZ73f0CkXaXp7hrann",
tokenUrl: "https://auth.openai.com/oauth/token"
},
qwen: {
baseUrl: "https://portal.qwen.ai/v1/chat/completions",
format: "openai",
clientId: "f0304373b74a44d2b584a3fb70ca9e56",
tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token",
authUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code"
},
iflow: {
baseUrl: "https://apis.iflow.cn/v1/chat/completions",
format: "openai",
headers: { "User-Agent": "iFlow-Cli" },
clientId: "10009311001",
clientSecret: "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW",
tokenUrl: "https://iflow.cn/oauth/token",
authUrl: "https://iflow.cn/oauth"
},
qoder: {
// The qoder executor builds the full URL itself (it has to append
// ?Encode=1 + sigPath query params and bypass any provider-level URL
// rewriting). baseUrl is kept for compatibility with introspection
// helpers but the executor ignores it.
baseUrl: "https://api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation",
format: "openai",
headers: {},
// Reasoning models think long before first byte; raise both timeouts.
timeoutMs: 120000,
stallTimeoutMs: 120000,
},
antigravity: {
baseUrls: [
"https://daily-cloudcode-pa.googleapis.com",
"https://daily-cloudcode-pa.sandbox.googleapis.com",
],
format: "antigravity",
headers: { "User-Agent": `antigravity/1.107.0 ${platform()}/${arch()}` },
clientId: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
clientSecret: "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"
},
openrouter: {
baseUrl: "https://openrouter.ai/api/v1/chat/completions",
format: "openai",
headers: {
"HTTP-Referer": "https://endpoint-proxy.local",
"X-Title": "Endpoint Proxy"
}
},
openai: {
baseUrl: "https://api.openai.com/v1/chat/completions",
format: "openai"
},
"vercel-ai-gateway": {
baseUrl: "https://ai-gateway.vercel.sh/v1/chat/completions",
format: "openai",
retry: { 429: 2 }
},
glm: {
baseUrl: "https://api.z.ai/api/anthropic/v1/messages",
format: "claude",
headers: { ...CLAUDE_API_HEADERS }
},
"glm-cn": {
baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions",
format: "openai",
headers: {}
},
kimi: {
baseUrl: KIMI_CODING_BASE_URL,
format: "claude",
headers: { ...CLAUDE_API_HEADERS }
},
minimax: {
baseUrl: "https://api.minimax.io/anthropic/v1/messages",
format: "claude",
headers: { ...CLAUDE_API_HEADERS }
},
"minimax-cn": {
baseUrl: "https://api.minimaxi.com/anthropic/v1/messages",
format: "claude",
headers: { ...CLAUDE_API_HEADERS }
},
alicode: {
baseUrl: "https://coding.dashscope.aliyuncs.com/v1/chat/completions",
format: "openai",
headers: {}
},
"alicode-intl": {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions",
format: "openai",
headers: {}
},
"volcengine-ark": {
baseUrl: "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions",
format: "openai",
headers: {}
},
byteplus: {
baseUrl: "https://ark.ap-southeast.bytepluses.com/api/coding/v3/chat/completions",
format: "openai",
headers: {}
},
github: {
baseUrl: "https://api.githubcopilot.com/chat/completions",
responsesUrl: "https://api.githubcopilot.com/responses",
format: "openai",
headers: {
"copilot-integration-id": "vscode-chat",
"editor-version": "vscode/1.110.0",
"editor-plugin-version": "copilot-chat/0.38.0",
"user-agent": "GitHubCopilotChat/0.38.0",
"openai-intent": "conversation-panel",
"x-github-api-version": "2025-04-01",
"x-vscode-user-agent-library-version": "electron-fetch",
"X-Initiator": "user",
"Accept": "application/json",
"Content-Type": "application/json"
},
clientId: "Iv1.b507a08c87ecfe98"
},
kiro: {
// All three hosts resolve to the same regional CodeWhisperer streaming service
// (GenerateAssistantResponse). They are alternate DNS surfaces, NOT separate quota
// buckets — AWS throttles per authenticated identity (token + profileArn), not per
// hostname. Listing them enables edge-level failover (5xx / connect timeout / a
// degraded surface); it does NOT multiply 429 headroom. To actually spread 429 load,
// add multiple Kiro accounts — account rotation in sse/handlers/chat.js handles that.
// Order: newest Kiro IDE endpoint first, legacy AWS domains as fallback.
baseUrl: "https://runtime.us-east-1.kiro.dev/generateAssistantResponse",
baseUrls: [
"https://runtime.us-east-1.kiro.dev/generateAssistantResponse",
"https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse",
"https://q.us-east-1.amazonaws.com/generateAssistantResponse",
],
format: "kiro",
// 429 = identity-level throttle; retrying the same identity only spams AWS.
// Rotate across the 3 host surfaces once each (shouldRetry) without per-host retries.
retry: { 429: 0 },
headers: {
"Content-Type": "application/json",
"Accept": "application/vnd.amazon.eventstream",
"X-Amz-Target": "AmazonCodeWhispererStreamingService.GenerateAssistantResponse",
"User-Agent": "AWS-SDK-JS/3.0.0 kiro-ide/1.0.0",
"X-Amz-User-Agent": "aws-sdk-js/3.0.0 kiro-ide/1.0.0"
},
tokenUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken",
authUrl: "https://prod.us-east-1.auth.desktop.kiro.dev"
},
cursor: {
baseUrl: "https://api2.cursor.sh",
chatPath: "/aiserver.v1.ChatService/StreamUnifiedChatWithTools",
format: "cursor",
headers: {
"connect-accept-encoding": "gzip",
"connect-protocol-version": "1",
"Content-Type": "application/connect+proto",
"User-Agent": "connect-es/1.6.1"
},
clientVersion: "3.1.0"
},
"kimi-coding": {
baseUrl: KIMI_CODING_BASE_URL,
format: "claude",
headers: { ...CLAUDE_API_HEADERS },
clientId: "17e5f671-d194-4dfb-9706-5516cb48c098",
tokenUrl: "https://auth.kimi.com/api/oauth/token",
refreshUrl: "https://auth.kimi.com/api/oauth/token"
},
kilocode: {
baseUrl: "https://api.kilo.ai/api/openrouter/chat/completions",
format: "openai",
headers: {}
},
opencode: {
baseUrl: "http://localhost:4096/v1/chat/completions",
format: "openai",
headers: {}
},
cline: {
baseUrl: "https://api.cline.bot/api/v1/chat/completions",
format: "openai",
headers: {
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline"
},
tokenUrl: "https://api.cline.bot/api/v1/auth/token",
refreshUrl: "https://api.cline.bot/api/v1/auth/refresh"
},
nvidia: {
baseUrl: "https://integrate.api.nvidia.com/v1/chat/completions",
format: "openai"
},
anthropic: {
baseUrl: "https://api.anthropic.com/v1/messages",
format: "claude",
headers: { ...CLAUDE_API_HEADERS }
},
deepseek: {
baseUrl: "https://api.deepseek.com/chat/completions",
format: "openai"
},
commandcode: {
baseUrl: "https://api.commandcode.ai/alpha/generate",
format: "commandcode",
headers: {
"x-command-code-version": "0.25.7",
"x-cli-environment": "cli"
}
},
groq: {
baseUrl: "https://api.groq.com/openai/v1/chat/completions",
format: "openai"
},
xai: {
baseUrl: "https://api.x.ai/v1/chat/completions",
responsesUrl: "https://api.x.ai/v1/responses",
format: "openai",
clientId: "b1a00492-073a-47ea-816f-4c329264a828",
tokenUrl: "https://auth.x.ai/oauth2/token",
refreshUrl: "https://auth.x.ai/oauth2/token"
},
mistral: {
baseUrl: "https://api.mistral.ai/v1/chat/completions",
format: "openai"
},
perplexity: {
baseUrl: "https://api.perplexity.ai/chat/completions",
format: "openai"
},
together: {
baseUrl: "https://api.together.xyz/v1/chat/completions",
format: "openai"
},
fireworks: {
baseUrl: "https://api.fireworks.ai/inference/v1/chat/completions",
format: "openai"
},
cerebras: {
baseUrl: "https://api.cerebras.ai/v1/chat/completions",
format: "openai"
},
cohere: {
baseUrl: "https://api.cohere.ai/v1/chat/completions",
format: "openai"
},
nebius: {
baseUrl: "https://api.studio.nebius.ai/v1/chat/completions",
format: "openai"
},
siliconflow: {
baseUrl: "https://api.siliconflow.com/v1/chat/completions",
format: "openai"
},
hyperbolic: {
baseUrl: "https://api.hyperbolic.xyz/v1/chat/completions",
format: "openai"
},
deepgram: {
baseUrl: "https://api.deepgram.com/v1/listen",
format: "openai"
},
assemblyai: {
baseUrl: "https://api.assemblyai.com/v1/audio/transcriptions",
format: "openai"
},
nanobanana: {
baseUrl: "https://api.nanobananaapi.ai/v1/chat/completions",
format: "openai"
},
chutes: {
baseUrl: "https://llm.chutes.ai/v1/chat/completions",
format: "openai"
},
ollama: {
baseUrl: "https://ollama.com/api/chat",
format: "ollama"
},
"ollama-local": {
baseUrl: "http://localhost:11434/api/chat",
format: "ollama"
},
// Vertex AI - Gemini models via Service Account JSON
// baseUrl is not used; VertexExecutor.buildUrl() constructs it dynamically
vertex: {
baseUrl: "https://aiplatform.googleapis.com",
format: "vertex"
},
// Vertex AI - Partner models (Claude, Llama, Mistral, GLM) via SA JSON
// Uses OpenAI-compatible global endpoint (or rawPredict for Anthropic)
"vertex-partner": {
baseUrl: "https://aiplatform.googleapis.com",
format: "openai"
},
// GitLab Duo - OpenAI-compatible chat endpoint
gitlab: {
baseUrl: "https://gitlab.com/api/v4/chat/completions",
format: "openai",
},
// CodeBuddy (Tencent) - uses device_code polling auth, no chat completions baseUrl needed
codebuddy: {
baseUrl: "https://copilot.tencent.com/v1/chat/completions",
format: "openai",
},
opencode: {
baseUrl: "https://opencode.ai",
format: "openai",
headers: { "x-opencode-client": "desktop" },
noAuth: true
},
"opencode-go": {
baseUrl: "https://opencode.ai/zen/go/v1/chat/completions",
format: "openai",
headers: {}
},
"grok-web": {
baseUrl: "https://grok.com/rest/app-chat/conversations/new",
format: "grok-web",
authType: "cookie"
},
"perplexity-web": {
baseUrl: "https://www.perplexity.ai/rest/sse/perplexity_ask",
format: "perplexity-web",
authType: "cookie"
},
azure: {
baseUrl: "",
format: "openai",
headers: {}
},
// Cloudflare Workers AI - {accountId} resolved from credentials.providerSpecificData.accountId
"cloudflare-ai": {
baseUrl: "https://api.cloudflare.com/client/v4/accounts/{accountId}/ai/v1/chat/completions",
format: "openai"
},
"xiaomi-mimo": {
baseUrl: "https://api.xiaomimimo.com/v1/chat/completions",
format: "openai"
},
"mimo-free": { baseUrl: "https://api.xiaomimimo.com/api/free-ai/openai/chat", format: "openai", noAuth: true },
mmf: { baseUrl: "https://api.xiaomimimo.com/api/free-ai/openai/chat", format: "openai", noAuth: true },
"xiaomi-tokenplan": {
baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1/chat/completions",
format: "openai"
},
// Region map for Xiaomi MiMo Token Plan (keys are cluster-specific)
// Used by resolveXiaomiTokenplanBaseUrl below
// === Free-tier providers (synced from OmniRoute) ===
// Claude-format with Claude CLI header spoofing (auth: x-api-key)
agentrouter: { baseUrl: "https://agentrouter.org/v1/messages", format: "claude", headers: { ...CLAUDE_CLI_SPOOF_HEADERS } },
// OpenAI-compatible (auth: bearer)
aimlapi: { baseUrl: "https://api.aimlapi.com/v1/chat/completions", format: "openai" },
novita: { baseUrl: "https://api.novita.ai/v3/openai/chat/completions", format: "openai" },
modal: { baseUrl: "https://api.modal.com/v1/chat/completions", format: "openai" },
reka: { baseUrl: "https://api.reka.ai/v1/chat/completions", format: "openai" },
nlpcloud: { baseUrl: "https://api.nlpcloud.io/v1/gpu/chatbot", format: "openai" },
bazaarlink: { baseUrl: "https://bazaarlink.ai/api/v1/chat/completions", format: "openai" },
completions: { baseUrl: "https://completions.me/api/v1/chat/completions", format: "openai" },
// enally uses X-API-Key header (not bearer); handled in validate route
enally: { baseUrl: "https://ai.enally.in/v1/chat/completions", format: "openai", authHeader: "x-api-key" },
freetheai: { baseUrl: "https://api.freetheai.xyz/v1/chat/completions", format: "openai" },
llm7: { baseUrl: "https://api.llm7.io/v1/chat/completions", format: "openai" },
lepton: { baseUrl: "https://api.lepton.ai/api/v1/chat/completions", format: "openai" },
kluster: { baseUrl: "https://api.kluster.ai/v1/chat/completions", format: "openai" },
ai21: { baseUrl: "https://api.ai21.com/studio/v1/chat/completions", format: "openai" },
"inference-net": { baseUrl: "https://api.inference.net/v1/chat/completions", format: "openai" },
predibase: { baseUrl: "https://serving.app.predibase.com/v1/chat/completions", format: "openai" },
bytez: { baseUrl: "https://api.bytez.com/models/v2", format: "openai" },
morph: { baseUrl: "https://api.morphllm.com/v1/chat/completions", format: "openai" },
longcat: { baseUrl: "https://api.longcat.chat/openai/v1/chat/completions", format: "openai" },
puter: { baseUrl: "https://api.puter.com/puterai/openai/v1/chat/completions", format: "openai" },
uncloseai: { baseUrl: "https://hermes.ai.unturf.com/v1/chat/completions", format: "openai", noAuth: true },
scaleway: { baseUrl: "https://api.scaleway.ai/v1/chat/completions", format: "openai" },
deepinfra: { baseUrl: "https://api.deepinfra.com/v1/openai/chat/completions", format: "openai" },
sambanova: { baseUrl: "https://api.sambanova.ai/v1/chat/completions", format: "openai" },
nscale: { baseUrl: "https://inference.api.nscale.com/v1/chat/completions", format: "openai" },
baseten: { baseUrl: "https://inference.baseten.co/v1/chat/completions", format: "openai" },
publicai: { baseUrl: "https://api.publicai.co/v1/chat/completions", format: "openai" },
"nous-research": { baseUrl: "https://inference-api.nousresearch.com/v1/chat/completions", format: "openai" },
glhf: { baseUrl: "https://glhf.chat/api/openai/v1/chat/completions", format: "openai" },
blackbox: { baseUrl: "https://api.blackbox.ai/chat/completions", format: "openai" },
};
export const OLLAMA_LOCAL_DEFAULT_HOST = "http://localhost:11434"; export const OLLAMA_LOCAL_DEFAULT_HOST = "http://localhost:11434";
@@ -464,12 +9,9 @@ export function resolveOllamaLocalHost(credentials) {
return (raw || OLLAMA_LOCAL_DEFAULT_HOST).replace(/\/$/, ""); return (raw || OLLAMA_LOCAL_DEFAULT_HOST).replace(/\/$/, "");
} }
export const XIAOMI_TOKENPLAN_REGIONS = { // Region URLs single-source from registry xiaomi-tokenplan.transport
sgp: "https://token-plan-sgp.xiaomimimo.com/v1", export const XIAOMI_TOKENPLAN_REGIONS = PROVIDERS["xiaomi-tokenplan"]?.regions || {};
cn: "https://token-plan-cn.xiaomimimo.com/v1", export const XIAOMI_TOKENPLAN_DEFAULT_REGION = PROVIDERS["xiaomi-tokenplan"]?.defaultRegion;
ams: "https://token-plan-ams.xiaomimimo.com/v1"
};
export const XIAOMI_TOKENPLAN_DEFAULT_REGION = "sgp";
export function resolveXiaomiTokenplanBaseUrl(credentials) { export function resolveXiaomiTokenplanBaseUrl(credentials) {
const region = credentials?.providerSpecificData?.region; const region = credentials?.providerSpecificData?.region;

View File

@@ -31,11 +31,26 @@ export const MEMORY_CONFIG = {
proxyDispatchersMaxSize: 20, proxyDispatchersMaxSize: 20,
}; };
// Stream stall timeout: abort if no chunk received within this duration // Parse a positive integer env override, falling back to a default.
export const STREAM_STALL_TIMEOUT_MS = 60 * 1000; function envMs(name, def) {
const raw = process.env[name];
if (raw == null || raw === "") return def;
const n = parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : def;
}
// Inter-chunk stall timeout (once tokens are flowing). Generous headroom so
// slow reasoning models aren't aborted mid-stream. Env: STREAM_STALL_TIMEOUT_MS.
export const STREAM_STALL_TIMEOUT_MS = envMs("STREAM_STALL_TIMEOUT_MS", 360 * 1000);
// Time-to-first-token timeout (prompt prefill). Env: STREAM_FIRST_CHUNK_TIMEOUT_MS.
export const STREAM_FIRST_CHUNK_TIMEOUT_MS = envMs("STREAM_FIRST_CHUNK_TIMEOUT_MS", 200 * 1000);
// Fetch connect timeout: abort if upstream doesn't return response headers within this duration // Fetch connect timeout: abort if upstream doesn't return response headers within this duration
export const FETCH_CONNECT_TIMEOUT_MS = 60 * 1000; export const FETCH_CONNECT_TIMEOUT_MS = envMs("FETCH_CONNECT_TIMEOUT_MS", 60 * 1000);
// Gemini native TTS fetch timeout: abort if Google does not return response headers in time.
export const GEMINI_NATIVE_TTS_FETCH_TIMEOUT_MS = envMs("GEMINI_NATIVE_TTS_FETCH_TIMEOUT_MS", 45 * 1000);
// Default token limits // Default token limits
export const DEFAULT_MAX_TOKENS = 64000; export const DEFAULT_MAX_TOKENS = 64000;

View File

@@ -96,10 +96,12 @@ export const TTS_MODELS_CONFIG = {
}, },
gemini: { gemini: {
models: [ models: [
{ id: "gemini-3.1-flash-tts-preview", name: "Gemini 3.1 Flash TTS", type: "tts" },
{ id: "gemini-2.5-flash-preview-tts", name: "Gemini 2.5 Flash TTS", type: "tts" }, { id: "gemini-2.5-flash-preview-tts", name: "Gemini 2.5 Flash TTS", type: "tts" },
{ id: "gemini-2.5-pro-preview-tts", name: "Gemini 2.5 Pro TTS", type: "tts" }, { id: "gemini-2.5-pro-preview-tts", name: "Gemini 2.5 Pro TTS", type: "tts" },
], ],
voices: { voices: {
"gemini-3.1-flash-tts-preview": GEMINI_VOICES,
"gemini-2.5-flash-preview-tts": GEMINI_VOICES, "gemini-2.5-flash-preview-tts": GEMINI_VOICES,
"gemini-2.5-pro-preview-tts": GEMINI_VOICES, "gemini-2.5-pro-preview-tts": GEMINI_VOICES,
}, },

View File

@@ -3,9 +3,9 @@ import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js"; import { PROVIDERS } from "../config/providers.js";
import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, INTERNAL_REQUEST_HEADER, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX } from "../config/appConstants.js"; import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, INTERNAL_REQUEST_HEADER, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX } from "../config/appConstants.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { deriveSessionId } from "../utils/sessionManager.js"; import { resolveSessionId } from "../utils/sessionManager.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { cleanJSONSchemaForAntigravity } from "../translator/helpers/geminiHelper.js"; import { cleanJSONSchemaForAntigravity } from "../translator/formats/gemini.js";
// Sanitize function name: Gemini requires [a-zA-Z_][a-zA-Z0-9_.:\-]{0,63} // Sanitize function name: Gemini requires [a-zA-Z_][a-zA-Z0-9_.:\-]{0,63}
function sanitizeFunctionName(name) { function sanitizeFunctionName(name) {
@@ -16,8 +16,76 @@ function sanitizeFunctionName(name) {
} }
const MAX_RETRY_AFTER_MS = 10000; const MAX_RETRY_AFTER_MS = 10000;
const ANTIGRAVITY_TRANSIENT_RETRY_MAX_MS = 15000;
const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 16384; const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 16384;
const ANTIGRAVITY_TRANSIENT_ERROR_PATTERNS = [
/high\s+traffic/i,
/agent\s+(execution\s+)?terminated\s+due\s+to\s+error/i,
/capacity/i,
/temporarily\s+unavailable/i,
/timeout/i,
/stream\s+(ended|closed|terminated|interrupted)/i,
/empty\s+response/i,
];
const ANTIGRAVITY_TRANSIENT_STATUSES = new Set([
HTTP_STATUS.SERVER_ERROR,
HTTP_STATUS.BAD_GATEWAY,
HTTP_STATUS.SERVICE_UNAVAILABLE,
HTTP_STATUS.GATEWAY_TIMEOUT,
]);
// Fields Google generateContent rejects (Claude/OpenAI/Qwen thinking fields set at body root by thinkingUnified.js)
const ANTIGRAVITY_REQUEST_BLACKLIST = [
"output_config",
"thinking",
"reasoning_effort",
"reasoning",
"enable_thinking",
"thinking_budget",
"thinkingConfig",
];
// Strip blacklisted fields from an object (used for both body.request and top-level body)
const stripBlacklisted = obj => {
for (const key of ANTIGRAVITY_REQUEST_BLACKLIST) delete obj[key];
};
// Image generation model name patterns
const IMAGE_MODEL_PATTERNS = [
/image/i,
/imagen/i,
/image-generation/i,
];
// Detect if a model is an image generation model
function isImageModel(model) {
if (!model) return false;
return IMAGE_MODEL_PATTERNS.some(p => p.test(model));
}
// Parse aspect ratio / resolution from model name suffixes
// e.g. "gemini-3.1-flash-image-16x9" -> { aspectRatio: "16:9" }
// e.g. "gemini-3.1-flash-image-1024x768" -> { aspectRatio: "4:3" }
function parseImageConfig(model) {
const config = { aspectRatio: "1:1" };
const resMatch = model.match(/(\d+)x(\d+)$/);
if (resMatch) {
const w = parseInt(resMatch[1]);
const h = parseInt(resMatch[2]);
if (w <= 16 && h <= 16) {
config.aspectRatio = `${w}:${h}`;
} else {
// Resolution like 1024x768 — derive aspect ratio
const gcd = (a, b) => b ? gcd(b, a % b) : a;
const d = gcd(w, h);
config.aspectRatio = `${w/d}:${h/d}`;
}
}
return config;
}
export class AntigravityExecutor extends BaseExecutor { export class AntigravityExecutor extends BaseExecutor {
constructor() { constructor() {
super("antigravity", PROVIDERS.antigravity); super("antigravity", PROVIDERS.antigravity);
@@ -26,17 +94,22 @@ export class AntigravityExecutor extends BaseExecutor {
buildUrl(model, stream, urlIndex = 0) { buildUrl(model, stream, urlIndex = 0) {
const baseUrls = this.getBaseUrls(); const baseUrls = this.getBaseUrls();
const baseUrl = baseUrls[urlIndex] || baseUrls[0]; const baseUrl = baseUrls[urlIndex] || baseUrls[0];
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent"; // Image generation MUST use non-streaming generateContent
const forceNonStream = isImageModel(model);
const action = (stream && !forceNonStream) ? "streamGenerateContent?alt=sse" : "generateContent";
return `${baseUrl}/v1internal:${action}`; return `${baseUrl}/v1internal:${action}`;
} }
// sessionId comes from transformRequest output; base.execute runs transformRequest before
// buildHeaders, so we read it from instance state cached there (fallback: explicit arg).
buildHeaders(credentials, stream = true, sessionId = null) { buildHeaders(credentials, stream = true, sessionId = null) {
const sid = sessionId || this._lastSessionId;
return { return {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `Bearer ${credentials.accessToken}`, "Authorization": `Bearer ${credentials.accessToken}`,
"User-Agent": this.config.headers?.["User-Agent"] || ANTIGRAVITY_HEADERS["User-Agent"], "User-Agent": this.config.headers?.["User-Agent"] || ANTIGRAVITY_HEADERS["User-Agent"],
[INTERNAL_REQUEST_HEADER.name]: INTERNAL_REQUEST_HEADER.value, [INTERNAL_REQUEST_HEADER.name]: INTERNAL_REQUEST_HEADER.value,
...(sessionId && { "X-Machine-Session-Id": sessionId }), ...(sid && { "X-Machine-Session-Id": sid }),
"Accept": stream ? "text/event-stream" : "application/json" "Accept": stream ? "text/event-stream" : "application/json"
}; };
} }
@@ -44,6 +117,53 @@ 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();
// ─── Image generation: completely different request structure ───
if (isImageModel(model)) {
const imageConfig = parseImageConfig(model);
// Strip model name suffixes for the actual API model name
const cleanModel = model.replace(/-(\d+)x(\d+)$/, "");
// Build simplified contents — text-only, merge all user messages
const contents = [];
const srcContents = body.request?.contents || body.contents || [];
for (const c of srcContents) {
const textParts = (c.parts || []).filter(p => p.text !== undefined).map(p => ({ text: p.text }));
if (textParts.length > 0) {
contents.push({ role: c.role || "user", parts: textParts });
}
}
const sessionId = resolveSessionId({
headers: credentials?.rawHeaders,
body,
connectionId: credentials?.email || credentials?.connectionId,
scope: "antigravity",
});
this._lastSessionId = sessionId;
return {
project: projectId,
model: cleanModel,
userAgent: "antigravity",
requestType: "image_gen",
requestId: `agent-${crypto.randomUUID()}`,
request: {
contents,
generationConfig: {
temperature: 1.0,
topP: 0.95,
topK: 40,
maxOutputTokens: 8192,
imageConfig,
},
sessionId,
// No tools, no systemInstruction, no safetySettings for image gen
},
};
}
// ─── Standard (non-image) request ───
// Fix contents for Claude models via Antigravity // Fix contents for Claude models via Antigravity
const contents = body.request?.contents?.map(c => { const contents = body.request?.contents?.map(c => {
let role = c.role; let role = c.role;
@@ -68,19 +188,28 @@ export class AntigravityExecutor extends BaseExecutor {
if (tools && tools.length > 0) { if (tools && tools.length > 0) {
// Merge all groups into a single functionDeclarations group (Gemini expects 1 group) // Merge all groups into a single functionDeclarations group (Gemini expects 1 group)
const allDeclarations = tools.flatMap(group => const seenToolNames = new Set();
(group.functionDeclarations || []).map(fn => ({ const allDeclarations = [];
...fn, for (const group of tools) {
name: sanitizeFunctionName(fn.name), for (const fn of group.functionDeclarations || []) {
parameters: fn.parameters const name = sanitizeFunctionName(fn.name);
? cleanJSONSchemaForAntigravity(structuredClone(fn.parameters)) if (seenToolNames.has(name)) continue;
: { type: "object", properties: { reason: { type: "string", description: "Brief explanation" } }, required: ["reason"] } seenToolNames.add(name);
})) allDeclarations.push({
); ...fn,
name,
parameters: fn.parameters
? cleanJSONSchemaForAntigravity(structuredClone(fn.parameters))
: { type: "object", properties: { reason: { type: "string", description: "Brief explanation" } }, required: ["reason"] }
});
}
}
tools = allDeclarations.length > 0 ? [{ functionDeclarations: allDeclarations }] : []; tools = allDeclarations.length > 0 ? [{ functionDeclarations: allDeclarations }] : [];
} }
// Strip tools/toolConfig (handled separately) and blacklisted fields that Google rejects
const { tools: _originalTools, toolConfig: _originalToolConfig, ...requestWithoutTools } = body.request || {}; const { tools: _originalTools, toolConfig: _originalToolConfig, ...requestWithoutTools } = body.request || {};
stripBlacklisted(requestWithoutTools);
const generationConfig = { ...(requestWithoutTools.generationConfig || {}) }; const generationConfig = { ...(requestWithoutTools.generationConfig || {}) };
if (generationConfig.maxOutputTokens > MAX_ANTIGRAVITY_OUTPUT_TOKENS) { if (generationConfig.maxOutputTokens > MAX_ANTIGRAVITY_OUTPUT_TOKENS) {
generationConfig.maxOutputTokens = MAX_ANTIGRAVITY_OUTPUT_TOKENS; generationConfig.maxOutputTokens = MAX_ANTIGRAVITY_OUTPUT_TOKENS;
@@ -91,11 +220,16 @@ export class AntigravityExecutor extends BaseExecutor {
generationConfig, generationConfig,
...(contents && { contents }), ...(contents && { contents }),
...(tools && { tools }), ...(tools && { tools }),
sessionId: body.request?.sessionId || deriveSessionId(credentials?.email || credentials?.connectionId), sessionId: body.request?.sessionId || resolveSessionId({ headers: credentials?.rawHeaders, body, connectionId: credentials?.email || credentials?.connectionId, scope: "antigravity" }),
safetySettings: undefined, safetySettings: undefined,
...(tools?.length > 0 && { toolConfig: { functionCallingConfig: { mode: "VALIDATED" } } }) ...(tools?.length > 0 && { toolConfig: { functionCallingConfig: { mode: "VALIDATED" } } })
}; };
// Strip blacklisted thinking fields from top-level body (set by thinkingUnified.js at root, not body.request)
stripBlacklisted(body);
this._lastSessionId = transformedRequest.sessionId; // cached for buildHeaders (base.execute order)
return { return {
...body, ...body,
project: projectId, project: projectId,
@@ -196,98 +330,49 @@ export class AntigravityExecutor extends BaseExecutor {
return totalMs > 0 ? totalMs : null; return totalMs > 0 ? totalMs : null;
} }
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { extractErrorMessage(errorJson, bodyText = "") {
const fallbackCount = this.getFallbackCount(); return [
let lastError = null; errorJson?.error?.message,
let lastStatus = 0; errorJson?.message,
const MAX_AUTO_RETRIES = 3; errorJson?.error,
const MAX_RETRY_AFTER_RETRIES = 3; bodyText,
const retryAttemptsByUrl = {}; // Track retry attempts per URL ].filter(Boolean).map(v => typeof v === "string" ? v : JSON.stringify(v)).join("\n");
const retryAfterAttemptsByUrl = {}; // Track Retry-After retries per URL }
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { isTransientAntigravityError(status, message) {
const url = this.buildUrl(model, stream, urlIndex); if (status === HTTP_STATUS.RATE_LIMITED) return true;
const transformedBody = this.transformRequest(model, body, stream, credentials); if (ANTIGRAVITY_TRANSIENT_STATUSES.has(status)) return true;
const sessionId = transformedBody.request?.sessionId; return ANTIGRAVITY_TRANSIENT_ERROR_PATTERNS.some(pattern => pattern.test(message || ""));
const headers = this.buildHeaders(credentials, stream, sessionId); }
// Initialize retry counters for this URL // Hook called by BaseExecutor.tryRetry: derive delay from Retry-After (header → body),
if (!retryAttemptsByUrl[urlIndex]) { // cap at MAX_RETRY_AFTER_MS, else retry transient Antigravity failures with backoff.
retryAttemptsByUrl[urlIndex] = 0; // Return false to veto (fallback URL / final error).
} async computeRetryDelay(response, attempt) {
if (!retryAfterAttemptsByUrl[urlIndex]) { let bodyText = "";
retryAfterAttemptsByUrl[urlIndex] = 0; let errorJson = null;
} let retryMs = this.parseRetryHeaders(response.headers);
try { try {
const response = await proxyAwareFetch(url, { bodyText = await response.clone().text();
method: "POST", errorJson = bodyText ? JSON.parse(bodyText) : null;
headers, } catch {
body: JSON.stringify(transformedBody), // ignore parse errors → fall through to status/message based retry
signal
}, proxyOptions);
if (response.status === HTTP_STATUS.RATE_LIMITED || response.status === HTTP_STATUS.SERVICE_UNAVAILABLE) {
// Try to get retry time from headers first
let retryMs = this.parseRetryHeaders(response.headers);
// If no retry time in headers, try to parse from error message body
if (!retryMs) {
try {
const errorBody = await response.clone().text();
const errorJson = JSON.parse(errorBody);
const errorMessage = errorJson?.error?.message || errorJson?.message || "";
retryMs = this.parseRetryFromErrorMessage(errorMessage);
} catch (e) {
// Ignore parse errors, will fall back to exponential backoff
}
}
if (retryMs && retryMs <= MAX_RETRY_AFTER_MS && retryAfterAttemptsByUrl[urlIndex] < MAX_RETRY_AFTER_RETRIES) {
retryAfterAttemptsByUrl[urlIndex]++;
log?.debug?.("RETRY", `${response.status} with Retry-After: ${Math.ceil(retryMs / 1000)}s, waiting... (${retryAfterAttemptsByUrl[urlIndex]}/${MAX_RETRY_AFTER_RETRIES})`);
await new Promise(resolve => setTimeout(resolve, retryMs));
urlIndex--;
continue;
}
// Auto retry only for 429 when retryMs is 0 or undefined
if (response.status === HTTP_STATUS.RATE_LIMITED && (!retryMs || retryMs === 0) && retryAttemptsByUrl[urlIndex] < MAX_AUTO_RETRIES) {
retryAttemptsByUrl[urlIndex]++;
// Exponential backoff: 2s, 4s, 8s...
const backoffMs = Math.min(1000 * (2 ** retryAttemptsByUrl[urlIndex]), MAX_RETRY_AFTER_MS);
log?.debug?.("RETRY", `429 auto retry ${retryAttemptsByUrl[urlIndex]}/${MAX_AUTO_RETRIES} after ${backoffMs / 1000}s`);
await new Promise(resolve => setTimeout(resolve, backoffMs));
urlIndex--;
continue;
}
log?.debug?.("RETRY", `${response.status}, Retry-After ${retryMs ? `too long (${Math.ceil(retryMs / 1000)}s)` : 'missing'}, trying fallback`);
lastStatus = response.status;
if (urlIndex + 1 < fallbackCount) {
continue;
}
}
if (this.shouldRetry(response.status, urlIndex)) {
log?.debug?.("RETRY", `${response.status} on ${url}, trying fallback ${urlIndex + 1}`);
lastStatus = response.status;
continue;
}
return { response, url, headers, transformedBody };
} catch (error) {
lastError = error;
if (urlIndex + 1 < fallbackCount) {
log?.debug?.("RETRY", `Error on ${url}, trying fallback ${urlIndex + 1}`);
continue;
}
throw error;
}
} }
throw lastError || new Error(`All ${fallbackCount} URLs failed with status ${lastStatus}`); const errorMessage = this.extractErrorMessage(errorJson, bodyText);
if (!retryMs) {
retryMs = this.parseRetryFromErrorMessage(errorMessage);
}
if (retryMs) return retryMs <= MAX_RETRY_AFTER_MS ? retryMs : false;
if (!this.isTransientAntigravityError(response.status, errorMessage)) return false;
const cap = response.status === HTTP_STATUS.RATE_LIMITED
? MAX_RETRY_AFTER_MS
: ANTIGRAVITY_TRANSIENT_RETRY_MAX_MS;
return Math.min(1000 * (2 ** attempt), cap); // exponential backoff
} }
/** /**

View File

@@ -2,6 +2,7 @@ import { HTTP_STATUS, RETRY_CONFIG, DEFAULT_RETRY_CONFIG, resolveRetryEntry, FET
import { shouldRefreshCredentials } from "../services/oauthCredentialManager.js"; import { shouldRefreshCredentials } from "../services/oauthCredentialManager.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { dbg } from "../utils/debugLog.js"; import { dbg } from "../utils/debugLog.js";
import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.js";
/** /**
* BaseExecutor - Base class for provider executors * BaseExecutor - Base class for provider executors
@@ -27,13 +28,13 @@ export class BaseExecutor {
buildUrl(model, stream, urlIndex = 0, credentials = null) { buildUrl(model, stream, urlIndex = 0, credentials = null) {
if (this.provider?.startsWith?.("openai-compatible-")) { if (this.provider?.startsWith?.("openai-compatible-")) {
const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1"; const baseUrl = credentials?.providerSpecificData?.baseUrl || OPENAI_COMPAT_BASE;
const normalized = baseUrl.replace(/\/$/, ""); const normalized = baseUrl.replace(/\/$/, "");
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions"; const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
return `${normalized}${path}`; return `${normalized}${path}`;
} }
if (this.provider?.startsWith?.("anthropic-compatible-")) { if (this.provider?.startsWith?.("anthropic-compatible-")) {
const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.anthropic.com/v1"; const baseUrl = credentials?.providerSpecificData?.baseUrl || ANTHROPIC_COMPAT_BASE;
const normalized = baseUrl.replace(/\/$/, ""); const normalized = baseUrl.replace(/\/$/, "");
return `${normalized}/messages`; return `${normalized}/messages`;
} }
@@ -55,7 +56,7 @@ export class BaseExecutor {
headers["Authorization"] = `Bearer ${credentials.accessToken}`; headers["Authorization"] = `Bearer ${credentials.accessToken}`;
} }
if (!headers["anthropic-version"]) { if (!headers["anthropic-version"]) {
headers["anthropic-version"] = "2023-06-01"; headers["anthropic-version"] = ANTHROPIC_API_VERSION;
} }
} else { } else {
// Standard Bearer token auth for other providers // Standard Bearer token auth for other providers
@@ -105,12 +106,20 @@ export class BaseExecutor {
const retryConfig = { ...DEFAULT_RETRY_CONFIG, ...this.config.retry }; const retryConfig = { ...DEFAULT_RETRY_CONFIG, ...this.config.retry };
// Schedule retry via retryConfig[statusKey]. Returns true when caller should `urlIndex--; continue` // Schedule retry via retryConfig[statusKey]. Returns true when caller should `urlIndex--; continue`
const tryRetry = async (urlIndex, statusKey, reason) => { // response (optional) lets a subclass hook compute a dynamic delay (e.g. antigravity Retry-After).
const tryRetry = async (urlIndex, statusKey, reason, response = null) => {
const { attempts, delayMs } = resolveRetryEntry(retryConfig[statusKey]); const { attempts, delayMs } = resolveRetryEntry(retryConfig[statusKey]);
if (attempts <= 0 || retryAttemptsByUrl[urlIndex] >= attempts) return false; if (attempts <= 0 || retryAttemptsByUrl[urlIndex] >= attempts) return false;
// Hook: subclass may derive delay from the response (headers/body). null → skip retry, use fallback.
let waitMs = delayMs;
if (response && this.computeRetryDelay) {
const dynamic = await this.computeRetryDelay(response, retryAttemptsByUrl[urlIndex] + 1, delayMs);
if (dynamic === false) return false; // hook vetoes retry (e.g. Retry-After too long)
if (dynamic != null) waitMs = dynamic;
}
retryAttemptsByUrl[urlIndex]++; retryAttemptsByUrl[urlIndex]++;
log?.debug?.("RETRY", `${reason} retry ${retryAttemptsByUrl[urlIndex]}/${attempts} after ${delayMs / 1000}s`); log?.debug?.("RETRY", `${reason} retry ${retryAttemptsByUrl[urlIndex]}/${attempts} after ${waitMs / 1000}s`);
await new Promise(resolve => setTimeout(resolve, delayMs)); await new Promise(resolve => setTimeout(resolve, waitMs));
return true; return true;
}; };
@@ -142,7 +151,7 @@ export class BaseExecutor {
const cl = response.headers?.get?.("content-length") || "?"; const cl = response.headers?.get?.("content-length") || "?";
dbg("FETCH", `${this.provider.toUpperCase()} ← ${response.status} | ttft=${Date.now() - fetchT0}ms | ct=${ct} | cl=${cl}`); dbg("FETCH", `${this.provider.toUpperCase()} ← ${response.status} | ttft=${Date.now() - fetchT0}ms | ct=${ct} | cl=${cl}`);
if (await tryRetry(urlIndex, response.status, `status ${response.status}`)) { urlIndex--; continue; } if (await tryRetry(urlIndex, response.status, `status ${response.status}`, response)) { urlIndex--; continue; }
if (this.shouldRetry(response.status, urlIndex)) { if (this.shouldRetry(response.status, urlIndex)) {
log?.debug?.("RETRY", `${response.status} on ${url}, trying fallback ${urlIndex + 1}`); log?.debug?.("RETRY", `${response.status} on ${url}, trying fallback ${urlIndex + 1}`);

View File

@@ -0,0 +1,40 @@
import { DefaultExecutor } from "./default.js";
/**
* CodeBuddyExecutor — talks to https://copilot.tencent.com/v2/chat/completions
*
* CodeBuddy is OpenAI-compatible but rejects non-stream chat requests
* (HTTP 400, code 11101 "Non-stream chat request is currently not supported").
* The same-format (openai→openai) translator path leaves body.stream as the
* client sent it, so we force it true here — 9router still re-aggregates the
* SSE into a JSON response for non-streaming clients.
*/
export class CodeBuddyExecutor extends DefaultExecutor {
constructor() {
super("codebuddy-cn");
}
transformRequest(model, body, stream, credentials) {
const transformed = super.transformRequest(model, body, stream, credentials);
transformed.stream = true;
// CodeBuddy only surfaces model reasoning when the request carries the CLI's
// OpenAI-style params: reasoning_effort + reasoning_summary:"auto". 9router's
// thinking pipeline sets reasoning_effort only when the client asks, and never
// sets reasoning_summary — so reasoning never shows. Mirror the CLI here.
const eff = transformed.reasoning_effort;
if (eff === "none" || eff === "off") {
delete transformed.reasoning_effort; // gateway has no "none" — just omit
} else if (eff) {
// Client explicitly asked for reasoning — mirror the CLI's reasoning_summary
// so CodeBuddy surfaces the model's reasoning.
transformed.reasoning_summary = "auto";
}
// No reasoning requested: leave both unset. Forcing reasoning_effort:"medium"
// + reasoning_summary on plain requests makes CodeBuddy trip its content
// filter and return an error (#2071).
return transformed;
}
}
export default CodeBuddyExecutor;

View File

@@ -1,4 +1,3 @@
import { createHash } from "crypto";
import { BaseExecutor } from "./base.js"; import { BaseExecutor } from "./base.js";
import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js"; import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js";
import { PROVIDERS } from "../config/providers.js"; import { PROVIDERS } from "../config/providers.js";
@@ -6,34 +5,35 @@ import {
refreshProviderCredentials, refreshProviderCredentials,
shouldRefreshCredentials, shouldRefreshCredentials,
} from "../services/oauthCredentialManager.js"; } from "../services/oauthCredentialManager.js";
import { normalizeResponsesInput } from "../translator/helpers/responsesApiHelper.js"; import { normalizeResponsesInput } from "../translator/formats/responsesApi.js";
import { fetchImageAsBase64 } from "../translator/helpers/imageHelper.js"; import { fetchImageAsBase64 } from "../translator/concerns/image.js";
import { getModelUpstreamId } from "../config/providerModels.js"; import { getModelUpstreamId } from "../config/providerModels.js";
import { getConsistentMachineId } from "../../src/shared/utils/machineId.js";
import { DEFAULT_RETRY_CONFIG, resolveRetryEntry } from "../config/runtimeConfig.js"; import { DEFAULT_RETRY_CONFIG, resolveRetryEntry } from "../config/runtimeConfig.js";
import { dbg } from "../utils/debugLog.js"; import { dbg } from "../utils/debugLog.js";
import { resolveSessionId } from "../utils/sessionManager.js";
// SSE error patterns inside 200-OK body that should trigger retry as if 503 // SSE error patterns inside 200-OK body that should trigger retry as if 503
const CODEX_SSE_OVERLOADED_PATTERNS = ["server_is_overloaded", "service_unavailable_error"]; const CODEX_SSE_OVERLOADED_PATTERNS = ["server_is_overloaded", "service_unavailable_error"];
const CODEX_SSE_PEEK_BYTES = 4096; const CODEX_SSE_PEEK_BYTES = 4096;
// In-memory map: hash(machineId + first assistant content) → { sessionId, lastUsed }
const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour
const assistantSessionMap = new Map();
// Server-generated item id prefixes that Codex /responses cannot resolve when store=false // Server-generated item id prefixes that Codex /responses cannot resolve when store=false
const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/; const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/;
// Hosted tool types that Codex/OpenAI Responses executes server-side // Hosted tool types that Codex/OpenAI Responses executes server-side
const CODEX_HOSTED_TOOL_TYPES = new Set([ const CODEX_HOSTED_TOOL_TYPES = new Set([
"image_generation", "web_search", "web_search_preview", "file_search", "image_generation", "web_search", "web_search_preview", "file_search",
"computer", "computer_use_preview", "code_interpreter", "mcp", "local_shell" "computer", "computer_use_preview", "code_interpreter", "mcp", "local_shell",
"tool_search"
]); ]);
// Responses-native freeform tools carry a name plus format payload and must pass through intact.
const CODEX_PASSTHROUGH_TOOL_TYPES = new Set(["custom"]);
// Allowlist of fields accepted by Codex Responses API — anything else is stripped // Allowlist of fields accepted by Codex Responses API — anything else is stripped
const RESPONSES_API_ALLOWLIST = new Set([ const RESPONSES_API_ALLOWLIST = new Set([
"model", "input", "instructions", "tools", "tool_choice", "stream", "store", "model", "input", "instructions", "tools", "tool_choice", "stream", "store",
"reasoning", "service_tier", "include", "prompt_cache_key", "client_metadata" "reasoning", "service_tier", "include", "prompt_cache_key", "client_metadata",
"text"
]); ]);
// Convert role=system → role=developer in body.input (keeps content in cacheable prefix) // Convert role=system → role=developer in body.input (keeps content in cacheable prefix)
@@ -76,6 +76,7 @@ function normalizeCodexTools(body) {
return true; return true;
} }
if (type !== "function") { if (type !== "function") {
if (CODEX_PASSTHROUGH_TOOL_TYPES.has(type)) return true;
if (!type || tool.function || typeof tool.name === "string") return false; if (!type || tool.function || typeof tool.name === "string") return false;
return CODEX_HOSTED_TOOL_TYPES.has(type); return CODEX_HOSTED_TOOL_TYPES.has(type);
} }
@@ -104,86 +105,17 @@ function normalizeCodexTools(body) {
} }
} }
// Cache machine ID at module level (resolved once) // Resolve prompt-cache session id: client session → assistant-text-hash → workspaceId → connection
let cachedMachineId = null; function resolveCacheSessionId(body, credentials) {
getConsistentMachineId().then(id => { cachedMachineId = id; }); return resolveSessionId({
headers: credentials?.rawHeaders,
function hashContent(text) { body,
return createHash("sha256").update(text).digest("hex").slice(0, 16); connectionId: credentials?.connectionId,
workspaceId: credentials?.providerSpecificData?.workspaceId,
scope: "codex"
});
} }
function generateSessionId() {
return `sess_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 9)}`;
}
// Extract text content from an input item
function extractItemText(item) {
if (!item) return "";
if (typeof item.content === "string") return item.content;
if (Array.isArray(item.content)) {
return item.content.map(c => c.text || c.output || "").filter(Boolean).join("");
}
return "";
}
// Normalize a session id candidate (trim, length cap)
function normalizeSessionId(value) {
if (typeof value !== "string") return null;
const v = value.trim();
if (!v || v.length > 256) return null;
return v;
}
// Resolve prompt-cache session id with priority: body → assistant-text-hash → workspaceId → machineId
function resolveCacheSessionId(body, credentials, machineId) {
// 1. Client-provided session/conversation id (highest priority — stable per conversation)
const fromBody =
normalizeSessionId(body?.prompt_cache_key) ||
normalizeSessionId(body?.session_id) ||
normalizeSessionId(body?.conversation_id);
if (fromBody) return fromBody;
// 2. Hash accumulated assistant text (≥50 chars) — sticky session across turns
if (Array.isArray(body?.input) && body.input.length > 0) {
let text = "";
const MIN_LEN = 50;
const CAP_LEN = 200;
for (const item of body.input) {
if (item?.role !== "assistant") continue;
const t = extractItemText(item);
if (!t) continue;
text += t;
if (text.length >= CAP_LEN) break;
}
if (text.length >= MIN_LEN) {
const hash = hashContent((machineId || "") + text.slice(0, CAP_LEN));
const entry = assistantSessionMap.get(hash);
if (entry) {
entry.lastUsed = Date.now();
return entry.sessionId;
}
const sessionId = generateSessionId();
assistantSessionMap.set(hash, { sessionId, lastUsed: Date.now() });
return sessionId;
}
}
// 3. Account-wide fallback (workspaceId from connection)
const workspaceId = normalizeSessionId(credentials?.providerSpecificData?.workspaceId);
if (workspaceId) return workspaceId;
// 4. Last resort — stable per-machine id
return machineId ? `sess_${hashContent(machineId)}` : generateSessionId();
}
// Cleanup expired entries periodically
setInterval(() => {
const now = Date.now();
for (const [key, entry] of assistantSessionMap) {
if (now - entry.lastUsed > SESSION_TTL_MS) assistantSessionMap.delete(key);
}
}, 10 * 60 * 1000);
/** /**
* Codex Executor - handles OpenAI Codex API (Responses API format) * Codex Executor - handles OpenAI Codex API (Responses API format)
* Automatically injects default instructions if missing * Automatically injects default instructions if missing
@@ -377,7 +309,7 @@ export class CodexExecutor extends BaseExecutor {
this._isCompact = !!body._compact; this._isCompact = !!body._compact;
delete body._compact; delete body._compact;
// Resolve conversation-stable session_id (priority: body → assistant-text → workspace → machine) // Resolve conversation-stable session_id (priority: body → assistant-text → workspace → machine)
this._currentSessionId = resolveCacheSessionId(body, credentials, cachedMachineId); this._currentSessionId = resolveCacheSessionId(body, credentials);
// Convert string input to array format (Codex API requires input as array) // Convert string input to array format (Codex API requires input as array)
const normalized = normalizeResponsesInput(body.input); const normalized = normalizeResponsesInput(body.input);
if (normalized) body.input = normalized; if (normalized) body.input = normalized;

View File

@@ -1,7 +1,8 @@
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { BaseExecutor } from "./base.js"; import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js"; import { PROVIDERS } from "../config/providers.js";
import { convertCommandCodeToOpenAI } from "../translator/response/commandcode-to-openai.js"; import { commandCodeToOpenAIResponse } from "../translator/response/commandcode-to-openai.js";
import { SSE_DONE } from "../utils/sseConstants.js";
/** /**
* CommandCodeExecutor — talks to https://api.commandcode.ai/alpha/generate * CommandCodeExecutor — talks to https://api.commandcode.ai/alpha/generate
@@ -70,15 +71,15 @@ function wrapNdjsonAsOpenAISse(originalResponse, model) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) continue; if (!trimmed) continue;
// Translate AI SDK v5 NDJSON line to one or more OpenAI chunks // Translate AI SDK v5 NDJSON line to one or more OpenAI chunks
emitChunks(convertCommandCodeToOpenAI(trimmed, state), controller); emitChunks(commandCodeToOpenAIResponse(trimmed, state), controller);
} }
}, },
flush(controller) { flush(controller) {
const trimmed = buffer.trim(); const trimmed = buffer.trim();
if (trimmed) { if (trimmed) {
emitChunks(convertCommandCodeToOpenAI(trimmed, state), controller); emitChunks(commandCodeToOpenAIResponse(trimmed, state), controller);
} }
controller.enqueue(encoder.encode("data: [DONE]\n\n")); controller.enqueue(encoder.encode(SSE_DONE));
}, },
}); });

View File

@@ -8,6 +8,8 @@ import {
} from "../utils/cursorProtobuf.js"; } from "../utils/cursorProtobuf.js";
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 { chatChunkSse } 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";
@@ -98,6 +100,32 @@ function decompressPayload(payload, flags) {
return payload; return payload;
} }
// Read one cursor protobuf frame: header + bounds + decompress. Returns status + payload + new offset.
function readCursorFrame(buffer, offset, frameNum, tag) {
if (offset + 5 > buffer.length) {
debugLog(`[CURSOR BUFFER${tag}] Reached end, offset=${offset}, remaining=${buffer.length - offset}`);
return { status: "done" };
}
const flags = buffer[offset];
const length = buffer.readUInt32BE(offset + 1);
debugLog(`[CURSOR BUFFER${tag}] Frame ${frameNum + 1}: flags=0x${flags.toString(16).padStart(2, "0")}, length=${length}`);
if (offset + 5 + length > buffer.length) {
debugLog(`[CURSOR BUFFER${tag}] Incomplete frame, offset=${offset}, length=${length}, buffer.length=${buffer.length}`);
return { status: "done" };
}
let payload = buffer.slice(offset + 5, offset + 5 + length);
const newOffset = offset + 5 + length;
payload = decompressPayload(payload, flags);
if (!payload) {
debugLog(`[CURSOR BUFFER${tag}] Frame ${frameNum + 1}: decompression failed, skipping`);
return { status: "skip", offset: newOffset };
}
return { status: "ok", payload, offset: newOffset };
}
function createErrorResponse(jsonError) { function createErrorResponse(jsonError) {
const errorMsg = jsonError?.error?.details?.[0]?.debug?.details?.title const errorMsg = jsonError?.error?.details?.[0]?.debug?.details?.title
|| jsonError?.error?.details?.[0]?.debug?.details?.detail || jsonError?.error?.details?.[0]?.debug?.details?.detail
@@ -141,7 +169,7 @@ export class CursorExecutor extends BaseExecutor {
transformRequest(model, body, stream, credentials) { transformRequest(model, body, stream, credentials) {
// Messages are already translated by chatCore (claude→openai→cursor) // Messages are already translated by chatCore (claude→openai→cursor)
// Do NOT call buildCursorRequest again — double-translation drops tool_results // Do NOT call openaiToCursorRequest again — double-translation drops tool_results
const messages = body.messages || []; const messages = body.messages || [];
const tools = body.tools || []; const tools = body.tools || [];
const reasoningEffort = body.reasoning_effort || null; const reasoningEffort = body.reasoning_effort || null;
@@ -286,36 +314,12 @@ export class CursorExecutor extends BaseExecutor {
debugLog(`[CURSOR BUFFER] Total length: ${buffer.length} bytes`); debugLog(`[CURSOR BUFFER] Total length: ${buffer.length} bytes`);
while (offset < buffer.length) { while (offset < buffer.length) {
if (offset + 5 > buffer.length) { const frame = readCursorFrame(buffer, offset, frameCount, "");
debugLog( if (frame.status === "done") break;
`[CURSOR BUFFER] Reached end, offset=${offset}, remaining=${buffer.length - offset}` offset = frame.offset;
);
break;
}
const flags = buffer[offset];
const length = buffer.readUInt32BE(offset + 1);
debugLog(
`[CURSOR BUFFER] Frame ${frameCount + 1}: flags=0x${flags.toString(16).padStart(2, "0")}, length=${length}`
);
if (offset + 5 + length > buffer.length) {
debugLog(
`[CURSOR BUFFER] Incomplete frame, offset=${offset}, length=${length}, buffer.length=${buffer.length}`
);
break;
}
let payload = buffer.slice(offset + 5, offset + 5 + length);
offset += 5 + length;
frameCount++; frameCount++;
if (frame.status === "skip") continue;
payload = decompressPayload(payload, flags); const payload = frame.payload;
if (!payload) {
debugLog(`[CURSOR BUFFER] Frame ${frameCount}: decompression failed, skipping`);
continue;
}
// Check for JSON error frames (byte guard: skip toString on non-JSON frames) // Check for JSON error frames (byte guard: skip toString on non-JSON frames)
if (payload.length > 0 && payload[0] === 0x7b) { if (payload.length > 0 && payload[0] === 0x7b) {
@@ -466,36 +470,12 @@ export class CursorExecutor extends BaseExecutor {
debugLog(`[CURSOR BUFFER SSE] Total length: ${buffer.length} bytes`); debugLog(`[CURSOR BUFFER SSE] Total length: ${buffer.length} bytes`);
while (offset < buffer.length) { while (offset < buffer.length) {
if (offset + 5 > buffer.length) { const frame = readCursorFrame(buffer, offset, frameCount, " SSE");
debugLog( if (frame.status === "done") break;
`[CURSOR BUFFER SSE] Reached end, offset=${offset}, remaining=${buffer.length - offset}` offset = frame.offset;
);
break;
}
const flags = buffer[offset];
const length = buffer.readUInt32BE(offset + 1);
debugLog(
`[CURSOR BUFFER SSE] Frame ${frameCount + 1}: flags=0x${flags.toString(16).padStart(2, "0")}, length=${length}`
);
if (offset + 5 + length > buffer.length) {
debugLog(
`[CURSOR BUFFER SSE] Incomplete frame, offset=${offset}, length=${length}, buffer.length=${buffer.length}`
);
break;
}
let payload = buffer.slice(offset + 5, offset + 5 + length);
offset += 5 + length;
frameCount++; frameCount++;
if (frame.status === "skip") continue;
payload = decompressPayload(payload, flags); const payload = frame.payload;
if (!payload) {
debugLog(`[CURSOR BUFFER SSE] Frame ${frameCount}: decompression failed, skipping`);
continue;
}
// Check for JSON error frames (byte-guard: only decode if starts with '{') // Check for JSON error frames (byte-guard: only decode if starts with '{')
if (payload[0] === 0x7b) { if (payload[0] === 0x7b) {
@@ -542,21 +522,7 @@ export class CursorExecutor extends BaseExecutor {
const tc = result.toolCall; const tc = result.toolCall;
if (chunks.length === 0) { if (chunks.length === 0) {
chunks.push( chunks.push(chatChunkSse({ id: responseId, created, model, delta: { role: "assistant", content: "" } }));
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: { role: "assistant", content: "" },
finish_reason: null
}
]
})}\n\n`
);
} }
if (toolCallsMap.has(tc.id)) { if (toolCallsMap.has(tc.id)) {
@@ -569,33 +535,22 @@ export class CursorExecutor extends BaseExecutor {
// Stream the delta arguments // Stream the delta arguments
if (tc.function.arguments) { if (tc.function.arguments) {
emittedToolCallIds.add(tc.id); emittedToolCallIds.add(tc.id);
chunks.push( chunks.push(chatChunkSse({
`data: ${JSON.stringify({ id: responseId, created, model,
id: responseId, delta: {
object: "chat.completion.chunk", tool_calls: [
created,
model,
choices: [
{ {
index: 0, index: existing.index,
delta: { id: tc.id,
tool_calls: [ type: "function",
{ function: {
index: existing.index, name: tc.function.name,
id: tc.id, arguments: tc.function.arguments
type: "function", }
function: {
name: tc.function.name,
arguments: tc.function.arguments
}
}
]
},
finish_reason: null
} }
] ]
})}\n\n` }
); }));
} }
} else { } else {
// New tool call - assign index and add to map // New tool call - assign index and add to map
@@ -606,56 +561,34 @@ export class CursorExecutor extends BaseExecutor {
// Stream initial tool call with name // Stream initial tool call with name
emittedToolCallIds.add(tc.id); emittedToolCallIds.add(tc.id);
chunks.push( chunks.push(chatChunkSse({
`data: ${JSON.stringify({ id: responseId, created, model,
id: responseId, delta: {
object: "chat.completion.chunk", tool_calls: [
created,
model,
choices: [
{ {
index: 0, index: toolCallIndex,
delta: { id: tc.id,
tool_calls: [ type: "function",
{ function: {
index: toolCallIndex, name: tc.function.name,
id: tc.id, arguments: tc.function.arguments
type: "function", }
function: {
name: tc.function.name,
arguments: tc.function.arguments
}
}
]
},
finish_reason: null
} }
] ]
})}\n\n` }
); }));
} }
} }
if (result.text) { if (result.text) {
totalContent += result.text; totalContent += result.text;
chunks.push( chunks.push(chatChunkSse({
`data: ${JSON.stringify({ id: responseId, created, model,
id: responseId, delta:
object: "chat.completion.chunk", chunks.length === 0 && toolCalls.length === 0
created, ? { role: "assistant", content: result.text }
model, : { content: result.text }
choices: [ }));
{
index: 0,
delta:
chunks.length === 0 && toolCalls.length === 0
? { role: "assistant", content: result.text }
: { content: result.text },
finish_reason: null
}
]
})}\n\n`
);
} }
if (isComposerModel(model) && result.thinking) { if (isComposerModel(model) && result.thinking) {
@@ -665,24 +598,13 @@ export class CursorExecutor extends BaseExecutor {
const deltaContent = visibleContent.slice(emittedComposerThinkingContentLength); const deltaContent = visibleContent.slice(emittedComposerThinkingContentLength);
emittedComposerThinkingContentLength = visibleContent.length; emittedComposerThinkingContentLength = visibleContent.length;
totalContent += deltaContent; totalContent += deltaContent;
chunks.push( chunks.push(chatChunkSse({
`data: ${JSON.stringify({ id: responseId, created, model,
id: responseId, delta:
object: "chat.completion.chunk", chunks.length === 0 && toolCalls.length === 0
created, ? { role: "assistant", content: deltaContent }
model, : { content: deltaContent }
choices: [ }));
{
index: 0,
delta:
chunks.length === 0 && toolCalls.length === 0
? { role: "assistant", content: deltaContent }
: { content: deltaContent },
finish_reason: null
}
]
})}\n\n`
);
} }
} }
} }
@@ -708,53 +630,28 @@ export class CursorExecutor extends BaseExecutor {
// Emit SSE chunk for the finalized tool call if not already emitted // Emit SSE chunk for the finalized tool call if not already emitted
if (!emittedToolCallIds.has(tc.id)) { if (!emittedToolCallIds.has(tc.id)) {
chunks.push( chunks.push(chatChunkSse({
`data: ${JSON.stringify({ id: responseId, created, model,
id: responseId, delta: {
object: "chat.completion.chunk", tool_calls: [
created,
model,
choices: [
{ {
index: 0, index: toolCallIndex,
delta: { id: tc.id,
tool_calls: [ type: "function",
{ function: {
index: toolCallIndex, name: tc.function.name,
id: tc.id, arguments: tc.function.arguments
type: "function", }
function: {
name: tc.function.name,
arguments: tc.function.arguments
}
}
]
},
finish_reason: null
} }
] ]
})}\n\n` }
); }));
} }
} }
} }
if (chunks.length === 0 && toolCalls.length === 0) { if (chunks.length === 0 && toolCalls.length === 0) {
chunks.push( chunks.push(chatChunkSse({ id: responseId, created, model, delta: { role: "assistant", content: "" } }));
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: { role: "assistant", content: "" },
finish_reason: null
}
]
})}\n\n`
);
} }
const usage = estimateUsage(body, totalContent.length, FORMATS.OPENAI); const usage = estimateUsage(body, totalContent.length, FORMATS.OPENAI);
@@ -775,15 +672,11 @@ export class CursorExecutor extends BaseExecutor {
usage usage
})}\n\n` })}\n\n`
); );
chunks.push("data: [DONE]\n\n"); chunks.push(SSE_DONE);
return new Response(chunks.join(""), { return new Response(chunks.join(""), {
status: 200, status: 200,
headers: { headers: { ...SSE_HEADERS }
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive"
}
}); });
} }

View File

@@ -1,10 +1,80 @@
import { BaseExecutor } from "./base.js"; import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js"; import { PROVIDERS, PROVIDER_OAUTH } from "../config/providers.js";
import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.js";
import { OAUTH_ENDPOINTS, buildKimiHeaders } from "../config/appConstants.js"; import { OAUTH_ENDPOINTS, buildKimiHeaders } from "../config/appConstants.js";
import { buildClineHeaders } from "../../src/shared/utils/clineAuth.js"; import { buildClineHeaders } from "../shared/clineAuth.js";
import { getCachedClaudeHeaders } from "../utils/claudeHeaderCache.js"; import { getCachedClaudeHeaders } from "../utils/claudeHeaderCache.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
import { stripUnsupportedParams } from "../translator/concerns/paramSupport.js";
// Auth header descriptors — derived from registry transport.auth, fallback to hardcoded defaults.
const BEARER = { combined: true, header: "Authorization", scheme: "bearer" };
const XAPIKEY = { combined: true, header: "x-api-key", scheme: "raw" };
const AUTH_DESCRIPTORS = Object.fromEntries(
Object.entries(PROVIDERS)
.filter(([, t]) => t.auth)
.map(([id, t]) => [id, t.auth])
);
// Apply a token to a header per scheme (matches legacy: combined always sets, even when undefined).
function setAuth(headers, spec, token) {
headers[spec.header] = spec.scheme === "bearer" ? `Bearer ${token}` : token;
}
// Resolve auth onto headers from a descriptor.
function applyAuth(headers, desc, credentials) {
if (desc.combined) {
// combined providers always set the header (legacy behavior, incl. noAuth → "Bearer undefined")
setAuth(headers, desc, credentials.apiKey || credentials.accessToken);
if (desc.anthropicVersion && !headers["anthropic-version"]) headers["anthropic-version"] = ANTHROPIC_API_VERSION;
return;
}
// split apiKey/oauth: set only the matching branch (legacy: anthropic-compatible skips when both absent)
if (credentials.apiKey) setAuth(headers, desc.apiKey, credentials.apiKey);
else if (credentials.accessToken) setAuth(headers, desc.oauth, credentials.accessToken);
if (desc.anthropicVersion && !headers["anthropic-version"]) headers["anthropic-version"] = ANTHROPIC_API_VERSION;
}
// Provider-specific header quirks kept as small hooks (not pure auth).
const HEADER_HOOKS = {
kimiHeaders: (h) => Object.assign(h, buildKimiHeaders()),
clineHeaders: (h, c) => Object.assign(h, buildClineHeaders(c.apiKey || c.accessToken)),
kilocodeOrg: (h, c) => { if (c.providerSpecificData?.orgId) h["X-Kilocode-OrganizationID"] = c.providerSpecificData.orgId; },
claudeOverlay: (h) => {
const cached = getCachedClaudeHeaders();
if (!cached) return;
for (const lcKey of Object.keys(cached)) {
const titleKey = lcKey.replace(/(^|-)([a-z])/g, (_, sep, ch) => sep + ch.toUpperCase());
if (lcKey === "anthropic-beta") {
const staticBetaStr = h[titleKey] || h[lcKey] || "";
const flags = new Set(staticBetaStr.split(",").map(f => f.trim()).filter(Boolean));
for (const f of cached[lcKey].split(",").map(f => f.trim()).filter(Boolean)) flags.add(f);
cached[lcKey] = Array.from(flags).join(",");
}
if (titleKey !== lcKey && h[titleKey] !== undefined) delete h[titleKey];
}
Object.assign(h, cached);
},
};
// Config-driven OAuth refresh grants — derived from registry oauth.refresh.
const REFRESH_GRANTS = Object.fromEntries(
Object.entries(PROVIDER_OAUTH)
.filter(([, o]) => o.refresh)
.map(([id, o]) => {
const tokenUrl = o.tokenUrl;
const encoding = o.refresh.encoding;
const extraParams = o.refresh.scope ? { scope: o.refresh.scope } : {};
return [id, {
encoding,
url: () => tokenUrl,
params: (ex) => id === "gemini"
? { client_id: ex.config.clientId, client_secret: ex.config.clientSecret, ...extraParams }
: { client_id: o.clientId, ...extraParams },
}];
})
);
export class DefaultExecutor extends BaseExecutor { export class DefaultExecutor extends BaseExecutor {
constructor(provider) { constructor(provider) {
@@ -15,9 +85,11 @@ export class DefaultExecutor extends BaseExecutor {
const transformed = this.applyJsonSchemaFallback(body); const transformed = this.applyJsonSchemaFallback(body);
if (transformed && typeof transformed === "object") { if (transformed && typeof transformed === "object") {
if (this.provider === "cerebras" || this.provider === "mistral") { // quirk: some openai-compatible providers reject Anthropic's client_metadata field
if (this.config.quirks?.dropClientMetadata) {
delete transformed.client_metadata; delete transformed.client_metadata;
} }
stripUnsupportedParams(this.provider, model, transformed);
} }
return injectReasoningContent({ provider: this.provider, model, body: transformed }); return injectReasoningContent({ provider: this.provider, model, body: transformed });
@@ -44,120 +116,57 @@ export class DefaultExecutor extends BaseExecutor {
} }
buildUrl(model, stream, urlIndex = 0, credentials = null) { buildUrl(model, stream, urlIndex = 0, credentials = null) {
// Runtime transport (multi-endpoint providers): use the sourceFormat-matched endpoint
const rt = credentials?.runtimeTransport;
if (rt?.baseUrl) {
return rt.urlSuffix ? `${rt.baseUrl}${rt.urlSuffix}` : rt.baseUrl;
}
if (this.provider?.startsWith?.("openai-compatible-")) { if (this.provider?.startsWith?.("openai-compatible-")) {
const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1"; const baseUrl = credentials?.providerSpecificData?.baseUrl || OPENAI_COMPAT_BASE;
const normalized = baseUrl.replace(/\/$/, ""); const normalized = baseUrl.replace(/\/$/, "");
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions"; const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
return `${normalized}${path}`; return `${normalized}${path}`;
} }
if (this.provider?.startsWith?.("anthropic-compatible-")) { if (this.provider?.startsWith?.("anthropic-compatible-")) {
const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.anthropic.com/v1"; const baseUrl = credentials?.providerSpecificData?.baseUrl || ANTHROPIC_COMPAT_BASE;
const normalized = baseUrl.replace(/\/$/, ""); const normalized = baseUrl.replace(/\/$/, "");
return `${normalized}/messages`; return `${normalized}/messages`;
} }
switch (this.provider) { // gemini-format: build :streamGenerateContent / :generateContent path
case "claude": if (this.config.format === "gemini") {
case "glm": return `${this.config.baseUrl}/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
case "kimi":
case "minimax":
case "minimax-cn":
return `${this.config.baseUrl}?beta=true`;
case "kimi-coding":
return `${this.config.baseUrl}?beta=true`;
case "gemini":
return `${this.config.baseUrl}/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
default: {
const url = this.config.baseUrl;
if (url?.includes("{accountId}")) {
const accountId = credentials?.providerSpecificData?.accountId;
if (!accountId) throw new Error(`${this.provider} requires accountId in providerSpecificData`);
return url.replace("{accountId}", accountId);
}
return url;
}
} }
// urlSuffix (e.g. ?beta=true) declared per-provider in registry
if (this.config.urlSuffix) {
return `${this.config.baseUrl}${this.config.urlSuffix}`;
}
const url = this.config.baseUrl;
if (url?.includes("{accountId}")) {
const accountId = credentials?.providerSpecificData?.accountId;
if (!accountId) throw new Error(`${this.provider} requires accountId in providerSpecificData`);
return url.replace("{accountId}", accountId);
}
return url;
}
// Fallback descriptor for providers without an explicit entry in AUTH_DESCRIPTORS.
resolveAuthDescriptor() {
if (this.provider?.startsWith?.("anthropic-compatible-")) {
return { apiKey: { header: "x-api-key", scheme: "raw" }, oauth: { header: "Authorization", scheme: "bearer" }, anthropicVersion: true };
}
if (this.config?.format === "claude") {
return { ...XAPIKEY, anthropicVersion: true };
}
return BEARER;
} }
buildHeaders(credentials, stream = true) { buildHeaders(credentials, stream = true) {
const headers = { "Content-Type": "application/json", ...this.config.headers }; const rt = credentials?.runtimeTransport;
const headers = { "Content-Type": "application/json", ...(rt ? rt.headers : this.config.headers) };
switch (this.provider) { const desc = rt?.auth || AUTH_DESCRIPTORS[this.provider] || this.resolveAuthDescriptor();
case "gemini": // Hooks run BEFORE auth so dynamic overlays (claude cached headers) can't clobber the token.
credentials.apiKey ? headers["x-goog-api-key"] = credentials.apiKey : headers["Authorization"] = `Bearer ${credentials.accessToken}`; for (const hook of desc.hooks || []) HEADER_HOOKS[hook]?.(headers, credentials);
break; applyAuth(headers, desc, credentials);
case "claude": {
// Overlay live cached headers from real Claude Code client over static defaults.
// Static headers (Title-Case) remain as cold-start fallback.
const cached = getCachedClaudeHeaders();
if (cached) {
// Remove Title-Case static keys that conflict with incoming lowercase cached keys
for (const lcKey of Object.keys(cached)) {
// Build the Title-Case equivalent: "anthropic-version" → "Anthropic-Version"
const titleKey = lcKey.replace(/(^|-)([a-z])/g, (_, sep, c) => sep + c.toUpperCase());
// Special handling for Anthropic-Beta to preserve required flags like OAuth
if (lcKey === "anthropic-beta") {
const staticBetaStr = headers[titleKey] || headers[lcKey] || "";
const staticFlags = new Set(staticBetaStr.split(",").map(f => f.trim()).filter(Boolean));
const cachedFlags = new Set(cached[lcKey].split(",").map(f => f.trim()).filter(Boolean));
// Merge all static flags (which contain oauth, thinking, etc) into the cached ones
for (const flag of staticFlags) {
cachedFlags.add(flag);
}
cached[lcKey] = Array.from(cachedFlags).join(",");
}
if (titleKey !== lcKey && headers[titleKey] !== undefined) {
delete headers[titleKey];
}
}
Object.assign(headers, cached);
}
credentials.apiKey
? (headers["x-api-key"] = credentials.apiKey)
: (headers["Authorization"] = `Bearer ${credentials.accessToken}`);
break;
}
case "glm":
case "kimi":
case "minimax":
case "minimax-cn":
case "kimi-coding":
headers["x-api-key"] = credentials.apiKey || credentials.accessToken;
if (this.provider === "kimi-coding") Object.assign(headers, buildKimiHeaders());
break;
default:
if (this.provider?.startsWith?.("anthropic-compatible-")) {
if (credentials.apiKey) {
headers["x-api-key"] = credentials.apiKey;
} else if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
}
if (!headers["anthropic-version"]) {
headers["anthropic-version"] = "2023-06-01";
}
} else if (this.provider === "gitlab") {
// GitLab Duo uses Bearer token (PAT with ai_features scope, or OAuth access token)
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
} else if (this.provider === "codebuddy") {
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
} else if (this.provider === "kilocode") {
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
if (credentials.providerSpecificData?.orgId) {
headers["X-Kilocode-OrganizationID"] = credentials.providerSpecificData.orgId;
}
} else if (this.provider === "cline") {
Object.assign(headers, buildClineHeaders(credentials.apiKey || credentials.accessToken));
} else if (this.config?.format === "claude") {
// Generic claude-format provider (e.g. agentrouter): x-api-key + anthropic-version
headers["x-api-key"] = credentials.apiKey || credentials.accessToken;
if (!headers["anthropic-version"]) headers["anthropic-version"] = "2023-06-01";
} else {
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
}
}
// Strip first-party Claude Code identity headers for non-Anthropic anthropic-compatible upstreams // Strip first-party Claude Code identity headers for non-Anthropic anthropic-compatible upstreams
if (this.provider?.startsWith?.("anthropic-compatible-")) { if (this.provider?.startsWith?.("anthropic-compatible-")) {
@@ -196,15 +205,25 @@ export class DefaultExecutor extends BaseExecutor {
return headers; return headers;
} }
// Generic OAuth refresh for the common {grant_type, refresh_token, client_id[, ...]} shape.
// grant = REFRESH_GRANTS[provider]; client creds resolved from PROVIDERS or this.config.
refreshFromGrant(credentials, proxyOptions) {
const grant = REFRESH_GRANTS[this.provider];
const params = { grant_type: "refresh_token", refresh_token: credentials.refreshToken, ...grant.params(this) };
return grant.encoding === "json"
? this.refreshWithJSON(grant.url(), params, proxyOptions)
: this.refreshWithForm(grant.url(), params, proxyOptions);
}
async refreshCredentials(credentials, log, proxyOptions = null) { async refreshCredentials(credentials, log, proxyOptions = null) {
if (!credentials.refreshToken) return null; if (!credentials.refreshToken) return null;
const refreshers = { const refreshers = {
claude: () => this.refreshWithJSON(OAUTH_ENDPOINTS.anthropic.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.claude.clientId }, proxyOptions), claude: () => this.refreshFromGrant(credentials, proxyOptions),
codex: () => this.refreshWithForm(OAUTH_ENDPOINTS.openai.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.codex.clientId, scope: "openid profile email offline_access" }, proxyOptions), codex: () => this.refreshFromGrant(credentials, proxyOptions),
qwen: () => this.refreshWithForm(OAUTH_ENDPOINTS.qwen.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.qwen.clientId }, proxyOptions), qwen: () => this.refreshWithForm(OAUTH_ENDPOINTS.qwen.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.qwen.clientId }, proxyOptions),
iflow: () => this.refreshIflow(credentials.refreshToken, proxyOptions), iflow: () => this.refreshIflow(credentials.refreshToken, proxyOptions),
gemini: () => this.refreshGoogle(credentials.refreshToken, proxyOptions), gemini: () => this.refreshFromGrant(credentials, proxyOptions),
kiro: () => this.refreshKiro(credentials.refreshToken, proxyOptions), kiro: () => this.refreshKiro(credentials.refreshToken, proxyOptions),
cline: () => this.refreshCline(credentials.refreshToken, proxyOptions), cline: () => this.refreshCline(credentials.refreshToken, proxyOptions),
"kimi-coding": () => this.refreshKimiCoding(credentials.refreshToken, proxyOptions), "kimi-coding": () => this.refreshKimiCoding(credentials.refreshToken, proxyOptions),
@@ -258,17 +277,6 @@ export class DefaultExecutor extends BaseExecutor {
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in }; return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in };
} }
async refreshGoogle(refreshToken, proxyOptions = null) {
const response = await proxyAwareFetch(OAUTH_ENDPOINTS.google.token, {
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: this.config.clientId, client_secret: this.config.clientSecret })
}, proxyOptions);
if (!response.ok) return null;
const tokens = await response.json();
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in };
}
async refreshKiro(refreshToken, proxyOptions = null) { async refreshKiro(refreshToken, proxyOptions = null) {
const response = await proxyAwareFetch(PROVIDERS.kiro.tokenUrl, { const response = await proxyAwareFetch(PROVIDERS.kiro.tokenUrl, {
method: "POST", method: "POST",
@@ -281,37 +289,29 @@ export class DefaultExecutor extends BaseExecutor {
} }
async refreshCline(refreshToken, proxyOptions = null) { async refreshCline(refreshToken, proxyOptions = null) {
console.log('[DEBUG] Refreshing Cline token, refreshToken length:', refreshToken?.length); const response = await proxyAwareFetch(PROVIDERS.cline.refreshUrl, {
const response = await proxyAwareFetch("https://api.cline.bot/api/v1/auth/refresh", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", "Accept": "application/json" }, headers: { "Content-Type": "application/json", "Accept": "application/json" },
body: JSON.stringify({ refreshToken, grantType: "refresh_token", clientType: "extension" }) body: JSON.stringify({ refreshToken, grantType: "refresh_token", clientType: "extension" })
}, proxyOptions); }, proxyOptions);
console.log('[DEBUG] Cline refresh response status:', response.status); if (!response.ok) return null;
if (!response.ok) {
const errorText = await response.text();
console.log('[DEBUG] Cline refresh error:', errorText);
return null;
}
const payload = await response.json(); const payload = await response.json();
console.log('[DEBUG] Cline refresh payload:', JSON.stringify(payload).substring(0, 200));
const data = payload?.data || payload; const data = payload?.data || payload;
const expiresAtIso = data?.expiresAt; const expiresAtIso = data?.expiresAt;
const expiresIn = expiresAtIso ? Math.max(1, Math.floor((new Date(expiresAtIso).getTime() - Date.now()) / 1000)) : undefined; const expiresIn = expiresAtIso ? Math.max(1, Math.floor((new Date(expiresAtIso).getTime() - Date.now()) / 1000)) : undefined;
console.log('[DEBUG] Cline refresh success, expiresIn:', expiresIn);
return { accessToken: data?.accessToken, refreshToken: data?.refreshToken || refreshToken, expiresIn }; return { accessToken: data?.accessToken, refreshToken: data?.refreshToken || refreshToken, expiresIn };
} }
async refreshKimiCoding(refreshToken, proxyOptions = null) { async refreshKimiCoding(refreshToken, proxyOptions = null) {
const kimiHeaders = buildKimiHeaders(); const kimiHeaders = buildKimiHeaders();
const response = await proxyAwareFetch("https://auth.kimi.com/api/oauth/token", { const response = await proxyAwareFetch(PROVIDERS["kimi-coding"].refreshUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json", "Accept": "application/json",
...kimiHeaders ...kimiHeaders
}, },
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: "17e5f671-d194-4dfb-9706-5516cb48c098" }) body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: PROVIDERS["kimi-coding"].clientId })
}, proxyOptions); }, proxyOptions);
if (!response.ok) return null; if (!response.ok) return null;
const tokens = await response.json(); const tokens = await response.json();

View File

@@ -7,6 +7,8 @@ import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-r
import { initState } from "../translator/index.js"; import { initState } from "../translator/index.js";
import { parseSSELine, formatSSE } from "../utils/streamHelpers.js"; import { parseSSELine, formatSSE } from "../utils/streamHelpers.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { stripUnsupportedParams } from "../translator/concerns/paramSupport.js";
import { SSE_DONE } from "../utils/sseConstants.js";
import crypto from "crypto"; import crypto from "crypto";
export class GithubExecutor extends BaseExecutor { export class GithubExecutor extends BaseExecutor {
@@ -108,54 +110,18 @@ export class GithubExecutor extends BaseExecutor {
return /gpt-5|o[134]-/i.test(model); return /gpt-5|o[134]-/i.test(model);
} }
// Some models (like gpt-5.4) don't support the temperature parameter
supportsTemperature(model) {
// gpt-5.4 and similar newer models don't support temperature
return !/gpt-5\.4/i.test(model);
}
// GitHub Copilot /chat/completions rejects Claude-style thinking payloads
// (OpenClaw sends thinking: { type: "enabled" } → upstream 400).
// GPT-5 family on Copilot DOES honor reasoning_effort, so only strip for Claude. (#713)
supportsThinking(model) {
return !/claude/i.test(model);
}
// reasoning_effort works for GPT-5 family AND Claude Opus 4.6 / Sonnet 4.6
// on GitHub Copilot. Only strip for models that don't support it:
// Claude Haiku 4.5, Claude Opus 4.7 (rejected upstream).
supportsReasoningEffort(model) {
const m = model.toLowerCase();
// Claude models that DO support reasoning_effort
if (/claude.*opus.*4\.6/i.test(m) || /claude.*sonnet.*4\.6/i.test(m)) return true;
// All other Claude models: strip
if (/claude/i.test(model)) return false;
// GPT-5 family, Gemini, etc.: keep
return true;
}
transformRequest(model, body, stream, credentials) { transformRequest(model, body, stream, credentials) {
const transformed = { ...body }; const transformed = { ...body };
if (this.requiresMaxCompletionTokens(model) && transformed.max_tokens !== undefined) { if (this.requiresMaxCompletionTokens(model) && transformed.max_tokens !== undefined) {
transformed.max_completion_tokens = transformed.max_tokens; transformed.max_completion_tokens = transformed.max_tokens;
delete transformed.max_tokens; delete transformed.max_tokens;
} }
// Strip temperature for models that don't support it
if (!this.supportsTemperature(model) && transformed.temperature !== undefined) {
delete transformed.temperature;
}
// Always strip Claude-style thinking payload (Copilot doesn't understand it)
if (!this.supportsThinking(model)) {
delete transformed.thinking;
}
// "none" means no thinking — strip it so models that don't support "none" don't 400 // "none" means no thinking — strip it so models that don't support "none" don't 400
if (transformed.reasoning_effort === "none") { if (transformed.reasoning_effort === "none") {
delete transformed.reasoning_effort; delete transformed.reasoning_effort;
} }
// Strip reasoning_effort only for models that reject it // Config-driven strip of params unsupported by this provider/model
if (!this.supportsReasoningEffort(model) && transformed.reasoning_effort !== undefined) { stripUnsupportedParams("github", model, transformed);
delete transformed.reasoning_effort;
}
return transformed; return transformed;
} }
@@ -244,7 +210,7 @@ export class GithubExecutor extends BaseExecutor {
if (!parsed) continue; if (!parsed) continue;
if (parsed.done && stream === true) { if (parsed.done && stream === true) {
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")); controller.enqueue(new TextEncoder().encode(SSE_DONE));
continue; continue;
} }

View File

@@ -1,5 +1,7 @@
import { BaseExecutor } from "./base.js"; import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js"; import { PROVIDERS } from "../config/providers.js";
import { SSE_DONE, SSE_HEADERS_NO_BUFFER } from "../utils/sseConstants.js";
import { sseChunk } from "../utils/sse.js";
const GROK_CHAT_API = PROVIDERS["grok-web"].baseUrl; const GROK_CHAT_API = PROVIDERS["grok-web"].baseUrl;
const GROK_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36"; const GROK_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36";
@@ -130,10 +132,6 @@ async function* extractContent(eventStream, isThinkingModel, signal) {
yield { done: true, fingerprint, responseId }; yield { done: true, fingerprint, responseId };
} }
function sseChunk(data) {
return `data: ${JSON.stringify(data)}\n\n`;
}
function buildStreamingResponse(eventStream, model, cid, created, isThinkingModel, signal) { function buildStreamingResponse(eventStream, model, cid, created, isThinkingModel, signal) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
return new ReadableStream({ return new ReadableStream({
@@ -175,13 +173,13 @@ function buildStreamingResponse(eventStream, model, cid, created, isThinkingMode
id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: fp || null, id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: fp || null,
choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }], choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }],
}))); })));
controller.enqueue(encoder.encode("data: [DONE]\n\n")); controller.enqueue(encoder.encode(SSE_DONE));
} catch (err) { } catch (err) {
controller.enqueue(encoder.encode(sseChunk({ controller.enqueue(encoder.encode(sseChunk({
id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null,
choices: [{ index: 0, delta: { content: `[Stream error: ${err.message || String(err)}]` }, finish_reason: "stop", logprobs: null }], choices: [{ index: 0, delta: { content: `[Stream error: ${err.message || String(err)}]` }, finish_reason: "stop", logprobs: null }],
}))); })));
controller.enqueue(encoder.encode("data: [DONE]\n\n")); controller.enqueue(encoder.encode(SSE_DONE));
} finally { } finally {
controller.close(); controller.close();
} }
@@ -333,7 +331,7 @@ export class GrokWebExecutor extends BaseExecutor {
const sseStream = buildStreamingResponse(response.body, model, cid, created, isThinking, signal); const sseStream = buildStreamingResponse(response.body, model, cid, created, isThinking, signal);
finalResponse = new Response(sseStream, { finalResponse = new Response(sseStream, {
status: 200, status: 200,
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" }, headers: { ...SSE_HEADERS_NO_BUFFER },
}); });
} else { } else {
finalResponse = await buildNonStreamingResponse(response.body, model, cid, created, isThinking, signal); finalResponse = await buildNonStreamingResponse(response.body, model, cid, created, isThinking, signal);

View File

@@ -17,6 +17,7 @@ import { OllamaLocalExecutor } from "./ollama-local.js";
import { CommandCodeExecutor } from "./commandcode.js"; 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 { DefaultExecutor } from "./default.js"; import { DefaultExecutor } from "./default.js";
const executors = { const executors = {
@@ -42,6 +43,7 @@ const executors = {
"xiaomi-tokenplan": new XiaomiTokenplanExecutor(), "xiaomi-tokenplan": new XiaomiTokenplanExecutor(),
"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(),
}; };
const defaultCache = new Map(); const defaultCache = new Map();
@@ -77,3 +79,4 @@ export { OllamaLocalExecutor } from "./ollama-local.js";
export { CommandCodeExecutor } from "./commandcode.js"; 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";

View File

@@ -1,7 +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 { 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 { getCapabilitiesForModel } from "../providers/capabilities.js";
/** /**
* KiroExecutor - Executor for Kiro AI (AWS CodeWhisperer) * KiroExecutor - Executor for Kiro AI (AWS CodeWhisperer)
@@ -19,13 +22,60 @@ export class KiroExecutor extends BaseExecutor {
"Amz-Sdk-Invocation-Id": uuidv4() "Amz-Sdk-Invocation-Id": uuidv4()
}; };
if (credentials.accessToken) { // 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`
// header so CodeWhisperer treats it as a long-lived API key rather than an
// OIDC/social access token. Mirrors the Kiro IDE headless-auth behavior.
// Enterprise / Microsoft Entra (external_idp) tokens are OAuth access tokens,
// but CodeWhisperer requires TokenType=EXTERNAL_IDP to bind them to profiles.
const authMethod = credentials?.providerSpecificData?.authMethod;
const isApiKey = authMethod === "api_key";
const isExternalIdp = authMethod === "external_idp";
const apiKey = credentials?.apiKey || (isApiKey ? credentials?.accessToken : null);
if (isApiKey && apiKey) {
headers["Authorization"] = `Bearer ${apiKey}`;
headers["tokentype"] = "API_KEY";
} else if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`; headers["Authorization"] = `Bearer ${credentials.accessToken}`;
if (isExternalIdp) {
headers["TokenType"] = "EXTERNAL_IDP";
}
} }
return headers; return headers;
} }
/**
* Auth-aware endpoint ordering.
*
* API-key Kiro connections store a raw CodeWhisperer credential (validated
* against codewhisperer.us-east-1.amazonaws.com via ListAvailableProfiles).
* The Kiro IDE gateway (runtime.*.kiro.dev) expects Kiro OIDC/social tokens
* and rejects an `tokentype: API_KEY` token with 401/403 — which
* BaseExecutor.execute() returns immediately (only 429 / network errors fall
* through to the next host). So for api-key auth we must try the *.amazonaws.com
* CodeWhisperer hosts FIRST, mirroring the Kiro-Go reference fork which never
* routes api-key traffic through kiro.dev. External IdP enterprise tokens also
* use the CodeWhisperer surface, with the `TokenType: EXTERNAL_IDP` header.
* Other OAuth methods keep the default order (kiro.dev first) since their
* tokens are what that gateway accepts.
*/
getOrderedBaseUrls(credentials) {
const baseUrls = this.getBaseUrls();
const authMethod = credentials?.providerSpecificData?.authMethod;
const isCodeWhispererSurface = authMethod === "api_key" || authMethod === "external_idp";
if (!isCodeWhispererSurface) return baseUrls;
const amazon = baseUrls.filter((u) => u.includes("amazonaws.com"));
const others = baseUrls.filter((u) => !u.includes("amazonaws.com"));
return amazon.length > 0 ? [...amazon, ...others] : baseUrls;
}
buildUrl(model, stream, urlIndex = 0, credentials = null) {
const baseUrls = this.getOrderedBaseUrls(credentials);
return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl;
}
transformRequest(model, body, stream, credentials) { transformRequest(model, body, stream, credentials) {
return body; return body;
} }
@@ -37,6 +87,8 @@ export class KiroExecutor extends BaseExecutor {
* BaseExecutor.execute() walks config.baseUrls (runtime.us-east-1.kiro.dev → * BaseExecutor.execute() walks config.baseUrls (runtime.us-east-1.kiro.dev →
* codewhisperer → q) advancing to the next host on 429 (shouldRetry) and on * codewhisperer → q) advancing to the next host on 429 (shouldRetry) and on
* network/5xx errors, while tryRetry handles in-place retries per `retry: {429: 2}`. * network/5xx errors, while tryRetry handles in-place retries per `retry: {429: 2}`.
* Note: api-key connections reorder these so the *.amazonaws.com hosts come
* first — see getOrderedBaseUrls/buildUrl above.
* Note: the baseUrls are alternate surfaces of one regional service, so rotation * Note: the baseUrls are alternate surfaces of one regional service, so rotation
* is edge-level failover — it does not grant fresh 429 quota. Per-account 429 * is edge-level failover — it does not grant fresh 429 quota. Per-account 429
* spreading is handled upstream by account rotation in sse/handlers/chat.js. * spreading is handled upstream by account rotation in sse/handlers/chat.js.
@@ -61,6 +113,8 @@ export class KiroExecutor extends BaseExecutor {
let chunkIndex = 0; let chunkIndex = 0;
const responseId = `chatcmpl-${Date.now()}`; const responseId = `chatcmpl-${Date.now()}`;
const created = Math.floor(Date.now() / 1000); const created = Math.floor(Date.now() / 1000);
const capabilityModel = resolveKiroModel(model).upstream;
const contextWindow = getCapabilitiesForModel("kiro", capabilityModel).contextWindow || 200000;
const state = { const state = {
endDetected: false, endDetected: false,
finishEmitted: false, finishEmitted: false,
@@ -73,6 +127,8 @@ export class KiroExecutor extends BaseExecutor {
const transformStream = new TransformStream({ const transformStream = new TransformStream({
async transform(chunk, controller) { async transform(chunk, controller) {
// Track output so we can emit a keepalive if this frame yields no chunk.
const enqueueCountBefore = chunkIndex;
// Append to buffer // Append to buffer
const newBuffer = new Uint8Array(buffer.length + chunk.length); const newBuffer = new Uint8Array(buffer.length + chunk.length);
newBuffer.set(buffer); newBuffer.set(buffer);
@@ -96,7 +152,7 @@ export class KiroExecutor extends BaseExecutor {
if (!event) continue; if (!event) continue;
const eventType = event.headers[":event-type"] || ""; const eventType = event.headers[":event-type"] || "";
// Track total content length for token estimation // Track total content length for token estimation
if (!state.totalContentLength) state.totalContentLength = 0; if (!state.totalContentLength) state.totalContentLength = 0;
if (!state.contextUsagePercentage) state.contextUsagePercentage = 0; if (!state.contextUsagePercentage) state.contextUsagePercentage = 0;
@@ -105,7 +161,7 @@ export class KiroExecutor extends BaseExecutor {
if (eventType === "assistantResponseEvent" && event.payload?.content) { if (eventType === "assistantResponseEvent" && event.payload?.content) {
const content = event.payload.content; const content = event.payload.content;
state.totalContentLength += content.length; state.totalContentLength += content.length;
const chunk = { const chunk = {
id: responseId, id: responseId,
object: "chat.completion.chunk", object: "chat.completion.chunk",
@@ -292,7 +348,7 @@ export class KiroExecutor extends BaseExecutor {
if (metrics && typeof metrics === 'object') { if (metrics && typeof metrics === 'object') {
const inputTokens = metrics.inputTokens || 0; const inputTokens = metrics.inputTokens || 0;
const outputTokens = metrics.outputTokens || 0; const outputTokens = metrics.outputTokens || 0;
if (inputTokens > 0 || outputTokens > 0) { if (inputTokens > 0 || outputTokens > 0) {
state.usage = { state.usage = {
prompt_tokens: inputTokens, prompt_tokens: inputTokens,
@@ -306,27 +362,26 @@ export class KiroExecutor extends BaseExecutor {
// Emit final chunk only after receiving BOTH meteringEvent AND contextUsageEvent // Emit final chunk only after receiving BOTH meteringEvent AND contextUsageEvent
if (state.hasMeteringEvent && state.hasContextUsage && !state.finishEmitted) { if (state.hasMeteringEvent && state.hasContextUsage && !state.finishEmitted) {
state.finishEmitted = true; state.finishEmitted = true;
// Estimate tokens if not available from events // Estimate tokens if not available from events
if (!state.usage) { if (!state.usage) {
// Estimate output tokens from content length // Estimate output tokens from content length
const estimatedOutputTokens = state.totalContentLength > 0 const estimatedOutputTokens = state.totalContentLength > 0
? Math.max(1, Math.floor(state.totalContentLength / 4)) ? Math.max(1, Math.floor(state.totalContentLength / 4))
: 0; : 0;
// Estimate input tokens from contextUsagePercentage // Estimate input tokens from contextUsagePercentage
// Kiro models typically have 200k context window
const estimatedInputTokens = state.contextUsagePercentage > 0 const estimatedInputTokens = state.contextUsagePercentage > 0
? Math.floor(state.contextUsagePercentage * 200000 / 100) ? Math.floor(state.contextUsagePercentage * contextWindow / 100)
: 0; : 0;
state.usage = { state.usage = {
prompt_tokens: estimatedInputTokens, prompt_tokens: estimatedInputTokens,
completion_tokens: estimatedOutputTokens, completion_tokens: estimatedOutputTokens,
total_tokens: estimatedInputTokens + estimatedOutputTokens total_tokens: estimatedInputTokens + estimatedOutputTokens
}; };
} }
const finishChunk = { const finishChunk = {
id: responseId, id: responseId,
object: "chat.completion.chunk", object: "chat.completion.chunk",
@@ -338,12 +393,12 @@ export class KiroExecutor extends BaseExecutor {
finish_reason: state.hasToolCalls ? "tool_calls" : "stop" finish_reason: state.hasToolCalls ? "tool_calls" : "stop"
}] }]
}; };
// Include usage in final chunk if available // Include usage in final chunk if available
if (state.usage) { if (state.usage) {
finishChunk.usage = state.usage; finishChunk.usage = state.usage;
} }
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`)); controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`));
} }
} }
@@ -351,6 +406,12 @@ export class KiroExecutor extends BaseExecutor {
if (iterations >= maxIterations) { if (iterations >= maxIterations) {
console.warn("[Kiro] Max iterations reached in event parsing"); console.warn("[Kiro] Max iterations reached in event parsing");
} }
// No client chunk produced this frame — emit an SSE comment keepalive
// so the stall watchdog sees upstream activity (ignored by parser/client).
if (chunkIndex === enqueueCountBefore && !state.finishEmitted) {
controller.enqueue(new TextEncoder().encode(": ka\n\n"));
}
}, },
flush(controller) { flush(controller) {
@@ -372,24 +433,20 @@ export class KiroExecutor extends BaseExecutor {
} }
// Send final done message // Send final done message
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")); controller.enqueue(new TextEncoder().encode(SSE_DONE));
} }
}); });
// Pipe response body through transform stream // Pipe response body through transform stream
if (!response.body) { if (!response.body) {
return new Response("data: [DONE]\n\n", { status: response.status, headers: { "Content-Type": "text/event-stream" } }); return new Response(SSE_DONE, { status: response.status, headers: { "Content-Type": "text/event-stream" } });
} }
const transformedStream = response.body.pipeThrough(transformStream); const transformedStream = response.body.pipeThrough(transformStream);
return new Response(transformedStream, { return new Response(transformedStream, {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
headers: { headers: { ...SSE_HEADERS }
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive"
}
}); });
} }

View File

@@ -5,13 +5,20 @@ import { createHash } from "crypto";
import os from "os"; import os from "os";
const BOOTSTRAP_URL = "https://api.xiaomimimo.com/api/free-ai/bootstrap"; const BOOTSTRAP_URL = "https://api.xiaomimimo.com/api/free-ai/bootstrap";
const CHAT_URL = "https://api.xiaomimimo.com/api/free-ai/openai/chat"; const CHAT_URL = PROVIDERS["mimo-free"].baseUrl;
const SESSION_AFFINITY_PREFIX = "ses_"; const SESSION_AFFINITY_PREFIX = "ses_";
const SESSION_ID_LENGTH = 24; const SESSION_ID_LENGTH = 24;
const JWT_FALLBACK_TTL_SEC = 3000; const JWT_FALLBACK_TTL_SEC = 3000;
const JWT_EXPIRY_BUFFER_MS = 300000; const JWT_EXPIRY_BUFFER_MS = 300000;
const SESSION_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"; const SESSION_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
// Anti-abuse gate: upstream rejects requests without a Chrome-like User-Agent with 403 "Illegal access"
const USER_AGENTS = [
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
];
// Anti-abuse gate marker: the free chat endpoint returns 403 "Illegal access" // Anti-abuse gate marker: the free chat endpoint returns 403 "Illegal access"
// unless a system message contains this exact MiMoCode signature substring. // unless a system message contains this exact MiMoCode signature substring.
export const MIMO_SYSTEM_MARKER = export const MIMO_SYSTEM_MARKER =
@@ -76,7 +83,10 @@ async function bootstrapJwt(proxyOptions = null) {
const response = await proxyAwareFetch(BOOTSTRAP_URL, { const response = await proxyAwareFetch(BOOTSTRAP_URL, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: {
"Content-Type": "application/json",
"User-Agent": USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)],
},
body: JSON.stringify({ client: generateFingerprint() }), body: JSON.stringify({ client: generateFingerprint() }),
}, proxyOptions); }, proxyOptions);
@@ -108,6 +118,7 @@ export class MimoFreeExecutor extends BaseExecutor {
return { return {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Mimo-Source": "mimocode-cli-free", "X-Mimo-Source": "mimocode-cli-free",
"User-Agent": USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)],
"x-session-affinity": this.sessionId, "x-session-affinity": this.sessionId,
"Accept": stream ? "text/event-stream" : "application/json", "Accept": stream ? "text/event-stream" : "application/json",
}; };

View File

@@ -1,9 +1,17 @@
import { BaseExecutor } from "./base.js"; import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js"; import { PROVIDERS } from "../config/providers.js";
import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
import { ANTHROPIC_API_VERSION } from "../providers/shared.js";
// Models that use /zen/go/v1/messages (Anthropic/Claude format + x-api-key auth) // Models that use /zen/go/v1/messages (Anthropic/Claude format + x-api-key auth)
const CLAUDE_FORMAT_MODELS = new Set(["minimax-m2.5", "minimax-m2.7"]); const MESSAGES_FORMAT_MODELS = new Set([
"minimax-m3",
"minimax-m2.7",
"minimax-m2.5",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.6-plus",
]);
const BASE = "https://opencode.ai/zen/go/v1"; const BASE = "https://opencode.ai/zen/go/v1";
@@ -15,7 +23,7 @@ export class OpenCodeGoExecutor extends BaseExecutor {
// buildUrl runs before buildHeaders in BaseExecutor.execute, cache model here // buildUrl runs before buildHeaders in BaseExecutor.execute, cache model here
buildUrl(model) { buildUrl(model) {
this._lastModel = model; this._lastModel = model;
return CLAUDE_FORMAT_MODELS.has(model) return MESSAGES_FORMAT_MODELS.has(model)
? `${BASE}/messages` ? `${BASE}/messages`
: `${BASE}/chat/completions`; : `${BASE}/chat/completions`;
} }
@@ -24,9 +32,9 @@ export class OpenCodeGoExecutor extends BaseExecutor {
const key = credentials?.apiKey || credentials?.accessToken; const key = credentials?.apiKey || credentials?.accessToken;
const headers = { "Content-Type": "application/json" }; const headers = { "Content-Type": "application/json" };
if (CLAUDE_FORMAT_MODELS.has(this._lastModel)) { if (MESSAGES_FORMAT_MODELS.has(this._lastModel)) {
headers["x-api-key"] = key; headers["x-api-key"] = key;
headers["anthropic-version"] = "2023-06-01"; headers["anthropic-version"] = ANTHROPIC_API_VERSION;
} else { } else {
headers["Authorization"] = `Bearer ${key}`; headers["Authorization"] = `Bearer ${key}`;
} }

View File

@@ -15,7 +15,7 @@ export class OpenCodeExecutor extends BaseExecutor {
} }
buildUrl(model) { buildUrl(model) {
const base = "https://opencode.ai"; const base = this.config.baseUrl;
return MESSAGES_MODELS.has(model) return MESSAGES_MODELS.has(model)
? `${base}/zen/v1/messages` ? `${base}/zen/v1/messages`
: `${base}/zen/v1/chat/completions`; : `${base}/zen/v1/chat/completions`;

View File

@@ -1,5 +1,7 @@
import { BaseExecutor } from "./base.js"; import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js"; import { PROVIDERS } from "../config/providers.js";
import { SSE_DONE, SSE_HEADERS_NO_BUFFER } from "../utils/sseConstants.js";
import { sseChunk } from "../utils/sse.js";
const PPLX_SSE_ENDPOINT = PROVIDERS["perplexity-web"].baseUrl; const PPLX_SSE_ENDPOINT = PROVIDERS["perplexity-web"].baseUrl;
const PPLX_API_VERSION = "2.18"; const PPLX_API_VERSION = "2.18";
@@ -289,10 +291,6 @@ async function* extractContent(eventStream, signal) {
yield { delta: "", answer: fullAnswer, backendUuid: backendUuid ?? undefined, done: true }; yield { delta: "", answer: fullAnswer, backendUuid: backendUuid ?? undefined, done: true };
} }
function sseChunk(data) {
return `data: ${JSON.stringify(data)}\n\n`;
}
function buildStreamingResponse(eventStream, model, cid, created, history, currentMsg, signal) { function buildStreamingResponse(eventStream, model, cid, created, history, currentMsg, signal) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
return new ReadableStream({ return new ReadableStream({
@@ -340,7 +338,7 @@ function buildStreamingResponse(eventStream, model, cid, created, history, curre
id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null,
choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }], choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }],
}))); })));
controller.enqueue(encoder.encode("data: [DONE]\n\n")); controller.enqueue(encoder.encode(SSE_DONE));
sessionStore(history, currentMsg, cleanResponse(fullAnswer), respBackendUuid); sessionStore(history, currentMsg, cleanResponse(fullAnswer), respBackendUuid);
} catch (err) { } catch (err) {
@@ -348,7 +346,7 @@ function buildStreamingResponse(eventStream, model, cid, created, history, curre
id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null,
choices: [{ index: 0, delta: { content: `[Stream error: ${err.message || String(err)}]` }, finish_reason: "stop", logprobs: null }], choices: [{ index: 0, delta: { content: `[Stream error: ${err.message || String(err)}]` }, finish_reason: "stop", logprobs: null }],
}))); })));
controller.enqueue(encoder.encode("data: [DONE]\n\n")); controller.enqueue(encoder.encode(SSE_DONE));
} finally { } finally {
controller.close(); controller.close();
} }
@@ -493,7 +491,7 @@ export class PerplexityWebExecutor extends BaseExecutor {
const sseStream = buildStreamingResponse(response.body, model, cid, created, parsed.history, parsed.currentMsg, signal); const sseStream = buildStreamingResponse(response.body, model, cid, created, parsed.history, parsed.currentMsg, signal);
finalResponse = new Response(sseStream, { finalResponse = new Response(sseStream, {
status: 200, status: 200,
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" }, headers: { ...SSE_HEADERS_NO_BUFFER },
}); });
} else { } else {
finalResponse = await buildNonStreamingResponse(response.body, model, cid, created, parsed.history, parsed.currentMsg, signal); finalResponse = await buildNonStreamingResponse(response.body, model, cid, created, parsed.history, parsed.currentMsg, signal);

View File

@@ -20,19 +20,20 @@
* different model upstream, so a missing entry is a hard error. * different model upstream, so a missing entry is a hard error.
*/ */
import { qoderEncodeBody } from "@/lib/qoder/encoding.js"; import { qoderEncodeBody } from "../shared/qoder/encoding.js";
import { buildCosyHeaders } from "@/lib/qoder/cosy.js"; import { buildCosyHeaders } from "../shared/qoder/cosy.js";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { createHash } from "crypto"; import { createHash } from "crypto";
import { BaseExecutor } from "./base.js"; import { BaseExecutor } from "./base.js";
import { PROVIDERS } from "../config/providers.js"; import { PROVIDERS } from "../config/providers.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js";
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_MODEL_MAP, QODER_MODEL_MAP,
} from "@/lib/qoder/constants.js"; } from "../shared/qoder/constants.js";
import { getQoderModelConfig, resolveQoderModels } from "../services/qoderModels.js"; import { getQoderModelConfig, resolveQoderModels } from "../services/qoderModels.js";
/** /**
@@ -294,7 +295,7 @@ async function wrapQoderSSE(response, model) {
const data = trimmed.slice(5).trimStart(); const data = trimmed.slice(5).trimStart();
if (data === "[DONE]") { if (data === "[DONE]") {
controller.enqueue(encoder.encode("data: [DONE]\n\n")); controller.enqueue(encoder.encode(SSE_DONE));
doneEmitted = true; doneEmitted = true;
return; return;
} }
@@ -313,13 +314,13 @@ async function wrapQoderSSE(response, model) {
choices: [{ index: 0, delta: { content: `\n[qoder error ${statusVal}: ${truncate(msg, 200)}]` }, finish_reason: "stop" }], choices: [{ index: 0, delta: { content: `\n[qoder error ${statusVal}: ${truncate(msg, 200)}]` }, finish_reason: "stop" }],
}); });
controller.enqueue(encoder.encode(`data: ${errChunk}\n\n`)); controller.enqueue(encoder.encode(`data: ${errChunk}\n\n`));
controller.enqueue(encoder.encode("data: [DONE]\n\n")); controller.enqueue(encoder.encode(SSE_DONE));
doneEmitted = true; doneEmitted = true;
return; return;
} }
if (!inner) return; if (!inner) return;
if (inner === "[DONE]") { if (inner === "[DONE]") {
controller.enqueue(encoder.encode("data: [DONE]\n\n")); controller.enqueue(encoder.encode(SSE_DONE));
doneEmitted = true; doneEmitted = true;
return; return;
} }
@@ -344,7 +345,7 @@ async function wrapQoderSSE(response, model) {
buffer = ""; buffer = "";
} }
if (!doneEmitted) { if (!doneEmitted) {
controller.enqueue(encoder.encode("data: [DONE]\n\n")); controller.enqueue(encoder.encode(SSE_DONE));
doneEmitted = true; doneEmitted = true;
} }
}, },

View File

@@ -1,18 +1,19 @@
import { DefaultExecutor } from "./default.js"; import { DefaultExecutor } from "./default.js";
import { resolveXiaomiTokenplanBaseUrl } from "../config/providers.js"; import { resolveXiaomiTokenplanBaseUrl } from "../config/providers.js";
import { getModelTargetFormat } from "../config/providerModels.js"; // import { getModelTargetFormat } from "../config/providerModels.js";
import { FORMATS } from "../translator/formats.js"; // import { FORMATS } from "../translator/formats.js";
export class XiaomiTokenplanExecutor extends DefaultExecutor { export class XiaomiTokenplanExecutor extends DefaultExecutor {
constructor() { constructor() {
super("xiaomi-tokenplan"); super("xiaomi-tokenplan");
} }
// Claude-native aliases route to the Anthropic-compatible messages endpoint // Token Plan keys are region-specific. Route per sourceFormat-matched transport:
// claude → Anthropic /anthropic/v1/messages, openai → /chat/completions.
buildUrl(model, stream, urlIndex = 0, credentials = null) { buildUrl(model, stream, urlIndex = 0, credentials = null) {
const baseUrl = resolveXiaomiTokenplanBaseUrl(credentials); const baseUrl = resolveXiaomiTokenplanBaseUrl(credentials);
if (getModelTargetFormat(model, model) === FORMATS.CLAUDE) { if (credentials?.runtimeTransport?.format === "claude") {
return `${baseUrl.replace(/\/v1\/?$/, "/anthropic/v1")}/messages`; return `${baseUrl.replace(/\/v1\/?$/, "")}/anthropic/v1/messages`;
} }
return `${baseUrl}/chat/completions`; return `${baseUrl}/chat/completions`;
} }

View File

@@ -1,12 +1,13 @@
import { detectFormat, getTargetFormat } from "../services/provider.js"; import { detectFormat, getTargetFormat, resolveTransport } from "../services/provider.js";
import { translateRequest } from "../translator/index.js"; import { translateRequest } from "../translator/index.js";
import { FORMATS } from "../translator/formats.js"; import { FORMATS } from "../translator/formats.js";
import { normalizeClaudePassthrough } from "../translator/helpers/claudeHelper.js"; import { normalizeClaudePassthrough } from "../translator/formats/claude.js";
import { COLORS } from "../utils/stream.js"; import { COLORS } from "../utils/stream.js";
import { createStreamController } from "../utils/streamHandler.js"; import { createStreamController } from "../utils/streamHandler.js";
import { refreshWithRetry } from "../services/tokenRefresh.js"; import { refreshWithRetry } from "../services/tokenRefresh.js";
import { createRequestLogger } from "../utils/requestLogger.js"; import { createRequestLogger } from "../utils/requestLogger.js";
import { getModelTargetFormat, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js"; import { getModelTargetFormat, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
import { PROVIDERS } from "../config/providers.js";
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js"; import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { handleBypassRequest } from "../utils/bypassHandler.js"; import { handleBypassRequest } from "../utils/bypassHandler.js";
@@ -19,7 +20,12 @@ import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/strea
import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.js"; import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.js";
import { dedupeTools } from "../utils/toolDeduper.js"; import { dedupeTools } from "../utils/toolDeduper.js";
import { injectCaveman } from "../rtk/caveman.js"; import { injectCaveman } from "../rtk/caveman.js";
import { injectPonytail } from "../rtk/ponytail.js";
import { compressMessages, formatRtkLog } from "../rtk/index.js"; import { compressMessages, formatRtkLog } from "../rtk/index.js";
import { compressWithHeadroom, formatHeadroomLog, formatHeadroomSizeLog, isHeadroomPhantomSavings } from "../rtk/headroom.js";
import { getCapabilitiesForModel } from "../providers/capabilities.js";
import { stripUnsupportedModalities } from "../translator/concerns/modality.js";
import { prefetchRemoteImages } from "../translator/concerns/prefetch.js";
/** /**
* Core chat handler - shared between SSE and Worker * Core chat handler - shared between SSE and Worker
@@ -28,7 +34,7 @@ import { compressMessages, formatRtkLog } from "../rtk/index.js";
* @param {object} options.credentials - Provider credentials * @param {object} options.credentials - Provider credentials
* @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses") * @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses")
*/ */
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, cavemanEnabled, cavemanLevel, sourceFormatOverride, providerThinking }) { export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, sourceFormatOverride, providerThinking }) {
const { provider, model } = modelInfo; const { provider, model } = modelInfo;
const requestStartTime = Date.now(); const requestStartTime = Date.now();
@@ -40,7 +46,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider; const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
const modelTargetFormat = getModelTargetFormat(alias, model); const modelTargetFormat = getModelTargetFormat(alias, model);
const targetFormat = modelTargetFormat || getTargetFormat(provider); // Multi-endpoint providers: pick transport matching sourceFormat → zero translation
const runtimeTransport = resolveTransport(provider, sourceFormat);
const targetFormat = modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider);
if (runtimeTransport && credentials) credentials.runtimeTransport = runtimeTransport;
const stripList = getModelStrip(alias, model); const stripList = getModelStrip(alias, model);
const upstreamModel = getModelUpstreamId(alias, model); const upstreamModel = getModelUpstreamId(alias, model);
@@ -59,9 +68,16 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
} }
const clientRequestedStreaming = body.stream === true || sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI; const clientRequestedStreaming = body.stream === true || sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI;
const providerRequiresStreaming = provider === "openai" || provider === "codex" || provider === "commandcode"; const providerRequiresStreaming = PROVIDERS[provider]?.forceStream === true;
let stream = providerRequiresStreaming ? true : (body.stream !== false); let stream = providerRequiresStreaming ? true : (body.stream !== false);
// Image generation models require non-streaming (Google v1internal:generateContent)
const modelType = getModelType(alias, model);
const isImageGenModel = modelType === "imageGen" || /image|imagen|image-generation/i.test(model);
if (isImageGenModel && (provider === "antigravity" || provider === "gemini-cli")) {
stream = false;
}
// DeepSeek-TUI: interactive TUI panel sends stream:true and needs SSE. // DeepSeek-TUI: interactive TUI panel sends stream:true and needs SSE.
// Non-interactive mode (-p flag) sends without stream and can't parse SSE. // Non-interactive mode (-p flag) sends without stream and can't parse SSE.
// Only force non-streaming when client didn't explicitly request it. // Only force non-streaming when client didn't explicitly request it.
@@ -73,7 +89,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
const acceptHeader = clientRawRequest?.headers?.accept || ""; const acceptHeader = clientRawRequest?.headers?.accept || "";
const clientPrefersJson = acceptHeader.includes("application/json"); const clientPrefersJson = acceptHeader.includes("application/json");
const clientPrefersSSE = acceptHeader.includes("text/event-stream"); const clientPrefersSSE = acceptHeader.includes("text/event-stream");
if (clientPrefersJson && !clientPrefersSSE && body.stream !== true) { if (clientPrefersJson && !clientPrefersSSE && body.stream !== true && !providerRequiresStreaming) {
stream = false; stream = false;
} }
@@ -87,6 +103,22 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
const clientTool = detectClientTool(clientRawRequest?.headers || {}, body); const clientTool = detectClientTool(clientRawRequest?.headers || {}, body);
const passthrough = isNativePassthrough(clientTool, provider); const passthrough = isNativePassthrough(clientTool, provider);
// Expose raw client headers to translators/executors for session-id resolution
if (credentials) credentials.rawHeaders = clientRawRequest?.headers || {};
// Auto-strip media blocks the model can't read (vision/audio/pdf) before translation.
if (!passthrough) {
const caps = getCapabilitiesForModel(provider, model);
if (stripUnsupportedModalities(body, sourceFormat, caps)) {
log?.debug?.("MODALITY", `stripped unsupported media for ${provider}/${model}`);
}
// Convert remote image URLs to base64 for targets that can't fetch URLs.
try {
const n = await prefetchRemoteImages(body, sourceFormat, targetFormat, { signal: undefined });
if (n > 0) log?.debug?.("MODALITY", `prefetched ${n} remote image(s) for ${targetFormat}`);
} catch (e) { log?.warn?.("MODALITY", `image prefetch failed: ${e.message}`); }
}
let translatedBody; let translatedBody;
let toolNameMap; let toolNameMap;
if (passthrough) { if (passthrough) {
@@ -129,12 +161,30 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
const rtkLine = formatRtkLog(rtkStats); const rtkLine = formatRtkLog(rtkStats);
if (rtkLine) console.log(rtkLine); if (rtkLine) console.log(rtkLine);
// Headroom: optional external proxy compression; fail open if proxy is absent.
const headroomDiagnostics = {};
const headroomStats = await compressWithHeadroom(translatedBody, { enabled: headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, diagnostics: headroomDiagnostics });
const headroomLine = formatHeadroomLog(headroomStats);
const headroomSizeLine = formatHeadroomSizeLog(headroomDiagnostics);
if (headroomLine) {
log?.info?.("HEADROOM", `${headroomLine}${headroomSizeLine ? ` | ${headroomSizeLine}` : ""}`);
if (isHeadroomPhantomSavings(headroomStats, headroomDiagnostics)) {
log?.warn?.("HEADROOM", `reported token delta, but outbound JSON shrank <5%; provider may bill near-original payload | ${headroomSizeLine}`);
}
} else if (headroomEnabled) log?.warn?.("HEADROOM", `skipped: ${headroomDiagnostics.reason || "compression unavailable"}${headroomDiagnostics.endpoint ? ` (${headroomDiagnostics.endpoint})` : ""}`);
// Caveman: inject terse-style system prompt // Caveman: inject terse-style system prompt
if (cavemanEnabled && cavemanLevel) { if (cavemanEnabled && cavemanLevel) {
injectCaveman(translatedBody, finalFormat, cavemanLevel); injectCaveman(translatedBody, finalFormat, cavemanLevel);
log?.debug?.("CAVEMAN", `${cavemanLevel} | ${finalFormat}`); log?.debug?.("CAVEMAN", `${cavemanLevel} | ${finalFormat}`);
} }
// Ponytail: inject lazy-senior-dev system prompt
if (ponytailEnabled && ponytailLevel) {
injectPonytail(translatedBody, finalFormat, ponytailLevel);
log?.debug?.("PONYTAIL", `${ponytailLevel} | ${finalFormat}`);
}
const executor = getExecutor(provider); const executor = getExecutor(provider);
trackPendingRequest(model, provider, connectionId, true); trackPendingRequest(model, provider, connectionId, true);
appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => { }); appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => { });

View File

@@ -37,6 +37,12 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source
function: { name: part.functionCall.name, arguments: JSON.stringify(part.functionCall.args || {}) } function: { name: part.functionCall.name, arguments: JSON.stringify(part.functionCall.args || {}) }
}); });
} }
// Handle inline image data (from image generation models)
const inlineData = part.inlineData || part.inline_data;
if (inlineData?.data) {
const mimeType = inlineData.mimeType || inlineData.mime_type || "image/png";
textContent += `\n![image](data:${mimeType};base64,${inlineData.data})\n`;
}
} }
} }
@@ -76,7 +82,12 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source
// missing/null (e.g. M3 with max_tokens:1 spends the budget on thinking // missing/null (e.g. M3 with max_tokens:1 spends the budget on thinking
// and returns `content: null`). Returning the raw body would leave the // and returns `content: null`). Returning the raw body would leave the
// OpenAI client without a `choices` array and surface as a UI test error. // OpenAI client without a `choices` array and surface as a UI test error.
if (responseBody.content && !Array.isArray(responseBody.content)) return responseBody; // Early return if the response is already in OpenAI format (has choices array)
// or if it has content as a non-array value (likely a different non-Claude format).
// Some providers (e.g. xiaomi-tokenplan) return OpenAI-format responses even when
// the request was translated to Claude format — the targetFormat is Claude but the
// actual response is OpenAI-native and needs no further translation.
if (responseBody.choices || (responseBody.content && !Array.isArray(responseBody.content))) return responseBody;
let textContent = "", thinkingContent = ""; let textContent = "", thinkingContent = "";
const toolCalls = []; const toolCalls = [];
@@ -156,7 +167,13 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
} }
reqLogger.logProviderResponse(providerResponse.status, providerResponse.statusText, providerResponse.headers, responseBody); reqLogger.logProviderResponse(providerResponse.status, providerResponse.statusText, providerResponse.headers, responseBody);
if (onRequestSuccess) await onRequestSuccess(); if (onRequestSuccess) {
Promise.resolve()
.then(onRequestSuccess)
.catch(err => {
console.error("[ChatCore] onRequestSuccess failed:", err?.message || err);
});
}
// Decloak tool_use names once on raw Claude body, before any translation (INPUT side) // Decloak tool_use names once on raw Claude body, before any translation (INPUT side)
responseBody = decloakToolNames(responseBody, toolNameMap); responseBody = decloakToolNames(responseBody, toolNameMap);
@@ -193,11 +210,14 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
translatedResponse.usage = filterUsageForFormat(addBufferToUsage(translatedResponse.usage), sourceFormat); translatedResponse.usage = filterUsageForFormat(addBufferToUsage(translatedResponse.usage), sourceFormat);
} }
// Strip reasoning_content — some clients (e.g. Firecrawl AI SDK) have JSON parsers that // Strip reasoning_content only when content is non-empty.
// break on this non-standard field, even though OpenAI allows it in extensions. // When content is empty (e.g. thinking models that used all tokens for reasoning),
// reasoning_content is the only useful output and must be preserved.
if (translatedResponse?.choices) { if (translatedResponse?.choices) {
for (const choice of translatedResponse.choices) { for (const choice of translatedResponse.choices) {
if (choice?.message) delete choice.message.reasoning_content; if (choice?.message?.reasoning_content && choice.message.content) {
delete choice.message.reasoning_content;
}
} }
} }

View File

@@ -2,7 +2,11 @@ import { convertResponsesStreamToJson } from "../../transformer/streamToJsonConv
import { createErrorResult } from "../../utils/error.js"; import { createErrorResult } from "../../utils/error.js";
import { HTTP_STATUS } from "../../config/runtimeConfig.js"; import { HTTP_STATUS } from "../../config/runtimeConfig.js";
import { FORMATS } from "../../translator/formats.js"; import { FORMATS } from "../../translator/formats.js";
import { PROVIDERS } from "../../config/providers.js";
import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js"; import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js";
// Responses-API providers (e.g. codex) may emit SSE without content-type + use Responses output shape
const isResponsesProvider = (p) => PROVIDERS[p]?.format === FORMATS.OPENAI_RESPONSES;
import { saveRequestDetail, appendRequestLog } from "@/lib/usageDb.js"; import { saveRequestDetail, appendRequestLog } from "@/lib/usageDb.js";
function textFromResponsesMessageItem(item) { function textFromResponsesMessageItem(item) {
@@ -100,7 +104,7 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
*/ */
export async function handleForcedSSEToJson({ providerResponse, sourceFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, trackDone, appendLog }) { export async function handleForcedSSEToJson({ providerResponse, sourceFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, trackDone, appendLog }) {
const contentType = providerResponse.headers.get("content-type") || ""; const contentType = providerResponse.headers.get("content-type") || "";
const isSSE = contentType.includes("text/event-stream") || (contentType === "" && provider === "codex"); const isSSE = contentType.includes("text/event-stream") || (contentType === "" && isResponsesProvider(provider));
if (!isSSE) return null; // not handled here if (!isSSE) return null; // not handled here
trackDone(); trackDone();
@@ -112,7 +116,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr
}; };
// Codex/Responses API SSE path // Codex/Responses API SSE path
const isCodexResponsesApi = provider === "codex" || sourceFormat === FORMATS.OPENAI_RESPONSES; const isCodexResponsesApi = isResponsesProvider(provider) || sourceFormat === FORMATS.OPENAI_RESPONSES;
if (isCodexResponsesApi) { if (isCodexResponsesApi) {
try { try {
const jsonResponse = await convertResponsesStreamToJson(providerResponse.body); const jsonResponse = await convertResponsesStreamToJson(providerResponse.body);

View File

@@ -7,12 +7,16 @@ import { STREAM_STALL_TIMEOUT_MS } from "../../config/runtimeConfig.js";
import { buildAbortedResponsesTerminalBytes } from "../../utils/responsesStreamHelpers.js"; import { buildAbortedResponsesTerminalBytes } from "../../utils/responsesStreamHelpers.js";
import { buildRequestDetail, extractRequestConfig } from "./requestDetail.js"; import { buildRequestDetail, extractRequestConfig } from "./requestDetail.js";
import { saveRequestDetail } from "@/lib/usageDb.js"; import { saveRequestDetail } from "@/lib/usageDb.js";
import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js";
const SSE_HEADERS = { // Codex returns Responses API SSE → which client format to translate INTO, by request sourceFormat.
"Content-Type": "text/event-stream", // Gemini-family all map to ANTIGRAVITY decoder; unknown sources fall back to OPENAI.
"Cache-Control": "no-cache", const CODEX_SOURCE_TO_TARGET = {
"Connection": "keep-alive", [FORMATS.OPENAI_RESPONSES]: FORMATS.OPENAI_RESPONSES,
"Access-Control-Allow-Origin": "*" [FORMATS.CLAUDE]: FORMATS.CLAUDE,
[FORMATS.ANTIGRAVITY]: FORMATS.ANTIGRAVITY,
[FORMATS.GEMINI]: FORMATS.ANTIGRAVITY,
[FORMATS.GEMINI_CLI]: FORMATS.ANTIGRAVITY,
}; };
/** /**
@@ -20,15 +24,12 @@ const SSE_HEADERS = {
*/ */
function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }) { function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }) {
const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli"); const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli");
const needsCodexTranslation = provider === "codex" && targetFormat === FORMATS.OPENAI_RESPONSES && !isDroidCLI; // Responses-API providers (e.g. codex) emit Responses SSE → translate into client format
const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES;
const needsCodexTranslation = isResponsesProvider && targetFormat === FORMATS.OPENAI_RESPONSES && !isDroidCLI;
if (needsCodexTranslation) { if (needsCodexTranslation) {
// Codex returns Responses API SSE → translate to client format const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI;
let codexTarget;
if (sourceFormat === FORMATS.OPENAI_RESPONSES) codexTarget = FORMATS.OPENAI_RESPONSES;
else if (sourceFormat === FORMATS.CLAUDE) codexTarget = FORMATS.CLAUDE;
else if (sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI) codexTarget = FORMATS.ANTIGRAVITY;
else codexTarget = FORMATS.OPENAI;
return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey); return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);
} }
@@ -43,7 +44,21 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent,
* Handle streaming response — pipe provider SSE through transform stream to client. * Handle streaming response — pipe provider SSE through transform stream to client.
*/ */
export function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete }) { export function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete }) {
if (onRequestSuccess) onRequestSuccess(); if (onRequestSuccess) {
Promise.resolve()
.then(onRequestSuccess)
.catch(err => {
console.error("[ChatCore] onRequestSuccess failed:", err?.message || err);
});
}
// Warn when upstream returns unexpected Content-Type for a streaming response.
// This often means the provider returned an HTML error page or plain-text error
// that the SSE transform stream would forward as garbage to the client.
const upstreamContentType = (providerResponse.headers.get('content-type') || '').toLowerCase();
if (upstreamContentType && !upstreamContentType.includes('text/event-stream') && !upstreamContentType.includes('application/json')) {
console.warn('[STREAM] ' + provider + ' | ' + model + ' | unexpected Content-Type: ' + upstreamContentType);
}
const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }); const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey });

View File

@@ -1,30 +1,21 @@
// OpenAI-compatible embeddings adapter (most providers) // OpenAI-compatible embeddings adapter (most providers)
import { bearerAuth } from "./_base.js"; import { bearerAuth } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
// media-only providers without a registry file keep URL here; rest derive from registry media.embeddingConfig.baseUrl
const ENDPOINTS = { const ENDPOINTS = {
openai: "https://api.openai.com/v1/embeddings",
openrouter: "https://openrouter.ai/api/v1/embeddings",
mistral: "https://api.mistral.ai/v1/embeddings",
"voyage-ai": "https://api.voyageai.com/v1/embeddings",
fireworks: "https://api.fireworks.ai/inference/v1/embeddings",
together: "https://api.together.xyz/v1/embeddings",
nebius: "https://api.tokenfactory.nebius.com/v1/embeddings",
github: "https://models.github.ai/inference/embeddings",
nvidia: "https://integrate.api.nvidia.com/v1/embeddings",
"jina-ai": "https://api.jina.ai/v1/embeddings", "jina-ai": "https://api.jina.ai/v1/embeddings",
"vercel-ai-gateway": "https://ai-gateway.vercel.sh/v1/embeddings",
}; };
const embedCfg = (id) => PROVIDER_MEDIA[id]?.embeddingConfig || {};
const embedUrl = (id) => embedCfg(id).baseUrl || ENDPOINTS[id];
export default function createOpenAIEmbeddingAdapter(providerId) { export default function createOpenAIEmbeddingAdapter(providerId) {
const cfg = embedCfg(providerId);
return { return {
buildUrl: () => ENDPOINTS[providerId], buildUrl: () => embedUrl(providerId),
buildHeaders: (creds) => { buildHeaders: (creds) => {
const headers = { "Content-Type": "application/json", ...bearerAuth(creds) }; return { "Content-Type": "application/json", ...bearerAuth(creds), ...(cfg.headers || {}) };
if (providerId === "openrouter") {
headers["HTTP-Referer"] = "https://endpoint-proxy.local";
headers["X-Title"] = "Endpoint Proxy";
}
return headers;
}, },
buildBody: (model, { input, encoding_format, dimensions }) => { buildBody: (model, { input, encoding_format, dimensions }) => {
const body = { model, input }; const body = { model, input };

View File

@@ -50,6 +50,47 @@ export async function handleImageGenerationCore({
); );
} }
// Executor-delegating adapters: skip manual URL/headers/body, use the proven executor flow
if (adapter.useExecutor && adapter.executeViaExecutor) {
try {
log?.debug?.("IMAGE", `${provider.toUpperCase()} | ${model} | prompt="${body.prompt.slice(0, 50)}..." (executor)`);
const responseBody = await adapter.executeViaExecutor(model, body, credentials, log);
if (onRequestSuccess) await onRequestSuccess();
const normalized = adapter.normalize(responseBody, body.prompt);
const finalBody = (normalized.created && Array.isArray(normalized.data)) ? normalized : responseBody;
if (binaryOutput) {
const first = finalBody.data?.[0];
let b64 = first?.b64_json;
if (!b64 && first?.url) {
try { b64 = await urlToBase64(first.url); } catch {}
}
if (b64) {
const buf = Buffer.from(b64, "base64");
const fmt = (body.output_format || "png").toLowerCase();
const mime = fmt === "jpeg" || fmt === "jpg" ? "image/jpeg" : fmt === "webp" ? "image/webp" : "image/png";
return {
success: true,
response: new Response(buf, {
headers: { "Content-Type": mime, "Content-Disposition": `inline; filename="image.${fmt === "jpeg" ? "jpg" : fmt}"`, "Access-Control-Allow-Origin": "*" },
}),
};
}
}
return {
success: true,
response: new Response(JSON.stringify(finalBody), {
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
}),
};
} catch (error) {
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
log?.debug?.("IMAGE", `Executor error: ${errMsg}`);
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg);
}
}
let url; let url;
let headers; let headers;
let requestBody; let requestBody;

View File

@@ -0,0 +1,73 @@
// Antigravity image adapter - delegates to the executor for correct request
// envelope (project, model, requestType, sessionId) and auth headers.
import { nowSec } from "./_base.js";
import { getExecutor } from "../../executors/index.js";
// Convert image input (data URI or raw base64) to Gemini inlineData part
function resolveImageInput(input) {
if (!input || typeof input !== "string") return null;
// data:image/png;base64,... format
const dataUriMatch = input.match(/^data:(image\/[^;]+);base64,(.+)$/);
if (dataUriMatch) {
return { inlineData: { mimeType: dataUriMatch[1], data: dataUriMatch[2] } };
}
// Raw base64 string (assume PNG)
if (/^[A-Za-z0-9+/]/.test(input) && input.length > 100 && !input.startsWith("http")) {
return { inlineData: { mimeType: "image/png", data: input } };
}
return null;
}
export default {
// Delegate to executor instead of building URL/headers/body manually
useExecutor: true,
// Stubs - required by imageGenerationCore interface but unused with useExecutor
buildUrl: () => "",
buildHeaders: () => ({}),
buildBody: () => ({}),
async executeViaExecutor(model, body, credentials, log) {
const executor = getExecutor("antigravity");
if (!executor) throw new Error("Antigravity executor not found");
// Build parts: text prompt + optional input image for editing
const parts = [{ text: body.prompt }];
const imageInput = body.image || (Array.isArray(body.images) && body.images[0]);
if (imageInput) {
const inlineData = resolveImageInput(imageInput);
if (inlineData) parts.unshift(inlineData);
}
const chatBody = {
contents: [{ role: "user", parts }],
};
const result = await executor.execute({
model,
body: chatBody,
stream: false,
credentials,
log,
});
if (!result.response.ok) {
const text = await result.response.text();
throw new Error(text || `HTTP ${result.response.status}`);
}
return result.response.json();
},
normalize: (responseBody, prompt) => {
const candidates = responseBody.candidates || responseBody.response?.candidates || [];
const parts = candidates[0]?.content?.parts || [];
const images = parts.filter((p) => p.inlineData?.data).map((p) => ({
b64_json: p.inlineData.data,
}));
return {
created: nowSec(),
data: images.length > 0 ? images : [{ b64_json: "", revised_prompt: prompt }],
};
},
};

View File

@@ -1,7 +1,8 @@
// Black Forest Labs (FLUX) — async submit + polling_url // Black Forest Labs (FLUX) — async submit + polling_url
import { sleep, nowSec, POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from "./_base.js"; import { sleep, nowSec, POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const BASE_URL = "https://api.bfl.ai/v1"; const BASE_URL = PROVIDER_MEDIA["black-forest-labs"]?.imageConfig?.baseUrl;
export default { export default {
async: true, async: true,

View File

@@ -1,6 +1,7 @@
import { nowSec, urlToBase64 } from "./_base.js"; import { nowSec, urlToBase64 } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const BASE_URL = "https://api.cloudflare.com/client/v4/accounts"; const BASE_URL = PROVIDER_MEDIA["cloudflare-ai"]?.imageConfig?.baseUrl;
const MULTIPART_MODELS = new Set([ const MULTIPART_MODELS = new Set([
"@cf/black-forest-labs/flux-2-dev", "@cf/black-forest-labs/flux-2-dev",

View File

@@ -1,8 +1,9 @@
// Codex (ChatGPT Plus/Pro) image generation via Responses API + SSE // Codex (ChatGPT Plus/Pro) image generation via Responses API + SSE
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { nowSec } from "./_base.js"; import { nowSec } from "./_base.js";
import { PROVIDERS } from "../../config/providers.js";
const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"; const CODEX_RESPONSES_URL = PROVIDERS["codex"].baseUrl;
const CODEX_USER_AGENT = "codex_cli_rs/0.136.0"; const CODEX_USER_AGENT = "codex_cli_rs/0.136.0";
const CODEX_VERSION = "0.136.0"; const CODEX_VERSION = "0.136.0";
const CODEX_ORIGINATOR = "codex_cli_rs"; const CODEX_ORIGINATOR = "codex_cli_rs";

View File

@@ -1,7 +1,11 @@
// ComfyUI — local, noAuth (placeholder; full graph workflow not implemented) // ComfyUI — local, noAuth (placeholder; full graph workflow not implemented)
import { PROVIDER_MEDIA } from "../../providers/index.js";
const BASE_URL = PROVIDER_MEDIA["comfyui"]?.imageConfig?.baseUrl;
export default { export default {
noAuth: true, noAuth: true,
buildUrl: () => "http://localhost:8188", buildUrl: () => BASE_URL,
buildHeaders: () => ({ "Content-Type": "application/json" }), buildHeaders: () => ({ "Content-Type": "application/json" }),
buildBody: (_model, body) => ({ prompt: body.prompt }), buildBody: (_model, body) => ({ prompt: body.prompt }),
normalize: (responseBody) => responseBody, normalize: (responseBody) => responseBody,

View File

@@ -1,7 +1,8 @@
// Fal.ai — async submit + queue polling // Fal.ai — async submit + queue polling
import { sleep, nowSec, sizeToAspectRatio, POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from "./_base.js"; import { sleep, nowSec, sizeToAspectRatio, POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const BASE_URL = "https://queue.fal.run"; const BASE_URL = PROVIDER_MEDIA["fal-ai"]?.imageConfig?.baseUrl;
export default { export default {
async: true, async: true,

View File

@@ -1,7 +1,8 @@
// Google Gemini adapter (Nano Banana models) // Google Gemini adapter (Nano Banana models)
import { nowSec } from "./_base.js"; import { nowSec } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models"; const BASE_URL = PROVIDER_MEDIA["gemini"]?.imageConfig?.baseUrl;
export default { export default {
buildUrl: (model, creds) => { buildUrl: (model, creds) => {

View File

@@ -1,7 +1,8 @@
// HuggingFace Inference API — returns binary image // HuggingFace Inference API — returns binary image
import { nowSec } from "./_base.js"; import { nowSec } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const BASE_URL = "https://api-inference.huggingface.co/models"; const BASE_URL = PROVIDER_MEDIA["huggingface"]?.imageConfig?.baseUrl;
export default { export default {
buildUrl: (model) => `${BASE_URL}/${model}`, buildUrl: (model) => `${BASE_URL}/${model}`,

View File

@@ -11,6 +11,7 @@ import stabilityAi from "./stabilityAi.js";
import blackForestLabs from "./blackForestLabs.js"; import blackForestLabs from "./blackForestLabs.js";
import runwayml from "./runwayml.js"; import runwayml from "./runwayml.js";
import cloudflareAi from "./cloudflareAi.js"; import cloudflareAi from "./cloudflareAi.js";
import antigravity from "./antigravity.js";
const ADAPTERS = { const ADAPTERS = {
openai: createOpenAIAdapter("openai"), openai: createOpenAIAdapter("openai"),
@@ -25,6 +26,7 @@ const ADAPTERS = {
comfyui, comfyui,
huggingface, huggingface,
nanobanana, nanobanana,
antigravity,
"fal-ai": falAi, "fal-ai": falAi,
"stability-ai": stabilityAi, "stability-ai": stabilityAi,
"black-forest-labs": blackForestLabs, "black-forest-labs": blackForestLabs,

View File

@@ -1,8 +1,10 @@
// NanoBanana API — async submit + poll record-info // NanoBanana API — async submit + poll record-info
import { sleep, nowSec, sizeToAspectRatio, POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from "./_base.js"; import { sleep, nowSec, sizeToAspectRatio, POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const SUBMIT_URL = "https://api.nanobananaapi.ai/api/v1/nanobanana/generate"; const IMG_CFG = PROVIDER_MEDIA["nanobanana"]?.imageConfig || {};
const POLL_BASE = "https://api.nanobananaapi.ai/api/v1/nanobanana/record-info"; const SUBMIT_URL = IMG_CFG.baseUrl;
const POLL_BASE = IMG_CFG.pollUrl;
export default { export default {
async: true, async: true,

View File

@@ -1,40 +1,32 @@
// OpenAI-compatible adapter (used by openai, minimax, openrouter, recraft) // OpenAI-compatible adapter (used by openai, minimax, openrouter, recraft)
import { PROVIDER_MEDIA } from "../../providers/index.js";
const ENDPOINTS = { const imageCfg = (id) => PROVIDER_MEDIA[id]?.imageConfig || {};
openai: "https://api.openai.com/v1/images/generations", const imageUrl = (id) => imageCfg(id).baseUrl;
minimax: "https://api.minimaxi.com/v1/images/generations",
openrouter: "https://openrouter.ai/api/v1/images/generations",
recraft: "https://external.api.recraft.ai/v1/images/generations",
"vercel-ai-gateway": "https://ai-gateway.vercel.sh/v1/images/generations",
xai: "https://api.x.ai/v1/images/generations",
};
export default function createOpenAIAdapter(providerId) { export default function createOpenAIAdapter(providerId) {
const cfg = imageCfg(providerId);
return { return {
buildUrl: () => ENDPOINTS[providerId], buildUrl: () => imageUrl(providerId),
buildHeaders: (creds) => { buildHeaders: (creds) => {
const headers = { "Content-Type": "application/json" }; const headers = { "Content-Type": "application/json", ...(cfg.headers || {}) };
const key = creds?.apiKey || creds?.accessToken; const key = creds?.apiKey || creds?.accessToken;
if (key) headers["Authorization"] = `Bearer ${key}`; if (key) headers["Authorization"] = `Bearer ${key}`;
if (providerId === "openrouter") {
headers["HTTP-Referer"] = "https://endpoint-proxy.local";
headers["X-Title"] = "Endpoint Proxy";
}
return headers; return headers;
}, },
buildBody: (model, body) => { buildBody: (model, body) => {
const { prompt, n = 1, size = "1024x1024", quality, style, response_format } = body; const { prompt, n = 1, size = "1024x1024", quality, style, response_format } = body;
// xAI only accepts prompt, model, n, response_format const full = { model, prompt, n, size };
if (providerId === "xai") { if (quality) full.quality = quality;
const req = { model, prompt, n }; if (style) full.style = style;
if (response_format) req.response_format = response_format; if (response_format) full.response_format = response_format;
// bodyFields whitelist (e.g. xAI accepts only model/prompt/n/response_format)
if (Array.isArray(cfg.bodyFields)) {
const req = {};
for (const f of cfg.bodyFields) if (full[f] !== undefined) req[f] = full[f];
return req; return req;
} }
const req = { model, prompt, n, size }; return full;
if (quality) req.quality = quality;
if (style) req.style = style;
if (response_format) req.response_format = response_format;
return req;
}, },
normalize: (responseBody) => responseBody, normalize: (responseBody) => responseBody,
}; };

View File

@@ -1,7 +1,8 @@
// Runway ML — async submit + /tasks/{id} polling // Runway ML — async submit + /tasks/{id} polling
import { sleep, nowSec, sizeToAspectRatio, POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from "./_base.js"; import { sleep, nowSec, sizeToAspectRatio, POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const BASE_URL = "https://api.dev.runwayml.com/v1"; const BASE_URL = PROVIDER_MEDIA["runwayml"]?.imageConfig?.baseUrl;
export default { export default {
async: true, async: true,

View File

@@ -1,9 +1,12 @@
// SD WebUI (AUTOMATIC1111) — local, noAuth // SD WebUI (AUTOMATIC1111) — local, noAuth
import { nowSec } from "./_base.js"; import { nowSec } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const BASE_URL = PROVIDER_MEDIA["sdwebui"]?.imageConfig?.baseUrl;
export default { export default {
noAuth: true, noAuth: true,
buildUrl: () => "http://localhost:7860/sdapi/v1/txt2img", buildUrl: () => BASE_URL,
buildHeaders: () => ({ "Content-Type": "application/json" }), buildHeaders: () => ({ "Content-Type": "application/json" }),
buildBody: (_model, body) => { buildBody: (_model, body) => {
const { prompt, n = 1, size = "1024x1024" } = body; const { prompt, n = 1, size = "1024x1024" } = body;

View File

@@ -1,7 +1,8 @@
// Stability AI v2 — sync, returns { image: "<b64>" } // Stability AI v2 — sync, returns { image: "<b64>" }
import { nowSec, sizeToAspectRatio } from "./_base.js"; import { nowSec, sizeToAspectRatio } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const BASE_URL = "https://api.stability.ai/v2beta/stable-image/generate"; const BASE_URL = PROVIDER_MEDIA["stability-ai"]?.imageConfig?.baseUrl;
// Map model id → endpoint segment // Map model id → endpoint segment
function modelToEndpoint(model) { function modelToEndpoint(model) {

View File

@@ -4,9 +4,10 @@
*/ */
import { handleChatCore } from "./chatCore.js"; import { handleChatCore } from "./chatCore.js";
import { convertResponsesApiFormat } from "../translator/helpers/responsesApiHelper.js"; import { convertResponsesApiFormat } from "../translator/formats/responsesApi.js";
import { createResponsesApiTransformStream } from "../transformer/responsesTransformer.js"; import { createResponsesApiTransformStream } from "../transformer/responsesTransformer.js";
import { convertResponsesStreamToJson } from "../transformer/streamToJsonConverter.js"; import { convertResponsesStreamToJson } from "../transformer/streamToJsonConverter.js";
import { SSE_HEADERS_CORS } from "../utils/sseConstants.js";
/** /**
* Handle /v1/responses request * Handle /v1/responses request
@@ -87,12 +88,7 @@ export async function handleResponsesCore({ body, modelInfo, credentials, log, o
success: true, success: true,
response: new Response(transformedBody, { response: new Response(transformedBody, {
status: 200, status: 200,
headers: { headers: { ...SSE_HEADERS_CORS }
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*"
}
}) })
}; };
} }

View File

@@ -2,6 +2,12 @@
* Wrap chat-completions endpoints (with built-in web search) into the unified * Wrap chat-completions endpoints (with built-in web search) into the unified
* /v1/search response format. Supports gemini, openai, xai, kimi, minimax, perplexity. * /v1/search response format. Supports gemini, openai, xai, kimi, minimax, perplexity.
*/ */
import { PROVIDER_MEDIA } from "../../providers/index.js";
// Default search model + endpoint derive from registry searchViaChat (single source)
const searchModel = (id) => PROVIDER_MEDIA[id]?.searchViaChat?.defaultModel;
const searchEndpoint = (id, model) =>
(PROVIDER_MEDIA[id]?.searchViaChat?.endpoint || "").replace("{model}", model || "");
const REQUEST_TIMEOUT_MS = 15000; const REQUEST_TIMEOUT_MS = 15000;
const DEFAULT_MAX_RESULTS = 10; const DEFAULT_MAX_RESULTS = 10;
@@ -43,9 +49,7 @@ function normalizeCitation(c) {
*/ */
const CHAT_SEARCH_CONFIG = { const CHAT_SEARCH_CONFIG = {
gemini: { gemini: {
endpoint: (model) => endpoint: (model) => searchEndpoint("gemini", model),
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
defaultModel: "gemini-2.5-flash",
buildBody: (query) => ({ buildBody: (query) => ({
contents: [{ role: "user", parts: [{ text: query }] }], contents: [{ role: "user", parts: [{ text: query }] }],
tools: [{ google_search: {} }] tools: [{ google_search: {} }]
@@ -70,8 +74,7 @@ const CHAT_SEARCH_CONFIG = {
}, },
openai: { openai: {
endpoint: () => "https://api.openai.com/v1/chat/completions", endpoint: () => searchEndpoint("openai"),
defaultModel: "gpt-4o-mini",
buildBody: (query, model) => { buildBody: (query, model) => {
const body = { const body = {
model, model,
@@ -105,8 +108,7 @@ const CHAT_SEARCH_CONFIG = {
}, },
xai: { xai: {
endpoint: () => "https://api.x.ai/v1/responses", endpoint: () => searchEndpoint("xai"),
defaultModel: "grok-4.20-reasoning",
buildBody: (query, model) => ({ buildBody: (query, model) => ({
model, model,
input: [{ role: "user", content: query }], input: [{ role: "user", content: query }],
@@ -145,8 +147,7 @@ const CHAT_SEARCH_CONFIG = {
}, },
kimi: { kimi: {
endpoint: () => "https://api.moonshot.cn/v1/chat/completions", endpoint: () => searchEndpoint("kimi"),
defaultModel: "kimi-k2.5",
buildBody: (query, model) => ({ buildBody: (query, model) => ({
model, model,
messages: [{ role: "user", content: query }], messages: [{ role: "user", content: query }],
@@ -195,8 +196,7 @@ const CHAT_SEARCH_CONFIG = {
}, },
minimax: { minimax: {
endpoint: () => "https://api.minimaxi.com/v1/text/chatcompletion_v2", endpoint: () => searchEndpoint("minimax"),
defaultModel: "MiniMax-M2.7",
buildBody: (query, model) => ({ buildBody: (query, model) => ({
model, model,
messages: [{ role: "user", content: query }], messages: [{ role: "user", content: query }],
@@ -254,8 +254,7 @@ const CHAT_SEARCH_CONFIG = {
}, },
perplexity: { perplexity: {
endpoint: () => "https://api.perplexity.ai/chat/completions", endpoint: () => searchEndpoint("perplexity"),
defaultModel: "sonar",
buildBody: (query, model) => ({ buildBody: (query, model) => ({
model, model,
messages: [{ role: "user", content: query }] messages: [{ role: "user", content: query }]
@@ -324,7 +323,7 @@ export async function handleChatSearch({
Number.isFinite(maxResults) && maxResults > 0 Number.isFinite(maxResults) && maxResults > 0
? Math.floor(maxResults) ? Math.floor(maxResults)
: DEFAULT_MAX_RESULTS; : DEFAULT_MAX_RESULTS;
const useModel = model || cfg.defaultModel; const useModel = model || searchModel(provider);
const url = cfg.endpoint(useModel); const url = cfg.endpoint(useModel);
const body = cfg.buildBody(query, useModel); const body = cfg.buildBody(query, useModel);
const headers = cfg.buildHeaders(token); const headers = cfg.buildHeaders(token);

View File

@@ -1,7 +1,6 @@
import { Buffer } from "node:buffer"; import { Buffer } from "node:buffer";
import { createErrorResult } from "../utils/error.js"; import { createErrorResult } from "../utils/error.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { AI_PROVIDERS } from "../../src/shared/constants/providers.js";
// Build auth headers from sttConfig + token // Build auth headers from sttConfig + token
function buildAuthHeaders(cfg, token) { function buildAuthHeaders(cfg, token) {
@@ -167,11 +166,11 @@ function jsonResponse(obj) {
* STT core handler — dispatch by sttConfig.format. * STT core handler — dispatch by sttConfig.format.
* @returns {Promise<{success, response, status?, error?}>} * @returns {Promise<{success, response, status?, error?}>}
*/ */
export async function handleSttCore({ provider, model, formData, credentials }) { export async function handleSttCore({ provider, model, formData, credentials, sttConfig }) {
const file = formData.get("file"); const file = formData.get("file");
if (!file) return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: file"); if (!file) return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: file");
const cfg = AI_PROVIDERS[provider]?.sttConfig; const cfg = sttConfig;
if (!cfg) return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Provider '${provider}' does not support STT`); if (!cfg) return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Provider '${provider}' does not support STT`);
const token = cfg.authType === "none" ? null : (credentials?.apiKey || credentials?.accessToken); const token = cfg.authType === "none" ? null : (credentials?.apiKey || credentials?.accessToken);

View File

@@ -1,9 +1,20 @@
// Gemini TTS — generateContent with AUDIO modality returns PCM L16, wrap as WAV // Gemini TTS — generateContent with AUDIO modality returns PCM L16, wrap as WAV
import { Buffer } from "node:buffer"; import { Buffer } from "node:buffer";
import { PROVIDER_MEDIA, PROVIDER_MODELS } from "../../providers/index.js";
const DEFAULT_MODEL = "gemini-2.5-flash-preview-tts"; const TTS_CFG = PROVIDER_MEDIA["gemini"]?.ttsConfig || {};
const TTS_BASE = TTS_CFG.baseUrl;
const FALLBACK_MODEL = "gemini-3.1-flash-tts-preview";
const KNOWN_MODELS = [
...(TTS_CFG.models || []),
...(PROVIDER_MODELS["gemini-tts-models"] || []),
...(PROVIDER_MODELS.gemini || []).filter((m) => (m.kind || m.type) === "tts"),
]
.map((m) => m?.id)
.filter(Boolean)
.filter((id, index, list) => list.indexOf(id) === index);
const DEFAULT_MODEL = KNOWN_MODELS[0] || FALLBACK_MODEL;
const DEFAULT_VOICE = "Kore"; const DEFAULT_VOICE = "Kore";
const KNOWN_MODELS = ["gemini-2.5-flash-preview-tts", "gemini-2.5-pro-preview-tts"];
// Parse "model/voice" — if input doesn't match a known TTS model, treat it as voice with default model // Parse "model/voice" — if input doesn't match a known TTS model, treat it as voice with default model
function parseGeminiModelVoice(input) { function parseGeminiModelVoice(input) {
@@ -51,7 +62,7 @@ export default {
async synthesize(text, model, credentials, _responseFormat, opts = {}) { async synthesize(text, model, credentials, _responseFormat, opts = {}) {
if (!credentials?.apiKey) throw new Error("No Gemini API key configured"); if (!credentials?.apiKey) throw new Error("No Gemini API key configured");
const { modelId, voiceId } = parseGeminiModelVoice(model); const { modelId, voiceId } = parseGeminiModelVoice(model);
const url = `https://generativelanguage.googleapis.com/v1beta/models/${modelId}:generateContent?key=${credentials.apiKey}`; const url = `${TTS_BASE}/${modelId}:generateContent?key=${credentials.apiKey}`;
const res = await fetch(url, { const res = await fetch(url, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },

View File

@@ -33,8 +33,10 @@ export async function synthesizeViaConfig(provider, text, model, credentials) {
if (!handler) return null; if (!handler) return null;
const apiKey = credentials?.apiKey; const apiKey = credentials?.apiKey;
if (cfg.authType !== "none" && !apiKey) throw new Error(`${provider} API key required`); if (cfg.authType !== "none" && !apiKey) throw new Error(`${provider} API key required`);
const defaultModel = cfg.models?.[0]?.id || ""; const { PROVIDER_MODELS } = await import("open-sse/config/providerModels.js");
const { modelId, voiceId } = parseModelVoice(model, defaultModel, "", cfg.models || []); const ttsModels = (PROVIDER_MODELS[provider] || []).filter(m => (m.kind || m.type) === "tts");
const defaultModel = ttsModels[0]?.id || "";
const { modelId, voiceId } = parseModelVoice(model, defaultModel, "", ttsModels);
return handler({ baseUrl: cfg.baseUrl, apiKey, text, modelId, voiceId }); return handler({ baseUrl: cfg.baseUrl, apiKey, text, modelId, voiceId });
} }

View File

@@ -1,11 +1,14 @@
// OpenAI TTS — model format: "tts-model/voice" // OpenAI TTS — model format: "tts-model/voice"
import { Buffer } from "node:buffer"; import { Buffer } from "node:buffer";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const DEFAULT_TTS_MODEL = PROVIDER_MEDIA["openai"]?.ttsConfig?.defaultModel;
export default { export default {
async synthesize(text, model, credentials) { async synthesize(text, model, credentials) {
if (!credentials?.apiKey) throw new Error("No OpenAI API key configured"); if (!credentials?.apiKey) throw new Error("No OpenAI API key configured");
let ttsModel = "gpt-4o-mini-tts"; let ttsModel = DEFAULT_TTS_MODEL;
let voice = "alloy"; let voice = "alloy";
if (model && model.includes("/")) { if (model && model.includes("/")) {
const parts = model.split("/"); const parts = model.split("/");

View File

@@ -1,10 +1,14 @@
// OpenRouter TTS — via chat completions + audio modality (SSE stream) // OpenRouter TTS — via chat completions + audio modality (SSE stream)
import { PROVIDER_MEDIA } from "../../providers/index.js";
const TTS_CFG = PROVIDER_MEDIA["openrouter"]?.ttsConfig || {};
export default { export default {
async synthesize(text, model, credentials) { async synthesize(text, model, credentials) {
if (!credentials?.apiKey) throw new Error("No OpenRouter API key configured"); if (!credentials?.apiKey) throw new Error("No OpenRouter API key configured");
// model format: "tts-model/voice" e.g. "openai/gpt-4o-mini-tts/alloy" // model format: "tts-model/voice" e.g. "openai/gpt-4o-mini-tts/alloy"
let ttsModel = "openai/gpt-4o-mini-tts"; let ttsModel = TTS_CFG.defaultModel;
let voice = "alloy"; let voice = "alloy";
if (model && model.includes("/")) { if (model && model.includes("/")) {
const lastSlash = model.lastIndexOf("/"); const lastSlash = model.lastIndexOf("/");
@@ -20,13 +24,12 @@ export default {
voice = model; voice = model;
} }
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { const res = await fetch(TTS_CFG.baseUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": `Bearer ${credentials.apiKey}`, "Authorization": `Bearer ${credentials.apiKey}`,
"HTTP-Referer": "https://endpoint-proxy.local", ...(TTS_CFG.headers || {}),
"X-Title": "Endpoint Proxy",
}, },
body: JSON.stringify({ body: JSON.stringify({
model: ttsModel, model: ttsModel,

View File

@@ -30,9 +30,6 @@ export {
// Services // Services
export { export {
detectFormat, detectFormat,
getProviderConfig,
buildProviderUrl,
buildProviderHeaders,
getTargetFormat getTargetFormat
} from "./services/provider.js"; } from "./services/provider.js";

View File

@@ -0,0 +1,98 @@
/**
* REGISTRY ENTRY TEMPLATE — copy into registry/{id}.js when adding a new provider.
*
* NOT imported by registry/index.js (lives outside registry/, static-import list ignores it).
* Delete every block your provider does not need. Only `id` + `category` are required.
* Field contract: see schema.js `@typedef RegistryEntry`. Runtime builders: providers/index.js.
*
* Quick recipes:
* - Plain API-key LLM → id, alias, category:"apikey", display, transport{baseUrl}, models.
* - OAuth LLM (device/PKCE)→ add oauth{...}; clientId/tokenUrl auto-inject into transport.
* - Media-only (tts/stt/…) → drop `models`+chat baseUrl, fill media{serviceKinds, *Config}.
*/
// import { CLAUDE_API_HEADERS, GOOGLE_OAUTH_CLIENT, OPENAI_COMPAT_BASE } from "./shared.js";
export default {
// ── identity ────────────────────────────────────────────────────────────
id: "example", // REQUIRED. kebab-case, unique.
alias: "ex", // short key for PROVIDER_MODELS (defaults to id if omitted).
aliases: ["example-ai"], // optional extra lookup tokens.
uiAlias: "ex", // optional UI badge token.
category: "apikey", // REQUIRED. "apikey" | "oauth" | "freeTier" | ...
// ── auth hints (only when relevant) ──────────────────────────────────────
authType: "apikey", // "apikey" | "oauth".
hasOAuth: false, // true if an OAuth flow exists.
authModes: ["apikey"], // e.g. ["oauth","apikey"] when both supported.
// noAuth: true, // local/free providers needing no credential.
// ── UI display ───────────────────────────────────────────────────────────
display: {
name: "Example",
icon: "bolt", // material icon name OR textIcon fallback.
color: "#3B82F6",
textIcon: "EX",
website: "https://example.com",
notice: { apiKeyUrl: "https://example.com/keys" }, // or signupUrl.
// deprecated: true, deprecationNotice: "RISK_NOTICE",
// kindNotice: { image: "Requires paid plan." },
// mediaPriority: 1,
},
// ── transport (HTTP runtime) → PROVIDERS[id] ─────────────────────────────
// Defaults applied: format:"openai". Declare ONLY what differs.
transport: {
baseUrl: "https://api.example.com/v1/chat/completions",
format: "openai", // "openai" | "claude" | "gemini" | "openai-responses" | ...
// validateUrl: "https://api.example.com/v1/models",
// headers: { "User-Agent": "..." }, // static fingerprint (anti-ban) lives here.
// auth: { header: "x-api-key", scheme: "raw" },
// forceStream: true, urlSuffix: "?beta=true",
// quirks: { dropOutputConfig: true },
// retry: { 429: { attempts: 6 }, 503: { attempts: 3 } },
// usage: { url: "https://api.example.com/usage" }, // or { urls: [...] } for multi-call.
// modelsFetcher: { url: "https://api.example.com/models", type: "openai" }, // dynamic model list.
// regions: { sgp: "https://sgp...", cn: "https://cn..." }, defaultRegion: "sgp",
// NOTE: clientId/clientSecret/tokenUrl are injected from `oauth` — do NOT duplicate here.
},
// ── oauth flow → PROVIDER_OAUTH[id] (omit for pure API-key) ───────────────
// oauth: {
// clientId: "app_xxx",
// authorizeUrl: "https://auth.example.com/oauth/authorize", // PKCE/code flow.
// tokenUrl: "https://auth.example.com/oauth/token",
// deviceCodeUrl: "https://auth.example.com/device", // device-code flow.
// refreshUrl: "https://auth.example.com/oauth/token",
// scope: "openid profile offline_access", // or scopes: [...].
// codeChallengeMethod: "S256",
// redirectUri: "http://127.0.0.1:1455/auth/callback", fixedPort: 1455, callbackPath: "/auth/callback",
// extraParams: { foo: "bar" },
// refresh: { encoding: "form", scope: "openid offline_access" }, // "form" | "json".
// refreshLeadMs: 300000,
// userInfoUrl: "https://example.com/userinfo",
// },
// ── media (non-LLM services) → PROVIDER_MEDIA[id] ────────────────────────
// media: {
// serviceKinds: ["llm", "tts", "stt", "embedding", "image", "imageToText", "webSearch"],
// ttsConfig: { baseUrl: "...", authType: "apikey", authHeader: "bearer", format: "openai", defaultModel: "tts-1", models: [{ id: "tts-1", name: "TTS-1" }] },
// sttConfig: { baseUrl: "...", authType: "apikey", authHeader: "bearer", format: "openai", models: [{ id: "whisper-1", name: "Whisper" }] },
// embeddingConfig: { baseUrl: "...", authType: "apikey", authHeader: "bearer", models: [{ id: "emb-1", name: "Emb", dimensions: 1536 }] },
// imageConfig: { baseUrl: "https://api.example.com/v1/images/generations" },
// searchViaChat: { defaultModel: "ex-search", pricingUrl: "https://example.com/pricing" },
// // hiddenKinds: ["image"],
// },
// ── models (omit = no key; [] = explicit empty) ──────────────────────────
models: [
{ id: "example-large", name: "Example Large" },
// { id: "example-img", name: "Example Image", type: "image", capabilities: ["text2img"], params: ["size"] },
// { id: "example-emb", name: "Example Embed", type: "embedding" },
],
// ── optional flags ───────────────────────────────────────────────────────
// features: { usage: true },
// thinkingConfig: { options: ["auto", "none", "low", "high"], defaultMode: "auto" },
// passthroughModels: true,
};

View File

@@ -0,0 +1,274 @@
// Model capabilities — what each model can read/do beyond plain text.
//
// Fallback order (first match wins), result merged over DEFAULT_CAPABILITIES:
// 1. PROVIDER_CAPABILITIES[provider][model] — provider-specific override
// 2. MODEL_CAPABILITIES[model] — canonical exact id (handles exceptions)
// 3. PATTERN_CAPABILITIES — glob match, ordered specific -> generic
// 4. DEFAULT_CAPABILITIES — safe floor (always returned)
//
// ── HOW TO ADD / UPDATE A MODEL ──────────────────────────────────────
// Authoritative data source: https://models.dev/api.json (145 providers, 4000+
// models, MIT). Each model exposes the exact fields we map below:
// modalities.input ["text","image","pdf","audio","video"] -> vision / pdf / audioInput / videoInput
// modalities.output ["text","image","audio"] -> imageOutput / audioOutput
// reasoning -> reasoning tool_call -> tools
// limit.context -> contextWindow limit.output -> maxOutput
// Look up the model id, then:
// • If a PATTERN below already covers it correctly -> nothing to do.
// • If it is an exception (pattern would mis-match) -> add an exact entry to
// MODEL_CAPABILITIES (only the fields that differ from DEFAULT).
// • If a whole new family -> add an ordered PATTERN (specific before generic).
// NOTE: models.dev has NO "search" flag (web search is a runtime tool, not a
// model spec); set `search` from vendor docs (Claude 4.x+, GPT-5.x/4o, Gemini
// 2.0+, Grok, Perplexity). Verify with: curl -s https://models.dev/api.json
import { matchPattern } from "./pricing.js";
/**
* Safe floor — every resolved result is merged over this so consumers
* never need null-checks. Most modern LLMs meet these limits.
*/
export const DEFAULT_CAPABILITIES = {
// input modalities
vision: false, // read images
pdf: false, // read PDF / documents
audioInput: false, // read audio
videoInput: false, // read video
// output modalities
imageOutput: false, // generate images
audioOutput: false, // generate audio
// features
search: false, // built-in web search tool / grounding
tools: true, // function / tool calling
reasoning: false, // thinking / reasoning
// thinking wire format (only meaningful when reasoning:true). null → derive from transport.format.
// enum: openai|claude-adaptive|claude-budget|gemini-level|gemini-budget|zai|qwen|deepseek|kimi|minimax|hunyuan|step
thinkingFormat: null,
thinkingCanDisable: true, // false → model cannot turn thinking off (clamp to min instead of disable)
thinkingRange: null, // { min, max } for budget formats; null = no clamp
// limits (tokens)
contextWindow: 200000,
maxOutput: 64000,
};
// User-added model metadata can carry dashboard service kinds instead of the
// runtime capability names used here. Map those typed model kinds into input /
// output capabilities so custom vision models are not treated as text-only.
const SERVICE_KIND_CAPABILITIES = {
imageToText: { vision: true },
image: { imageOutput: true },
stt: { audioInput: true },
tts: { audioOutput: true },
embedding: { tools: false },
};
export function capabilitiesFromServiceKind(kind) {
return SERVICE_KIND_CAPABILITIES[kind] || null;
}
/**
* Canonical exact-id overrides — used for exceptions that patterns would
* otherwise mis-match. Only declare deltas vs DEFAULT.
*/
export const MODEL_CAPABILITIES = {
// Claude 4.6/4.7/4.8 have 1M context + adaptive thinking (override generic claude pattern)
"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.8": { 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-8": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-4.8-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-4-8-thinking": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-sonnet-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-sonnet-4-6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
// Gemini image-gen / OpenAI image / xai image variants
"gpt-image-1": { imageOutput: true, tools: false },
// GLM vision variant (text GLM has no vision)
"glm-4.6v": { vision: true, reasoning: true, thinkingFormat: "zai", contextWindow: 128000 },
// Qwen plain coder/text (no vision) — registry "vision-model" / "coder-model" aliases
"vision-model": { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 },
"coder-model": { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 },
};
/**
* Provider-specific capability overrides. Keyed by provider alias/id.
*/
export const PROVIDER_CAPABILITIES = {
// CodeBuddy.cn — authoritative per-model metadata from the gateway's model
// config (contextWindow=maxInputTokens, maxOutput=maxOutputTokens, vision=
// supportsImages). Every model reasons via OpenAI-style reasoning_effort
// (see registry thinkingFormat). `onlyReasoning` models can't turn thinking
// off → thinkingCanDisable:false (clamped to minimal instead of disabled).
"codebuddy-cn": {
"glm-5.2": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 48000 },
"glm-5.1": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 48000 },
"glm-5.0": { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 48000 },
"glm-5.0-turbo": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 48000 },
"glm-5v-turbo": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 38000 },
"glm-4.7": { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 48000 },
"minimax-m3": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 512000, maxOutput: 48000 },
"minimax-m2.7": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 48000 },
"kimi-k2.7": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 256000, maxOutput: 32000 },
"kimi-k2.6": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 256000, maxOutput: 32000 },
"kimi-k2.5": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 164000, maxOutput: 32000 },
"hy3-preview": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 192000, maxOutput: 64000 },
"deepseek-v4-pro": { 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 },
},
};
/**
* Pattern fallback — glob (* = wildcard), matched case-insensitively and
* anchored (^...$) so a pattern must match the full model id. ORDER MATTERS:
* vision/specific variants first, text-only/generic families last, to avoid
* a broad family pattern swallowing an exception (e.g. glm-4.6v vs glm-5).
*/
export const PATTERN_CAPABILITIES = [
// ── Claude (4.6+ = adaptive thinking; older/haiku = budget) ──────
{ pattern: "*claude*opus-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
{ pattern: "*claude*opus-4.7*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
{ pattern: "*claude*opus-4.8*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
{ pattern: "*claude*sonnet-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
{ pattern: "*claude*sonnet-4.7*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
{ pattern: "*claude*haiku*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget" } },
{ pattern: "*claude*opus*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget" } },
{ pattern: "*claude*sonnet*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget" } },
{ pattern: "*claude*fable*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget", contextWindow: 1000000, maxOutput: 128000 } },
{ pattern: "*claude*mythos*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget", contextWindow: 1000000, maxOutput: 128000 } },
{ pattern: "*claude-3*", caps: { vision: true } },
{ pattern: "*claude*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget" } },
// ── Gemini (all 2.0+ multimodal + google_search grounding, 1M ctx) ─
{ pattern: "*gemini*image*", caps: { vision: true, imageOutput: true, contextWindow: 1048576 } },
{ pattern: "*gemini-3*pro*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65535 } },
{ pattern: "*gemini-3*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65536 } },
{ pattern: "*gemini-2.5*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-budget", thinkingRange: { min: 0, max: 24576 }, contextWindow: 1048576, maxOutput: 65536 } },
{ pattern: "*gemini-2*", caps: { vision: true, audioInput: true, videoInput: true, search: true, contextWindow: 1048576, maxOutput: 65536 } },
{ pattern: "*gemini*", caps: { vision: true, search: true, contextWindow: 1048576 } },
{ pattern: "*gemma*", caps: { vision: true, contextWindow: 128000 } },
{ pattern: "*nanobanana*", caps: { vision: true, imageOutput: true } },
// ── OpenAI GPT-5.x (vision + thinking + web search) ──────────────
{ pattern: "*gpt-5*image*", caps: { imageOutput: true } },
{ pattern: "*gpt-5*codex*", caps: { reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 400000, maxOutput: 128000 } },
{ pattern: "*gpt-5*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 400000, maxOutput: 128000 } },
{ pattern: "*gpt-4o*", caps: { vision: true, search: true, contextWindow: 128000, maxOutput: 16384 } },
{ pattern: "*gpt-4.1*", caps: { vision: true, contextWindow: 1000000, maxOutput: 32768 } },
{ pattern: "*gpt-4-turbo*", caps: { vision: true, contextWindow: 128000 } },
{ pattern: "*gpt-4*", caps: { contextWindow: 128000 } },
{ pattern: "*gpt-3.5*", caps: { contextWindow: 16385, maxOutput: 4096 } },
{ pattern: "*gpt-oss*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 128000 } },
// ── OpenAI o-series (reasoning, vision) ──────────────────────────
{ pattern: "*o1-mini*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 128000 } },
{ pattern: "*o1*", caps: { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 100000 } },
{ pattern: "*o3*", caps: { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 100000 } },
{ pattern: "*o4*", caps: { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 100000 } },
// ── Grok (vision + Live Search) ──────────────────────────────────
{ pattern: "*grok*image*", caps: { imageOutput: true } },
{ pattern: "*grok-code*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 256000 } },
{ pattern: "*grok-4*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 256000 } },
{ pattern: "*grok-3*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 131072 } },
{ pattern: "*grok*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 256000 } },
// ── Qwen (enable_thinking + thinking_budget; QwQ = thinking-only) ─
{ pattern: "*qwen*vl*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } },
{ pattern: "*qwen*max*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
{ pattern: "*qwen*plus*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
{ pattern: "*qwen*235b*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } },
{ pattern: "*qwen*coder*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 } },
{ pattern: "*qwq*", caps: { reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 131072 } },
{ pattern: "*qwen*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } },
// ── Kimi (enabled→reasoning_effort; K2.7-code cannot disable) ─────
{ pattern: "*kimi*k2.7*code*", caps: { vision: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 262144 } },
{ pattern: "*kimi*k2*", caps: { vision: true, reasoning: true, thinkingFormat: "kimi", contextWindow: 262144, maxOutput: 262144 } },
{ pattern: "*kimi*", caps: { reasoning: true, thinkingFormat: "kimi", contextWindow: 262144 } },
// ── GLM / Z.ai (thinking.enabled; disable via enable_thinking:false) ─
{ pattern: "*glm-5*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000, maxOutput: 128000 } },
{ pattern: "*glm-4.7*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000, maxOutput: 128000 } },
{ pattern: "*glm-4*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000 } },
{ pattern: "*glm*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000 } },
// ── DeepSeek (thinking.enabled + reasoning_effort; r1 = thinking-only) ─
{ pattern: "*deepseek-v4*", caps: { reasoning: true, thinkingFormat: "deepseek", contextWindow: 1000000, maxOutput: 384000 } },
{ pattern: "*reasoner*", caps: { reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 128000 } },
{ pattern: "*deepseek-r*", caps: { reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 128000 } },
{ pattern: "*deepseek-chat*", caps: { contextWindow: 128000 } },
{ pattern: "*deepseek*", caps: { reasoning: true, thinkingFormat: "deepseek", contextWindow: 128000 } },
// ── MiniMax (M3 = adaptive; M2.x cannot disable) ─────────────────
{ pattern: "*minimax*image*", caps: { imageOutput: true } },
{ pattern: "*minimax-m3*", caps: { vision: true, reasoning: true, thinkingFormat: "minimax", contextWindow: 1048576, maxOutput: 512000 } },
{ pattern: "*minimax-m2.7*", caps: { reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 204800, maxOutput: 131072 } },
{ pattern: "*minimax*", caps: { reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 131072 } },
// ── Xiaomi MiMo (vision, 1M / 262K ctx) ──────────────────────────
{ pattern: "*mimo*v2.5*", caps: { vision: true, contextWindow: 1048576, maxOutput: 131072 } },
{ pattern: "*mimo*omni*", caps: { vision: true, audioInput: true, contextWindow: 262144, maxOutput: 131072 } },
{ pattern: "*mimo*", caps: { vision: true, contextWindow: 262144, maxOutput: 131072 } },
// ── Llama (4 = vision/1M; 3.x = text-only/128K) ──────────────────
{ pattern: "*llama-4*", caps: { vision: true, contextWindow: 1000000 } },
{ pattern: "*llama*", caps: { contextWindow: 128000 } },
// ── Mistral (Large 3 = vision/256K; codestral text) ──────────────
{ pattern: "*codestral*", caps: { contextWindow: 256000 } },
{ pattern: "*mistral-large*", caps: { vision: true, contextWindow: 256000 } },
{ pattern: "*mistral*", caps: { contextWindow: 128000 } },
// ── Cohere (Command A Vision = vision; others text) ──────────────
{ pattern: "*command-a-vision*", caps: { vision: true, contextWindow: 128000 } },
{ pattern: "*command*", caps: { contextWindow: 128000 } },
// ── Perplexity (web search native) ───────────────────────────────
{ pattern: "*sonar*", caps: { search: true, contextWindow: 128000 } },
{ pattern: "*pplx*", caps: { search: true, contextWindow: 128000 } },
{ pattern: "*perplexity*", caps: { search: true, contextWindow: 128000 } },
// ── Others ───────────────────────────────────────────────────────
{ pattern: "*hunyuan*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } },
{ pattern: "hy3*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } },
{ pattern: "*step-*", caps: { reasoning: true, thinkingFormat: "step", contextWindow: 128000 } },
{ pattern: "*nemotron*", caps: { reasoning: true, contextWindow: 128000 } },
{ pattern: "*ling-*", caps: { reasoning: true, contextWindow: 128000 } },
];
/**
* Resolve capabilities for a model using the 4-step fallback chain,
* merged over DEFAULT_CAPABILITIES so the result is always complete.
*
* @param {string} provider
* @param {string} model
* @returns {object} full capabilities object
*/
export function getCapabilitiesForModel(provider, model) {
if (!model) return { ...DEFAULT_CAPABILITIES };
// 1. Provider-specific override
if (provider && PROVIDER_CAPABILITIES[provider]?.[model]) {
return { ...DEFAULT_CAPABILITIES, ...PROVIDER_CAPABILITIES[provider][model] };
}
// 2. Canonical exact (strip vendor prefix: "anthropic/claude-opus-4.7" -> "claude-opus-4.7")
const baseModel = model.includes("/") ? model.split("/").pop() : model;
if (MODEL_CAPABILITIES[baseModel]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[baseModel] };
if (MODEL_CAPABILITIES[model]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[model] };
// 3. Pattern match (first match wins)
for (const { pattern, caps } of PATTERN_CAPABILITIES) {
if (matchPattern(pattern, baseModel) || matchPattern(pattern, model)) {
return { ...DEFAULT_CAPABILITIES, ...caps };
}
}
// 4. Floor
return { ...DEFAULT_CAPABILITIES };
}

View File

@@ -0,0 +1,51 @@
// Single source: build PROVIDERS + PROVIDER_MODELS from registry/{id}.js (transport + models co-located).
import REGISTRY from "./registry/index.js";
import { PROVIDER_DEFAULTS } from "./schema.js";
import { normalizeModel } from "./models/schema.js";
import { buildTtsProviderModels } from "../config/ttsModels.js";
// oauth block is canonical for these fields; inject into transport so executors reading
// this.config.{clientId,clientSecret,tokenUrl} keep working without duplicating in transport
const OAUTH_INJECT_FIELDS = ["clientId", "clientSecret", "tokenUrl"];
// transport: re-apply shared default (format:"openai") + inject oauth-canonical fields
function buildTransport(transport, oauth) {
const t = { ...transport };
if (!t.format) t.format = PROVIDER_DEFAULTS.format;
if (oauth) {
for (const f of OAUTH_INJECT_FIELDS) {
if (t[f] === undefined && oauth[f] !== undefined) t[f] = oauth[f];
}
}
return t;
}
const MEDIA_KEYS = new Set([
"serviceKinds", "ttsConfig", "sttConfig", "embeddingConfig",
"imageConfig", "imageToTextConfig", "videoConfig", "musicConfig",
"searchViaChat", "searchConfig", "fetchConfig",
"modelsFetcher", "mediaPriority", "hiddenKinds",
]);
export const PROVIDERS = {};
export const PROVIDER_MODELS = {};
export const PROVIDER_OAUTH = {};
export const PROVIDER_MEDIA = {};
for (const entry of REGISTRY) {
if (entry.transport) {
PROVIDERS[entry.id] = buildTransport(entry.transport, entry.oauth);
if (entry.transports) PROVIDERS[entry.id].transports = entry.transports;
}
if (entry.models !== undefined) PROVIDER_MODELS[entry.alias || entry.id] = entry.models.map(normalizeModel);
if (entry.oauth) PROVIDER_OAUTH[entry.id] = entry.oauth;
// Build PROVIDER_MEDIA from top-level fields (post-migration) + legacy entry.media
const mediaFields = {};
for (const k of MEDIA_KEYS) {
if (entry[k] !== undefined) mediaFields[k] = entry[k];
}
if (entry.media) Object.assign(mediaFields, entry.media);
if (Object.keys(mediaFields).length) PROVIDER_MEDIA[entry.id] = mediaFields;
}
// TTS model/voice tables keyed by special names (openai-tts-models, ...), not provider ids
Object.assign(PROVIDER_MODELS, buildTtsProviderModels());

View File

@@ -0,0 +1,20 @@
// Codex auto-generates a "-review" variant for each llm model (review quota family)
export const CODEX_REVIEW_SUFFIX = "-review";
export function withCodexReviewModels(models) {
return models.flatMap((model) => {
if ((model.kind || model.type || "llm") !== "llm" || model.id.endsWith(CODEX_REVIEW_SUFFIX)) {
return [model];
}
return [
model,
{
...model,
id: `${model.id}${CODEX_REVIEW_SUFFIX}`,
name: `${model.name} Review`,
upstreamModelId: model.upstreamModelId || model.id,
quotaFamily: "review"
}
];
});
}

View File

@@ -0,0 +1,33 @@
// Derive a display name from a model id when the entry omits `name` (mirrors PATTERN_PRICING).
// Provider entries that ship their own `name` always win; this is only a fallback for terse entries.
// Capitalize a hyphen/space separated token group: "coder-plus" → "Coder Plus".
function titleCase(s) {
return s
.split(/[-_\s]+/)
.filter(Boolean)
.map((w) => (/^\d/.test(w) ? w : w.charAt(0).toUpperCase() + w.slice(1)))
.join(" ");
}
// Ordered: first match wins. Keep specific patterns above generic ones.
export const NAME_PATTERNS = [
[/^kimi-k(\d+(?:\.\d+)?)(-thinking)?$/i, (m) => `Kimi K${m[1]}${m[2] ? " Thinking" : ""}`],
[/^glm-(\d+(?:\.\d+)?)(v)?$/i, (m) => `GLM ${m[1]}${m[2] ? "V (Vision)" : ""}`],
[/^minimax-m(\d+(?:\.\d+)?)$/i, (m) => `MiniMax M${m[1]}`],
[/^gpt-(.+)$/i, (m) => `GPT ${titleCase(m[1])}`],
[/^gemini-(.+)$/i, (m) => `Gemini ${titleCase(m[1])}`],
[/^grok-(.+)$/i, (m) => `Grok ${titleCase(m[1])}`],
[/^deepseek-(.+)$/i, (m) => `DeepSeek ${titleCase(m[1])}`],
[/^qwen([\d.]+.*)$/i, (m) => `Qwen ${titleCase(m[1])}`],
];
// id → display name (regex fallback → id verbatim)
export function deriveModelName(id) {
if (typeof id !== "string") return id;
for (const [re, fn] of NAME_PATTERNS) {
const m = id.match(re);
if (m) return fn(m);
}
return id;
}

View File

@@ -0,0 +1,31 @@
import { deriveModelName } from "./namePatterns.js";
// Model defaults centralized (was scattered as `m.kind || "llm"`, `quotaFamily || "normal"`, etc.)
export const MODEL_DEFAULTS = {
kind: "llm",
quotaFamily: "normal",
strip: [],
targetFormat: null
};
// Normalize a registry model entry: accept terse "id" string, fill name via regex when omitted.
// Override always wins (raw spread last); name falls back to regex → id.
export function normalizeModel(raw) {
const model = typeof raw === "string" ? { id: raw } : raw;
if (model.name !== undefined) return model;
return { ...model, name: deriveModelName(model.id) };
}
// Resolve model kind with default (accepts legacy `type` field)
export function modelKind(model) {
return model?.kind || model?.type || MODEL_DEFAULTS.kind;
}
export function modelQuotaFamily(model) {
return model?.quotaFamily || MODEL_DEFAULTS.quotaFamily;
}
export function modelStrip(model) {
return model?.strip || [];
}
export function modelTargetFormat(model) {
return model?.targetFormat || MODEL_DEFAULTS.targetFormat;
}

View File

@@ -207,10 +207,11 @@ export const PATTERN_PRICING = [
]; ];
/** /**
* Match a model ID against a glob pattern (* = wildcard). * Match a model ID against a glob pattern (* = wildcard). Case-insensitive:
* registry ids mix casing (e.g. "MiniMax-M2.5" vs "minimax-m2.5").
*/ */
function matchPattern(pattern, model) { export function matchPattern(pattern, model) {
const regex = new RegExp("^" + pattern.split("*").map(s => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$"); const regex = new RegExp("^" + pattern.split("*").map(s => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$", "i");
return regex.test(model); return regex.test(model);
} }

View File

@@ -0,0 +1,29 @@
export default {
id: "alicode-intl",
priority: 10,
alias: "alicode-intl",
display: {
name: "Alibaba Intl",
icon: "cloud",
color: "#FF6A00",
textIcon: "ALi",
website: "https://modelstudio.console.alibabacloud.com",
notice: {
apiKeyUrl: "https://modelstudio.console.alibabacloud.com/?apiKey=1",
},
},
category: "apikey",
transport: {
baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions",
headers: {},
},
models: [
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "glm-5", name: "GLM 5" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "glm-4.7", name: "GLM 4.7" },
],
};

View File

@@ -0,0 +1,30 @@
export default {
id: "alicode",
priority: 20,
alias: "alicode",
display: {
name: "Alibaba",
icon: "cloud",
color: "#FF6A00",
textIcon: "ALi",
website: "https://bailian.console.aliyun.com",
notice: {
apiKeyUrl: "https://bailian.console.aliyun.com/?apiKey=1",
},
},
category: "apikey",
transport: {
baseUrl: "https://coding.dashscope.aliyuncs.com/v1/chat/completions",
headers: {},
},
models: [
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "glm-5", name: "GLM 5" },
{ id: "MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "qwen3-max-2026-01-23", name: "Qwen3 Max" },
{ id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "glm-4.7", name: "GLM 4.7" },
],
};

View File

@@ -0,0 +1,32 @@
import { CLAUDE_API_HEADERS } from "../shared.js";
export default {
id: "anthropic",
priority: 30,
alias: "anthropic",
display: {
name: "Anthropic",
icon: "smart_toy",
color: "#D97757",
textIcon: "AN",
website: "https://console.anthropic.com",
notice: {
apiKeyUrl: "https://console.anthropic.com/settings/keys",
},
},
category: "apikey",
transport: {
baseUrl: "https://api.anthropic.com/v1/messages",
format: "claude",
headers: {
"Anthropic-Version": "2023-06-01",
"Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14",
},
},
models: [
{ id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4" },
{ id: "claude-opus-4-20250514", name: "Claude Opus 4" },
{ id: "claude-3-5-sonnet-20241022", name: "Claude 3.5 Sonnet" },
],
serviceKinds: ["llm","imageToText"],
};

View File

@@ -0,0 +1,85 @@
import { platform, arch } from "os";
import { ANTIGRAVITY_OAUTH_CLIENT } from "../shared.js";
export default {
id: "antigravity",
priority: 20,
alias: "ag",
uiAlias: "ag",
display: {
name: "Antigravity",
icon: "rocket_launch",
color: "#F59E0B",
website: "https://antigravity.google",
notice: {
signupUrl: "https://antigravity.google",
},
deprecated: true,
deprecationNotice: "RISK_NOTICE",
},
category: "oauth",
serviceKinds: ["llm", "image"],
transport: {
baseUrls: [
"https://daily-cloudcode-pa.googleapis.com",
"https://daily-cloudcode-pa.sandbox.googleapis.com",
],
format: "antigravity",
headers: {
"User-Agent": "antigravity/1.107.0 darwin/arm64",
},
retry: {
"429": {
attempts: 3,
},
"500": {
attempts: 3,
},
"503": {
attempts: 3,
},
},
usage: {
quotaApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels",
loadProjectApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
tokenUrl: "https://oauth2.googleapis.com/token",
},
clientId: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
clientSecret: "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",
},
models: [
{ id: "gemini-3-flash-agent", name: "Gemini 3.5 Flash (High)" },
{ id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium)" },
{ id: "gemini-3.5-flash-extra-low", name: "Gemini 3.5 Flash (Low)" },
{ id: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)" },
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Thinking)" },
{ id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 (Thinking)" },
{ id: "gpt-oss-120b-medium", name: "GPT-OSS 120B (Medium)" },
{ id: "gemini-3-flash", name: "Gemini 3 Flash", thinking: false },
// Image generation models
{ id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash (Image)", kind: "image", imageGen: true, capabilities: ["textToImage"] },
],
oauth: {
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
userInfoUrl: "https://www.googleapis.com/oauth2/v1/userinfo",
scopes: [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
],
apiEndpoint: "https://cloudcode-pa.googleapis.com",
apiVersion: "v1internal",
loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
onboardUserEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser",
loadCodeAssistUserAgent: "google-api-nodejs-client/9.15.1",
loadCodeAssistApiClient: "google-cloud-sdk vscode_cloudshelleditor/0.1",
refreshLeadMs: 300000,
},
features: {
usage: true,
},
};

View File

@@ -0,0 +1,38 @@
export default {
id: "assemblyai",
priority: 30,
alias: "assemblyai",
aliases: [
"aai",
],
uiAlias: "aai",
display: {
name: "AssemblyAI",
icon: "record_voice_over",
color: "#0062FF",
textIcon: "AA",
website: "https://assemblyai.com",
notice: {
apiKeyUrl: "https://www.assemblyai.com/app/api-keys",
},
},
category: "apikey",
authType: "apikey",
transport: {
baseUrl: "https://api.assemblyai.com/v1/audio/transcriptions",
validateUrl: "https://api.assemblyai.com/v1/account",
},
models: [
{ id: "universal-3-pro", name: "Universal 3 Pro", params: ["language"], kind: "stt" },
{ id: "universal-2", name: "Universal 2", params: ["language"], kind: "stt" },
{ id: "best", name: "Best (Nano + Universal)", kind: "stt" },
{ id: "nano", name: "Nano (Fast)", kind: "stt" },
],
serviceKinds: ["stt"],
sttConfig: {
baseUrl: "https://api.assemblyai.com/v2/transcript",
authType: "apikey",
authHeader: "authorization",
format: "assemblyai",
},
};

View File

@@ -0,0 +1,45 @@
export default {
id: "aws-polly",
alias: "polly",
display: {
name: "AWS Polly",
icon: "record_voice_over",
color: "#FF9900",
textIcon: "PL",
website: "https://aws.amazon.com/polly/",
notice: {
text: "Use AWS Secret Access Key as API key; set providerSpecificData.accessKeyId and optional region.",
apiKeyUrl: "https://console.aws.amazon.com/iam/home#/security_credentials"
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"tts"
],
ttsConfig: {
baseUrl: "https://polly.{region}.amazonaws.com/v1/speech",
authType: "apikey",
authHeader: "aws-sigv4",
format: "aws-polly",
models: [
{
id: "standard",
name: "Standard"
},
{
id: "neural",
name: "Neural"
},
{
id: "long-form",
name: "Long-form"
},
{
id: "generative",
name: "Generative"
}
]
},
hasProviderSpecificData: true
};

View File

@@ -0,0 +1,21 @@
export default {
id: "azure",
priority: 40,
alias: "azure",
display: {
name: "Azure OpenAI",
icon: "cloud",
color: "#0078D4",
textIcon: "AZ",
website: "https://azure.microsoft.com/en-us/products/ai-services/openai-service",
notice: {
apiKeyUrl: "https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/OpenAI",
},
},
category: "apikey",
hasProviderSpecificData: true,
transport: {
baseUrl: "",
headers: {},
},
};

View File

@@ -0,0 +1,32 @@
export default {
id: "black-forest-labs",
priority: 50,
alias: "black-forest-labs",
aliases: [
"bfl",
],
uiAlias: "bfl",
display: {
name: "Black Forest Labs",
icon: "image",
color: "#111827",
textIcon: "BF",
website: "https://blackforestlabs.ai",
notice: {
apiKeyUrl: "https://api.bfl.ai",
},
},
category: "apikey",
authType: "apikey",
transport: null,
models: [
{ id: "flux-pro-1.1", name: "FLUX Pro 1.1", params: ["n","size"], kind: "image" },
{ id: "flux-pro-1.1-ultra", name: "FLUX Pro 1.1 Ultra", params: ["size"], kind: "image" },
{ id: "flux-pro", name: "FLUX Pro", params: ["n","size"], kind: "image" },
{ id: "flux-dev", name: "FLUX Dev", params: ["n","size"], kind: "image" },
{ id: "flux-kontext-pro", name: "FLUX Kontext Pro (Edit)", params: ["size"], capabilities: ["edit"], kind: "image" },
{ id: "flux-kontext-max", name: "FLUX Kontext Max (Edit)", params: ["size"], capabilities: ["edit"], kind: "image" },
],
serviceKinds: ["image"],
imageConfig: { baseUrl: "https://api.bfl.ai/v1" },
};

View File

@@ -0,0 +1,41 @@
export default {
id: "blackbox",
priority: 50,
alias: "blackbox",
aliases: [
"bb",
],
uiAlias: "bb",
display: {
name: "Blackbox AI",
icon: "smart_toy",
color: "#5B5FEF",
textIcon: "BB",
website: "https://blackbox.ai",
notice: {
apiKeyUrl: "https://www.blackbox.ai/api-management",
},
},
category: "apikey",
serviceKinds: ["llm"],
thinkingConfig: {
options: ["auto", "none", "low", "medium", "high", "xhigh"],
defaultMode: "auto",
},
transport: {
baseUrl: "https://api.blackbox.ai/v1/chat/completions",
thinkingFormat: "openai",
},
models: [
{ id: "claude-fable-5", name: "Claude Fable 5", upstreamModelId: "blackboxai/anthropic/claude-fable-5" },
{ id: "claude-opus-4.8", name: "Claude Opus 4.8", upstreamModelId: "blackboxai/anthropic/claude-opus-4.8" },
{ id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", upstreamModelId: "blackboxai/anthropic/claude-sonnet-4.6" },
{ id: "gpt-5.5", name: "GPT-5.5", upstreamModelId: "blackboxai/openai/gpt-5.5" },
{ id: "gpt-5.4-pro", name: "GPT-5.4 Pro", upstreamModelId: "blackboxai/openai/gpt-5.4-pro" },
{ id: "gpt-5.4", name: "GPT-5.4", upstreamModelId: "blackboxai/openai/gpt-5.4" },
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex", upstreamModelId: "blackboxai/openai/gpt-5.3-codex" },
{ id: "gpt-5.4-nano", name: "GPT-5.4 Nano", upstreamModelId: "blackboxai/openai/gpt-5.4-nano" },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", upstreamModelId: "blackboxai/deepseek/deepseek-v4-flash" },
{ id: "grok-4.3", name: "Grok 4.3", upstreamModelId: "blackboxai/x-ai/grok-4.3" },
],
};

View File

@@ -0,0 +1,35 @@
export default {
id: "brave-search",
alias: "brave",
display: {
name: "Brave Search",
icon: "travel_explore",
color: "#FB542B",
textIcon: "BR",
website: "https://brave.com/search/api",
notice: {
apiKeyUrl: "https://api-dashboard.search.brave.com/app/keys"
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"webSearch"
],
searchConfig: {
baseUrl: "https://api.search.brave.com/res/v1",
method: "GET",
authType: "apikey",
authHeader: "x-subscription-token",
costPerQuery: 0.005,
freeMonthlyQuota: 1000,
searchTypes: [
"web",
"news"
],
defaultMaxResults: 5,
maxMaxResults: 20,
timeoutMs: 10000,
cacheTTLMs: 300000
}
};

View File

@@ -0,0 +1,35 @@
export default {
id: "byteplus",
priority: 70,
alias: "byteplus",
aliases: [
"bpm",
],
uiAlias: "bpm",
display: {
name: "BytePlus ModelArk",
icon: "cloud",
color: "#2563EB",
textIcon: "BP",
website: "https://console.byteplus.com/ark",
notice: {
text: "Free credits for new accounts. Access to Seed 2.0, Kimi K2 Thinking, GLM 4.7, GPT-OSS-120B models.",
apiKeyUrl: "https://console.byteplus.com/ark/region:ark+ap-southeast-1/apiKey",
},
},
category: "freeTier",
transport: {
baseUrl: "https://ark.ap-southeast.bytepluses.com/api/coding/v3/chat/completions",
headers: {},
},
models: [
{ id: "seed-2-0-pro-260328", name: "Seed 2.0 Pro" },
{ id: "seed-2-0-code-preview-260328", name: "Seed 2.0 Code Preview" },
{ id: "seed-2-0-mini-260215", name: "Seed 2.0 Mini" },
{ id: "seed-2-0-lite-260228", name: "Seed 2.0 Lite" },
{ id: "kimi-k2-thinking-251104", name: "Kimi K2 Thinking" },
{ id: "glm-4-7-251222", name: "GLM 4.7" },
{ id: "gpt-oss-120b-250805", name: "GPT-OSS-120B" },
],
serviceKinds: ["llm"],
};

View File

@@ -0,0 +1,36 @@
export default {
id: "cartesia",
alias: "cartesia",
display: {
name: "Cartesia",
icon: "spatial_audio",
color: "#FF4F8B",
textIcon: "CA",
website: "https://cartesia.ai",
notice: {
apiKeyUrl: "https://play.cartesia.ai/keys"
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"tts"
],
ttsConfig: {
baseUrl: "https://api.cartesia.ai/tts/bytes",
authType: "apikey",
authHeader: "x-api-key",
format: "cartesia",
models: [
{
id: "sonic-2",
name: "Sonic 2"
},
{
id: "sonic-3",
name: "Sonic 3"
}
]
},
hidden: true
};

View File

@@ -0,0 +1,31 @@
export default {
id: "cerebras",
priority: 60,
alias: "cerebras",
display: {
name: "Cerebras",
icon: "memory",
color: "#FF4F00",
textIcon: "CB",
website: "https://www.cerebras.ai",
notice: {
apiKeyUrl: "https://cloud.cerebras.ai/platform",
},
},
category: "apikey",
transport: {
baseUrl: "https://api.cerebras.ai/v1/chat/completions",
validateUrl: "https://api.cerebras.ai/v1/models",
quirks: {
dropClientMetadata: true,
},
},
models: [
{ id: "gpt-oss-120b", name: "GPT OSS 120B" },
{ id: "zai-glm-4.7", name: "ZAI GLM 4.7" },
{ id: "llama-3.3-70b", name: "Llama 3.3 70B" },
{ id: "llama-4-scout-17b-16e-instruct", name: "Llama 4 Scout" },
{ id: "qwen-3-235b-a22b-instruct-2507", name: "Qwen3 235B A22B" },
{ id: "qwen-3-32b", name: "Qwen3 32B" },
],
};

View File

@@ -0,0 +1,24 @@
export default {
id: "chutes",
priority: 70,
alias: "chutes",
aliases: [
"ch",
],
uiAlias: "ch",
display: {
name: "Chutes AI",
icon: "water_drop",
color: "#ffffffff",
textIcon: "CH",
website: "https://chutes.ai",
notice: {
apiKeyUrl: "https://chutes.ai/app/api",
},
},
category: "apikey",
transport: {
baseUrl: "https://llm.chutes.ai/v1/chat/completions",
validateUrl: "https://llm.chutes.ai/v1/models",
},
};

View File

@@ -0,0 +1,89 @@
import { CLAUDE_CLI_SPOOF_HEADERS } from "../shared.js";
export default {
id: "claude",
priority: 10,
alias: "cc",
uiAlias: "cc",
display: {
name: "Claude Code",
icon: "smart_toy",
color: "#D97757",
website: "https://claude.ai",
notice: {
signupUrl: "https://claude.ai",
},
deprecated: true,
deprecationNotice: "RISK_NOTICE",
},
category: "oauth",
transport: {
baseUrl: "https://api.anthropic.com/v1/messages",
format: "claude",
urlSuffix: "?beta=true",
headers: {
"Anthropic-Version": "2023-06-01",
"Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28",
"Anthropic-Dangerous-Direct-Browser-Access": "true",
"User-Agent": "claude-cli/2.1.92 (external, sdk-cli)",
"X-App": "cli",
"X-Stainless-Helper-Method": "stream",
"X-Stainless-Retry-Count": "0",
"X-Stainless-Runtime-Version": "v24.14.0",
"X-Stainless-Package-Version": "0.80.0",
"X-Stainless-Runtime": "node",
"X-Stainless-Lang": "js",
"X-Stainless-Arch": "arm64",
"X-Stainless-Os": "MacOS",
"X-Stainless-Timeout": "600",
},
quirks: {
cloakToolsOnOAuth: true,
},
auth: {
apiKey: {
header: "x-api-key",
scheme: "raw",
},
oauth: {
header: "Authorization",
scheme: "bearer",
},
hooks: [
"claudeOverlay",
],
},
usage: {
oauthUrl: "https://api.anthropic.com/api/oauth/usage",
orgUrl: "https://api.anthropic.com/v1/organizations/{org_id}/usage",
settingsUrl: "https://api.anthropic.com/v1/settings",
},
},
models: [
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
{ id: "claude-opus-4-7", name: "Claude Opus 4.7" },
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ id: "claude-opus-4-5-20251101", name: "Claude 4.5 Opus" },
{ id: "claude-sonnet-4-5-20250929", name: "Claude 4.5 Sonnet" },
{ id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" },
],
oauth: {
clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
authorizeUrl: "https://claude.ai/oauth/authorize",
tokenUrl: "https://api.anthropic.com/v1/oauth/token",
scopes: [
"org:create_api_key",
"user:profile",
"user:inference",
],
codeChallengeMethod: "S256",
refreshLeadMs: 14400000,
refresh: {
encoding: "json",
},
},
features: {
usage: true,
},
};

View File

@@ -0,0 +1,51 @@
export default {
id: "cline",
priority: 80,
alias: "cl",
uiAlias: "cl",
display: {
name: "Cline",
icon: "smart_toy",
color: "#5B9BD5",
textIcon: "CL",
website: "https://cline.bot",
notice: {
signupUrl: "https://cline.bot",
},
},
category: "oauth",
transport: {
baseUrl: "https://api.cline.bot/api/v1/chat/completions",
headers: {
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
},
tokenUrl: "https://api.cline.bot/api/v1/auth/token",
refreshUrl: "https://api.cline.bot/api/v1/auth/refresh",
auth: {
combined: true,
header: "Authorization",
scheme: "bearer",
hooks: [
"clineHeaders",
],
},
},
models: [
{ id: "anthropic/claude-opus-4.7", name: "Claude Opus 4.7" },
{ id: "anthropic/claude-sonnet-4.6", name: "Claude Sonnet 4.6" },
{ id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" },
{ id: "openai/gpt-5.3-codex", name: "GPT-5.3 Codex" },
{ id: "openai/gpt-5.4", name: "GPT-5.4" },
{ id: "google/gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
{ id: "google/gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" },
{ id: "kwaipilot/kat-coder-pro", name: "KAT Coder Pro" },
],
oauth: {
appBaseUrl: "https://app.cline.bot",
apiBaseUrl: "https://api.cline.bot",
authorizeUrl: "https://api.cline.bot/api/v1/auth/authorize",
tokenExchangeUrl: "https://api.cline.bot/api/v1/auth/token",
refreshUrl: "https://api.cline.bot/api/v1/auth/refresh",
},
};

View File

@@ -0,0 +1,55 @@
export default {
id: "cloudflare-ai",
priority: 60,
hasFree: true,
alias: "cloudflare-ai",
aliases: [
"cf",
],
uiAlias: "cf",
display: {
name: "Cloudflare",
icon: "cloud",
color: "#F38020",
textIcon: "CF",
website: "https://developers.cloudflare.com/workers-ai/",
notice: {
text: "Workers AI free tier. Requires a Cloudflare API token and Account ID.",
apiKeyUrl: "https://dash.cloudflare.com/profile/api-tokens",
},
},
category: "freeTier",
hasProviderSpecificData: true,
transport: {
baseUrl: "https://api.cloudflare.com/client/v4/accounts/{accountId}/ai/v1/chat/completions",
thinkingFormat: "openai",
},
models: [
{ id: "@cf/meta/llama-3.2-1b-instruct", name: "Llama 3.2 1B Instruct" },
{ id: "@cf/meta/llama-3.2-3b-instruct", name: "Llama 3.2 3B Instruct" },
{ id: "@cf/meta/llama-3.1-8b-instruct-fp8-fast", name: "Llama 3.1 8B Instruct FP8 Fast" },
{ id: "@cf/meta/llama-3.1-8b-instruct-awq", name: "Llama 3.1 8B Instruct AWQ" },
{ id: "@cf/mistralai/mistral-small-3.1-24b-instruct", name: "Mistral Small 3.1 24B Instruct" },
{ id: "@cf/meta/llama-3.1-70b-instruct-fp8-fast", name: "Llama 3.1 70B Instruct FP8 Fast" },
{ id: "@cf/meta/llama-3.3-70b-instruct-fp8-fast", name: "Llama 3.3 70B Instruct FP8 Fast" },
{ id: "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b", name: "DeepSeek R1 Distill Qwen 32B" },
{ id: "@cf/moonshotai/kimi-k2.5", name: "Kimi K2.5" },
{ id: "@cf/moonshotai/kimi-k2.6", name: "Kimi K2.6" },
{ id: "@cf/zai-org/glm-4.7-flash", name: "GLM 4.7 Flash" },
{ id: "@cf/qwen/qwq-32b", name: "QwQ 32B" },
{ id: "@cf/qwen/qwen2.5-coder-32b-instruct", name: "Qwen 2.5 Coder 32B Instruct" },
{ id: "@cf/black-forest-labs/flux-2-klein-9b", name: "FLUX.2 Klein 9B", params: ["size"], kind: "image" },
{ id: "@cf/black-forest-labs/flux-2-klein-4b", name: "FLUX.2 Klein 4B", params: ["size"], kind: "image" },
{ id: "@cf/black-forest-labs/flux-2-dev", name: "FLUX.2 Dev", params: ["size"], kind: "image" },
{ id: "@cf/leonardo/lucid-origin", name: "Lucid Origin", params: ["size"], kind: "image" },
{ id: "@cf/leonardo/phoenix-1.0", name: "Phoenix 1.0", params: ["size"], kind: "image" },
{ id: "@cf/black-forest-labs/flux-1-schnell", name: "FLUX.1 Schnell", params: ["size"], kind: "image" },
{ id: "@cf/bytedance/stable-diffusion-xl-lightning", name: "SDXL Lightning", params: ["size"], kind: "image" },
{ id: "@cf/lykon/dreamshaper-8-lcm", name: "DreamShaper 8 LCM", params: ["size"], kind: "image" },
{ id: "@cf/runwayml/stable-diffusion-v1-5-img2img", name: "Stable Diffusion v1.5 Img2Img", params: ["size"], capabilities: ["edit"], kind: "image" },
{ id: "@cf/runwayml/stable-diffusion-v1-5-inpainting", name: "Stable Diffusion v1.5 Inpainting", params: ["size"], capabilities: ["edit","mask"], kind: "image" },
{ id: "@cf/stabilityai/stable-diffusion-xl-base-1.0", name: "SDXL Base 1.0", params: ["size"], kind: "image" },
],
serviceKinds: ["llm","image"],
imageConfig: { baseUrl: "https://api.cloudflare.com/client/v4/accounts" },
};

View File

@@ -0,0 +1,77 @@
export default {
id: "codebuddy-cn",
// Short model prefix (cbcn/glm-5.2). "cbcn" = CodeBuddy CN; reserve "cbai"
// for a future codebuddy-ai (intl) provider. The full id still resolves.
alias: "cbcn",
uiAlias: "cbcn",
hidden: false,
priority: 90,
display: {
name: "CodeBuddy CN",
icon: "smart_toy",
color: "#006EFF",
website: "https://copilot.tencent.com",
notice: {
signupUrl: "https://copilot.tencent.com",
},
},
category: "oauth",
authModes: ["oauth", "apikey"],
hasOAuth: true,
transport: {
baseUrl: "https://copilot.tencent.com/v2/chat/completions",
forceStream: true,
// CodeBuddy is a unified OpenAI-compatible gateway: every model (GLM, Kimi,
// MiniMax, DeepSeek, Hunyuan) takes reasoning via OpenAI-style reasoning_effort,
// not its vendor-native thinking shape. Force the openai thinking format.
thinkingFormat: "openai",
headers: {
"User-Agent": "CLI/2.108.1 CodeBuddy/2.108.1",
"X-Product": "SaaS",
"X-IDE-Type": "CLI",
"X-IDE-Name": "CLI",
"x-requested-with": "XMLHttpRequest",
"x-codebuddy-request": "1",
},
auth: {
combined: true,
header: "Authorization",
scheme: "bearer",
},
// Quota endpoint differs from the chat gateway: POST returns nested Tencent
// billing payload (data.Response.Data.Accounts[]). See services/usage/codebuddy-cn.js.
usage: {
url: "https://copilot.tencent.com/v2/billing/meter/get-user-resource",
},
},
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://copilot.tencent.com",
stateUrl: "https://copilot.tencent.com/v2/plugin/auth/state",
tokenUrl: "https://copilot.tencent.com/v2/plugin/auth/token",
refreshUrl: "https://copilot.tencent.com/v2/plugin/auth/token/refresh",
userAgent: "CLI/2.63.2 CodeBuddy/2.63.2",
platform: "CLI",
pollInterval: 5000,
},
features: {
usage: true,
usageApikey: true,
},
};

View File

@@ -0,0 +1,94 @@
import { withCodexReviewModels } from "../models/helpers.js";
export default {
id: "codex",
priority: 30,
alias: "cx",
uiAlias: "cx",
display: {
name: "OpenAI Codex",
icon: "code",
color: "#3B82F6",
website: "https://chatgpt.com/codex",
notice: {
signupUrl: "https://chatgpt.com/codex",
},
deprecated: true,
deprecationNotice: "RISK_NOTICE",
kindNotice: {
image: "Requires a ChatGPT Plus (or higher) account. Free accounts are not supported for image generation.",
},
},
category: "oauth",
thinkingConfig: {
options: [
"auto",
"none",
"low",
"medium",
"high",
],
defaultMode: "auto",
},
transport: {
baseUrl: "https://chatgpt.com/backend-api/codex/responses",
format: "openai-responses",
forceStream: true,
headers: {
originator: "codex_cli_rs",
"User-Agent": "codex_cli_rs/0.136.0",
},
usage: {
url: "https://chatgpt.com/backend-api/wham/usage",
resetCreditsConsumeUrl: "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume",
},
},
models: [
{ id: "gpt-5.5", name: "GPT 5.5" },
{ id: "gpt-5.5-review", name: "GPT 5.5 Review", upstreamModelId: "gpt-5.5", quotaFamily: "review" },
{ id: "gpt-5.4", name: "GPT 5.4" },
{ id: "gpt-5.4-review", name: "GPT 5.4 Review", upstreamModelId: "gpt-5.4", quotaFamily: "review" },
{ id: "gpt-5.4-mini", name: "GPT 5.4 Mini" },
{ id: "gpt-5.4-mini-review", name: "GPT 5.4 Mini Review", upstreamModelId: "gpt-5.4-mini", quotaFamily: "review" },
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex" },
{ id: "gpt-5.3-codex-review", name: "GPT 5.3 Codex Review", upstreamModelId: "gpt-5.3-codex", quotaFamily: "review" },
{ id: "gpt-5.3-codex-xhigh", name: "GPT 5.3 Codex (xHigh)" },
{ id: "gpt-5.3-codex-xhigh-review", name: "GPT 5.3 Codex (xHigh) Review", upstreamModelId: "gpt-5.3-codex-xhigh", quotaFamily: "review" },
{ id: "gpt-5.3-codex-high", name: "GPT 5.3 Codex (High)" },
{ id: "gpt-5.3-codex-high-review", name: "GPT 5.3 Codex (High) Review", upstreamModelId: "gpt-5.3-codex-high", quotaFamily: "review" },
{ id: "gpt-5.3-codex-low", name: "GPT 5.3 Codex (Low)" },
{ id: "gpt-5.3-codex-low-review", name: "GPT 5.3 Codex (Low) Review", upstreamModelId: "gpt-5.3-codex-low", quotaFamily: "review" },
{ id: "gpt-5.3-codex-none", name: "GPT 5.3 Codex (None)" },
{ id: "gpt-5.3-codex-none-review", name: "GPT 5.3 Codex (None) Review", upstreamModelId: "gpt-5.3-codex-none", quotaFamily: "review" },
{ id: "gpt-5.3-codex-spark", name: "GPT 5.3 Codex Spark" },
{ id: "gpt-5.3-codex-spark-review", name: "GPT 5.3 Codex Spark Review", upstreamModelId: "gpt-5.3-codex-spark", quotaFamily: "review" },
{ id: "gpt-5.5-image", name: "GPT 5.5 Image", capabilities: ["text2img","edit"], params: ["size","quality","background","image_detail","output_format"], kind: "image" },
{ id: "gpt-5.4-image", name: "GPT 5.4 Image", capabilities: ["text2img","edit"], params: ["size","quality","background","image_detail","output_format"], kind: "image" },
{ id: "gpt-5.3-image", name: "GPT 5.3 Image", capabilities: ["text2img","edit"], params: ["size","quality","background","image_detail","output_format"], kind: "image" },
],
serviceKinds: ["llm","image"],
oauth: {
clientId: "app_EMoamEEZ73f0CkXaXp7hrann",
authorizeUrl: "https://auth.openai.com/oauth/authorize",
tokenUrl: "https://auth.openai.com/oauth/token",
scope: "openid profile email offline_access",
codeChallengeMethod: "S256",
fixedPort: 1455,
callbackPath: "/auth/callback",
extraParams: {
id_token_add_organizations: "true",
codex_cli_simplified_flow: "true",
originator: "codex_cli_rs",
},
refreshLeadMs: 432000000,
refresh: {
encoding: "form",
scope: "openid profile email offline_access",
},
maxRefreshAgeMs: 691200000,
trackRefreshAt: true,
},
features: {
usage: true,
},
};

View File

@@ -0,0 +1,25 @@
export default {
id: "cohere",
priority: 90,
alias: "cohere",
display: {
name: "Cohere",
icon: "hub",
color: "#39594D",
textIcon: "CO",
website: "https://cohere.com",
notice: {
apiKeyUrl: "https://dashboard.cohere.com/api-keys",
},
},
category: "apikey",
transport: {
baseUrl: "https://api.cohere.ai/v1/chat/completions",
validateUrl: "https://api.cohere.ai/v1/models",
},
models: [
{ id: "command-r-plus-08-2024", name: "Command R+ (Aug 2024)" },
{ id: "command-r-08-2024", name: "Command R (Aug 2024)" },
{ id: "command-a-03-2025", name: "Command A (Mar 2025)" },
],
};

View File

@@ -0,0 +1,20 @@
export default {
id: "comfyui",
priority: 120,
alias: "comfyui",
display: {
name: "ComfyUI",
icon: "account_tree",
color: "#4CAF50",
textIcon: "CF",
website: "https://github.com/comfyanonymous/ComfyUI",
},
category: "apikey",
transport: null,
models: [
{ id: "flux-dev", name: "FLUX Dev", params: ["n","size"], kind: "image" },
{ id: "sdxl", name: "SDXL", params: ["n","size"], kind: "image" },
],
serviceKinds: ["image"],
imageConfig: { baseUrl: "http://localhost:8188" },
};

View File

@@ -0,0 +1,43 @@
export default {
id: "commandcode",
priority: 100,
alias: "commandcode",
aliases: [
"cmc",
],
uiAlias: "cmc",
display: {
name: "Command Code",
icon: "smart_toy",
color: "#000000",
textIcon: "CC",
website: "https://commandcode.ai",
notice: {
text: "Use your CommandCode CLI API key (starts with user_...) from ~/.commandcode/auth.json or commandcode.ai/studio.",
apiKeyUrl: "https://commandcode.ai/studio",
},
},
category: "apikey",
transport: {
baseUrl: "https://api.commandcode.ai/alpha/generate",
format: "commandcode",
forceStream: true,
headers: {
"x-command-code-version": "0.25.7",
"x-cli-environment": "cli",
},
},
models: [
{ id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro" },
{ id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash" },
{ id: "moonshotai/Kimi-K2.6", name: "Kimi K2.6" },
{ id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5" },
{ id: "zai-org/GLM-5.1", name: "GLM 5.1" },
{ id: "zai-org/GLM-5", name: "GLM 5" },
{ id: "MiniMaxAI/MiniMax-M2.7", name: "MiniMax M2.7" },
{ id: "MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5" },
{ id: "Qwen/Qwen3.6-Max-Preview", name: "Qwen 3.6 Max Preview" },
{ id: "Qwen/Qwen3.6-Plus", name: "Qwen 3.6 Plus" },
{ id: "stepfun/Step-3.5-Flash", name: "Step 3.5 Flash" },
],
};

View File

@@ -0,0 +1,30 @@
export default {
id: "coqui",
alias: "coqui",
display: {
name: "Coqui TTS",
icon: "record_voice_over",
color: "#10B981",
textIcon: "CQ",
website: "https://github.com/coqui-ai/TTS"
},
category: "freeTier",
authType: "none",
serviceKinds: [
"tts"
],
noAuth: true,
ttsConfig: {
baseUrl: "http://localhost:5002/api/tts",
authType: "none",
authHeader: "none",
format: "coqui",
models: [
{
id: "tts_models/en/ljspeech/tacotron2-DDC",
name: "Tacotron2 DDC (LJSpeech)"
}
]
},
hidden: true
};

View File

@@ -0,0 +1,58 @@
export default {
id: "cursor",
priority: 50,
alias: "cu",
uiAlias: "cu",
display: {
name: "Cursor IDE",
icon: "edit_note",
color: "#00D4AA",
website: "https://cursor.com",
notice: {
signupUrl: "https://cursor.com",
},
},
category: "oauth",
transport: {
baseUrl: "https://api2.cursor.sh",
chatPath: "/aiserver.v1.ChatService/StreamUnifiedChatWithTools",
format: "cursor",
headers: {
"connect-accept-encoding": "gzip",
"connect-protocol-version": "1",
"Content-Type": "application/connect+proto",
"User-Agent": "connect-es/1.6.1",
},
clientVersion: "3.1.0",
},
models: [
{ id: "default", name: "Auto (Server Picks)" },
{ id: "claude-4.5-opus-high-thinking", name: "Claude 4.5 Opus High Thinking" },
{ id: "claude-4.5-opus-high", name: "Claude 4.5 Opus High" },
{ id: "claude-4.5-sonnet-thinking", name: "Claude 4.5 Sonnet Thinking" },
{ id: "claude-4.5-sonnet", name: "Claude 4.5 Sonnet" },
{ id: "claude-4.5-haiku", name: "Claude 4.5 Haiku" },
{ id: "claude-4.5-opus", name: "Claude 4.5 Opus" },
{ id: "gpt-5.2-codex", name: "GPT 5.2 Codex" },
{ id: "claude-4.6-opus-max", name: "Claude 4.6 Opus Max" },
{ id: "claude-4.6-sonnet-medium-thinking", name: "Claude 4.6 Sonnet Medium Thinking" },
{ id: "kimi-k2.5", name: "Kimi K2.5" },
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
{ id: "gpt-5.2", name: "GPT 5.2" },
{ id: "gpt-5.3-codex", name: "GPT 5.3 Codex" },
],
oauth: {
apiEndpoint: "https://api2.cursor.sh",
chatEndpoint: "/aiserver.v1.ChatService/StreamUnifiedChatWithTools",
modelsEndpoint: "/aiserver.v1.AiService/GetDefaultModelNudgeData",
api3Endpoint: "https://api3.cursor.sh",
agentEndpoint: "https://agent.api5.cursor.sh",
agentNonPrivacyEndpoint: "https://agentn.api5.cursor.sh",
clientVersion: "3.1.0",
clientType: "ide",
dbKeys: {
accessToken: "cursorAuth/accessToken",
machineId: "storage.serviceMachineId",
},
},
};

View File

@@ -0,0 +1,33 @@
export default {
id: "deepgram",
priority: 20,
alias: "deepgram",
aliases: [
"dg",
],
uiAlias: "dg",
display: {
name: "Deepgram",
icon: "mic",
color: "#13EF93",
textIcon: "DG",
website: "https://deepgram.com",
notice: {
text: "$200 free credit on signup (no card required). Aura-1: $0.015/1k chars, Aura-2: $0.030/1k chars (Pay-As-You-Go).",
apiKeyUrl: "https://console.deepgram.com/api-keys",
},
},
category: "apikey",
authType: "apikey",
transport: {
baseUrl: "https://api.deepgram.com/v1/listen",
},
models: [
{ id: "nova-3", name: "Nova 3", params: ["language"], kind: "stt" },
{ id: "nova-2", name: "Nova 2", params: ["language"], kind: "stt" },
{ id: "whisper-large", name: "Whisper Large", params: ["language"], kind: "stt" },
{ id: "nova", name: "Nova", kind: "stt" },
],
serviceKinds: ["stt"],
sttConfig: { baseUrl: "https://api.deepgram.com/v1/listen", authType: "apikey", authHeader: "token", format: "deepgram" },
};

View File

@@ -0,0 +1,51 @@
import { CLAUDE_API_HEADERS } from "../shared.js";
export default {
id: "deepseek",
priority: 110,
alias: "deepseek",
aliases: [
"ds",
],
uiAlias: "ds",
display: {
name: "DeepSeek",
icon: "bolt",
color: "#4D6BFE",
textIcon: "DS",
website: "https://deepseek.com",
notice: {
apiKeyUrl: "https://platform.deepseek.com/api_keys",
},
},
category: "apikey",
transport: {
baseUrl: "https://api.deepseek.com/chat/completions",
validateUrl: "https://api.deepseek.com/models",
reasoningInject: {
scope: "all",
},
},
// Multi-endpoint: pick the transport matching client sourceFormat to skip translation.
transports: [
{
format: "openai",
baseUrl: "https://api.deepseek.com/chat/completions",
auth: { combined: true, header: "Authorization", scheme: "bearer" },
},
{
format: "claude",
baseUrl: "https://api.deepseek.com/anthropic/v1/messages",
headers: { ...CLAUDE_API_HEADERS },
auth: { combined: true, header: "x-api-key", scheme: "raw" },
},
],
models: [
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
{ id: "deepseek-v4-pro-max", name: "DeepSeek V4 Pro Max", upstreamModelId: "deepseek-v4-pro" },
{ id: "deepseek-v4-pro-none", name: "DeepSeek V4 Pro No Thinking", upstreamModelId: "deepseek-v4-pro" },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" },
{ id: "deepseek-chat", name: "DeepSeek V3.2 Chat" },
{ id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" },
],
};

View File

@@ -0,0 +1,24 @@
export default {
id: "edge-tts",
alias: "edge-tts",
display: {
name: "Edge TTS",
icon: "record_voice_over",
color: "#0078D4",
textIcon: "ET"
},
category: "freeTier",
authType: "none",
serviceKinds: [
"tts"
],
mediaPriority: 5,
noAuth: true,
ttsConfig: {
baseUrl: "edge-tts",
authType: "none",
authHeader: "none",
format: "edge-tts",
models: []
}
};

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