diff --git a/.gitignore b/.gitignore index 328c2e41..552f00b4 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,6 @@ ecosystem.config.* scripts/agSniffer/* gitbooks/* gitbook/README.md + +# Refactor backup reference (do not bundle/lint) +open-sse.old/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d11ea24..e9571aca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) ## Features @@ -220,34 +321,3 @@ ## Fixes - 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) \ No newline at end of file diff --git a/DOCKER.md b/DOCKER.md index 91f1156d..1f280d97 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -64,6 +64,34 @@ docker run -d \ 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 ```bash diff --git a/README.md b/README.md index d2066e4d..7f4b0552 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ Default URLs: FREE OpenClaw + Claude Opus 4.6
by Build AI With Hamid
- + Claude CLI Free Setup @@ -178,6 +178,13 @@ Default URLs: 🇮🇩 Indonesia
Koding 24 Jam Anti Rate Limit! Hemat Token AI 65% | Tutorial Quick Setup 9Router 🚀
by
Krisswuh + + + Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB +
+ 🇮🇩 Indonesia
+ Cara Deploy 9Router di Hugging Face GRATIS Non-Stop! | Alternatif VPS RAM 16GB
by Krisswuh
+ @@ -400,7 +407,9 @@ Default URLs: | 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 | +| 🧠 **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** | +| 🐴 **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 | | 📊 **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 | @@ -430,6 +439,50 @@ Without RTK: 47K tokens sent to LLM 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 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. - **[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**. +- **[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! diff --git a/cli/cli.js b/cli/cli.js index 01300ca1..09057b2e 100755 --- a/cli/cli.js +++ b/cli/cli.js @@ -61,6 +61,21 @@ const INSTALL_CMD_LATEST = `npm i -g ${APP_NAME}@latest --prefer-online`; const DEFAULT_PORT = 20128; 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; // Identifiers for killAllAppProcesses - only kill 9router specifically const PROCESS_IDENTIFIERS = [ @@ -501,7 +516,7 @@ async function showInterfaceMenu(latestVersion) { clearScreen(); - const displayHost = host === DEFAULT_HOST ? "localhost" : host; + const displayHost = getDisplayHost(); // Detect tunnel/local mode for server URL display let serverUrl; @@ -542,8 +557,13 @@ const MAX_RESTARTS = 2; const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s function startServer(latestVersion) { - const displayHost = host === DEFAULT_HOST ? "localhost" : host; + const displayHost = getDisplayHost(); 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 serverStartTime = Date.now(); diff --git a/cli/hooks/sqliteRuntime.js b/cli/hooks/sqliteRuntime.js index 50e62629..feca2f59 100644 --- a/cli/hooks/sqliteRuntime.js +++ b/cli/hooks/sqliteRuntime.js @@ -7,6 +7,7 @@ const os = require("os"); const path = require("path"); const BETTER_SQLITE3_VERSION = "12.6.2"; +const SQL_JS_VERSION = "1.14.1"; function getDataDir() { 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 -// runtime dir. sql.js is bundled in bin/app already; node:sqlite is built-in. -// This is purely a *speed optimization* — app works without it via fallbacks. +// runtime dir. sql.js may be bundled in bin/app, but npm publish strips .wasm +// 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 } = {}) { ensureRuntimeDir(); + let sqlJsOk = isSqlJsWasmValid(); + if (!sqlJsOk) { + sqlJsOk = npmInstall([`sql.js@${SQL_JS_VERSION}`], { silent }); + if (sqlJsOk) sqlJsOk = isSqlJsWasmValid(); + } + const needBetterSqlite = !hasModule("better-sqlite3") || !isBetterSqliteBinaryValid(); if (!needBetterSqlite) { 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 }); return { betterSqlite: ok && hasModule("better-sqlite3") && isBetterSqliteBinaryValid(), + sqlJs: sqlJsOk, }; } diff --git a/cli/package.json b/cli/package.json index 846d36dc..f687cc43 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.4.80", + "version": "0.5.12", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/cli/scripts/build-cli.js b/cli/scripts/build-cli.js index be2e97c3..625d94ba 100644 --- a/cli/scripts/build-cli.js +++ b/cli/scripts/build-cli.js @@ -17,7 +17,6 @@ const EXCLUDE_PATTERNS = [ "@img", // Sharp image processing (not needed with unoptimized images) "sharp", // Sharp core lib (not needed with unoptimized images) "detect-libc", // Sharp dependency - "logs", // Runtime logs ".env", // Environment files ".env.local", ".env.*.local", @@ -136,7 +135,15 @@ console.log("✅ Cleaned\n"); console.log("3️⃣ Copying Next.js standalone build to app/cli/app..."); const standaloneRoot = path.join(appDir, ".next", "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")) ? standaloneRootToUse : path.join(standaloneRootToUse, "app"); diff --git a/cli/src/cli/menus/settings.js b/cli/src/cli/menus/settings.js index a86a7c49..ce779339 100644 --- a/cli/src/cli/menus/settings.js +++ b/cli/src/cli/menus/settings.js @@ -39,6 +39,8 @@ async function showSettingsMenu(breadcrumb = []) { // RTK section 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}`); + 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 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; } }, + { + 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", action: async () => { await resetPassword(); return true; } @@ -160,6 +169,17 @@ async function toggleRtk(currentlyOn) { 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). * After reset, user can log in with the default password "123456". diff --git a/custom-server.js b/custom-server.js index a12aa747..6e39683f 100644 --- a/custom-server.js +++ b/custom-server.js @@ -10,10 +10,20 @@ http.createServer = (...args) => { const rest = args.filter((a) => typeof a !== "function"); if (!handler) return origCreate(...args); 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-forwarded-for"]; + delete req.headers["x-9r-via-proxy"]; req.headers["x-9r-real-ip"] = ip; + if (viaProxy) req.headers["x-9r-via-proxy"] = "1"; return handler(req, res); }; return origCreate(...rest, wrapped); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..8331b260 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/images/fusion-combo-ui.png b/images/fusion-combo-ui.png new file mode 100644 index 00000000..8799d720 Binary files /dev/null and b/images/fusion-combo-ui.png differ diff --git a/next.config.mjs b/next.config.mjs index 3853b4b0..91f55af9 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -28,6 +28,8 @@ const nextConfig = { experimental: { // #1529/#1572: LLM clients can send long context or base64 image payloads through /v1 rewrites. proxyClientMaxBodySize, + // Cache fetch responses across HMR refreshes for faster dev reloads. + serverComponentsHmrCache: true, }, webpack: (config, { isServer }) => { // Ignore fs/path modules in browser bundle @@ -38,8 +40,12 @@ const nextConfig = { path: false, }; } - // Exclude logs, .next, gitbook subapp from watcher - config.watchOptions = { ...config.watchOptions, ignored: /[\\/](logs|\.next|gitbook|cli)[\\/]/ }; + // Exclude non-source dirs from watcher to reduce inotify load + config.watchOptions = { + ...config.watchOptions, + aggregateTimeout: 300, + ignored: /[\\/](node_modules|\.git|logs|\.next|\.next-cli-build|gitbook|cli|open-sse\.old|tests|docs)[\\/]/, + }; return config; }, async rewrites() { @@ -56,6 +62,18 @@ const nextConfig = { source: "/codex/:path*", 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*", destination: "/api/v1/:path*" diff --git a/open-sse/AGENTS.md b/open-sse/AGENTS.md new file mode 100644 index 00000000..5ea0d971 --- /dev/null +++ b/open-sse/AGENTS.md @@ -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/-to-.js`, `response/-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/-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. diff --git a/open-sse/config/appConstants.js b/open-sse/config/appConstants.js index ba1ccf3b..6ac9d324 100644 --- a/open-sse/config/appConstants.js +++ b/open-sse/config/appConstants.js @@ -1,8 +1,9 @@ import { platform, arch } from "os"; +import { PROVIDERS, PROVIDER_OAUTH } from "./providers.js"; -// === Gemini CLI === -export const GEMINI_CLI_VERSION = "0.34.0"; -export const GEMINI_CLI_API_CLIENT = "google-genai-sdk/1.41.0 gl-node/v22.19.0"; +// === Gemini CLI === derive từ registry gemini-cli.transport +export const GEMINI_CLI_VERSION = PROVIDERS["gemini-cli"]?.cliVersion; +export const GEMINI_CLI_API_CLIENT = PROVIDERS["gemini-cli"]?.apiClient; // Map Node arch to Gemini CLI arch string (x64/x86/arm64/...) function geminiCLIArch() { @@ -16,11 +17,13 @@ export function geminiCLIUserAgent(model = "unknown") { } // === GitHub Copilot === +// Derive từ registry github.transport.copilot +const _ghCopilot = PROVIDERS.github?.copilot || {}; export const GITHUB_COPILOT = { - VSCODE_VERSION: "1.110.0", - COPILOT_CHAT_VERSION: "0.38.0", - USER_AGENT: "GitHubCopilotChat/0.38.0", - API_VERSION: "2025-04-01", + VSCODE_VERSION: _ghCopilot.vscodeVersion, + COPILOT_CHAT_VERSION: _ghCopilot.chatVersion, + USER_AGENT: _ghCopilot.userAgent, + API_VERSION: _ghCopilot.apiVersion, }; // === 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 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) -export const REFRESH_LEAD_MS = { - codex: 5 * 24 * 60 * 60 * 1000, // 5 days - 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 -}; +// Derive từ registry oauth.refreshLeadMs +export const REFRESH_LEAD_MS = Object.fromEntries( + Object.entries(PROVIDER_OAUTH).filter(([, o]) => o.refreshLeadMs).map(([id, o]) => [id, o.refreshLeadMs]) +); // OAuth endpoints export const OAUTH_ENDPOINTS = { - google: { - token: "https://oauth2.googleapis.com/token", - auth: "https://accounts.google.com/o/oauth2/auth" - }, - openai: { - token: "https://auth.openai.com/oauth/token", - 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" - } + google: { token: "https://oauth2.googleapis.com/token", auth: "https://accounts.google.com/o/oauth2/auth" }, + openai: { token: PROVIDER_OAUTH["codex"]?.tokenUrl, auth: PROVIDER_OAUTH["codex"]?.authorizeUrl }, + 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 }, + iflow: { token: PROVIDER_OAUTH["iflow"]?.tokenUrl, auth: PROVIDER_OAUTH["iflow"]?.authorizeUrl }, + github: { token: PROVIDER_OAUTH["github"]?.tokenUrl, auth: PROVIDER_OAUTH["github"]?.authorizeUrl, deviceCode: PROVIDER_OAUTH["github"]?.deviceCodeUrl }, }; // Generate Kimi OAuth custom headers diff --git a/open-sse/config/kiroConstants.js b/open-sse/config/kiroConstants.js index 707e46db..3ff6acbb 100644 --- a/open-sse/config/kiroConstants.js +++ b/open-sse/config/kiroConstants.js @@ -15,6 +15,9 @@ * 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_THINKING_SUFFIX = "-thinking"; @@ -89,16 +92,48 @@ REMEMBER: When in doubt, write LESS per operation. Multiple small operations > o `.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): - * - HTTP header `Anthropic-Beta: ...interleaved-thinking...` - * - JSON `thinking.type === "enabled"` (Claude Messages API) - * - JSON `reasoning_effort` in {low, medium, high, auto} (OpenAI o1/o3) - * - JSON `reasoning.effort` in {low, medium, high, auto} (OpenAI Responses) - * - System prompt contains `enabled` or - * `interleaved` (AMP / Cursor) - * - Model name contains `thinking` or `-reason` + * Reuses the shared thinkingUnified parser (extractThinking) so every client + * shape (Claude output_config.effort / thinking.budget_tokens, OpenAI + * reasoning_effort / reasoning.effort, Gemini, Qwen) maps consistently. Explicit + * `none`/`off`/disabled wins and returns null (no prefix injected). + * buildThinkingSystemPrefix performs Kiro's final 1..32000 clamp. + * + * @param {object} body OpenAI/Claude-shaped request body + * @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} [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} */ export function isThinkingEnabled(body, headers, model) { - if (headers) { - 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; + return resolveKiroThinkingBudget(body, headers, model) !== null; } /** diff --git a/open-sse/config/mediaConfig.js b/open-sse/config/mediaConfig.js new file mode 100644 index 00000000..5b8fbb2a --- /dev/null +++ b/open-sse/config/mediaConfig.js @@ -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", +]); diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 68285d36..c4cfa413 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -1,845 +1,12 @@ 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 -// 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) +export { PROVIDER_MODELS }; -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 enabled 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 export function getProviderModels(aliasOrId) { @@ -868,15 +35,14 @@ export function findModelName(aliasOrId, modelId) { export function getModelTargetFormat(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; if (!models) return null; - const found = models.find(m => m.id === modelId); - return found?.targetFormat || null; + return modelTargetFormat(models.find(m => m.id === modelId)); } export function getModelType(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; if (!models) return null; const found = models.find(m => m.id === modelId); - return found?.type || null; + return found?.kind || found?.type || null; } export function getModelUpstreamId(aliasOrId, modelId) { @@ -891,30 +57,14 @@ export function getModelUpstreamId(aliasOrId, modelId) { export function getModelQuotaFamily(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; - const found = models?.find(m => m.id === modelId); - return found?.quotaFamily || "normal"; + return modelQuotaFamily(models?.find(m => m.id === modelId)); } -// OAuth providers that use short aliases (everything else: alias = id) -const OAUTH_ALIASES = { - claude: "cc", - codex: "cx", - "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", -}; +// OAuth short aliases — derived from registry `alias` (single source). everything else: alias = id. +// vertex/vertex-partner keep alias=id (kept via the `|| id` fallback in consumers). +export const OAUTH_ALIASES = Object.fromEntries( + REGISTRY.filter(r => r.alias && r.alias !== r.id).map(r => [r.id, r.alias]) +); // Derived from PROVIDERS — no need to maintain manually 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) // Returns array of content types to strip, e.g. ["image", "audio"] export function getModelStrip(alias, modelId) { - const entry = PROVIDER_MODELS[alias]?.find(m => m.id === modelId); - return entry?.strip || []; + return modelStrip(PROVIDER_MODELS[alias]?.find(m => m.id === modelId)); } diff --git a/open-sse/config/providers.js b/open-sse/config/providers.js index 3f99cd04..f24ab96e 100644 --- a/open-sse/config/providers.js +++ b/open-sse/config/providers.js @@ -1,461 +1,6 @@ -import { platform, arch } from "os"; - -// === OS/Arch helpers === -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" }, -}; +// Barrel: PROVIDERS now built from providers/registry (transport co-located with models) +import { PROVIDERS } from "../providers/index.js"; +export { PROVIDERS, PROVIDER_OAUTH } from "../providers/index.js"; export const OLLAMA_LOCAL_DEFAULT_HOST = "http://localhost:11434"; @@ -464,12 +9,9 @@ export function resolveOllamaLocalHost(credentials) { return (raw || OLLAMA_LOCAL_DEFAULT_HOST).replace(/\/$/, ""); } -export const XIAOMI_TOKENPLAN_REGIONS = { - sgp: "https://token-plan-sgp.xiaomimimo.com/v1", - cn: "https://token-plan-cn.xiaomimimo.com/v1", - ams: "https://token-plan-ams.xiaomimimo.com/v1" -}; -export const XIAOMI_TOKENPLAN_DEFAULT_REGION = "sgp"; +// Region URLs single-source from registry xiaomi-tokenplan.transport +export const XIAOMI_TOKENPLAN_REGIONS = PROVIDERS["xiaomi-tokenplan"]?.regions || {}; +export const XIAOMI_TOKENPLAN_DEFAULT_REGION = PROVIDERS["xiaomi-tokenplan"]?.defaultRegion; export function resolveXiaomiTokenplanBaseUrl(credentials) { const region = credentials?.providerSpecificData?.region; diff --git a/open-sse/config/runtimeConfig.js b/open-sse/config/runtimeConfig.js index 37a5f84e..de199233 100644 --- a/open-sse/config/runtimeConfig.js +++ b/open-sse/config/runtimeConfig.js @@ -31,11 +31,26 @@ export const MEMORY_CONFIG = { proxyDispatchersMaxSize: 20, }; -// Stream stall timeout: abort if no chunk received within this duration -export const STREAM_STALL_TIMEOUT_MS = 60 * 1000; +// Parse a positive integer env override, falling back to a default. +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 -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 export const DEFAULT_MAX_TOKENS = 64000; diff --git a/open-sse/config/ttsModels.js b/open-sse/config/ttsModels.js index 78ae7fbe..6925f5f5 100644 --- a/open-sse/config/ttsModels.js +++ b/open-sse/config/ttsModels.js @@ -96,10 +96,12 @@ export const TTS_MODELS_CONFIG = { }, gemini: { 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-pro-preview-tts", name: "Gemini 2.5 Pro TTS", type: "tts" }, ], voices: { + "gemini-3.1-flash-tts-preview": GEMINI_VOICES, "gemini-2.5-flash-preview-tts": GEMINI_VOICES, "gemini-2.5-pro-preview-tts": GEMINI_VOICES, }, diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index ce27a307..cfd63482 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -3,9 +3,9 @@ import { BaseExecutor } from "./base.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 { 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 { 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} function sanitizeFunctionName(name) { @@ -16,8 +16,76 @@ function sanitizeFunctionName(name) { } const MAX_RETRY_AFTER_MS = 10000; +const ANTIGRAVITY_TRANSIENT_RETRY_MAX_MS = 15000; 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 { constructor() { super("antigravity", PROVIDERS.antigravity); @@ -26,17 +94,22 @@ export class AntigravityExecutor extends BaseExecutor { buildUrl(model, stream, urlIndex = 0) { const baseUrls = this.getBaseUrls(); 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}`; } + // 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) { + const sid = sessionId || this._lastSessionId; return { "Content-Type": "application/json", "Authorization": `Bearer ${credentials.accessToken}`, "User-Agent": this.config.headers?.["User-Agent"] || ANTIGRAVITY_HEADERS["User-Agent"], [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" }; } @@ -44,6 +117,53 @@ export class AntigravityExecutor extends BaseExecutor { transformRequest(model, body, stream, credentials) { 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 const contents = body.request?.contents?.map(c => { let role = c.role; @@ -68,19 +188,28 @@ export class AntigravityExecutor extends BaseExecutor { if (tools && tools.length > 0) { // Merge all groups into a single functionDeclarations group (Gemini expects 1 group) - const allDeclarations = tools.flatMap(group => - (group.functionDeclarations || []).map(fn => ({ - ...fn, - name: sanitizeFunctionName(fn.name), - parameters: fn.parameters - ? cleanJSONSchemaForAntigravity(structuredClone(fn.parameters)) - : { type: "object", properties: { reason: { type: "string", description: "Brief explanation" } }, required: ["reason"] } - })) - ); + const seenToolNames = new Set(); + const allDeclarations = []; + for (const group of tools) { + for (const fn of group.functionDeclarations || []) { + const name = sanitizeFunctionName(fn.name); + if (seenToolNames.has(name)) continue; + 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 }] : []; } + // Strip tools/toolConfig (handled separately) and blacklisted fields that Google rejects const { tools: _originalTools, toolConfig: _originalToolConfig, ...requestWithoutTools } = body.request || {}; + stripBlacklisted(requestWithoutTools); const generationConfig = { ...(requestWithoutTools.generationConfig || {}) }; if (generationConfig.maxOutputTokens > MAX_ANTIGRAVITY_OUTPUT_TOKENS) { generationConfig.maxOutputTokens = MAX_ANTIGRAVITY_OUTPUT_TOKENS; @@ -91,11 +220,16 @@ export class AntigravityExecutor extends BaseExecutor { generationConfig, ...(contents && { contents }), ...(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, ...(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 { ...body, project: projectId, @@ -196,98 +330,49 @@ export class AntigravityExecutor extends BaseExecutor { return totalMs > 0 ? totalMs : null; } - async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { - const fallbackCount = this.getFallbackCount(); - let lastError = null; - let lastStatus = 0; - const MAX_AUTO_RETRIES = 3; - const MAX_RETRY_AFTER_RETRIES = 3; - const retryAttemptsByUrl = {}; // Track retry attempts per URL - const retryAfterAttemptsByUrl = {}; // Track Retry-After retries per URL + extractErrorMessage(errorJson, bodyText = "") { + return [ + errorJson?.error?.message, + errorJson?.message, + errorJson?.error, + bodyText, + ].filter(Boolean).map(v => typeof v === "string" ? v : JSON.stringify(v)).join("\n"); + } - for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { - const url = this.buildUrl(model, stream, urlIndex); - const transformedBody = this.transformRequest(model, body, stream, credentials); - const sessionId = transformedBody.request?.sessionId; - const headers = this.buildHeaders(credentials, stream, sessionId); + isTransientAntigravityError(status, message) { + if (status === HTTP_STATUS.RATE_LIMITED) return true; + if (ANTIGRAVITY_TRANSIENT_STATUSES.has(status)) return true; + return ANTIGRAVITY_TRANSIENT_ERROR_PATTERNS.some(pattern => pattern.test(message || "")); + } - // Initialize retry counters for this URL - if (!retryAttemptsByUrl[urlIndex]) { - retryAttemptsByUrl[urlIndex] = 0; - } - if (!retryAfterAttemptsByUrl[urlIndex]) { - retryAfterAttemptsByUrl[urlIndex] = 0; - } + // Hook called by BaseExecutor.tryRetry: derive delay from Retry-After (header → body), + // cap at MAX_RETRY_AFTER_MS, else retry transient Antigravity failures with backoff. + // Return false to veto (fallback URL / final error). + async computeRetryDelay(response, attempt) { + let bodyText = ""; + let errorJson = null; + let retryMs = this.parseRetryHeaders(response.headers); - try { - const response = await proxyAwareFetch(url, { - method: "POST", - headers, - body: JSON.stringify(transformedBody), - 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; - } + try { + bodyText = await response.clone().text(); + errorJson = bodyText ? JSON.parse(bodyText) : null; + } catch { + // ignore parse errors → fall through to status/message based retry } - 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 } /** diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index aaf6ede8..71418deb 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -2,6 +2,7 @@ import { HTTP_STATUS, RETRY_CONFIG, DEFAULT_RETRY_CONFIG, resolveRetryEntry, FET import { shouldRefreshCredentials } from "../services/oauthCredentialManager.js"; import { proxyAwareFetch } from "../utils/proxyFetch.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 @@ -27,13 +28,13 @@ export class BaseExecutor { buildUrl(model, stream, urlIndex = 0, credentials = null) { 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 path = this.provider.includes("responses") ? "/responses" : "/chat/completions"; return `${normalized}${path}`; } 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(/\/$/, ""); return `${normalized}/messages`; } @@ -55,7 +56,7 @@ export class BaseExecutor { headers["Authorization"] = `Bearer ${credentials.accessToken}`; } if (!headers["anthropic-version"]) { - headers["anthropic-version"] = "2023-06-01"; + headers["anthropic-version"] = ANTHROPIC_API_VERSION; } } else { // Standard Bearer token auth for other providers @@ -105,12 +106,20 @@ export class BaseExecutor { const retryConfig = { ...DEFAULT_RETRY_CONFIG, ...this.config.retry }; // 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]); 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]++; - log?.debug?.("RETRY", `${reason} retry ${retryAttemptsByUrl[urlIndex]}/${attempts} after ${delayMs / 1000}s`); - await new Promise(resolve => setTimeout(resolve, delayMs)); + log?.debug?.("RETRY", `${reason} retry ${retryAttemptsByUrl[urlIndex]}/${attempts} after ${waitMs / 1000}s`); + await new Promise(resolve => setTimeout(resolve, waitMs)); return true; }; @@ -142,7 +151,7 @@ export class BaseExecutor { const cl = response.headers?.get?.("content-length") || "?"; 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)) { log?.debug?.("RETRY", `${response.status} on ${url}, trying fallback ${urlIndex + 1}`); diff --git a/open-sse/executors/codebuddy-cn.js b/open-sse/executors/codebuddy-cn.js new file mode 100644 index 00000000..5f37d015 --- /dev/null +++ b/open-sse/executors/codebuddy-cn.js @@ -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; diff --git a/open-sse/executors/codex.js b/open-sse/executors/codex.js index fa818ae7..e19b0787 100644 --- a/open-sse/executors/codex.js +++ b/open-sse/executors/codex.js @@ -1,4 +1,3 @@ -import { createHash } from "crypto"; import { BaseExecutor } from "./base.js"; import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js"; import { PROVIDERS } from "../config/providers.js"; @@ -6,34 +5,35 @@ import { refreshProviderCredentials, shouldRefreshCredentials, } from "../services/oauthCredentialManager.js"; -import { normalizeResponsesInput } from "../translator/helpers/responsesApiHelper.js"; -import { fetchImageAsBase64 } from "../translator/helpers/imageHelper.js"; +import { normalizeResponsesInput } from "../translator/formats/responsesApi.js"; +import { fetchImageAsBase64 } from "../translator/concerns/image.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 { 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 const CODEX_SSE_OVERLOADED_PATTERNS = ["server_is_overloaded", "service_unavailable_error"]; 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 const SERVER_ID_PATTERN = /^(rs|fc|resp|msg)_/; // Hosted tool types that Codex/OpenAI Responses executes server-side const CODEX_HOSTED_TOOL_TYPES = new Set([ "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 const RESPONSES_API_ALLOWLIST = new Set([ "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) @@ -76,6 +76,7 @@ function normalizeCodexTools(body) { return true; } if (type !== "function") { + if (CODEX_PASSTHROUGH_TOOL_TYPES.has(type)) return true; if (!type || tool.function || typeof tool.name === "string") return false; return CODEX_HOSTED_TOOL_TYPES.has(type); } @@ -104,86 +105,17 @@ function normalizeCodexTools(body) { } } -// Cache machine ID at module level (resolved once) -let cachedMachineId = null; -getConsistentMachineId().then(id => { cachedMachineId = id; }); - -function hashContent(text) { - return createHash("sha256").update(text).digest("hex").slice(0, 16); +// Resolve prompt-cache session id: client session → assistant-text-hash → workspaceId → connection +function resolveCacheSessionId(body, credentials) { + return resolveSessionId({ + headers: credentials?.rawHeaders, + body, + 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) * Automatically injects default instructions if missing @@ -377,7 +309,7 @@ export class CodexExecutor extends BaseExecutor { this._isCompact = !!body._compact; delete body._compact; // 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) const normalized = normalizeResponsesInput(body.input); if (normalized) body.input = normalized; diff --git a/open-sse/executors/commandcode.js b/open-sse/executors/commandcode.js index 772bf4c8..aad40439 100644 --- a/open-sse/executors/commandcode.js +++ b/open-sse/executors/commandcode.js @@ -1,7 +1,8 @@ import { randomUUID } from "crypto"; import { BaseExecutor } from "./base.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 @@ -70,15 +71,15 @@ function wrapNdjsonAsOpenAISse(originalResponse, model) { const trimmed = line.trim(); if (!trimmed) continue; // Translate AI SDK v5 NDJSON line to one or more OpenAI chunks - emitChunks(convertCommandCodeToOpenAI(trimmed, state), controller); + emitChunks(commandCodeToOpenAIResponse(trimmed, state), controller); } }, flush(controller) { const trimmed = buffer.trim(); 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)); }, }); diff --git a/open-sse/executors/cursor.js b/open-sse/executors/cursor.js index de64870c..fe06d80d 100644 --- a/open-sse/executors/cursor.js +++ b/open-sse/executors/cursor.js @@ -8,6 +8,8 @@ import { } from "../utils/cursorProtobuf.js"; import { buildCursorHeaders } from "../utils/cursorChecksum.js"; import { estimateUsage } from "../utils/usageTracking.js"; +import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js"; +import { chatChunkSse } from "../utils/sse.js"; import { FORMATS } from "../translator/formats.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import zlib from "zlib"; @@ -98,6 +100,32 @@ function decompressPayload(payload, flags) { 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) { const errorMsg = jsonError?.error?.details?.[0]?.debug?.details?.title || jsonError?.error?.details?.[0]?.debug?.details?.detail @@ -141,7 +169,7 @@ export class CursorExecutor extends BaseExecutor { transformRequest(model, body, stream, credentials) { // 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 tools = body.tools || []; const reasoningEffort = body.reasoning_effort || null; @@ -286,36 +314,12 @@ export class CursorExecutor extends BaseExecutor { debugLog(`[CURSOR BUFFER] Total length: ${buffer.length} bytes`); while (offset < buffer.length) { - if (offset + 5 > buffer.length) { - debugLog( - `[CURSOR BUFFER] Reached end, offset=${offset}, remaining=${buffer.length - 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; + const frame = readCursorFrame(buffer, offset, frameCount, ""); + if (frame.status === "done") break; + offset = frame.offset; frameCount++; - - payload = decompressPayload(payload, flags); - if (!payload) { - debugLog(`[CURSOR BUFFER] Frame ${frameCount}: decompression failed, skipping`); - continue; - } + if (frame.status === "skip") continue; + const payload = frame.payload; // Check for JSON error frames (byte guard: skip toString on non-JSON frames) 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`); while (offset < buffer.length) { - if (offset + 5 > buffer.length) { - debugLog( - `[CURSOR BUFFER SSE] Reached end, offset=${offset}, remaining=${buffer.length - 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; + const frame = readCursorFrame(buffer, offset, frameCount, " SSE"); + if (frame.status === "done") break; + offset = frame.offset; frameCount++; - - payload = decompressPayload(payload, flags); - if (!payload) { - debugLog(`[CURSOR BUFFER SSE] Frame ${frameCount}: decompression failed, skipping`); - continue; - } + if (frame.status === "skip") continue; + const payload = frame.payload; // Check for JSON error frames (byte-guard: only decode if starts with '{') if (payload[0] === 0x7b) { @@ -542,21 +522,7 @@ export class CursorExecutor extends BaseExecutor { const tc = result.toolCall; if (chunks.length === 0) { - chunks.push( - `data: ${JSON.stringify({ - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [ - { - index: 0, - delta: { role: "assistant", content: "" }, - finish_reason: null - } - ] - })}\n\n` - ); + chunks.push(chatChunkSse({ id: responseId, created, model, delta: { role: "assistant", content: "" } })); } if (toolCallsMap.has(tc.id)) { @@ -569,33 +535,22 @@ export class CursorExecutor extends BaseExecutor { // Stream the delta arguments if (tc.function.arguments) { emittedToolCallIds.add(tc.id); - chunks.push( - `data: ${JSON.stringify({ - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [ + chunks.push(chatChunkSse({ + id: responseId, created, model, + delta: { + tool_calls: [ { - index: 0, - delta: { - tool_calls: [ - { - index: existing.index, - id: tc.id, - type: "function", - function: { - name: tc.function.name, - arguments: tc.function.arguments - } - } - ] - }, - finish_reason: null + index: existing.index, + id: tc.id, + type: "function", + function: { + name: tc.function.name, + arguments: tc.function.arguments + } } ] - })}\n\n` - ); + } + })); } } else { // New tool call - assign index and add to map @@ -606,56 +561,34 @@ export class CursorExecutor extends BaseExecutor { // Stream initial tool call with name emittedToolCallIds.add(tc.id); - chunks.push( - `data: ${JSON.stringify({ - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [ + chunks.push(chatChunkSse({ + id: responseId, created, model, + delta: { + tool_calls: [ { - index: 0, - delta: { - tool_calls: [ - { - index: toolCallIndex, - id: tc.id, - type: "function", - function: { - name: tc.function.name, - arguments: tc.function.arguments - } - } - ] - }, - finish_reason: null + index: toolCallIndex, + id: tc.id, + type: "function", + function: { + name: tc.function.name, + arguments: tc.function.arguments + } } ] - })}\n\n` - ); + } + })); } } if (result.text) { totalContent += result.text; - chunks.push( - `data: ${JSON.stringify({ - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [ - { - index: 0, - delta: - chunks.length === 0 && toolCalls.length === 0 - ? { role: "assistant", content: result.text } - : { content: result.text }, - finish_reason: null - } - ] - })}\n\n` - ); + chunks.push(chatChunkSse({ + id: responseId, created, model, + delta: + chunks.length === 0 && toolCalls.length === 0 + ? { role: "assistant", content: result.text } + : { content: result.text } + })); } if (isComposerModel(model) && result.thinking) { @@ -665,24 +598,13 @@ export class CursorExecutor extends BaseExecutor { const deltaContent = visibleContent.slice(emittedComposerThinkingContentLength); emittedComposerThinkingContentLength = visibleContent.length; totalContent += deltaContent; - chunks.push( - `data: ${JSON.stringify({ - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [ - { - index: 0, - delta: - chunks.length === 0 && toolCalls.length === 0 - ? { role: "assistant", content: deltaContent } - : { content: deltaContent }, - finish_reason: null - } - ] - })}\n\n` - ); + chunks.push(chatChunkSse({ + id: responseId, created, model, + delta: + chunks.length === 0 && toolCalls.length === 0 + ? { role: "assistant", content: deltaContent } + : { content: deltaContent } + })); } } } @@ -708,53 +630,28 @@ export class CursorExecutor extends BaseExecutor { // Emit SSE chunk for the finalized tool call if not already emitted if (!emittedToolCallIds.has(tc.id)) { - chunks.push( - `data: ${JSON.stringify({ - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [ + chunks.push(chatChunkSse({ + id: responseId, created, model, + delta: { + tool_calls: [ { - index: 0, - delta: { - tool_calls: [ - { - index: toolCallIndex, - id: tc.id, - type: "function", - function: { - name: tc.function.name, - arguments: tc.function.arguments - } - } - ] - }, - finish_reason: null + index: toolCallIndex, + id: tc.id, + type: "function", + function: { + name: tc.function.name, + arguments: tc.function.arguments + } } ] - })}\n\n` - ); + } + })); } } } if (chunks.length === 0 && toolCalls.length === 0) { - chunks.push( - `data: ${JSON.stringify({ - id: responseId, - object: "chat.completion.chunk", - created, - model, - choices: [ - { - index: 0, - delta: { role: "assistant", content: "" }, - finish_reason: null - } - ] - })}\n\n` - ); + chunks.push(chatChunkSse({ id: responseId, created, model, delta: { role: "assistant", content: "" } })); } const usage = estimateUsage(body, totalContent.length, FORMATS.OPENAI); @@ -775,15 +672,11 @@ export class CursorExecutor extends BaseExecutor { usage })}\n\n` ); - chunks.push("data: [DONE]\n\n"); + chunks.push(SSE_DONE); return new Response(chunks.join(""), { status: 200, - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "Connection": "keep-alive" - } + headers: { ...SSE_HEADERS } }); } diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index 1297c351..ca3061d6 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -1,10 +1,80 @@ 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 { buildClineHeaders } from "../../src/shared/utils/clineAuth.js"; +import { buildClineHeaders } from "../shared/clineAuth.js"; import { getCachedClaudeHeaders } from "../utils/claudeHeaderCache.js"; import { proxyAwareFetch } from "../utils/proxyFetch.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 { constructor(provider) { @@ -15,9 +85,11 @@ export class DefaultExecutor extends BaseExecutor { const transformed = this.applyJsonSchemaFallback(body); 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; } + stripUnsupportedParams(this.provider, model, 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) { + // 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-")) { - const baseUrl = credentials?.providerSpecificData?.baseUrl || "https://api.openai.com/v1"; + const baseUrl = credentials?.providerSpecificData?.baseUrl || OPENAI_COMPAT_BASE; const normalized = baseUrl.replace(/\/$/, ""); const path = this.provider.includes("responses") ? "/responses" : "/chat/completions"; return `${normalized}${path}`; } 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(/\/$/, ""); return `${normalized}/messages`; } - switch (this.provider) { - case "claude": - case "glm": - 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; - } + // gemini-format: build :streamGenerateContent / :generateContent path + if (this.config.format === "gemini") { + return `${this.config.baseUrl}/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`; } + // 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) { - const headers = { "Content-Type": "application/json", ...this.config.headers }; - - switch (this.provider) { - case "gemini": - credentials.apiKey ? headers["x-goog-api-key"] = credentials.apiKey : headers["Authorization"] = `Bearer ${credentials.accessToken}`; - break; - 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}`; - } - } + const rt = credentials?.runtimeTransport; + const headers = { "Content-Type": "application/json", ...(rt ? rt.headers : this.config.headers) }; + const desc = rt?.auth || AUTH_DESCRIPTORS[this.provider] || this.resolveAuthDescriptor(); + // Hooks run BEFORE auth so dynamic overlays (claude cached headers) can't clobber the token. + for (const hook of desc.hooks || []) HEADER_HOOKS[hook]?.(headers, credentials); + applyAuth(headers, desc, credentials); // Strip first-party Claude Code identity headers for non-Anthropic anthropic-compatible upstreams if (this.provider?.startsWith?.("anthropic-compatible-")) { @@ -196,15 +205,25 @@ export class DefaultExecutor extends BaseExecutor { 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) { if (!credentials.refreshToken) return null; const refreshers = { - claude: () => this.refreshWithJSON(OAUTH_ENDPOINTS.anthropic.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.claude.clientId }, 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), + claude: () => this.refreshFromGrant(credentials, 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), iflow: () => this.refreshIflow(credentials.refreshToken, proxyOptions), - gemini: () => this.refreshGoogle(credentials.refreshToken, proxyOptions), + gemini: () => this.refreshFromGrant(credentials, proxyOptions), kiro: () => this.refreshKiro(credentials.refreshToken, proxyOptions), cline: () => this.refreshCline(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 }; } - 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) { const response = await proxyAwareFetch(PROVIDERS.kiro.tokenUrl, { method: "POST", @@ -281,37 +289,29 @@ export class DefaultExecutor extends BaseExecutor { } async refreshCline(refreshToken, proxyOptions = null) { - console.log('[DEBUG] Refreshing Cline token, refreshToken length:', refreshToken?.length); - const response = await proxyAwareFetch("https://api.cline.bot/api/v1/auth/refresh", { + const response = await proxyAwareFetch(PROVIDERS.cline.refreshUrl, { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json" }, body: JSON.stringify({ refreshToken, grantType: "refresh_token", clientType: "extension" }) }, proxyOptions); - console.log('[DEBUG] Cline refresh response status:', response.status); - if (!response.ok) { - const errorText = await response.text(); - console.log('[DEBUG] Cline refresh error:', errorText); - return null; - } + if (!response.ok) return null; const payload = await response.json(); - console.log('[DEBUG] Cline refresh payload:', JSON.stringify(payload).substring(0, 200)); const data = payload?.data || payload; const expiresAtIso = data?.expiresAt; 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 }; } async refreshKimiCoding(refreshToken, proxyOptions = null) { const kimiHeaders = buildKimiHeaders(); - const response = await proxyAwareFetch("https://auth.kimi.com/api/oauth/token", { + const response = await proxyAwareFetch(PROVIDERS["kimi-coding"].refreshUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", ...kimiHeaders }, - body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: "17e5f671-d194-4dfb-9706-5516cb48c098" }) + body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: PROVIDERS["kimi-coding"].clientId }) }, proxyOptions); if (!response.ok) return null; const tokens = await response.json(); diff --git a/open-sse/executors/github.js b/open-sse/executors/github.js index cc26cd27..2f4d68ba 100644 --- a/open-sse/executors/github.js +++ b/open-sse/executors/github.js @@ -7,6 +7,8 @@ import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-r import { initState } from "../translator/index.js"; import { parseSSELine, formatSSE } from "../utils/streamHelpers.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"; export class GithubExecutor extends BaseExecutor { @@ -108,54 +110,18 @@ export class GithubExecutor extends BaseExecutor { 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) { const transformed = { ...body }; if (this.requiresMaxCompletionTokens(model) && transformed.max_tokens !== undefined) { transformed.max_completion_tokens = 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 if (transformed.reasoning_effort === "none") { delete transformed.reasoning_effort; } - // Strip reasoning_effort only for models that reject it - if (!this.supportsReasoningEffort(model) && transformed.reasoning_effort !== undefined) { - delete transformed.reasoning_effort; - } + // Config-driven strip of params unsupported by this provider/model + stripUnsupportedParams("github", model, transformed); return transformed; } @@ -244,7 +210,7 @@ export class GithubExecutor extends BaseExecutor { if (!parsed) continue; if (parsed.done && stream === true) { - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")); + controller.enqueue(new TextEncoder().encode(SSE_DONE)); continue; } diff --git a/open-sse/executors/grok-web.js b/open-sse/executors/grok-web.js index 2f366f99..9e6cdb12 100644 --- a/open-sse/executors/grok-web.js +++ b/open-sse/executors/grok-web.js @@ -1,5 +1,7 @@ import { BaseExecutor } from "./base.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_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 }; } -function sseChunk(data) { - return `data: ${JSON.stringify(data)}\n\n`; -} - function buildStreamingResponse(eventStream, model, cid, created, isThinkingModel, signal) { const encoder = new TextEncoder(); 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, 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) { controller.enqueue(encoder.encode(sseChunk({ 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 }], }))); - controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.enqueue(encoder.encode(SSE_DONE)); } finally { controller.close(); } @@ -333,7 +331,7 @@ export class GrokWebExecutor extends BaseExecutor { const sseStream = buildStreamingResponse(response.body, model, cid, created, isThinking, signal); finalResponse = new Response(sseStream, { status: 200, - headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" }, + headers: { ...SSE_HEADERS_NO_BUFFER }, }); } else { finalResponse = await buildNonStreamingResponse(response.body, model, cid, created, isThinking, signal); diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index bac26447..77d12fb3 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -17,6 +17,7 @@ import { OllamaLocalExecutor } from "./ollama-local.js"; import { CommandCodeExecutor } from "./commandcode.js"; import { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; import { MimoFreeExecutor } from "./mimo-free.js"; +import { CodeBuddyExecutor } from "./codebuddy-cn.js"; import { DefaultExecutor } from "./default.js"; const executors = { @@ -42,6 +43,7 @@ const executors = { "xiaomi-tokenplan": new XiaomiTokenplanExecutor(), "mimo-free": new MimoFreeExecutor(), mmf: new MimoFreeExecutor(), // Alias for mimo-free + "codebuddy-cn": new CodeBuddyExecutor(), }; const defaultCache = new Map(); @@ -77,3 +79,4 @@ export { OllamaLocalExecutor } from "./ollama-local.js"; export { CommandCodeExecutor } from "./commandcode.js"; export { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; export { MimoFreeExecutor } from "./mimo-free.js"; +export { CodeBuddyExecutor } from "./codebuddy-cn.js"; diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index 47aba379..d40a7833 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -1,7 +1,10 @@ import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; +import { resolveKiroModel } from "../config/kiroConstants.js"; import { v4 as uuidv4 } from "uuid"; import { refreshKiroToken } from "../services/tokenRefresh.js"; +import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js"; +import { getCapabilitiesForModel } from "../providers/capabilities.js"; /** * KiroExecutor - Executor for Kiro AI (AWS CodeWhisperer) @@ -19,13 +22,60 @@ export class KiroExecutor extends BaseExecutor { "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}`; + if (isExternalIdp) { + headers["TokenType"] = "EXTERNAL_IDP"; + } } 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) { return body; } @@ -37,6 +87,8 @@ export class KiroExecutor extends BaseExecutor { * BaseExecutor.execute() walks config.baseUrls (runtime.us-east-1.kiro.dev → * 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}`. + * 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 * 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. @@ -61,6 +113,8 @@ export class KiroExecutor extends BaseExecutor { let chunkIndex = 0; const responseId = `chatcmpl-${Date.now()}`; const created = Math.floor(Date.now() / 1000); + const capabilityModel = resolveKiroModel(model).upstream; + const contextWindow = getCapabilitiesForModel("kiro", capabilityModel).contextWindow || 200000; const state = { endDetected: false, finishEmitted: false, @@ -73,6 +127,8 @@ export class KiroExecutor extends BaseExecutor { const transformStream = new TransformStream({ async transform(chunk, controller) { + // Track output so we can emit a keepalive if this frame yields no chunk. + const enqueueCountBefore = chunkIndex; // Append to buffer const newBuffer = new Uint8Array(buffer.length + chunk.length); newBuffer.set(buffer); @@ -96,7 +152,7 @@ export class KiroExecutor extends BaseExecutor { if (!event) continue; const eventType = event.headers[":event-type"] || ""; - + // Track total content length for token estimation if (!state.totalContentLength) state.totalContentLength = 0; if (!state.contextUsagePercentage) state.contextUsagePercentage = 0; @@ -105,7 +161,7 @@ export class KiroExecutor extends BaseExecutor { if (eventType === "assistantResponseEvent" && event.payload?.content) { const content = event.payload.content; state.totalContentLength += content.length; - + const chunk = { id: responseId, object: "chat.completion.chunk", @@ -292,7 +348,7 @@ export class KiroExecutor extends BaseExecutor { if (metrics && typeof metrics === 'object') { const inputTokens = metrics.inputTokens || 0; const outputTokens = metrics.outputTokens || 0; - + if (inputTokens > 0 || outputTokens > 0) { state.usage = { prompt_tokens: inputTokens, @@ -306,27 +362,26 @@ export class KiroExecutor extends BaseExecutor { // Emit final chunk only after receiving BOTH meteringEvent AND contextUsageEvent if (state.hasMeteringEvent && state.hasContextUsage && !state.finishEmitted) { state.finishEmitted = true; - + // Estimate tokens if not available from events if (!state.usage) { // Estimate output tokens from content length - const estimatedOutputTokens = state.totalContentLength > 0 + const estimatedOutputTokens = state.totalContentLength > 0 ? Math.max(1, Math.floor(state.totalContentLength / 4)) : 0; - + // Estimate input tokens from contextUsagePercentage - // Kiro models typically have 200k context window const estimatedInputTokens = state.contextUsagePercentage > 0 - ? Math.floor(state.contextUsagePercentage * 200000 / 100) + ? Math.floor(state.contextUsagePercentage * contextWindow / 100) : 0; - + state.usage = { prompt_tokens: estimatedInputTokens, completion_tokens: estimatedOutputTokens, total_tokens: estimatedInputTokens + estimatedOutputTokens }; } - + const finishChunk = { id: responseId, object: "chat.completion.chunk", @@ -338,12 +393,12 @@ export class KiroExecutor extends BaseExecutor { finish_reason: state.hasToolCalls ? "tool_calls" : "stop" }] }; - + // Include usage in final chunk if available if (state.usage) { finishChunk.usage = state.usage; } - + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`)); } } @@ -351,6 +406,12 @@ export class KiroExecutor extends BaseExecutor { if (iterations >= maxIterations) { 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) { @@ -372,24 +433,20 @@ export class KiroExecutor extends BaseExecutor { } // 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 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); return new Response(transformedStream, { status: response.status, statusText: response.statusText, - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "Connection": "keep-alive" - } + headers: { ...SSE_HEADERS } }); } diff --git a/open-sse/executors/mimo-free.js b/open-sse/executors/mimo-free.js index 2432d502..04d44679 100644 --- a/open-sse/executors/mimo-free.js +++ b/open-sse/executors/mimo-free.js @@ -5,13 +5,20 @@ import { createHash } from "crypto"; import os from "os"; 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_ID_LENGTH = 24; const JWT_FALLBACK_TTL_SEC = 3000; const JWT_EXPIRY_BUFFER_MS = 300000; 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" // unless a system message contains this exact MiMoCode signature substring. export const MIMO_SYSTEM_MARKER = @@ -76,7 +83,10 @@ async function bootstrapJwt(proxyOptions = null) { const response = await proxyAwareFetch(BOOTSTRAP_URL, { 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() }), }, proxyOptions); @@ -108,6 +118,7 @@ export class MimoFreeExecutor extends BaseExecutor { return { "Content-Type": "application/json", "X-Mimo-Source": "mimocode-cli-free", + "User-Agent": USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)], "x-session-affinity": this.sessionId, "Accept": stream ? "text/event-stream" : "application/json", }; diff --git a/open-sse/executors/opencode-go.js b/open-sse/executors/opencode-go.js index 39e9e0aa..7bf47edb 100644 --- a/open-sse/executors/opencode-go.js +++ b/open-sse/executors/opencode-go.js @@ -1,9 +1,17 @@ import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.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) -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"; @@ -15,7 +23,7 @@ export class OpenCodeGoExecutor extends BaseExecutor { // buildUrl runs before buildHeaders in BaseExecutor.execute, cache model here buildUrl(model) { this._lastModel = model; - return CLAUDE_FORMAT_MODELS.has(model) + return MESSAGES_FORMAT_MODELS.has(model) ? `${BASE}/messages` : `${BASE}/chat/completions`; } @@ -24,9 +32,9 @@ export class OpenCodeGoExecutor extends BaseExecutor { const key = credentials?.apiKey || credentials?.accessToken; 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["anthropic-version"] = "2023-06-01"; + headers["anthropic-version"] = ANTHROPIC_API_VERSION; } else { headers["Authorization"] = `Bearer ${key}`; } diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js index 2908c1e6..f7aee211 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.js @@ -15,7 +15,7 @@ export class OpenCodeExecutor extends BaseExecutor { } buildUrl(model) { - const base = "https://opencode.ai"; + const base = this.config.baseUrl; return MESSAGES_MODELS.has(model) ? `${base}/zen/v1/messages` : `${base}/zen/v1/chat/completions`; diff --git a/open-sse/executors/perplexity-web.js b/open-sse/executors/perplexity-web.js index 2c39cf4c..87d64574 100644 --- a/open-sse/executors/perplexity-web.js +++ b/open-sse/executors/perplexity-web.js @@ -1,5 +1,7 @@ import { BaseExecutor } from "./base.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_API_VERSION = "2.18"; @@ -289,10 +291,6 @@ async function* extractContent(eventStream, signal) { 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) { const encoder = new TextEncoder(); 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, 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); } 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, 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 { controller.close(); } @@ -493,7 +491,7 @@ export class PerplexityWebExecutor extends BaseExecutor { const sseStream = buildStreamingResponse(response.body, model, cid, created, parsed.history, parsed.currentMsg, signal); finalResponse = new Response(sseStream, { status: 200, - headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" }, + headers: { ...SSE_HEADERS_NO_BUFFER }, }); } else { finalResponse = await buildNonStreamingResponse(response.body, model, cid, created, parsed.history, parsed.currentMsg, signal); diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.js index 8e2f263a..8b166089 100644 --- a/open-sse/executors/qoder.js +++ b/open-sse/executors/qoder.js @@ -20,19 +20,20 @@ * different model upstream, so a missing entry is a hard error. */ -import { qoderEncodeBody } from "@/lib/qoder/encoding.js"; -import { buildCosyHeaders } from "@/lib/qoder/cosy.js"; +import { qoderEncodeBody } from "../shared/qoder/encoding.js"; +import { buildCosyHeaders } from "../shared/qoder/cosy.js"; import { v4 as uuidv4 } from "uuid"; import { createHash } from "crypto"; import { BaseExecutor } from "./base.js"; import { PROVIDERS } from "../config/providers.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { SSE_DONE } from "../utils/sseConstants.js"; import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js"; import { QODER_CHAT_URL_ENCODED, QODER_MODEL_MAP, -} from "@/lib/qoder/constants.js"; +} from "../shared/qoder/constants.js"; import { getQoderModelConfig, resolveQoderModels } from "../services/qoderModels.js"; /** @@ -294,7 +295,7 @@ async function wrapQoderSSE(response, model) { const data = trimmed.slice(5).trimStart(); if (data === "[DONE]") { - controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.enqueue(encoder.encode(SSE_DONE)); doneEmitted = true; 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" }], }); controller.enqueue(encoder.encode(`data: ${errChunk}\n\n`)); - controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.enqueue(encoder.encode(SSE_DONE)); doneEmitted = true; return; } if (!inner) return; if (inner === "[DONE]") { - controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.enqueue(encoder.encode(SSE_DONE)); doneEmitted = true; return; } @@ -344,7 +345,7 @@ async function wrapQoderSSE(response, model) { buffer = ""; } if (!doneEmitted) { - controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.enqueue(encoder.encode(SSE_DONE)); doneEmitted = true; } }, diff --git a/open-sse/executors/xiaomi-tokenplan.js b/open-sse/executors/xiaomi-tokenplan.js index 259da07f..75799adb 100644 --- a/open-sse/executors/xiaomi-tokenplan.js +++ b/open-sse/executors/xiaomi-tokenplan.js @@ -1,18 +1,19 @@ import { DefaultExecutor } from "./default.js"; import { resolveXiaomiTokenplanBaseUrl } from "../config/providers.js"; -import { getModelTargetFormat } from "../config/providerModels.js"; -import { FORMATS } from "../translator/formats.js"; +// import { getModelTargetFormat } from "../config/providerModels.js"; +// import { FORMATS } from "../translator/formats.js"; export class XiaomiTokenplanExecutor extends DefaultExecutor { constructor() { 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) { const baseUrl = resolveXiaomiTokenplanBaseUrl(credentials); - if (getModelTargetFormat(model, model) === FORMATS.CLAUDE) { - return `${baseUrl.replace(/\/v1\/?$/, "/anthropic/v1")}/messages`; + if (credentials?.runtimeTransport?.format === "claude") { + return `${baseUrl.replace(/\/v1\/?$/, "")}/anthropic/v1/messages`; } return `${baseUrl}/chat/completions`; } diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 65281cfa..190cbb44 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -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 { 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 { createStreamController } from "../utils/streamHandler.js"; import { refreshWithRetry } from "../services/tokenRefresh.js"; import { createRequestLogger } from "../utils/requestLogger.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 { HTTP_STATUS } from "../config/runtimeConfig.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 { dedupeTools } from "../utils/toolDeduper.js"; import { injectCaveman } from "../rtk/caveman.js"; +import { injectPonytail } from "../rtk/ponytail.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 @@ -28,7 +34,7 @@ import { compressMessages, formatRtkLog } from "../rtk/index.js"; * @param {object} options.credentials - Provider credentials * @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 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 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 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 providerRequiresStreaming = provider === "openai" || provider === "codex" || provider === "commandcode"; + const providerRequiresStreaming = PROVIDERS[provider]?.forceStream === true; 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. // Non-interactive mode (-p flag) sends without stream and can't parse SSE. // 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 clientPrefersJson = acceptHeader.includes("application/json"); const clientPrefersSSE = acceptHeader.includes("text/event-stream"); - if (clientPrefersJson && !clientPrefersSSE && body.stream !== true) { + if (clientPrefersJson && !clientPrefersSSE && body.stream !== true && !providerRequiresStreaming) { stream = false; } @@ -87,6 +103,22 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred const clientTool = detectClientTool(clientRawRequest?.headers || {}, body); 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 toolNameMap; if (passthrough) { @@ -129,12 +161,30 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred const rtkLine = formatRtkLog(rtkStats); 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 if (cavemanEnabled && cavemanLevel) { injectCaveman(translatedBody, finalFormat, cavemanLevel); 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); trackPendingRequest(model, provider, connectionId, true); appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => { }); diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index 3c31e7a1..0053e9b1 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -37,6 +37,12 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source 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 // and returns `content: null`). Returning the raw body would leave the // 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 = ""; const toolCalls = []; @@ -156,7 +167,13 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m } 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) responseBody = decloakToolNames(responseBody, toolNameMap); @@ -193,11 +210,14 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m translatedResponse.usage = filterUsageForFormat(addBufferToUsage(translatedResponse.usage), sourceFormat); } - // Strip reasoning_content — some clients (e.g. Firecrawl AI SDK) have JSON parsers that - // break on this non-standard field, even though OpenAI allows it in extensions. + // Strip reasoning_content only when content is non-empty. + // 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) { 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; + } } } diff --git a/open-sse/handlers/chatCore/sseToJsonHandler.js b/open-sse/handlers/chatCore/sseToJsonHandler.js index 9919173e..1e0edeba 100644 --- a/open-sse/handlers/chatCore/sseToJsonHandler.js +++ b/open-sse/handlers/chatCore/sseToJsonHandler.js @@ -2,7 +2,11 @@ import { convertResponsesStreamToJson } from "../../transformer/streamToJsonConv import { createErrorResult } from "../../utils/error.js"; import { HTTP_STATUS } from "../../config/runtimeConfig.js"; import { FORMATS } from "../../translator/formats.js"; +import { PROVIDERS } from "../../config/providers.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"; 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 }) { 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 trackDone(); @@ -112,7 +116,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr }; // Codex/Responses API SSE path - const isCodexResponsesApi = provider === "codex" || sourceFormat === FORMATS.OPENAI_RESPONSES; + const isCodexResponsesApi = isResponsesProvider(provider) || sourceFormat === FORMATS.OPENAI_RESPONSES; if (isCodexResponsesApi) { try { const jsonResponse = await convertResponsesStreamToJson(providerResponse.body); diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js index 757bb5b4..8ff6073d 100644 --- a/open-sse/handlers/chatCore/streamingHandler.js +++ b/open-sse/handlers/chatCore/streamingHandler.js @@ -7,12 +7,16 @@ import { STREAM_STALL_TIMEOUT_MS } from "../../config/runtimeConfig.js"; import { buildAbortedResponsesTerminalBytes } from "../../utils/responsesStreamHelpers.js"; import { buildRequestDetail, extractRequestConfig } from "./requestDetail.js"; import { saveRequestDetail } from "@/lib/usageDb.js"; +import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js"; -const SSE_HEADERS = { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "Access-Control-Allow-Origin": "*" +// Codex returns Responses API SSE → which client format to translate INTO, by request sourceFormat. +// Gemini-family all map to ANTIGRAVITY decoder; unknown sources fall back to OPENAI. +const CODEX_SOURCE_TO_TARGET = { + [FORMATS.OPENAI_RESPONSES]: FORMATS.OPENAI_RESPONSES, + [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 }) { 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) { - // Codex returns Responses API SSE → translate to client format - 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; + const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI; 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. */ 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 }); diff --git a/open-sse/handlers/embeddingProviders/openai.js b/open-sse/handlers/embeddingProviders/openai.js index 877890d2..7d61c7e4 100644 --- a/open-sse/handlers/embeddingProviders/openai.js +++ b/open-sse/handlers/embeddingProviders/openai.js @@ -1,30 +1,21 @@ // OpenAI-compatible embeddings adapter (most providers) 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 = { - 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", - "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) { + const cfg = embedCfg(providerId); return { - buildUrl: () => ENDPOINTS[providerId], + buildUrl: () => embedUrl(providerId), buildHeaders: (creds) => { - const headers = { "Content-Type": "application/json", ...bearerAuth(creds) }; - if (providerId === "openrouter") { - headers["HTTP-Referer"] = "https://endpoint-proxy.local"; - headers["X-Title"] = "Endpoint Proxy"; - } - return headers; + return { "Content-Type": "application/json", ...bearerAuth(creds), ...(cfg.headers || {}) }; }, buildBody: (model, { input, encoding_format, dimensions }) => { const body = { model, input }; diff --git a/open-sse/handlers/imageGenerationCore.js b/open-sse/handlers/imageGenerationCore.js index 280b3158..d2d0eba2 100644 --- a/open-sse/handlers/imageGenerationCore.js +++ b/open-sse/handlers/imageGenerationCore.js @@ -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 headers; let requestBody; diff --git a/open-sse/handlers/imageProviders/antigravity.js b/open-sse/handlers/imageProviders/antigravity.js new file mode 100644 index 00000000..a1f90519 --- /dev/null +++ b/open-sse/handlers/imageProviders/antigravity.js @@ -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 }], + }; + }, +}; \ No newline at end of file diff --git a/open-sse/handlers/imageProviders/blackForestLabs.js b/open-sse/handlers/imageProviders/blackForestLabs.js index c1e73675..051e35ab 100644 --- a/open-sse/handlers/imageProviders/blackForestLabs.js +++ b/open-sse/handlers/imageProviders/blackForestLabs.js @@ -1,7 +1,8 @@ // Black Forest Labs (FLUX) — async submit + polling_url 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 { async: true, diff --git a/open-sse/handlers/imageProviders/cloudflareAi.js b/open-sse/handlers/imageProviders/cloudflareAi.js index 9b0d2ef5..d7ecef88 100644 --- a/open-sse/handlers/imageProviders/cloudflareAi.js +++ b/open-sse/handlers/imageProviders/cloudflareAi.js @@ -1,6 +1,7 @@ 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([ "@cf/black-forest-labs/flux-2-dev", diff --git a/open-sse/handlers/imageProviders/codex.js b/open-sse/handlers/imageProviders/codex.js index 591c7262..218302ab 100644 --- a/open-sse/handlers/imageProviders/codex.js +++ b/open-sse/handlers/imageProviders/codex.js @@ -1,8 +1,9 @@ // Codex (ChatGPT Plus/Pro) image generation via Responses API + SSE import { randomUUID } from "node:crypto"; 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_VERSION = "0.136.0"; const CODEX_ORIGINATOR = "codex_cli_rs"; diff --git a/open-sse/handlers/imageProviders/comfyui.js b/open-sse/handlers/imageProviders/comfyui.js index 6a37a44b..767d7de3 100644 --- a/open-sse/handlers/imageProviders/comfyui.js +++ b/open-sse/handlers/imageProviders/comfyui.js @@ -1,7 +1,11 @@ // 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 { noAuth: true, - buildUrl: () => "http://localhost:8188", + buildUrl: () => BASE_URL, buildHeaders: () => ({ "Content-Type": "application/json" }), buildBody: (_model, body) => ({ prompt: body.prompt }), normalize: (responseBody) => responseBody, diff --git a/open-sse/handlers/imageProviders/falAi.js b/open-sse/handlers/imageProviders/falAi.js index 191d9a89..874c635d 100644 --- a/open-sse/handlers/imageProviders/falAi.js +++ b/open-sse/handlers/imageProviders/falAi.js @@ -1,7 +1,8 @@ // Fal.ai — async submit + queue polling 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 { async: true, diff --git a/open-sse/handlers/imageProviders/gemini.js b/open-sse/handlers/imageProviders/gemini.js index 3a52ea95..f1f6a996 100644 --- a/open-sse/handlers/imageProviders/gemini.js +++ b/open-sse/handlers/imageProviders/gemini.js @@ -1,7 +1,8 @@ // Google Gemini adapter (Nano Banana models) 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 { buildUrl: (model, creds) => { diff --git a/open-sse/handlers/imageProviders/huggingface.js b/open-sse/handlers/imageProviders/huggingface.js index 9b3a03b3..2093d9c3 100644 --- a/open-sse/handlers/imageProviders/huggingface.js +++ b/open-sse/handlers/imageProviders/huggingface.js @@ -1,7 +1,8 @@ // HuggingFace Inference API — returns binary image 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 { buildUrl: (model) => `${BASE_URL}/${model}`, diff --git a/open-sse/handlers/imageProviders/index.js b/open-sse/handlers/imageProviders/index.js index 95d8e005..520c3d60 100644 --- a/open-sse/handlers/imageProviders/index.js +++ b/open-sse/handlers/imageProviders/index.js @@ -11,6 +11,7 @@ import stabilityAi from "./stabilityAi.js"; import blackForestLabs from "./blackForestLabs.js"; import runwayml from "./runwayml.js"; import cloudflareAi from "./cloudflareAi.js"; +import antigravity from "./antigravity.js"; const ADAPTERS = { openai: createOpenAIAdapter("openai"), @@ -25,6 +26,7 @@ const ADAPTERS = { comfyui, huggingface, nanobanana, + antigravity, "fal-ai": falAi, "stability-ai": stabilityAi, "black-forest-labs": blackForestLabs, diff --git a/open-sse/handlers/imageProviders/nanobanana.js b/open-sse/handlers/imageProviders/nanobanana.js index 6bb18fc1..4685fde6 100644 --- a/open-sse/handlers/imageProviders/nanobanana.js +++ b/open-sse/handlers/imageProviders/nanobanana.js @@ -1,8 +1,10 @@ // NanoBanana API — async submit + poll record-info 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 POLL_BASE = "https://api.nanobananaapi.ai/api/v1/nanobanana/record-info"; +const IMG_CFG = PROVIDER_MEDIA["nanobanana"]?.imageConfig || {}; +const SUBMIT_URL = IMG_CFG.baseUrl; +const POLL_BASE = IMG_CFG.pollUrl; export default { async: true, diff --git a/open-sse/handlers/imageProviders/openai.js b/open-sse/handlers/imageProviders/openai.js index d5032e93..5744aa5e 100644 --- a/open-sse/handlers/imageProviders/openai.js +++ b/open-sse/handlers/imageProviders/openai.js @@ -1,40 +1,32 @@ // OpenAI-compatible adapter (used by openai, minimax, openrouter, recraft) +import { PROVIDER_MEDIA } from "../../providers/index.js"; -const ENDPOINTS = { - openai: "https://api.openai.com/v1/images/generations", - 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", -}; +const imageCfg = (id) => PROVIDER_MEDIA[id]?.imageConfig || {}; +const imageUrl = (id) => imageCfg(id).baseUrl; export default function createOpenAIAdapter(providerId) { + const cfg = imageCfg(providerId); return { - buildUrl: () => ENDPOINTS[providerId], + buildUrl: () => imageUrl(providerId), buildHeaders: (creds) => { - const headers = { "Content-Type": "application/json" }; + const headers = { "Content-Type": "application/json", ...(cfg.headers || {}) }; const key = creds?.apiKey || creds?.accessToken; if (key) headers["Authorization"] = `Bearer ${key}`; - if (providerId === "openrouter") { - headers["HTTP-Referer"] = "https://endpoint-proxy.local"; - headers["X-Title"] = "Endpoint Proxy"; - } return headers; }, buildBody: (model, body) => { const { prompt, n = 1, size = "1024x1024", quality, style, response_format } = body; - // xAI only accepts prompt, model, n, response_format - if (providerId === "xai") { - const req = { model, prompt, n }; - if (response_format) req.response_format = response_format; + const full = { model, prompt, n, size }; + if (quality) full.quality = quality; + if (style) full.style = style; + 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; } - const req = { model, prompt, n, size }; - if (quality) req.quality = quality; - if (style) req.style = style; - if (response_format) req.response_format = response_format; - return req; + return full; }, normalize: (responseBody) => responseBody, }; diff --git a/open-sse/handlers/imageProviders/runwayml.js b/open-sse/handlers/imageProviders/runwayml.js index 229e9a36..7eabadb5 100644 --- a/open-sse/handlers/imageProviders/runwayml.js +++ b/open-sse/handlers/imageProviders/runwayml.js @@ -1,7 +1,8 @@ // Runway ML — async submit + /tasks/{id} polling 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 { async: true, diff --git a/open-sse/handlers/imageProviders/sdwebui.js b/open-sse/handlers/imageProviders/sdwebui.js index ecabfe55..33e3a696 100644 --- a/open-sse/handlers/imageProviders/sdwebui.js +++ b/open-sse/handlers/imageProviders/sdwebui.js @@ -1,9 +1,12 @@ // SD WebUI (AUTOMATIC1111) — local, noAuth import { nowSec } from "./_base.js"; +import { PROVIDER_MEDIA } from "../../providers/index.js"; + +const BASE_URL = PROVIDER_MEDIA["sdwebui"]?.imageConfig?.baseUrl; export default { noAuth: true, - buildUrl: () => "http://localhost:7860/sdapi/v1/txt2img", + buildUrl: () => BASE_URL, buildHeaders: () => ({ "Content-Type": "application/json" }), buildBody: (_model, body) => { const { prompt, n = 1, size = "1024x1024" } = body; diff --git a/open-sse/handlers/imageProviders/stabilityAi.js b/open-sse/handlers/imageProviders/stabilityAi.js index f5f3fe83..79f93d55 100644 --- a/open-sse/handlers/imageProviders/stabilityAi.js +++ b/open-sse/handlers/imageProviders/stabilityAi.js @@ -1,7 +1,8 @@ // Stability AI v2 — sync, returns { image: "" } 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 function modelToEndpoint(model) { diff --git a/open-sse/handlers/responsesHandler.js b/open-sse/handlers/responsesHandler.js index 823ff8c3..8c17f98a 100644 --- a/open-sse/handlers/responsesHandler.js +++ b/open-sse/handlers/responsesHandler.js @@ -4,9 +4,10 @@ */ 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 { convertResponsesStreamToJson } from "../transformer/streamToJsonConverter.js"; +import { SSE_HEADERS_CORS } from "../utils/sseConstants.js"; /** * Handle /v1/responses request @@ -87,12 +88,7 @@ export async function handleResponsesCore({ body, modelInfo, credentials, log, o success: true, response: new Response(transformedBody, { status: 200, - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "Access-Control-Allow-Origin": "*" - } + headers: { ...SSE_HEADERS_CORS } }) }; } diff --git a/open-sse/handlers/search/chatSearch.js b/open-sse/handlers/search/chatSearch.js index c089b999..a8a7841e 100644 --- a/open-sse/handlers/search/chatSearch.js +++ b/open-sse/handlers/search/chatSearch.js @@ -2,6 +2,12 @@ * Wrap chat-completions endpoints (with built-in web search) into the unified * /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 DEFAULT_MAX_RESULTS = 10; @@ -43,9 +49,7 @@ function normalizeCitation(c) { */ const CHAT_SEARCH_CONFIG = { gemini: { - endpoint: (model) => - `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`, - defaultModel: "gemini-2.5-flash", + endpoint: (model) => searchEndpoint("gemini", model), buildBody: (query) => ({ contents: [{ role: "user", parts: [{ text: query }] }], tools: [{ google_search: {} }] @@ -70,8 +74,7 @@ const CHAT_SEARCH_CONFIG = { }, openai: { - endpoint: () => "https://api.openai.com/v1/chat/completions", - defaultModel: "gpt-4o-mini", + endpoint: () => searchEndpoint("openai"), buildBody: (query, model) => { const body = { model, @@ -105,8 +108,7 @@ const CHAT_SEARCH_CONFIG = { }, xai: { - endpoint: () => "https://api.x.ai/v1/responses", - defaultModel: "grok-4.20-reasoning", + endpoint: () => searchEndpoint("xai"), buildBody: (query, model) => ({ model, input: [{ role: "user", content: query }], @@ -145,8 +147,7 @@ const CHAT_SEARCH_CONFIG = { }, kimi: { - endpoint: () => "https://api.moonshot.cn/v1/chat/completions", - defaultModel: "kimi-k2.5", + endpoint: () => searchEndpoint("kimi"), buildBody: (query, model) => ({ model, messages: [{ role: "user", content: query }], @@ -195,8 +196,7 @@ const CHAT_SEARCH_CONFIG = { }, minimax: { - endpoint: () => "https://api.minimaxi.com/v1/text/chatcompletion_v2", - defaultModel: "MiniMax-M2.7", + endpoint: () => searchEndpoint("minimax"), buildBody: (query, model) => ({ model, messages: [{ role: "user", content: query }], @@ -254,8 +254,7 @@ const CHAT_SEARCH_CONFIG = { }, perplexity: { - endpoint: () => "https://api.perplexity.ai/chat/completions", - defaultModel: "sonar", + endpoint: () => searchEndpoint("perplexity"), buildBody: (query, model) => ({ model, messages: [{ role: "user", content: query }] @@ -324,7 +323,7 @@ export async function handleChatSearch({ Number.isFinite(maxResults) && maxResults > 0 ? Math.floor(maxResults) : DEFAULT_MAX_RESULTS; - const useModel = model || cfg.defaultModel; + const useModel = model || searchModel(provider); const url = cfg.endpoint(useModel); const body = cfg.buildBody(query, useModel); const headers = cfg.buildHeaders(token); diff --git a/open-sse/handlers/sttCore.js b/open-sse/handlers/sttCore.js index cfce0580..acb7d13b 100644 --- a/open-sse/handlers/sttCore.js +++ b/open-sse/handlers/sttCore.js @@ -1,7 +1,6 @@ import { Buffer } from "node:buffer"; import { createErrorResult } from "../utils/error.js"; import { HTTP_STATUS } from "../config/runtimeConfig.js"; -import { AI_PROVIDERS } from "../../src/shared/constants/providers.js"; // Build auth headers from sttConfig + token function buildAuthHeaders(cfg, token) { @@ -167,11 +166,11 @@ function jsonResponse(obj) { * STT core handler — dispatch by sttConfig.format. * @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"); 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`); const token = cfg.authType === "none" ? null : (credentials?.apiKey || credentials?.accessToken); diff --git a/open-sse/handlers/ttsProviders/gemini.js b/open-sse/handlers/ttsProviders/gemini.js index 9dff6e11..15afd88c 100644 --- a/open-sse/handlers/ttsProviders/gemini.js +++ b/open-sse/handlers/ttsProviders/gemini.js @@ -1,9 +1,20 @@ // Gemini TTS — generateContent with AUDIO modality returns PCM L16, wrap as WAV 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 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 function parseGeminiModelVoice(input) { @@ -51,7 +62,7 @@ export default { async synthesize(text, model, credentials, _responseFormat, opts = {}) { if (!credentials?.apiKey) throw new Error("No Gemini API key configured"); 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, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/open-sse/handlers/ttsProviders/index.js b/open-sse/handlers/ttsProviders/index.js index f80ddf8b..e1bb8b83 100644 --- a/open-sse/handlers/ttsProviders/index.js +++ b/open-sse/handlers/ttsProviders/index.js @@ -33,8 +33,10 @@ export async function synthesizeViaConfig(provider, text, model, credentials) { if (!handler) return null; const apiKey = credentials?.apiKey; if (cfg.authType !== "none" && !apiKey) throw new Error(`${provider} API key required`); - const defaultModel = cfg.models?.[0]?.id || ""; - const { modelId, voiceId } = parseModelVoice(model, defaultModel, "", cfg.models || []); + const { PROVIDER_MODELS } = await import("open-sse/config/providerModels.js"); + 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 }); } diff --git a/open-sse/handlers/ttsProviders/openai.js b/open-sse/handlers/ttsProviders/openai.js index 6a19f342..b1f680a1 100644 --- a/open-sse/handlers/ttsProviders/openai.js +++ b/open-sse/handlers/ttsProviders/openai.js @@ -1,11 +1,14 @@ // OpenAI TTS — model format: "tts-model/voice" import { Buffer } from "node:buffer"; +import { PROVIDER_MEDIA } from "../../providers/index.js"; + +const DEFAULT_TTS_MODEL = PROVIDER_MEDIA["openai"]?.ttsConfig?.defaultModel; export default { async synthesize(text, model, credentials) { 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"; if (model && model.includes("/")) { const parts = model.split("/"); diff --git a/open-sse/handlers/ttsProviders/openrouter.js b/open-sse/handlers/ttsProviders/openrouter.js index 0b2d932b..84fac0cb 100644 --- a/open-sse/handlers/ttsProviders/openrouter.js +++ b/open-sse/handlers/ttsProviders/openrouter.js @@ -1,10 +1,14 @@ // 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 { async synthesize(text, model, credentials) { if (!credentials?.apiKey) throw new Error("No OpenRouter API key configured"); // 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"; if (model && model.includes("/")) { const lastSlash = model.lastIndexOf("/"); @@ -20,13 +24,12 @@ export default { voice = model; } - const res = await fetch("https://openrouter.ai/api/v1/chat/completions", { + const res = await fetch(TTS_CFG.baseUrl, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${credentials.apiKey}`, - "HTTP-Referer": "https://endpoint-proxy.local", - "X-Title": "Endpoint Proxy", + ...(TTS_CFG.headers || {}), }, body: JSON.stringify({ model: ttsModel, diff --git a/open-sse/index.js b/open-sse/index.js index 4b2ac3a9..b8181f0b 100644 --- a/open-sse/index.js +++ b/open-sse/index.js @@ -30,9 +30,6 @@ export { // Services export { detectFormat, - getProviderConfig, - buildProviderUrl, - buildProviderHeaders, getTargetFormat } from "./services/provider.js"; diff --git a/open-sse/providers/REGISTRY_TEMPLATE.js b/open-sse/providers/REGISTRY_TEMPLATE.js new file mode 100644 index 00000000..66875cdf --- /dev/null +++ b/open-sse/providers/REGISTRY_TEMPLATE.js @@ -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, +}; diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js new file mode 100644 index 00000000..23268f08 --- /dev/null +++ b/open-sse/providers/capabilities.js @@ -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 }; +} diff --git a/open-sse/providers/index.js b/open-sse/providers/index.js new file mode 100644 index 00000000..ab5123b1 --- /dev/null +++ b/open-sse/providers/index.js @@ -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()); diff --git a/open-sse/providers/models/helpers.js b/open-sse/providers/models/helpers.js new file mode 100644 index 00000000..a7d273d6 --- /dev/null +++ b/open-sse/providers/models/helpers.js @@ -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" + } + ]; + }); +} diff --git a/open-sse/providers/models/namePatterns.js b/open-sse/providers/models/namePatterns.js new file mode 100644 index 00000000..5e0548c7 --- /dev/null +++ b/open-sse/providers/models/namePatterns.js @@ -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; +} diff --git a/open-sse/providers/models/schema.js b/open-sse/providers/models/schema.js new file mode 100644 index 00000000..8be351ad --- /dev/null +++ b/open-sse/providers/models/schema.js @@ -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; +} diff --git a/src/shared/constants/pricing.js b/open-sse/providers/pricing.js similarity index 98% rename from src/shared/constants/pricing.js rename to open-sse/providers/pricing.js index e2b6c04e..9e767a80 100644 --- a/src/shared/constants/pricing.js +++ b/open-sse/providers/pricing.js @@ -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) { - const regex = new RegExp("^" + pattern.split("*").map(s => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$"); +export function matchPattern(pattern, model) { + const regex = new RegExp("^" + pattern.split("*").map(s => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$", "i"); return regex.test(model); } diff --git a/open-sse/providers/registry/alicode-intl.js b/open-sse/providers/registry/alicode-intl.js new file mode 100644 index 00000000..ac98cb2d --- /dev/null +++ b/open-sse/providers/registry/alicode-intl.js @@ -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" }, + ], +}; diff --git a/open-sse/providers/registry/alicode.js b/open-sse/providers/registry/alicode.js new file mode 100644 index 00000000..5b6a088f --- /dev/null +++ b/open-sse/providers/registry/alicode.js @@ -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" }, + ], +}; diff --git a/open-sse/providers/registry/anthropic.js b/open-sse/providers/registry/anthropic.js new file mode 100644 index 00000000..1f6a3494 --- /dev/null +++ b/open-sse/providers/registry/anthropic.js @@ -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"], +}; diff --git a/open-sse/providers/registry/antigravity.js b/open-sse/providers/registry/antigravity.js new file mode 100644 index 00000000..29003527 --- /dev/null +++ b/open-sse/providers/registry/antigravity.js @@ -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, + }, +}; diff --git a/open-sse/providers/registry/assemblyai.js b/open-sse/providers/registry/assemblyai.js new file mode 100644 index 00000000..bb4eb77d --- /dev/null +++ b/open-sse/providers/registry/assemblyai.js @@ -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", + }, +}; diff --git a/open-sse/providers/registry/aws-polly.js b/open-sse/providers/registry/aws-polly.js new file mode 100644 index 00000000..3e269bef --- /dev/null +++ b/open-sse/providers/registry/aws-polly.js @@ -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 +}; diff --git a/open-sse/providers/registry/azure.js b/open-sse/providers/registry/azure.js new file mode 100644 index 00000000..32feca0f --- /dev/null +++ b/open-sse/providers/registry/azure.js @@ -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: {}, + }, +}; diff --git a/open-sse/providers/registry/black-forest-labs.js b/open-sse/providers/registry/black-forest-labs.js new file mode 100644 index 00000000..720c5eaf --- /dev/null +++ b/open-sse/providers/registry/black-forest-labs.js @@ -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" }, +}; diff --git a/open-sse/providers/registry/blackbox.js b/open-sse/providers/registry/blackbox.js new file mode 100644 index 00000000..2764cdf9 --- /dev/null +++ b/open-sse/providers/registry/blackbox.js @@ -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" }, + ], +}; diff --git a/open-sse/providers/registry/brave-search.js b/open-sse/providers/registry/brave-search.js new file mode 100644 index 00000000..6fcad119 --- /dev/null +++ b/open-sse/providers/registry/brave-search.js @@ -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 + } +}; diff --git a/open-sse/providers/registry/byteplus.js b/open-sse/providers/registry/byteplus.js new file mode 100644 index 00000000..6440cc48 --- /dev/null +++ b/open-sse/providers/registry/byteplus.js @@ -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"], +}; diff --git a/open-sse/providers/registry/cartesia.js b/open-sse/providers/registry/cartesia.js new file mode 100644 index 00000000..411925c5 --- /dev/null +++ b/open-sse/providers/registry/cartesia.js @@ -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 +}; diff --git a/open-sse/providers/registry/cerebras.js b/open-sse/providers/registry/cerebras.js new file mode 100644 index 00000000..964250fd --- /dev/null +++ b/open-sse/providers/registry/cerebras.js @@ -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" }, + ], +}; diff --git a/open-sse/providers/registry/chutes.js b/open-sse/providers/registry/chutes.js new file mode 100644 index 00000000..0eed21b1 --- /dev/null +++ b/open-sse/providers/registry/chutes.js @@ -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", + }, +}; diff --git a/open-sse/providers/registry/claude.js b/open-sse/providers/registry/claude.js new file mode 100644 index 00000000..9d483d8f --- /dev/null +++ b/open-sse/providers/registry/claude.js @@ -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, + }, +}; diff --git a/open-sse/providers/registry/cline.js b/open-sse/providers/registry/cline.js new file mode 100644 index 00000000..cfa788c4 --- /dev/null +++ b/open-sse/providers/registry/cline.js @@ -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", + }, +}; diff --git a/open-sse/providers/registry/cloudflare-ai.js b/open-sse/providers/registry/cloudflare-ai.js new file mode 100644 index 00000000..4d440b74 --- /dev/null +++ b/open-sse/providers/registry/cloudflare-ai.js @@ -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" }, +}; diff --git a/open-sse/providers/registry/codebuddy-cn.js b/open-sse/providers/registry/codebuddy-cn.js new file mode 100644 index 00000000..01a78a30 --- /dev/null +++ b/open-sse/providers/registry/codebuddy-cn.js @@ -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, + }, +}; diff --git a/open-sse/providers/registry/codex.js b/open-sse/providers/registry/codex.js new file mode 100644 index 00000000..4620c4d3 --- /dev/null +++ b/open-sse/providers/registry/codex.js @@ -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, + }, +}; diff --git a/open-sse/providers/registry/cohere.js b/open-sse/providers/registry/cohere.js new file mode 100644 index 00000000..68236bb8 --- /dev/null +++ b/open-sse/providers/registry/cohere.js @@ -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)" }, + ], +}; diff --git a/open-sse/providers/registry/comfyui.js b/open-sse/providers/registry/comfyui.js new file mode 100644 index 00000000..74216fa0 --- /dev/null +++ b/open-sse/providers/registry/comfyui.js @@ -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" }, +}; diff --git a/open-sse/providers/registry/commandcode.js b/open-sse/providers/registry/commandcode.js new file mode 100644 index 00000000..3b21fbbc --- /dev/null +++ b/open-sse/providers/registry/commandcode.js @@ -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" }, + ], +}; diff --git a/open-sse/providers/registry/coqui.js b/open-sse/providers/registry/coqui.js new file mode 100644 index 00000000..8f108e21 --- /dev/null +++ b/open-sse/providers/registry/coqui.js @@ -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 +}; diff --git a/open-sse/providers/registry/cursor.js b/open-sse/providers/registry/cursor.js new file mode 100644 index 00000000..ca0ecdb1 --- /dev/null +++ b/open-sse/providers/registry/cursor.js @@ -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", + }, + }, +}; diff --git a/open-sse/providers/registry/deepgram.js b/open-sse/providers/registry/deepgram.js new file mode 100644 index 00000000..9d2b41a3 --- /dev/null +++ b/open-sse/providers/registry/deepgram.js @@ -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" }, +}; diff --git a/open-sse/providers/registry/deepseek.js b/open-sse/providers/registry/deepseek.js new file mode 100644 index 00000000..5c167f7a --- /dev/null +++ b/open-sse/providers/registry/deepseek.js @@ -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" }, + ], +}; diff --git a/open-sse/providers/registry/edge-tts.js b/open-sse/providers/registry/edge-tts.js new file mode 100644 index 00000000..74781e8a --- /dev/null +++ b/open-sse/providers/registry/edge-tts.js @@ -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: [] + } +}; diff --git a/open-sse/providers/registry/elevenlabs.js b/open-sse/providers/registry/elevenlabs.js new file mode 100644 index 00000000..fad3227d --- /dev/null +++ b/open-sse/providers/registry/elevenlabs.js @@ -0,0 +1,35 @@ +export default { + id: "elevenlabs", + alias: "el", + display: { + name: "ElevenLabs", + icon: "record_voice_over", + color: "#6C47FF", + textIcon: "EL", + website: "https://elevenlabs.io", + notice: { + apiKeyUrl: "https://elevenlabs.io/app/settings/api-keys" + } + }, + category: "apikey", + authType: "apikey", + serviceKinds: [ + "tts" + ], + ttsConfig: { + baseUrl: "https://api.elevenlabs.io/v1/text-to-speech", + authType: "apikey", + authHeader: "xi-api-key", + format: "elevenlabs", + models: [ + { + id: "eleven_multilingual_v2", + name: "Eleven Multilingual v2" + }, + { + id: "eleven_turbo_v2_5", + name: "Eleven Turbo v2.5" + } + ] + } +}; diff --git a/open-sse/providers/registry/exa.js b/open-sse/providers/registry/exa.js new file mode 100644 index 00000000..75b8bf0a --- /dev/null +++ b/open-sse/providers/registry/exa.js @@ -0,0 +1,50 @@ +export default { + id: "exa", + alias: "exa", + display: { + name: "Exa", + icon: "manage_search", + color: "#2563EB", + textIcon: "EX", + website: "https://exa.ai", + notice: { + apiKeyUrl: "https://dashboard.exa.ai/api-keys" + } + }, + category: "apikey", + authType: "apikey", + serviceKinds: [ + "webSearch", + "webFetch" + ], + searchConfig: { + baseUrl: "https://api.exa.ai/search", + method: "POST", + authType: "apikey", + authHeader: "x-api-key", + costPerQuery: 0.007, + freeMonthlyQuota: 1000, + searchTypes: [ + "web", + "news" + ], + defaultMaxResults: 5, + maxMaxResults: 100, + timeoutMs: 10000, + cacheTTLMs: 300000 + }, + fetchConfig: { + baseUrl: "https://api.exa.ai/contents", + method: "POST", + authType: "apikey", + authHeader: "x-api-key", + costPerQuery: 0.001, + freeMonthlyQuota: 1000, + formats: [ + "text", + "markdown" + ], + maxCharacters: 100000, + timeoutMs: 15000 + } +}; diff --git a/open-sse/providers/registry/fal-ai.js b/open-sse/providers/registry/fal-ai.js new file mode 100644 index 00000000..a18d7d05 --- /dev/null +++ b/open-sse/providers/registry/fal-ai.js @@ -0,0 +1,34 @@ +export default { + id: "fal-ai", + priority: 90, + hasFree: true, + alias: "fal-ai", + aliases: [ + "fal", + ], + uiAlias: "fal", + display: { + name: "Fal.ai", + icon: "image", + color: "#2563EB", + textIcon: "FL", + website: "https://fal.ai", + notice: { + apiKeyUrl: "https://fal.ai/dashboard/keys", + }, + }, + category: "apikey", + authType: "apikey", + transport: null, + models: [ + { id: "fal-ai/flux/schnell", name: "FLUX Schnell", params: ["n","size"], kind: "image" }, + { id: "fal-ai/flux/dev", name: "FLUX Dev", params: ["n","size"], kind: "image" }, + { id: "fal-ai/flux-pro/v1.1", name: "FLUX Pro v1.1", params: ["n","size"], kind: "image" }, + { id: "fal-ai/flux-pro/v1.1-ultra", name: "FLUX Pro v1.1 Ultra", params: ["n","size"], kind: "image" }, + { id: "fal-ai/recraft-v3", name: "Recraft V3", params: ["n","size","style"], kind: "image" }, + { id: "fal-ai/ideogram/v2", name: "Ideogram V2", params: ["n","size","style"], kind: "image" }, + { id: "fal-ai/stable-diffusion-v35-large", name: "SD 3.5 Large", params: ["n","size"], kind: "image" }, + ], + serviceKinds: ["image"], + imageConfig: { baseUrl: "https://queue.fal.run" }, +}; diff --git a/open-sse/providers/registry/firecrawl.js b/open-sse/providers/registry/firecrawl.js new file mode 100644 index 00000000..fab98fd7 --- /dev/null +++ b/open-sse/providers/registry/firecrawl.js @@ -0,0 +1,34 @@ +export default { + id: "firecrawl", + alias: "firecrawl", + display: { + name: "Firecrawl", + icon: "local_fire_department", + color: "#F59E0B", + textIcon: "FC", + website: "https://firecrawl.dev", + notice: { + apiKeyUrl: "https://www.firecrawl.dev/app/api-keys" + } + }, + category: "apikey", + authType: "apikey", + serviceKinds: [ + "webFetch" + ], + fetchConfig: { + baseUrl: "https://api.firecrawl.dev/v1/scrape", + method: "POST", + authType: "apikey", + authHeader: "bearer", + costPerQuery: 0.002, + freeMonthlyQuota: 500, + formats: [ + "markdown", + "html", + "text" + ], + maxCharacters: 200000, + timeoutMs: 30000 + } +}; diff --git a/open-sse/providers/registry/fireworks.js b/open-sse/providers/registry/fireworks.js new file mode 100644 index 00000000..211fd590 --- /dev/null +++ b/open-sse/providers/registry/fireworks.js @@ -0,0 +1,29 @@ +export default { + id: "fireworks", + priority: 50, + alias: "fireworks", + display: { + name: "Fireworks AI", + icon: "local_fire_department", + color: "#7B2EF2", + textIcon: "FW", + website: "https://fireworks.ai", + notice: { + apiKeyUrl: "https://fireworks.ai/account/api-keys", + }, + }, + category: "apikey", + authType: "apikey", + transport: { + baseUrl: "https://api.fireworks.ai/inference/v1/chat/completions", + validateUrl: "https://api.fireworks.ai/inference/v1/models", + }, + models: [ + { 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", kind: "embedding" }, + ], + serviceKinds: ["llm", "embedding"], + embeddingConfig: { baseUrl: "https://api.fireworks.ai/inference/v1/embeddings" }, +}; diff --git a/open-sse/providers/registry/gemini-cli.js b/open-sse/providers/registry/gemini-cli.js new file mode 100644 index 00000000..3e4a94c2 --- /dev/null +++ b/open-sse/providers/registry/gemini-cli.js @@ -0,0 +1,58 @@ +import { GOOGLE_OAUTH_CLIENT } from "../shared.js"; + +export default { + id: "gemini-cli", + priority: 20, + hasFree: true, + alias: "gc", + uiAlias: "gc", + display: { + name: "Gemini CLI", + icon: "terminal", + color: "#4285F4", + website: "https://github.com/google-gemini/gemini-cli", + notice: { + signupUrl: "https://github.com/google-gemini/gemini-cli", + }, + deprecated: true, + deprecationNotice: "RISK_NOTICE", + }, + category: "free", + transport: { + baseUrl: "https://cloudcode-pa.googleapis.com/v1internal", + format: "gemini-cli", + cliVersion: "0.34.0", + apiClient: "google-genai-sdk/1.41.0 gl-node/v22.19.0", + usage: { + quotaUrl: "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", + loadCodeAssistUrl: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + }, + clientId: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com", + clientSecret: "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", + }, + models: [ + { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, + { id: "gemini-3-pro-preview", name: "Gemini 3 Pro Preview" }, + { id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" }, + { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" }, + { 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" }, + ], + 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", + ], + refresh: { + encoding: "form", + }, + }, + features: { + usage: true, + }, +}; diff --git a/open-sse/providers/registry/gemini.js b/open-sse/providers/registry/gemini.js new file mode 100644 index 00000000..5c811042 --- /dev/null +++ b/open-sse/providers/registry/gemini.js @@ -0,0 +1,81 @@ +import { GOOGLE_OAUTH_CLIENT } from "../shared.js"; + +export default { + id: "gemini", + priority: 50, + hasFree: true, + alias: "gemini", + display: { + name: "Gemini", + icon: "diamond", + color: "#4285F4", + textIcon: "GE", + website: "https://ai.google.dev", + notice: { + apiKeyUrl: "https://aistudio.google.com/app/apikey", + }, + }, + category: "freeTier", + mediaPriority: 1, + transport: { + baseUrl: "https://generativelanguage.googleapis.com/v1beta/models", + format: "gemini", + clientId: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com", + clientSecret: "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", + auth: { + apiKey: { + header: "x-goog-api-key", + scheme: "raw", + }, + oauth: { + header: "Authorization", + scheme: "bearer", + }, + }, + }, + models: [ + { 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-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" }, + { id: "gemma-4-31b-it", name: "Gemma 4 31B IT" }, + { id: "gemini-embedding-2-preview", name: "Gemini Embedding 2 Preview", kind: "embedding" }, + { id: "gemini-embedding-001", name: "Gemini Embedding 001", kind: "embedding" }, + { id: "text-embedding-005", name: "Text Embedding 005", kind: "embedding" }, + { id: "text-embedding-004", name: "Text Embedding 004 (Legacy)", kind: "embedding" }, + { id: "gemini-3.1-flash-image-preview", name: "Gemini 3.1 Flash Image (Nano Banana 2)", params: [], kind: "image" }, + { id: "gemini-3-pro-image-preview", name: "Gemini 3 Pro Image (Nano Banana Pro)", params: [], kind: "image" }, + { id: "gemini-2.5-flash-image", name: "Gemini 2.5 Flash Image (Nano Banana)", params: [], kind: "image" }, + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (Best)", params: ["language","prompt"], kind: "stt" }, + { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", params: ["language","prompt"], kind: "stt" }, + { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite (Cheapest)", params: ["language","prompt"], kind: "stt" }, + { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", params: ["language","prompt"], kind: "stt" }, + { id: "gemini-3.1-flash-tts-preview", name: "Gemini 3.1 Flash TTS", kind: "tts" }, + { id: "gemini-2.5-flash-preview-tts", name: "Gemini 2.5 Flash TTS", kind: "tts" }, + { id: "gemini-2.5-pro-preview-tts", name: "Gemini 2.5 Pro TTS", kind: "tts" }, + { id: "embedding-001", name: "Embedding 001", dimensions: 768, kind: "embedding" }, + ], + serviceKinds: ["llm","embedding","image","imageToText","webSearch","tts","stt"], + ttsConfig: { + baseUrl: "https://generativelanguage.googleapis.com/v1beta/models", + authType: "apikey", + authHeader: "key", + format: "gemini-tts", + }, + sttConfig: { + baseUrl: "https://generativelanguage.googleapis.com/v1beta/models", + authType: "apikey", + authHeader: "key", + format: "gemini-stt", + }, + embeddingConfig: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/models", authType: "apikey", authHeader: "key" }, + imageConfig: { baseUrl: "https://generativelanguage.googleapis.com/v1beta/models" }, + searchViaChat: { + defaultModel: "gemini-2.5-flash", + endpoint: "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent", + pricingUrl: "https://ai.google.dev/pricing", + freeTier: "Free tier: 15 RPM, 1M tokens/day on gemini-2.5-flash via AI Studio.", + }, +}; diff --git a/open-sse/providers/registry/github.js b/open-sse/providers/registry/github.js new file mode 100644 index 00000000..95169eb3 --- /dev/null +++ b/open-sse/providers/registry/github.js @@ -0,0 +1,82 @@ +export default { + id: "github", + priority: 40, + alias: "gh", + uiAlias: "gh", + display: { + name: "GitHub Copilot", + icon: "code", + color: "#333333", + website: "https://github.com/features/copilot", + notice: { + signupUrl: "https://github.com/features/copilot", + }, + deprecated: true, + deprecationNotice: "RISK_NOTICE", + }, + category: "oauth", + transport: { + baseUrl: "https://api.githubcopilot.com/chat/completions", + responsesUrl: "https://api.githubcopilot.com/responses", + 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", + }, + copilot: { + vscodeVersion: "1.110.0", + chatVersion: "0.38.0", + userAgent: "GitHubCopilotChat/0.38.0", + apiVersion: "2025-04-01", + }, + usage: { + url: "https://api.github.com/copilot_internal/user", + }, + }, + models: [ + { 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" }, + { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, + { id: "claude-opus-4.5", name: "Claude Opus 4.5" }, + { 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" }, + { 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" }, + { id: "grok-code-fast-1", name: "Grok Code Fast 1" }, + { id: "oswe-vscode-prime", name: "Raptor Mini" }, + { id: "goldeneye-free-auto", name: "GoldenEye" }, + { id: "text-embedding-3-small", name: "Text Embedding 3 Small (GitHub)", kind: "embedding" }, + { id: "text-embedding-3-large", name: "Text Embedding 3 Large (GitHub)", kind: "embedding" }, + ], + serviceKinds: ["llm","embedding"], + embeddingConfig: { baseUrl: "https://models.github.ai/inference/embeddings", authType: "apikey", authHeader: "bearer" }, + oauth: { + clientId: "Iv1.b507a08c87ecfe98", + authorizeUrl: "https://github.com/login/oauth/authorize", + deviceCodeUrl: "https://github.com/login/device/code", + tokenUrl: "https://github.com/login/oauth/access_token", + userInfoUrl: "https://api.github.com/user", + scopes: "read:user", + apiVersion: "2022-11-28", + copilotTokenUrl: "https://api.github.com/copilot_internal/v2/token", + userAgent: "GitHubCopilotChat/0.26.7", + editorVersion: "vscode/1.85.0", + editorPluginVersion: "copilot-chat/0.26.7", + }, + features: { + usage: true, + }, +}; diff --git a/open-sse/providers/registry/gitlab.js b/open-sse/providers/registry/gitlab.js new file mode 100644 index 00000000..319379f6 --- /dev/null +++ b/open-sse/providers/registry/gitlab.js @@ -0,0 +1,32 @@ +export default { + id: "gitlab", + hidden: true, + priority: 100, + display: { + name: "GitLab Duo", + icon: "code", + color: "#FC6D26", + textIcon: "GL", + website: "https://gitlab.com", + notice: { + signupUrl: "https://gitlab.com", + }, + }, + category: "oauth", + transport: { + baseUrl: "https://gitlab.com/api/v4/chat/completions", + auth: { + combined: true, + header: "Authorization", + scheme: "bearer", + }, + }, + oauth: { + defaultBaseUrl: "https://gitlab.com", + authorizeUrlPath: "/oauth/authorize", + tokenUrlPath: "/oauth/token", + userInfoUrlPath: "/api/v4/user", + scope: "api read_user", + codeChallengeMethod: "S256", + }, +}; diff --git a/open-sse/providers/registry/glm-cn.js b/open-sse/providers/registry/glm-cn.js new file mode 100644 index 00000000..90a71b4c --- /dev/null +++ b/open-sse/providers/registry/glm-cn.js @@ -0,0 +1,35 @@ +export default { + id: "glm-cn", + priority: 130, + alias: "glm-cn", + display: { + name: "GLM (China)", + icon: "code", + color: "#DC2626", + textIcon: "GC", + website: "https://open.bigmodel.cn", + notice: { + apiKeyUrl: "https://open.bigmodel.cn/usercenter/apikeys", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", + headers: {}, + usage: { + url: "https://open.bigmodel.cn/api/monitor/usage/quota/limit", + }, + }, + models: [ + { id: "glm-5.2", name: "GLM 5.2" }, + { 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" }, + ], + features: { + usage: true, + usageApikey: true, + }, +}; diff --git a/open-sse/providers/registry/glm.js b/open-sse/providers/registry/glm.js new file mode 100644 index 00000000..95e11533 --- /dev/null +++ b/open-sse/providers/registry/glm.js @@ -0,0 +1,58 @@ +import { CLAUDE_API_HEADERS } from "../shared.js"; + +export default { + id: "glm", + priority: 140, + alias: "glm", + display: { + name: "GLM Coding", + icon: "code", + color: "#2563EB", + textIcon: "GL", + website: "https://open.bigmodel.cn", + notice: { + apiKeyUrl: "https://open.bigmodel.cn/usercenter/apikeys", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://api.z.ai/api/anthropic/v1/messages", + format: "claude", + urlSuffix: "?beta=true", + headers: { ...CLAUDE_API_HEADERS }, + auth: { + combined: true, + header: "x-api-key", + scheme: "raw", + }, + usage: { + url: "https://api.z.ai/api/monitor/usage/quota/limit", + }, + }, + // Multi-endpoint: pick the transport matching client sourceFormat to skip translation. + transports: [ + { + format: "openai", + baseUrl: "https://api.z.ai/api/coding/paas/v4/chat/completions", + auth: { combined: true, header: "Authorization", scheme: "bearer" }, + }, + { + format: "claude", + baseUrl: "https://api.z.ai/api/anthropic/v1/messages", + urlSuffix: "?beta=true", + headers: { ...CLAUDE_API_HEADERS }, + auth: { combined: true, header: "x-api-key", scheme: "raw" }, + }, + ], + models: [ + { id: "glm-5.2", name: "GLM 5.2" }, + { 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)" }, + ], + features: { + usage: true, + usageApikey: true, + }, +}; diff --git a/open-sse/providers/registry/google-pse.js b/open-sse/providers/registry/google-pse.js new file mode 100644 index 00000000..f2a1c0b4 --- /dev/null +++ b/open-sse/providers/registry/google-pse.js @@ -0,0 +1,35 @@ +export default { + id: "google-pse", + alias: "gpse", + display: { + name: "Google PSE", + icon: "search", + color: "#4285F4", + textIcon: "GP", + website: "https://programmablesearchengine.google.com", + notice: { + apiKeyUrl: "https://programmablesearchengine.google.com/controlpanel/create" + } + }, + category: "apikey", + authType: "apikey", + serviceKinds: [ + "webSearch" + ], + searchConfig: { + baseUrl: "https://www.googleapis.com/customsearch/v1", + method: "GET", + authType: "apikey", + authHeader: "key", + costPerQuery: 0.005, + freeMonthlyQuota: 3000, + searchTypes: [ + "web", + "news" + ], + defaultMaxResults: 5, + maxMaxResults: 10, + timeoutMs: 10000, + cacheTTLMs: 300000 + } +}; diff --git a/open-sse/providers/registry/google-tts.js b/open-sse/providers/registry/google-tts.js new file mode 100644 index 00000000..0b4d748e --- /dev/null +++ b/open-sse/providers/registry/google-tts.js @@ -0,0 +1,24 @@ +export default { + id: "google-tts", + alias: "google-tts", + display: { + name: "Google TTS", + icon: "record_voice_over", + color: "#4285F4", + textIcon: "GT" + }, + category: "freeTier", + authType: "none", + serviceKinds: [ + "tts" + ], + mediaPriority: 5, + noAuth: true, + ttsConfig: { + baseUrl: "google-tts", + authType: "none", + authHeader: "none", + format: "google-tts", + models: [] + } +}; diff --git a/open-sse/providers/registry/grok-web.js b/open-sse/providers/registry/grok-web.js new file mode 100644 index 00000000..0fbaa457 --- /dev/null +++ b/open-sse/providers/registry/grok-web.js @@ -0,0 +1,39 @@ +export default { + id: "grok-web", + priority: 150, + alias: "grok-web", + aliases: [ + "gw", + ], + uiAlias: "gw", + display: { + name: "Grok Web (Subscription)", + icon: "auto_awesome", + color: "#1DA1F2", + textIcon: "GW", + website: "https://grok.com", + }, + category: "webCookie", + authType: "cookie", + authHint: "Paste your sso= cookie value from grok.com", + transport: { + baseUrl: "https://grok.com/rest/app-chat/conversations/new", + format: "grok-web", + authType: "cookie", + }, + models: [ + { 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)" }, + ], + passthroughModels: true, +}; diff --git a/open-sse/providers/registry/groq.js b/open-sse/providers/registry/groq.js new file mode 100644 index 00000000..2ad8a6d8 --- /dev/null +++ b/open-sse/providers/registry/groq.js @@ -0,0 +1,37 @@ +export default { + id: "groq", + priority: 60, + hasFree: true, + alias: "groq", + display: { + name: "Groq", + icon: "speed", + color: "#F55036", + textIcon: "GQ", + website: "https://groq.com", + notice: { + apiKeyUrl: "https://console.groq.com/keys", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://api.groq.com/openai/v1/chat/completions", + validateUrl: "https://api.groq.com/openai/v1/models", + }, + models: [ + { 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" }, + { id: "whisper-large-v3", name: "Whisper Large v3", params: ["language","response_format","temperature","prompt"], kind: "stt" }, + { id: "whisper-large-v3-turbo", name: "Whisper Large v3 Turbo", params: ["language","response_format","temperature","prompt"], kind: "stt" }, + { id: "distil-whisper-large-v3-en", name: "Distil Whisper Large v3 EN", params: ["language","response_format","temperature","prompt"], kind: "stt" }, + ], + serviceKinds: ["llm","imageToText","stt"], + sttConfig: { + baseUrl: "https://api.groq.com/openai/v1/audio/transcriptions", + authType: "apikey", + authHeader: "bearer", + format: "openai", + }, +}; diff --git a/open-sse/providers/registry/huggingface.js b/open-sse/providers/registry/huggingface.js new file mode 100644 index 00000000..768b0ded --- /dev/null +++ b/open-sse/providers/registry/huggingface.js @@ -0,0 +1,34 @@ +export default { + id: "huggingface", + priority: 70, + hasFree: true, + alias: "huggingface", + aliases: [ + "hf", + ], + uiAlias: "hf", + display: { + name: "HuggingFace", + icon: "face", + color: "#FFD21E", + textIcon: "HF", + website: "https://huggingface.co", + notice: { + apiKeyUrl: "https://huggingface.co/settings/tokens", + }, + }, + category: "apikey", + authType: "apikey", + hiddenKinds: [ + "tts", + ], + transport: null, + models: [ + { id: "black-forest-labs/FLUX.1-schnell", name: "FLUX.1 Schnell", params: [], kind: "image" }, + { id: "stabilityai/stable-diffusion-xl-base-1.0", name: "SDXL Base 1.0", params: [], kind: "image" }, + { id: "openai/whisper-large-v3", name: "Whisper Large v3 (HF)", params: ["language"], kind: "stt" }, + { id: "openai/whisper-small", name: "Whisper Small (HF)", params: ["language"], kind: "stt" }, + ], + serviceKinds: ["image", "stt"], + imageConfig: { baseUrl: "https://api-inference.huggingface.co/models" }, +}; diff --git a/open-sse/providers/registry/hyperbolic.js b/open-sse/providers/registry/hyperbolic.js new file mode 100644 index 00000000..9796cc93 --- /dev/null +++ b/open-sse/providers/registry/hyperbolic.js @@ -0,0 +1,35 @@ +export default { + id: "hyperbolic", + priority: 160, + alias: "hyperbolic", + aliases: [ + "hyp", + ], + uiAlias: "hyp", + display: { + name: "Hyperbolic", + icon: "bolt", + color: "#00D4FF", + textIcon: "HY", + website: "https://hyperbolic.xyz", + notice: { + apiKeyUrl: "https://app.hyperbolic.xyz/settings", + }, + }, + category: "apikey", + authType: "apikey", + transport: { + baseUrl: "https://api.hyperbolic.xyz/v1/chat/completions", + validateUrl: "https://api.hyperbolic.xyz/v1/models", + }, + models: [ + { 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" }, + ], +}; diff --git a/open-sse/providers/registry/iflow.js b/open-sse/providers/registry/iflow.js new file mode 100644 index 00000000..00f550e8 --- /dev/null +++ b/open-sse/providers/registry/iflow.js @@ -0,0 +1,52 @@ +export default { + id: "iflow", + hidden: true, + priority: 110, + alias: "if", + display: { + name: "iFlow AI", + icon: "water_drop", + color: "#6366F1", + website: "https://iflow.cn", + notice: { + signupUrl: "https://iflow.cn", + }, + }, + category: "oauth", + transport: { + baseUrl: "https://apis.iflow.cn/v1/chat/completions", + thinkingFormat: "openai", + headers: { + "User-Agent": "iFlow-Cli", + }, + }, + models: [ + { 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" }, + ], + oauth: { + clientId: "10009311001", + clientSecret: "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW", + authorizeUrl: "https://iflow.cn/oauth", + tokenUrl: "https://iflow.cn/oauth/token", + userInfoUrl: "https://iflow.cn/api/oauth/getUserInfo", + extraParams: { + loginMethod: "phone", + type: "phone", + }, + refreshLeadMs: 86400000, + }, +}; diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js new file mode 100644 index 00000000..d0a2fe57 --- /dev/null +++ b/open-sse/providers/registry/index.js @@ -0,0 +1,194 @@ +// Auto-generated: static imports of all registry entries +import p0 from "./alicode-intl.js"; +import p1 from "./alicode.js"; +import p2 from "./anthropic.js"; +import p3 from "./antigravity.js"; +import p4 from "./assemblyai.js"; +import p5 from "./aws-polly.js"; +import p6 from "./azure.js"; +import p7 from "./black-forest-labs.js"; +import p8 from "./blackbox.js"; +import p9 from "./brave-search.js"; +import p10 from "./byteplus.js"; +import p11 from "./cartesia.js"; +import p12 from "./cerebras.js"; +import p13 from "./chutes.js"; +import p14 from "./claude.js"; +import p15 from "./cline.js"; +import p16 from "./cloudflare-ai.js"; +import p17 from "./codebuddy-cn.js"; +import p18 from "./codex.js"; +import p19 from "./cohere.js"; +import p20 from "./comfyui.js"; +import p21 from "./commandcode.js"; +import p22 from "./coqui.js"; +import p23 from "./cursor.js"; +import p24 from "./deepgram.js"; +import p25 from "./deepseek.js"; +import p26 from "./edge-tts.js"; +import p27 from "./elevenlabs.js"; +import p28 from "./exa.js"; +import p29 from "./fal-ai.js"; +import p30 from "./firecrawl.js"; +import p31 from "./fireworks.js"; +import p32 from "./gemini-cli.js"; +import p33 from "./gemini.js"; +import p34 from "./github.js"; +import p35 from "./gitlab.js"; +import p36 from "./glm-cn.js"; +import p37 from "./glm.js"; +import p38 from "./google-pse.js"; +import p39 from "./google-tts.js"; +import p40 from "./grok-web.js"; +import p41 from "./groq.js"; +import p42 from "./huggingface.js"; +import p43 from "./hyperbolic.js"; +import p44 from "./iflow.js"; +import p45 from "./inworld.js"; +import p46 from "./jina-ai.js"; +import p47 from "./jina-reader.js"; +import p48 from "./kilocode.js"; +import p49 from "./kimi-coding.js"; +import p50 from "./kimi.js"; +import p51 from "./kiro.js"; +import p52 from "./linkup.js"; +import p53 from "./local-device.js"; +import p54 from "./mimo-free.js"; +import p55 from "./minimax-cn.js"; +import p56 from "./minimax.js"; +import p57 from "./mistral.js"; +import p58 from "./mmf.js"; +import p59 from "./nanobanana.js"; +import p60 from "./nebius.js"; +import p61 from "./nvidia.js"; +import p62 from "./ollama-local.js"; +import p63 from "./ollama.js"; +import p64 from "./openai.js"; +import p65 from "./opencode-go.js"; +import p66 from "./opencode.js"; +import p67 from "./openrouter.js"; +import p68 from "./perplexity-web.js"; +import p69 from "./perplexity.js"; +import p70 from "./playht.js"; +import p71 from "./qoder.js"; +import p72 from "./qwen.js"; +import p73 from "./recraft.js"; +import p74 from "./runwayml.js"; +import p75 from "./sdwebui.js"; +import p76 from "./searchapi.js"; +import p77 from "./searxng.js"; +import p78 from "./serper.js"; +import p79 from "./siliconflow.js"; +import p80 from "./stability-ai.js"; +import p81 from "./tavily.js"; +import p82 from "./together.js"; +import p83 from "./topaz.js"; +import p84 from "./tortoise.js"; +import p85 from "./venice.js"; +import p86 from "./vercel-ai-gateway.js"; +import p87 from "./vertex-partner.js"; +import p88 from "./vertex.js"; +import p89 from "./volcengine-ark.js"; +import p90 from "./voyage-ai.js"; +import p91 from "./xai.js"; +import p92 from "./xiaomi-mimo.js"; +import p93 from "./xiaomi-tokenplan.js"; +import p94 from "./youcom.js"; + +export default [ + p0, + p1, + p2, + p3, + p4, + p5, + p6, + p7, + p8, + p9, + p10, + p11, + p12, + p13, + p14, + p15, + p16, + p17, + p18, + p19, + p20, + p21, + p22, + p23, + p24, + p25, + p26, + p27, + p28, + p29, + p30, + p31, + p32, + p33, + p34, + p35, + p36, + p37, + p38, + p39, + p40, + p41, + p42, + p43, + p44, + p45, + p46, + p47, + p48, + p49, + p50, + p51, + p52, + p53, + p54, + p55, + p56, + p57, + p58, + p59, + p60, + p61, + p62, + p63, + p64, + p65, + p66, + p67, + p68, + p69, + p70, + p71, + p72, + p73, + p74, + p75, + p76, + p77, + p78, + p79, + p80, + p81, + p82, + p83, + p84, + p85, + p86, + p87, + p88, + p89, + p90, + p91, + p92, + p93, + p94 +]; diff --git a/open-sse/providers/registry/inworld.js b/open-sse/providers/registry/inworld.js new file mode 100644 index 00000000..bc9bad57 --- /dev/null +++ b/open-sse/providers/registry/inworld.js @@ -0,0 +1,36 @@ +export default { + id: "inworld", + alias: "inworld", + display: { + name: "Inworld TTS", + icon: "record_voice_over", + color: "#FF6B6B", + textIcon: "IW", + website: "https://inworld.ai", + notice: { + text: "Free tier: 40 minutes/month TTS. Paid: TTS-1.5 Mini $0.01/min ($15/1M chars), TTS-1.5 Max $0.025/min ($30/1M chars). 270+ voices, 15 languages.", + apiKeyUrl: "https://platform.inworld.ai/api-keys" + } + }, + category: "apikey", + authType: "apikey", + serviceKinds: [ + "tts" + ], + ttsConfig: { + baseUrl: "https://api.inworld.ai/tts/v1/voice", + authType: "apikey", + authHeader: "basic", + format: "inworld", + models: [ + { + id: "inworld-tts-1.5-mini", + name: "Inworld TTS 1.5 Mini ($0.01/min)" + }, + { + id: "inworld-tts-1.5-max", + name: "Inworld TTS 1.5 Max ($0.025/min)" + } + ] + } +}; diff --git a/open-sse/providers/registry/jina-ai.js b/open-sse/providers/registry/jina-ai.js new file mode 100644 index 00000000..90c7da2b --- /dev/null +++ b/open-sse/providers/registry/jina-ai.js @@ -0,0 +1,42 @@ +export default { + id: "jina-ai", + alias: "jina", + display: { + name: "Jina AI", + icon: "blur_on", + color: "#2563EB", + textIcon: "JA", + website: "https://jina.ai", + notice: { + text: "10M free tokens on signup (non-commercial), no credit card required.", + apiKeyUrl: "https://jina.ai/?sui=apikey" + } + }, + category: "apikey", + authType: "apikey", + serviceKinds: [ + "embedding" + ], + embeddingConfig: { + baseUrl: "https://api.jina.ai/v1/embeddings", + authType: "apikey", + authHeader: "bearer", + models: [ + { + id: "jina-embeddings-v3", + name: "Jina Embeddings v3", + dimensions: 1024 + }, + { + id: "jina-embeddings-v2-base-en", + name: "Jina Embeddings v2 Base EN", + dimensions: 768 + }, + { + id: "jina-embeddings-v2-base-code", + name: "Jina Embeddings v2 Base Code", + dimensions: 768 + } + ] + } +}; diff --git a/open-sse/providers/registry/jina-reader.js b/open-sse/providers/registry/jina-reader.js new file mode 100644 index 00000000..35ffae94 --- /dev/null +++ b/open-sse/providers/registry/jina-reader.js @@ -0,0 +1,34 @@ +export default { + id: "jina-reader", + alias: "jina-reader", + display: { + name: "Jina Reader", + icon: "menu_book", + color: "#000000", + textIcon: "JR", + website: "https://jina.ai/reader", + notice: { + apiKeyUrl: "https://jina.ai/?sui=apikey" + } + }, + category: "apikey", + authType: "apikey", + serviceKinds: [ + "webFetch" + ], + fetchConfig: { + baseUrl: "https://r.jina.ai", + method: "GET", + authType: "apikey", + authHeader: "bearer", + costPerQuery: 0, + freeMonthlyQuota: 1000000, + formats: [ + "markdown", + "text", + "html" + ], + maxCharacters: 200000, + timeoutMs: 30000 + } +}; diff --git a/open-sse/providers/registry/kilocode.js b/open-sse/providers/registry/kilocode.js new file mode 100644 index 00000000..c259ac79 --- /dev/null +++ b/open-sse/providers/registry/kilocode.js @@ -0,0 +1,44 @@ +export default { + id: "kilocode", + priority: 70, + alias: "kc", + uiAlias: "kc", + display: { + name: "Kilo Code", + icon: "code", + color: "#FF6B35", + textIcon: "KC", + website: "https://kilocode.ai", + notice: { + signupUrl: "https://kilocode.ai", + }, + }, + category: "oauth", + transport: { + baseUrl: "https://api.kilo.ai/api/openrouter/chat/completions", + headers: {}, + auth: { + combined: true, + header: "Authorization", + scheme: "bearer", + hooks: [ + "kilocodeOrg", + ], + }, + }, + models: [ + { 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" }, + ], + oauth: { + apiBaseUrl: "https://api.kilo.ai", + initiateUrl: "https://api.kilo.ai/api/device-auth/codes", + pollUrlBase: "https://api.kilo.ai/api/device-auth/codes", + }, +}; diff --git a/open-sse/providers/registry/kimi-coding.js b/open-sse/providers/registry/kimi-coding.js new file mode 100644 index 00000000..15705a86 --- /dev/null +++ b/open-sse/providers/registry/kimi-coding.js @@ -0,0 +1,65 @@ +import { CLAUDE_API_HEADERS, KIMI_CODING_BASE_URL } from "../shared.js"; + +export default { + id: "kimi-coding", + hidden: true, + priority: 120, + alias: "kmc", + display: { + name: "Kimi Coding", + icon: "psychology", + color: "#1E40AF", + textIcon: "KC", + website: "https://kimi.moonshot.cn", + notice: { + signupUrl: "https://kimi.moonshot.cn", + }, + }, + category: "oauth", + transport: { + baseUrl: "https://api.kimi.com/coding/v1/messages", + format: "claude", + urlSuffix: "?beta=true", + headers: { ...CLAUDE_API_HEADERS }, + clientId: "17e5f671-d194-4dfb-9706-5516cb48c098", + tokenUrl: "https://auth.kimi.com/api/oauth/token", + refreshUrl: "https://auth.kimi.com/api/oauth/token", + auth: { + combined: true, + header: "x-api-key", + scheme: "raw", + hooks: [ + "kimiHeaders", + ], + }, + }, + // Multi-endpoint: pick the transport matching client sourceFormat to skip translation. + transports: [ + { + format: "openai", + baseUrl: "https://api.kimi.com/coding/v1/chat/completions", + auth: { combined: true, header: "Authorization", scheme: "bearer", hooks: ["kimiHeaders"] }, + }, + { + format: "claude", + baseUrl: "https://api.kimi.com/coding/v1/messages", + urlSuffix: "?beta=true", + headers: { ...CLAUDE_API_HEADERS }, + auth: { combined: true, header: "x-api-key", scheme: "raw", hooks: ["kimiHeaders"] }, + }, + ], + models: [ + { id: "kimi-k2.6", name: "Kimi K2.6" }, + { id: "kimi-k2.5", name: "Kimi K2.5" }, + { id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" }, + { id: "kimi-latest", name: "Kimi Latest" }, + ], + oauth: { + deviceCodeUrl: "https://auth.kimi.com/api/oauth/device_authorization", + tokenUrl: "https://auth.kimi.com/api/oauth/token", + refreshLeadMs: 300000, + }, + features: { + usage: true, + }, +}; diff --git a/open-sse/providers/registry/kimi.js b/open-sse/providers/registry/kimi.js new file mode 100644 index 00000000..e22286b4 --- /dev/null +++ b/open-sse/providers/registry/kimi.js @@ -0,0 +1,56 @@ +import { CLAUDE_API_HEADERS, KIMI_CODING_BASE_URL } from "../shared.js"; + +export default { + id: "kimi", + priority: 170, + alias: "kimi", + display: { + name: "Kimi", + icon: "psychology", + color: "#1E3A8A", + textIcon: "KM", + website: "https://kimi.moonshot.cn", + notice: { + apiKeyUrl: "https://platform.moonshot.ai/console/api-keys", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://api.kimi.com/coding/v1/messages", + format: "claude", + urlSuffix: "?beta=true", + headers: { ...CLAUDE_API_HEADERS }, + auth: { + combined: true, + header: "x-api-key", + scheme: "raw", + }, + }, + // Multi-endpoint: pick the transport matching client sourceFormat to skip translation. + transports: [ + { + format: "openai", + baseUrl: "https://api.kimi.com/coding/v1/chat/completions", + auth: { combined: true, header: "Authorization", scheme: "bearer" }, + }, + { + format: "claude", + baseUrl: "https://api.kimi.com/coding/v1/messages", + urlSuffix: "?beta=true", + headers: { ...CLAUDE_API_HEADERS }, + auth: { combined: true, header: "x-api-key", scheme: "raw" }, + }, + ], + models: [ + { id: "kimi-k2.6", name: "Kimi K2.6" }, + { id: "kimi-k2.5", name: "Kimi K2.5" }, + { id: "kimi-k2.5-thinking", name: "Kimi K2.5 Thinking" }, + { id: "kimi-latest", name: "Kimi Latest" }, + ], + serviceKinds: ["llm","webSearch"], + searchViaChat: { + defaultModel: "kimi-k2.5", + endpoint: "https://api.moonshot.cn/v1/chat/completions", + pricingUrl: "https://platform.moonshot.ai/docs/pricing/chat", + }, +}; diff --git a/open-sse/providers/registry/kiro.js b/open-sse/providers/registry/kiro.js new file mode 100644 index 00000000..fb78a227 --- /dev/null +++ b/open-sse/providers/registry/kiro.js @@ -0,0 +1,92 @@ +export default { + id: "kiro", + priority: 10, + alias: "kr", + uiAlias: "kr", + display: { + name: "Kiro AI", + icon: "psychology_alt", + color: "#FF6B35", + website: "https://kiro.dev", + notice: { + signupUrl: "https://kiro.dev", + }, + deprecated: true, + deprecationNotice: "RISK_NOTICE", + }, + category: "free", + transport: { + 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", + 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", + usage: { + cwHost: "https://codewhisperer.us-east-1.amazonaws.com", + qHost: "https://q.us-east-1.amazonaws.com", + limitsPath: "/getUsageLimits", + }, + }, + models: [ + { 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" }, + { id: "claude-sonnet-4.5-thinking", name: "Claude Sonnet 4.5 (Thinking)" }, + { id: "claude-haiku-4.5-thinking", name: "Claude Haiku 4.5 (Thinking)" }, + { 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)" }, + ], + oauth: { + ssoOidcEndpoint: "https://oidc.us-east-1.amazonaws.com", + registerClientUrl: "https://oidc.us-east-1.amazonaws.com/client/register", + deviceAuthUrl: "https://oidc.us-east-1.amazonaws.com/device_authorization", + tokenUrl: "https://oidc.us-east-1.amazonaws.com/token", + startUrl: "https://view.awsapps.com/start", + clientName: "kiro-oauth-client", + clientType: "public", + scopes: [ + "codewhisperer:completions", + "codewhisperer:analysis", + "codewhisperer:conversations", + ], + grantTypes: [ + "urn:ietf:params:oauth:grant-type:device_code", + "refresh_token", + ], + issuerUrl: "https://identitycenter.amazonaws.com/ssoins-722374e8c3c8e6c6", + socialAuthEndpoint: "https://prod.us-east-1.auth.desktop.kiro.dev", + socialLoginUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/login", + socialTokenUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/oauth/token", + socialRefreshUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken", + authMethods: [ + "builder-id", + "idc", + "google", + "github", + "import", + ], + }, + features: { + usage: true, + usageApikey: true, + }, +}; diff --git a/open-sse/providers/registry/linkup.js b/open-sse/providers/registry/linkup.js new file mode 100644 index 00000000..19be6bb2 --- /dev/null +++ b/open-sse/providers/registry/linkup.js @@ -0,0 +1,34 @@ +export default { + id: "linkup", + alias: "linkup", + display: { + name: "Linkup", + icon: "link", + color: "#0EA5E9", + textIcon: "LK", + website: "https://linkup.so", + notice: { + apiKeyUrl: "https://app.linkup.so/api-keys" + } + }, + category: "apikey", + authType: "apikey", + serviceKinds: [ + "webSearch" + ], + searchConfig: { + baseUrl: "https://api.linkup.so/v1/search", + method: "POST", + authType: "apikey", + authHeader: "bearer", + costPerQuery: 0.005, + freeMonthlyQuota: 1000, + searchTypes: [ + "web" + ], + defaultMaxResults: 5, + maxMaxResults: 50, + timeoutMs: 10000, + cacheTTLMs: 300000 + } +}; diff --git a/open-sse/providers/registry/local-device.js b/open-sse/providers/registry/local-device.js new file mode 100644 index 00000000..b24fc2de --- /dev/null +++ b/open-sse/providers/registry/local-device.js @@ -0,0 +1,24 @@ +export default { + id: "local-device", + alias: "local-device", + display: { + name: "Local Device", + icon: "speaker", + color: "#64748B", + textIcon: "LD" + }, + category: "freeTier", + authType: "none", + serviceKinds: [ + "tts" + ], + mediaPriority: 5, + noAuth: true, + ttsConfig: { + baseUrl: "local-device", + authType: "none", + authHeader: "none", + format: "local-device", + models: [] + } +}; diff --git a/open-sse/providers/registry/mimo-free.js b/open-sse/providers/registry/mimo-free.js new file mode 100644 index 00000000..4b9074d1 --- /dev/null +++ b/open-sse/providers/registry/mimo-free.js @@ -0,0 +1,24 @@ +export default { + id: "mimo-free", + priority: 50, + hasFree: true, + alias: "mmf", + uiAlias: "mmf", + display: { + name: "MiMo Code Free", + icon: "smart_toy", + color: "#FF6900", + textIcon: "MF", + }, + category: "free", + noAuth: true, + transport: { + baseUrl: "https://api.xiaomimimo.com/api/free-ai/openai/chat", + noAuth: true, + }, + models: [ + { id: "mimo-auto", name: "MiMo Auto" }, + ], + modelsFetcher: { url: "https://models.dev/api.json", type: "mimo-free" }, + passthroughModels: true, +}; diff --git a/open-sse/providers/registry/minimax-cn.js b/open-sse/providers/registry/minimax-cn.js new file mode 100644 index 00000000..19aa3127 --- /dev/null +++ b/open-sse/providers/registry/minimax-cn.js @@ -0,0 +1,76 @@ +import { CLAUDE_API_HEADERS } from "../shared.js"; + +export default { + id: "minimax-cn", + priority: 190, + alias: "minimax-cn", + display: { + name: "Minimax (China)", + icon: "memory", + color: "#DC2626", + textIcon: "MC", + website: "https://www.minimaxi.com", + notice: { + apiKeyUrl: "https://platform.minimaxi.com/user-center/basic-information/interface-key", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://api.minimaxi.com/anthropic/v1/messages", + format: "claude", + urlSuffix: "?beta=true", + headers: { ...CLAUDE_API_HEADERS }, + quirks: { + dropOutputConfig: true, + }, + reasoningInject: { + scope: "all", + }, + auth: { + combined: true, + header: "x-api-key", + scheme: "raw", + }, + usage: { + urls: [ + "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains", + "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains", + ], + }, + }, + // Multi-endpoint: pick the transport matching client sourceFormat to skip translation. + transports: [ + { + format: "openai", + baseUrl: "https://api.minimaxi.com/v1/chat/completions", + auth: { combined: true, header: "Authorization", scheme: "bearer" }, + }, + { + format: "claude", + baseUrl: "https://api.minimaxi.com/anthropic/v1/messages", + urlSuffix: "?beta=true", + headers: { ...CLAUDE_API_HEADERS }, + auth: { combined: true, header: "x-api-key", scheme: "raw" }, + }, + ], + models: [ + { 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" }, + { id: "speech-2.8-hd", name: "Speech 2.8 HD", kind: "tts" }, + { id: "speech-2.8-turbo", name: "Speech 2.8 Turbo", kind: "tts" }, + { id: "speech-2.6-hd", name: "Speech 2.6 HD", kind: "tts" }, + { id: "speech-2.6-turbo", name: "Speech 2.6 Turbo", kind: "tts" }, + { id: "speech-02-hd", name: "Speech 02 HD", kind: "tts" }, + { id: "speech-02-turbo", name: "Speech 02 Turbo", kind: "tts" }, + { id: "speech-01-hd", name: "Speech 01 HD", kind: "tts" }, + { id: "speech-01-turbo", name: "Speech 01 Turbo", kind: "tts" }, + ], + serviceKinds: ["llm","tts"], + ttsConfig: { baseUrl: "https://api.minimaxi.com/v1/t2a_v2", authType: "apikey", authHeader: "bearer", format: "minimax-tts" }, + features: { + usage: true, + usageApikey: true, + }, +}; diff --git a/open-sse/providers/registry/minimax.js b/open-sse/providers/registry/minimax.js new file mode 100644 index 00000000..47b82c89 --- /dev/null +++ b/open-sse/providers/registry/minimax.js @@ -0,0 +1,83 @@ +import { CLAUDE_API_HEADERS } from "../shared.js"; + +export default { + id: "minimax", + priority: 90, + alias: "minimax", + display: { + name: "Minimax Coding", + icon: "memory", + color: "#7C3AED", + textIcon: "MM", + website: "https://www.minimaxi.com", + notice: { + apiKeyUrl: "https://platform.minimaxi.com/user-center/basic-information/interface-key", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://api.minimax.io/anthropic/v1/messages", + format: "claude", + urlSuffix: "?beta=true", + headers: { ...CLAUDE_API_HEADERS }, + quirks: { + dropOutputConfig: true, + }, + reasoningInject: { + scope: "all", + }, + auth: { + combined: true, + header: "x-api-key", + scheme: "raw", + }, + usage: { + urls: [ + "https://www.minimax.io/v1/token_plan/remains", + "https://api.minimax.io/v1/api/openplatform/coding_plan/remains", + ], + }, + }, + // Multi-endpoint: pick the transport matching client sourceFormat to skip translation. + transports: [ + { + format: "openai", + baseUrl: "https://api.minimax.io/v1/chat/completions", + auth: { combined: true, header: "Authorization", scheme: "bearer" }, + }, + { + format: "claude", + baseUrl: "https://api.minimax.io/anthropic/v1/messages", + urlSuffix: "?beta=true", + headers: { ...CLAUDE_API_HEADERS }, + auth: { combined: true, header: "x-api-key", scheme: "raw" }, + }, + ], + models: [ + { 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" }, + { id: "minimax-image-01", name: "MiniMax Image 01", params: ["n","size","response_format"], kind: "image" }, + { id: "speech-2.8-hd", name: "Speech 2.8 HD", kind: "tts" }, + { id: "speech-2.8-turbo", name: "Speech 2.8 Turbo", kind: "tts" }, + { id: "speech-2.6-hd", name: "Speech 2.6 HD", kind: "tts" }, + { id: "speech-2.6-turbo", name: "Speech 2.6 Turbo", kind: "tts" }, + { id: "speech-02-hd", name: "Speech 02 HD", kind: "tts" }, + { id: "speech-02-turbo", name: "Speech 02 Turbo", kind: "tts" }, + { id: "speech-01-hd", name: "Speech 01 HD", kind: "tts" }, + { id: "speech-01-turbo", name: "Speech 01 Turbo", kind: "tts" }, + ], + serviceKinds: ["llm","image","imageToText","webSearch","tts"], + ttsConfig: { baseUrl: "https://api.minimax.io/v1/t2a_v2", authType: "apikey", authHeader: "bearer", format: "minimax-tts" }, + imageConfig: { baseUrl: "https://api.minimaxi.com/v1/images/generations" }, + searchViaChat: { + defaultModel: "MiniMax-M2.7", + endpoint: "https://api.minimaxi.com/v1/text/chatcompletion_v2", + pricingUrl: "https://www.minimaxi.com/document/price", + }, + features: { + usage: true, + usageApikey: true, + }, +}; diff --git a/open-sse/providers/registry/mistral.js b/open-sse/providers/registry/mistral.js new file mode 100644 index 00000000..b5869135 --- /dev/null +++ b/open-sse/providers/registry/mistral.js @@ -0,0 +1,31 @@ +export default { + id: "mistral", + priority: 80, + alias: "mistral", + display: { + name: "Mistral", + icon: "air", + color: "#FF7000", + textIcon: "MI", + website: "https://mistral.ai", + notice: { + apiKeyUrl: "https://console.mistral.ai/api-keys", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://api.mistral.ai/v1/chat/completions", + validateUrl: "https://api.mistral.ai/v1/models", + quirks: { + dropClientMetadata: true, + }, + }, + models: [ + { 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", kind: "embedding" }, + ], + serviceKinds: ["llm","imageToText","embedding"], + embeddingConfig: { baseUrl: "https://api.mistral.ai/v1/embeddings", authType: "apikey", authHeader: "bearer" }, +}; diff --git a/open-sse/providers/registry/mmf.js b/open-sse/providers/registry/mmf.js new file mode 100644 index 00000000..63c776f4 --- /dev/null +++ b/open-sse/providers/registry/mmf.js @@ -0,0 +1,19 @@ +export default { + id: "mmf", + hidden: true, + priority: 200, + display: { + name: "MMF", + icon: "hub", + color: "#6366F1", + textIcon: "MF", + }, + category: "apikey", + transport: { + baseUrl: "https://api.xiaomimimo.com/api/free-ai/openai/chat", + noAuth: true, + }, + models: [ + { id: "mimo-auto", name: "MiMo Auto" }, + ], +}; diff --git a/open-sse/providers/registry/nanobanana.js b/open-sse/providers/registry/nanobanana.js new file mode 100644 index 00000000..f57b49af --- /dev/null +++ b/open-sse/providers/registry/nanobanana.js @@ -0,0 +1,35 @@ +export default { + id: "nanobanana", + priority: 80, + hasFree: true, + alias: "nanobanana", + aliases: [ + "nb", + ], + uiAlias: "nb", + display: { + name: "NanoBanana API", + icon: "extension", + color: "#FFD700", + textIcon: "🍌", + website: "https://nanobananaapi.ai", + notice: { + text: "3rd-party proxy for Google Nano Banana (Gemini 2.5/3 Flash Image). For official, use Gemini provider.", + apiKeyUrl: "https://nanobananaapi.ai/dashboard", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://api.nanobananaapi.ai/v1/chat/completions", + validateUrl: "https://api.nanobananaapi.ai/v1/models", + }, + models: [ + { id: "nanobanana-flash", name: "NanoBanana Flash", params: ["n","size"], kind: "image" }, + { id: "nanobanana-pro", name: "NanoBanana Pro", params: ["n","size"], kind: "image" }, + ], + serviceKinds: ["image"], + imageConfig: { + baseUrl: "https://api.nanobananaapi.ai/api/v1/nanobanana/generate", + pollUrl: "https://api.nanobananaapi.ai/api/v1/nanobanana/record-info", + }, +}; diff --git a/open-sse/providers/registry/nebius.js b/open-sse/providers/registry/nebius.js new file mode 100644 index 00000000..bcfdd25d --- /dev/null +++ b/open-sse/providers/registry/nebius.js @@ -0,0 +1,27 @@ +export default { + id: "nebius", + priority: 70, + alias: "nebius", + display: { + name: "Nebius AI", + icon: "cloud", + color: "#6C5CE7", + textIcon: "NB", + website: "https://nebius.com", + notice: { + apiKeyUrl: "https://studio.nebius.com/settings/api-keys", + }, + }, + category: "apikey", + authType: "apikey", + transport: { + baseUrl: "https://api.studio.nebius.ai/v1/chat/completions", + validateUrl: "https://api.studio.nebius.ai/v1/models", + }, + models: [ + { id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B Instruct" }, + { id: "Qwen/Qwen3-Embedding-8B", name: "Qwen3 Embedding 8B", kind: "embedding" }, + ], + serviceKinds: ["llm", "embedding"], + embeddingConfig: { baseUrl: "https://api.tokenfactory.nebius.com/v1/embeddings" }, +}; diff --git a/open-sse/providers/registry/nvidia.js b/open-sse/providers/registry/nvidia.js new file mode 100644 index 00000000..35a1f768 --- /dev/null +++ b/open-sse/providers/registry/nvidia.js @@ -0,0 +1,38 @@ +export default { + id: "nvidia", + priority: 20, + hasFree: true, + alias: "nvidia", + display: { + name: "NVIDIA NIM", + icon: "developer_board", + color: "#76B900", + textIcon: "NV", + website: "https://developer.nvidia.com/nim", + notice: { + text: "Free access for NVIDIA Developer Program members (prototyping & testing).", + apiKeyUrl: "https://build.nvidia.com/settings/api-keys", + }, + }, + category: "freeTier", + transport: { + baseUrl: "https://integrate.api.nvidia.com/v1/chat/completions", + validateUrl: "https://integrate.api.nvidia.com/v1/models", + }, + models: [ + { 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", kind: "embedding" }, + { id: "nvidia/parakeet-ctc-1.1b-asr", name: "Parakeet CTC 1.1B", params: ["language"], kind: "stt" }, + { id: "fastpitch", name: "FastPitch", kind: "tts" }, + { id: "tacotron2", name: "Tacotron2", kind: "tts" }, + ], + serviceKinds: ["llm","tts","embedding"], + ttsConfig: { + baseUrl: "https://integrate.api.nvidia.com/v1/audio/speech", + authType: "apikey", + authHeader: "bearer", + format: "nvidia-tts", + }, + embeddingConfig: { baseUrl: "https://integrate.api.nvidia.com/v1/embeddings", authType: "apikey", authHeader: "bearer" }, +}; diff --git a/open-sse/providers/registry/ollama-local.js b/open-sse/providers/registry/ollama-local.js new file mode 100644 index 00000000..1d83238a --- /dev/null +++ b/open-sse/providers/registry/ollama-local.js @@ -0,0 +1,19 @@ +export default { + id: "ollama-local", + priority: 50, + hasFree: true, + alias: "ollama-local", + display: { + name: "Ollama Local", + icon: "cloud", + color: "#ffffffff", + textIcon: "OL", + website: "https://ollama.com", + }, + category: "apikey", + transport: { + baseUrl: "http://localhost:11434/api/chat", + format: "ollama", + }, + serviceKinds: ["llm"], +}; diff --git a/open-sse/providers/registry/ollama.js b/open-sse/providers/registry/ollama.js new file mode 100644 index 00000000..69923aa1 --- /dev/null +++ b/open-sse/providers/registry/ollama.js @@ -0,0 +1,36 @@ +export default { + id: "ollama", + priority: 30, + hasFree: true, + alias: "ollama", + display: { + name: "Ollama Cloud", + icon: "cloud", + color: "#ffffffff", + textIcon: "OL", + website: "https://ollama.com", + notice: { + text: "Free tier: light usage, 1 cloud model at a time (limits reset every 5h & 7d). Pro $20/mo · Max $100/mo.", + apiKeyUrl: "https://ollama.com/settings/keys", + }, + }, + category: "freeTier", + transport: { + baseUrl: "https://ollama.com/api/chat", + validateUrl: "https://ollama.com/api/tags", + format: "ollama", + }, + models: [ + { 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" }, + { id: "minimax-m3", name: "MiniMax M3" }, + ], + serviceKinds: ["llm"], + features: { + usage: true, + }, +}; diff --git a/open-sse/providers/registry/openai.js b/open-sse/providers/registry/openai.js new file mode 100644 index 00000000..9a1ca57b --- /dev/null +++ b/open-sse/providers/registry/openai.js @@ -0,0 +1,81 @@ +export default { + id: "openai", + priority: 30, + alias: "openai", + display: { + name: "OpenAI", + icon: "auto_awesome", + color: "#10A37F", + textIcon: "OA", + website: "https://platform.openai.com", + notice: { + apiKeyUrl: "https://platform.openai.com/api-keys", + }, + }, + category: "apikey", + thinkingConfig: { + options: [ + "auto", + "none", + "low", + "medium", + "high", + ], + defaultMode: "auto", + }, + transport: { + baseUrl: "https://api.openai.com/v1/chat/completions", + forceStream: true, + }, + 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" }, + { 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" }, + { id: "text-embedding-3-large", name: "Text Embedding 3 Large", kind: "embedding" }, + { id: "text-embedding-3-small", name: "Text Embedding 3 Small", kind: "embedding" }, + { id: "text-embedding-ada-002", name: "Text Embedding Ada 002", kind: "embedding" }, + { id: "tts-1", name: "TTS-1", kind: "tts" }, + { id: "tts-1-hd", name: "TTS-1 HD", kind: "tts" }, + { id: "gpt-4o-mini-tts", name: "GPT-4o Mini TTS", kind: "tts" }, + { id: "whisper-1", name: "Whisper 1", params: ["language","response_format","temperature","prompt"], kind: "stt" }, + { id: "gpt-4o-transcribe", name: "GPT-4o Transcribe", params: ["language","response_format","temperature","prompt"], kind: "stt" }, + { id: "gpt-4o-mini-transcribe", name: "GPT-4o Mini Transcribe", params: ["language","response_format","temperature","prompt"], kind: "stt" }, + { id: "gpt-image-1", name: "GPT Image 1", params: ["n","size","quality","response_format"], kind: "image" }, + { id: "dall-e-3", name: "DALL-E 3", params: ["size","quality","style","response_format"], kind: "image" }, + { id: "dall-e-2", name: "DALL-E 2", params: ["n","size","response_format"], kind: "image" }, + ], + serviceKinds: ["llm","embedding","tts","stt","image","imageToText","webSearch"], + ttsConfig: { + baseUrl: "https://api.openai.com/v1/audio/speech", + authType: "apikey", + authHeader: "bearer", + format: "openai", + defaultModel: "gpt-4o-mini-tts", + }, + sttConfig: { + baseUrl: "https://api.openai.com/v1/audio/transcriptions", + authType: "apikey", + authHeader: "bearer", + format: "openai", + }, + embeddingConfig: { baseUrl: "https://api.openai.com/v1/embeddings", authType: "apikey", authHeader: "bearer" }, + imageConfig: { baseUrl: "https://api.openai.com/v1/images/generations" }, + searchViaChat: { defaultModel: "gpt-4o-mini", pricingUrl: "https://openai.com/api/pricing" }, +}; diff --git a/open-sse/providers/registry/opencode-go.js b/open-sse/providers/registry/opencode-go.js new file mode 100644 index 00000000..c980cc85 --- /dev/null +++ b/open-sse/providers/registry/opencode-go.js @@ -0,0 +1,41 @@ +export default { + id: "opencode-go", + priority: 210, + alias: "opencode-go", + aliases: [ + "ocg", + ], + uiAlias: "ocg", + display: { + name: "OpenCode Go", + icon: "terminal", + color: "#E87040", + textIcon: "OC", + website: "https://opencode.ai/auth", + notice: { + text: "OpenCode Go subscription: $5/mo (then 0/mo). Access to Kimi, GLM, Qwen, MiMo, MiniMax models.", + apiKeyUrl: "https://opencode.ai/auth", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", + headers: {}, + }, + models: [ + { id: "glm-5.2", name: "GLM 5.2" }, + { id: "glm-5.1", name: "GLM 5.1" }, + { id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }, + { id: "kimi-k2.6", name: "Kimi K2.6" }, + { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" }, + { id: "mimo-v2.5", name: "MiMo V2.5" }, + { id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" }, + { id: "minimax-m3", name: "MiniMax M3", targetFormat: "claude" }, + { id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" }, + { id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" }, + { id: "qwen3.7-max", name: "Qwen 3.7 Max", targetFormat: "claude" }, + { id: "qwen3.7-plus", name: "Qwen 3.7 Plus", targetFormat: "claude" }, + { id: "qwen3.6-plus", name: "Qwen 3.6 Plus", targetFormat: "claude" }, + ], +}; diff --git a/open-sse/providers/registry/opencode.js b/open-sse/providers/registry/opencode.js new file mode 100644 index 00000000..e83ad3a7 --- /dev/null +++ b/open-sse/providers/registry/opencode.js @@ -0,0 +1,25 @@ +export default { + id: "opencode", + priority: 40, + hasFree: true, + alias: "oc", + uiAlias: "oc", + display: { + name: "OpenCode Free", + icon: "terminal", + color: "#E87040", + textIcon: "OC", + }, + category: "free", + noAuth: true, + transport: { + baseUrl: "https://opencode.ai", + headers: { + "x-opencode-client": "desktop", + }, + noAuth: true, + }, + models: [], + modelsFetcher: { url: "https://opencode.ai/zen/v1/models", type: "opencode-free" }, + passthroughModels: true, +}; diff --git a/open-sse/providers/registry/openrouter.js b/open-sse/providers/registry/openrouter.js new file mode 100644 index 00000000..4ac03641 --- /dev/null +++ b/open-sse/providers/registry/openrouter.js @@ -0,0 +1,60 @@ +export default { + id: "openrouter", + priority: 10, + hasFree: true, + alias: "openrouter", + display: { + name: "OpenRouter", + icon: "router", + color: "#F97316", + textIcon: "OR", + website: "https://openrouter.ai", + notice: { + text: "Free tier: 27+ free models, no credit card needed, 200 req/day. After 0 credit: 1,000 req/day.", + apiKeyUrl: "https://openrouter.ai/settings/keys", + }, + }, + category: "freeTier", + transport: { + baseUrl: "https://openrouter.ai/api/v1/chat/completions", + thinkingFormat: "openai", + headers: { + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy", + }, + }, + models: [ + { id: "openai/text-embedding-3-large", name: "OpenAI Text Embedding 3 Large", kind: "embedding" }, + { id: "openai/text-embedding-3-small", name: "OpenAI Text Embedding 3 Small", kind: "embedding" }, + { id: "openai/text-embedding-ada-002", name: "OpenAI Text Embedding Ada 002", kind: "embedding" }, + { id: "qwen/qwen3-embedding-8b", name: "Qwen3 Embedding 8B", kind: "embedding" }, + { id: "perplexity/pplx-embed-v1-4b", name: "Perplexity Embed V1 4B", kind: "embedding" }, + { id: "perplexity/pplx-embed-v1-0.6b", name: "Perplexity Embed V1 0.6B", kind: "embedding" }, + { id: "nvidia/llama-nemotron-embed-vl-1b-v2:free", name: "NVIDIA Nemotron Embed VL 1B V2 (Free)", kind: "embedding" }, + { id: "openai/gpt-4o-mini-tts", name: "GPT-4o Mini TTS", kind: "tts" }, + { id: "openai/tts-1-hd", name: "TTS-1 HD", kind: "tts" }, + { id: "openai/tts-1", name: "TTS-1", kind: "tts" }, + { id: "openai/dall-e-3", name: "DALL-E 3 (via OpenRouter)", params: ["size","quality","style","response_format"], kind: "image" }, + { id: "openai/gpt-image-1", name: "GPT Image 1 (via OpenRouter)", params: ["n","size","quality","response_format"], kind: "image" }, + { id: "google/imagen-3.0-generate-002", name: "Imagen 3 (via OpenRouter)", params: ["n","size"], kind: "image" }, + { id: "black-forest-labs/FLUX.1-schnell", name: "FLUX.1 Schnell (via OpenRouter)", params: ["n","size"], kind: "image" }, + ], + serviceKinds: ["llm","embedding","tts","imageToText"], + ttsConfig: { + baseUrl: "https://openrouter.ai/api/v1/chat/completions", + defaultModel: "openai/gpt-4o-mini-tts", + headers: {"HTTP-Referer":"https://endpoint-proxy.local","X-Title":"Endpoint Proxy"}, + }, + embeddingConfig: { + baseUrl: "https://openrouter.ai/api/v1/embeddings", + authType: "apikey", + authHeader: "bearer", + headers: {"HTTP-Referer":"https://endpoint-proxy.local","X-Title":"Endpoint Proxy"}, + }, + imageConfig: { + baseUrl: "https://openrouter.ai/api/v1/images/generations", + headers: {"HTTP-Referer":"https://endpoint-proxy.local","X-Title":"Endpoint Proxy"}, + }, + modelsFetcher: { url: "https://openrouter.ai/api/v1/models", type: "openrouter-free" }, + passthroughModels: true, +}; diff --git a/open-sse/providers/registry/perplexity-web.js b/open-sse/providers/registry/perplexity-web.js new file mode 100644 index 00000000..fcaa3571 --- /dev/null +++ b/open-sse/providers/registry/perplexity-web.js @@ -0,0 +1,33 @@ +export default { + id: "perplexity-web", + priority: 220, + alias: "perplexity-web", + aliases: [ + "pw", + ], + uiAlias: "pw", + display: { + name: "Perplexity Web (Pro/Max)", + icon: "search", + color: "#20808D", + textIcon: "PW", + website: "https://www.perplexity.ai", + }, + category: "webCookie", + authType: "cookie", + authHint: "Paste your __Secure-next-auth.session-token cookie value from perplexity.ai", + transport: { + baseUrl: "https://www.perplexity.ai/rest/sse/perplexity_ask", + format: "perplexity-web", + authType: "cookie", + }, + models: [ + { 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)" }, + ], +}; diff --git a/open-sse/providers/registry/perplexity.js b/open-sse/providers/registry/perplexity.js new file mode 100644 index 00000000..f594b500 --- /dev/null +++ b/open-sse/providers/registry/perplexity.js @@ -0,0 +1,35 @@ +export default { + id: "perplexity", + priority: 180, + alias: "perplexity", + aliases: [ + "pplx", + ], + uiAlias: "pplx", + display: { + name: "Perplexity", + icon: "search", + color: "#20808D", + textIcon: "PP", + website: "https://www.perplexity.ai", + notice: { + apiKeyUrl: "https://www.perplexity.ai/settings/api", + }, + }, + category: "apikey", + authType: "apikey", + transport: { + baseUrl: "https://api.perplexity.ai/chat/completions", + validateUrl: "https://api.perplexity.ai/models", + }, + models: [ + { id: "sonar-pro", name: "Sonar Pro" }, + { id: "sonar", name: "Sonar" }, + ], + serviceKinds: ["llm","webSearch"], + searchViaChat: { + defaultModel: "sonar", + endpoint: "https://api.perplexity.ai/chat/completions", + pricingUrl: "https://docs.perplexity.ai/guides/pricing", + }, +}; diff --git a/open-sse/providers/registry/playht.js b/open-sse/providers/registry/playht.js new file mode 100644 index 00000000..1373e563 --- /dev/null +++ b/open-sse/providers/registry/playht.js @@ -0,0 +1,36 @@ +export default { + id: "playht", + alias: "playht", + display: { + name: "PlayHT", + icon: "play_circle", + color: "#00B4D8", + textIcon: "PH", + website: "https://play.ht", + notice: { + apiKeyUrl: "https://play.ht/studio/api-access" + } + }, + category: "apikey", + authType: "apikey", + serviceKinds: [ + "tts" + ], + ttsConfig: { + baseUrl: "https://api.play.ht/api/v2/tts/stream", + authType: "apikey", + authHeader: "playht", + format: "playht", + models: [ + { + id: "PlayDialog", + name: "PlayDialog" + }, + { + id: "Play3.0-mini", + name: "Play 3.0 Mini" + } + ] + }, + hidden: true +}; diff --git a/open-sse/providers/registry/qoder.js b/open-sse/providers/registry/qoder.js new file mode 100644 index 00000000..4ee2b52f --- /dev/null +++ b/open-sse/providers/registry/qoder.js @@ -0,0 +1,54 @@ +export default { + id: "qoder", + priority: 30, + alias: "qd", + uiAlias: "qd", + display: { + name: "Qoder", + icon: "water_drop", + color: "#EC4899", + website: "https://qoder.com", + notice: { + signupUrl: "https://qoder.com", + }, + deprecated: true, + deprecationNotice: "RISK_NOTICE", + }, + category: "free", + transport: { + baseUrl: "https://api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation", + headers: {}, + timeoutMs: 120000, + stallTimeoutMs: 120000, + usage: { + url: "https://openapi.qoder.sh/api/v2/quota/usage", + }, + }, + models: [ + // { id: "auto", name: "Qoder Auto" }, + // { id: "ultimate", name: "Qoder Ultimate" }, + // { id: "performance", name: "Qoder Performance" }, + // { id: "efficient", name: "Qoder Efficient" }, + // { id: "lite", name: "Qoder Lite" }, + // { id: "qmodel", name: "Qwen 3.6 Plus (Qoder)" }, + { id: "qmodel_latest", name: "Qoder Qwen 3.7 Max" }, + // { id: "dmodel", name: "DeepSeek V4 Pro (Qoder)" }, + // { id: "dfmodel", name: "DeepSeek V4 Flash (Qoder)" }, + // { id: "gm51model", name: "GLM 5.1 (Qoder)" }, + // { id: "kmodel", name: "Kimi K2.6 (Qoder)" }, + // { id: "mmodel", name: "MiniMax M2.7 (Qoder)" }, + ], + oauth: { + openApiBaseUrl: "https://openapi.qoder.sh", + centerBaseUrl: "https://center.qoder.sh", + chatBaseUrl: "https://api3.qoder.sh", + deviceTokenUrl: "https://openapi.qoder.sh/api/v1/deviceToken/poll", + refreshUrl: "https://center.qoder.sh/algo/api/v3/user/refresh_token", + userInfoUrl: "https://openapi.qoder.sh/api/v1/userinfo", + quotaUsageUrl: "https://openapi.qoder.sh/api/v2/quota/usage", + loginUrl: "https://qoder.com/device/selectAccounts", + }, + features: { + usage: true, + }, +}; diff --git a/open-sse/providers/registry/qwen.js b/open-sse/providers/registry/qwen.js new file mode 100644 index 00000000..0df381ab --- /dev/null +++ b/open-sse/providers/registry/qwen.js @@ -0,0 +1,33 @@ +export default { + id: "qwen", + hidden: true, + priority: 130, + alias: "qw", + display: { + name: "Qwen Code", + icon: "psychology", + color: "#10B981", + website: "https://chat.qwen.ai", + notice: { + signupUrl: "https://chat.qwen.ai", + }, + }, + category: "oauth", + transport: { + baseUrl: "https://portal.qwen.ai/v1/chat/completions", + }, + models: [ + { 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" }, + ], + oauth: { + clientId: "f0304373b74a44d2b584a3fb70ca9e56", + deviceCodeUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code", + tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token", + scope: "openid profile email model.completion", + codeChallengeMethod: "S256", + refreshLeadMs: 1200000, + }, +}; diff --git a/open-sse/providers/registry/recraft.js b/open-sse/providers/registry/recraft.js new file mode 100644 index 00000000..e64a70a6 --- /dev/null +++ b/open-sse/providers/registry/recraft.js @@ -0,0 +1,24 @@ +export default { + id: "recraft", + priority: 70, + alias: "recraft", + display: { + name: "Recraft", + icon: "image", + color: "#EC4899", + textIcon: "RC", + website: "https://recraft.ai", + notice: { + apiKeyUrl: "https://www.recraft.ai/profile/api", + }, + }, + category: "apikey", + authType: "apikey", + transport: null, + models: [ + { id: "recraftv3", name: "Recraft V3", params: ["n","size","style"], kind: "image" }, + { id: "recraftv2", name: "Recraft V2", params: ["n","size","style"], kind: "image" }, + ], + serviceKinds: ["image"], + imageConfig: { baseUrl: "https://external.api.recraft.ai/v1/images/generations" }, +}; diff --git a/open-sse/providers/registry/runwayml.js b/open-sse/providers/registry/runwayml.js new file mode 100644 index 00000000..4c86e522 --- /dev/null +++ b/open-sse/providers/registry/runwayml.js @@ -0,0 +1,30 @@ +export default { + id: "runwayml", + priority: 80, + alias: "runwayml", + aliases: [ + "runway", + ], + uiAlias: "runway", + display: { + name: "Runway ML", + icon: "movie", + color: "#000000", + textIcon: "RW", + website: "https://runwayml.com", + notice: { + apiKeyUrl: "https://dev.runwayml.com", + }, + }, + category: "apikey", + authType: "apikey", + transport: null, + models: [ + { id: "gen4_image", name: "Gen-4 Image", params: ["size"], kind: "image" }, + { id: "gen4_image_turbo", name: "Gen-4 Image Turbo", params: ["size"], kind: "image" }, + { id: "gen4_turbo", name: "Gen-4 Turbo", params: [], kind: "video" }, + { id: "gen3a_turbo", name: "Gen-3 Alpha Turbo", params: [], kind: "video" }, + ], + serviceKinds: ["image"], + imageConfig: { baseUrl: "https://api.dev.runwayml.com/v1" }, +}; diff --git a/open-sse/providers/registry/sdwebui.js b/open-sse/providers/registry/sdwebui.js new file mode 100644 index 00000000..f253c925 --- /dev/null +++ b/open-sse/providers/registry/sdwebui.js @@ -0,0 +1,20 @@ +export default { + id: "sdwebui", + priority: 110, + alias: "sdwebui", + display: { + name: "SD WebUI", + icon: "brush", + color: "#FF7043", + textIcon: "SD", + website: "https://github.com/AUTOMATIC1111/stable-diffusion-webui", + }, + category: "apikey", + transport: null, + models: [ + { id: "stable-diffusion-v1-5", name: "Stable Diffusion v1.5", params: ["n","size"], kind: "image" }, + { id: "sdxl-base-1.0", name: "SDXL Base 1.0", params: ["n","size"], kind: "image" }, + ], + serviceKinds: ["image"], + imageConfig: { baseUrl: "http://localhost:7860/sdapi/v1/txt2img" }, +}; diff --git a/open-sse/providers/registry/searchapi.js b/open-sse/providers/registry/searchapi.js new file mode 100644 index 00000000..c558ba9b --- /dev/null +++ b/open-sse/providers/registry/searchapi.js @@ -0,0 +1,35 @@ +export default { + id: "searchapi", + alias: "searchapi", + display: { + name: "SearchAPI", + icon: "search", + color: "#0EA5A4", + textIcon: "SA", + website: "https://www.searchapi.io", + notice: { + apiKeyUrl: "https://www.searchapi.io/dashboard" + } + }, + category: "apikey", + authType: "apikey", + serviceKinds: [ + "webSearch" + ], + searchConfig: { + baseUrl: "https://www.searchapi.io/api/v1/search", + method: "GET", + authType: "apikey", + authHeader: "api_key", + costPerQuery: 0.004, + freeMonthlyQuota: 100, + searchTypes: [ + "web", + "news" + ], + defaultMaxResults: 5, + maxMaxResults: 100, + timeoutMs: 10000, + cacheTTLMs: 300000 + } +}; diff --git a/open-sse/providers/registry/searxng.js b/open-sse/providers/registry/searxng.js new file mode 100644 index 00000000..308eabbc --- /dev/null +++ b/open-sse/providers/registry/searxng.js @@ -0,0 +1,33 @@ +export default { + id: "searxng", + alias: "searxng", + display: { + name: "SearXNG", + icon: "saved_search", + color: "#3B82F6", + textIcon: "SX", + website: "https://docs.searxng.org" + }, + category: "freeTier", + authType: "none", + serviceKinds: [ + "webSearch" + ], + noAuth: true, + searchConfig: { + baseUrl: "http://localhost:8888/search", + method: "GET", + authType: "none", + authHeader: "none", + costPerQuery: 0, + freeMonthlyQuota: 999999, + searchTypes: [ + "web", + "news" + ], + defaultMaxResults: 5, + maxMaxResults: 50, + timeoutMs: 10000, + cacheTTLMs: 180000 + } +}; diff --git a/open-sse/providers/registry/serper.js b/open-sse/providers/registry/serper.js new file mode 100644 index 00000000..b98d5af0 --- /dev/null +++ b/open-sse/providers/registry/serper.js @@ -0,0 +1,35 @@ +export default { + id: "serper", + alias: "serper", + display: { + name: "Serper", + icon: "search", + color: "#4F46E5", + textIcon: "SP", + website: "https://serper.dev", + notice: { + apiKeyUrl: "https://serper.dev/api-key" + } + }, + category: "apikey", + authType: "apikey", + serviceKinds: [ + "webSearch" + ], + searchConfig: { + baseUrl: "https://google.serper.dev", + method: "POST", + authType: "apikey", + authHeader: "x-api-key", + costPerQuery: 0.001, + freeMonthlyQuota: 2500, + searchTypes: [ + "web", + "news" + ], + defaultMaxResults: 5, + maxMaxResults: 100, + timeoutMs: 10000, + cacheTTLMs: 300000 + } +}; diff --git a/open-sse/providers/registry/siliconflow.js b/open-sse/providers/registry/siliconflow.js new file mode 100644 index 00000000..902cd814 --- /dev/null +++ b/open-sse/providers/registry/siliconflow.js @@ -0,0 +1,39 @@ +export default { + id: "siliconflow", + priority: 250, + alias: "siliconflow", + display: { + name: "SiliconFlow", + icon: "cloud_queue", + color: "#5B6EF5", + textIcon: "SF", + website: "https://cloud.siliconflow.com", + notice: { + apiKeyUrl: "https://cloud.siliconflow.com/account/ak", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://api.siliconflow.com/v1/chat/completions", + validateUrl: "https://api.siliconflow.com/v1/models", + thinkingFormat: "openai", + }, + 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" }, + { id: "Qwen/Qwen3.5-397B-A17B", name: "Qwen 3.5 397B A17B" }, + { id: "Qwen/Qwen3.5-122B-A10B", name: "Qwen 3.5 122B A10B" }, + { id: "zai-org/GLM-5.1", name: "GLM 5.1" }, + { id: "zai-org/GLM-5", name: "GLM 5" }, + { id: "moonshotai/Kimi-K2.6", name: "Kimi K2.6" }, + { id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5" }, + { 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" }, + ], +}; diff --git a/open-sse/providers/registry/stability-ai.js b/open-sse/providers/registry/stability-ai.js new file mode 100644 index 00000000..7b0368f0 --- /dev/null +++ b/open-sse/providers/registry/stability-ai.js @@ -0,0 +1,31 @@ +export default { + id: "stability-ai", + priority: 60, + alias: "stability-ai", + aliases: [ + "stability", + ], + uiAlias: "stability", + display: { + name: "Stability AI", + icon: "image", + color: "#8B5CF6", + textIcon: "SA", + website: "https://stability.ai", + notice: { + apiKeyUrl: "https://platform.stability.ai/account/keys", + }, + }, + category: "apikey", + authType: "apikey", + transport: null, + models: [ + { id: "stable-image-ultra", name: "Stable Image Ultra", params: ["size"], kind: "image" }, + { id: "stable-image-core", name: "Stable Image Core", params: ["size","style"], kind: "image" }, + { id: "sd3.5-large", name: "Stable Diffusion 3.5 Large", params: ["size"], kind: "image" }, + { id: "sd3.5-large-turbo", name: "Stable Diffusion 3.5 Large Turbo", params: ["size"], kind: "image" }, + { id: "sd3.5-medium", name: "Stable Diffusion 3.5 Medium", params: ["size"], kind: "image" }, + ], + serviceKinds: ["image"], + imageConfig: { baseUrl: "https://api.stability.ai/v2beta/stable-image/generate" }, +}; diff --git a/open-sse/providers/registry/tavily.js b/open-sse/providers/registry/tavily.js new file mode 100644 index 00000000..4386c973 --- /dev/null +++ b/open-sse/providers/registry/tavily.js @@ -0,0 +1,50 @@ +export default { + id: "tavily", + alias: "tavily", + display: { + name: "Tavily", + icon: "search", + color: "#5B21B6", + textIcon: "TV", + website: "https://tavily.com", + notice: { + apiKeyUrl: "https://app.tavily.com/home" + } + }, + category: "apikey", + authType: "apikey", + serviceKinds: [ + "webSearch", + "webFetch" + ], + searchConfig: { + baseUrl: "https://api.tavily.com/search", + method: "POST", + authType: "apikey", + authHeader: "bearer", + costPerQuery: 0.008, + freeMonthlyQuota: 1000, + searchTypes: [ + "web", + "news" + ], + defaultMaxResults: 5, + maxMaxResults: 20, + timeoutMs: 10000, + cacheTTLMs: 300000 + }, + fetchConfig: { + baseUrl: "https://api.tavily.com/extract", + method: "POST", + authType: "apikey", + authHeader: "bearer", + costPerQuery: 0.008, + freeMonthlyQuota: 1000, + formats: [ + "markdown", + "text" + ], + maxCharacters: 100000, + timeoutMs: 15000 + } +}; diff --git a/open-sse/providers/registry/together.js b/open-sse/providers/registry/together.js new file mode 100644 index 00000000..85b06f6c --- /dev/null +++ b/open-sse/providers/registry/together.js @@ -0,0 +1,31 @@ +export default { + id: "together", + priority: 60, + alias: "together", + display: { + name: "Together AI", + icon: "group_work", + color: "#0F6FFF", + textIcon: "TG", + website: "https://www.together.ai", + notice: { + apiKeyUrl: "https://api.together.xyz/settings/api-keys", + }, + }, + category: "apikey", + authType: "apikey", + transport: { + baseUrl: "https://api.together.xyz/v1/chat/completions", + validateUrl: "https://api.together.xyz/v1/models", + }, + models: [ + { 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", kind: "embedding" }, + { id: "togethercomputer/m2-bert-80M-8k-retrieval", name: "M2 BERT 80M 8K", kind: "embedding" }, + ], + serviceKinds: ["llm", "embedding"], + embeddingConfig: { baseUrl: "https://api.together.xyz/v1/embeddings" }, +}; diff --git a/open-sse/providers/registry/topaz.js b/open-sse/providers/registry/topaz.js new file mode 100644 index 00000000..1a4bb7a5 --- /dev/null +++ b/open-sse/providers/registry/topaz.js @@ -0,0 +1,19 @@ +export default { + id: "topaz", + alias: "topaz", + display: { + name: "Topaz", + icon: "image", + color: "#059669", + textIcon: "TP", + website: "https://topazlabs.com", + notice: { + apiKeyUrl: "https://topazlabs.com/account" + } + }, + category: "apikey", + authType: "apikey", + serviceKinds: [ + "image" + ] +}; diff --git a/open-sse/providers/registry/tortoise.js b/open-sse/providers/registry/tortoise.js new file mode 100644 index 00000000..4d3b87c1 --- /dev/null +++ b/open-sse/providers/registry/tortoise.js @@ -0,0 +1,30 @@ +export default { + id: "tortoise", + alias: "tortoise", + display: { + name: "Tortoise TTS", + icon: "record_voice_over", + color: "#7C3AED", + textIcon: "TT", + website: "https://github.com/neonbjb/tortoise-tts" + }, + category: "freeTier", + authType: "none", + serviceKinds: [ + "tts" + ], + noAuth: true, + ttsConfig: { + baseUrl: "http://localhost:5000/api/tts", + authType: "none", + authHeader: "none", + format: "tortoise", + models: [ + { + id: "tortoise-v2", + name: "Tortoise v2" + } + ] + }, + hidden: true +}; diff --git a/open-sse/providers/registry/venice.js b/open-sse/providers/registry/venice.js new file mode 100644 index 00000000..cd686461 --- /dev/null +++ b/open-sse/providers/registry/venice.js @@ -0,0 +1,56 @@ +export default { + id: "venice", + priority: 115, + alias: "venice", + aliases: [ + "vn", + ], + uiAlias: "venice", + display: { + name: "Venice AI", + icon: "shield", + color: "#DC2626", + textIcon: "VE", + website: "https://venice.ai", + notice: { + text: "OpenAI-compatible. Private inference + uncensored models (Venice Uncensored, GLM, Qwen, DeepSeek, Llama).", + apiKeyUrl: "https://venice.ai/settings/api", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://api.venice.ai/api/v1/chat/completions", + validateUrl: "https://api.venice.ai/api/v1/models", + thinkingFormat: "openai", + }, + // Curated seed; the full live catalogue (90+ text models) is fetched via + // modelsFetcher and any other id is accepted via passthroughModels. + models: [ + { id: "venice-uncensored-1-2", name: "Venice Uncensored 1.2" }, + { id: "zai-org-glm-5", name: "GLM-5" }, + { id: "qwen3-235b-a22b-instruct-2507", name: "Qwen3 235B A22B Instruct" }, + { id: "qwen3-coder-480b-a35b-instruct-turbo", name: "Qwen3 Coder 480B A35B Turbo" }, + { id: "qwen3-vl-235b-a22b", name: "Qwen3 VL 235B A22B" }, + { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" }, + { id: "llama-3.3-70b", name: "Llama 3.3 70B" }, + { id: "hermes-3-llama-3.1-405b", name: "Hermes 3 Llama 3.1 405B" }, + { id: "mistral-small-3-2-24b-instruct", name: "Mistral Small 3.2 24B" }, + { id: "text-embedding-3-large", name: "Text Embedding 3 Large", kind: "embedding" }, + { id: "text-embedding-bge-m3", name: "BGE-M3 Embedding", kind: "embedding" }, + { id: "text-embedding-qwen3-8b", name: "Qwen3 8B Embedding", kind: "embedding" }, + { id: "venice-sd35", name: "Venice SD3.5", params: ["n", "size"], kind: "image" }, + { id: "flux-2-pro", name: "FLUX.2 Pro", params: ["n", "size"], kind: "image" }, + { id: "gpt-image-2", name: "GPT Image 2 (via Venice)", params: ["n", "size", "quality"], kind: "image" }, + ], + serviceKinds: ["llm", "embedding", "image"], + embeddingConfig: { + baseUrl: "https://api.venice.ai/api/v1/embeddings", + authType: "apikey", + authHeader: "bearer", + }, + imageConfig: { + baseUrl: "https://api.venice.ai/api/v1/images/generations", + }, + modelsFetcher: { url: "https://api.venice.ai/api/v1/models", type: "openai" }, + passthroughModels: true, +}; diff --git a/open-sse/providers/registry/vercel-ai-gateway.js b/open-sse/providers/registry/vercel-ai-gateway.js new file mode 100644 index 00000000..798d94c0 --- /dev/null +++ b/open-sse/providers/registry/vercel-ai-gateway.js @@ -0,0 +1,41 @@ +export default { + id: "vercel-ai-gateway", + priority: 160, + alias: "vercel-ai-gateway", + aliases: [ + "vercel", + ], + uiAlias: "vercel", + display: { + name: "Vercel AI Gateway", + icon: "deployed_code", + color: "#111827", + textIcon: "VG", + website: "https://vercel.com/ai-gateway", + notice: { + text: "Unified OpenAI-compatible endpoint from Vercel. Use your AI Gateway API key, then pick models with provider/model IDs like anthropic/claude-sonnet-4.6 or openai/gpt-5.4.", + apiKeyUrl: "https://vercel.com/dashboard/~/ai-gateway", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://ai-gateway.vercel.sh/v1/chat/completions", + thinkingFormat: "openai", + retry: { + "429": 2, + }, + usage: { + url: "https://ai-gateway.vercel.sh/v1/credits", + }, + }, + serviceKinds: ["llm","embedding","image","imageToText","webSearch"], + embeddingConfig: { baseUrl: "https://ai-gateway.vercel.sh/v1/embeddings" }, + imageConfig: { baseUrl: "https://ai-gateway.vercel.sh/v1/images/generations" }, + searchViaChat: { defaultModel: "openai/gpt-4o-mini", pricingUrl: "https://vercel.com/docs/ai-gateway/pricing" }, + modelsFetcher: { url: "https://ai-gateway.vercel.sh/v1/models", type: "openai" }, + passthroughModels: true, + features: { + usage: true, + usageApikey: true, + }, +}; diff --git a/open-sse/providers/registry/vertex-partner.js b/open-sse/providers/registry/vertex-partner.js new file mode 100644 index 00000000..6e494946 --- /dev/null +++ b/open-sse/providers/registry/vertex-partner.js @@ -0,0 +1,29 @@ +export default { + id: "vertex-partner", + priority: 260, + alias: "vertex-partner", + aliases: [ + "vxp", + ], + uiAlias: "vxp", + display: { + name: "Vertex Partner", + icon: "cloud", + color: "#34A853", + textIcon: "VP", + website: "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-partner-models", + notice: { + apiKeyUrl: "https://console.cloud.google.com/iam-admin/serviceaccounts", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://aiplatform.googleapis.com", + }, + models: [ + { 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)" }, + ], +}; diff --git a/open-sse/providers/registry/vertex.js b/open-sse/providers/registry/vertex.js new file mode 100644 index 00000000..b8765de3 --- /dev/null +++ b/open-sse/providers/registry/vertex.js @@ -0,0 +1,32 @@ +export default { + id: "vertex", + priority: 40, + alias: "vertex", + aliases: [ + "vx", + ], + uiAlias: "vx", + display: { + name: "Vertex AI", + icon: "cloud", + color: "#4285F4", + textIcon: "VX", + website: "https://cloud.google.com/vertex-ai", + notice: { + text: "New Google Cloud accounts get $300 free credits. Requires GCP project + Service Account with Vertex AI API enabled.", + apiKeyUrl: "https://console.cloud.google.com/iam-admin/serviceaccounts", + }, + }, + category: "freeTier", + transport: { + baseUrl: "https://aiplatform.googleapis.com", + format: "vertex", + }, + models: [ + { 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" }, + ], + serviceKinds: ["llm","imageToText"], +}; diff --git a/open-sse/providers/registry/volcengine-ark.js b/open-sse/providers/registry/volcengine-ark.js new file mode 100644 index 00000000..16ad8b83 --- /dev/null +++ b/open-sse/providers/registry/volcengine-ark.js @@ -0,0 +1,35 @@ +export default { + id: "volcengine-ark", + priority: 270, + alias: "volcengine-ark", + aliases: [ + "ark", + ], + uiAlias: "ark", + display: { + name: "Volcengine Ark", + icon: "cloud", + color: "#1677FF", + textIcon: "ARK", + website: "https://ark.cn-beijing.volces.com", + notice: { + apiKeyUrl: "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions", + headers: {}, + }, + models: [ + { 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" }, + ], +}; diff --git a/open-sse/providers/registry/voyage-ai.js b/open-sse/providers/registry/voyage-ai.js new file mode 100644 index 00000000..b27a6d4e --- /dev/null +++ b/open-sse/providers/registry/voyage-ai.js @@ -0,0 +1,30 @@ +export default { + id: "voyage-ai", + priority: 40, + alias: "voyage-ai", + uiAlias: "voyage", + display: { + name: "Voyage AI", + icon: "data_array", + color: "#0EA5E9", + textIcon: "VG", + website: "https://www.voyageai.com", + notice: { + apiKeyUrl: "https://dash.voyageai.com/api-keys", + }, + }, + category: "apikey", + authType: "apikey", + transport: null, + models: [ + { id: "voyage-3-large", name: "Voyage 3 Large", kind: "embedding" }, + { id: "voyage-3.5", name: "Voyage 3.5", kind: "embedding" }, + { id: "voyage-3.5-lite", name: "Voyage 3.5 Lite", kind: "embedding" }, + { id: "voyage-code-3", name: "Voyage Code 3", kind: "embedding" }, + { id: "voyage-finance-2", name: "Voyage Finance 2", kind: "embedding" }, + { id: "voyage-law-2", name: "Voyage Law 2", kind: "embedding" }, + { id: "voyage-multilingual-2", name: "Voyage Multilingual 2", kind: "embedding" }, + ], + serviceKinds: ["embedding"], + embeddingConfig: { baseUrl: "https://api.voyageai.com/v1/embeddings" }, +}; diff --git a/open-sse/providers/registry/xai.js b/open-sse/providers/registry/xai.js new file mode 100644 index 00000000..efe13bdd --- /dev/null +++ b/open-sse/providers/registry/xai.js @@ -0,0 +1,43 @@ +export default { + id: "xai", + priority: 280, + alias: "xai", + display: { + name: "xAI (Grok)", + icon: "auto_awesome", + color: "#1DA1F2", + textIcon: "XA", + website: "https://x.ai", + notice: { + apiKeyUrl: "https://console.x.ai", + }, + }, + category: "oauth", + authModes: [ + "oauth", + "apikey", + ], + hasOAuth: true, + transport: { + baseUrl: "https://api.x.ai/v1/chat/completions", + validateUrl: "https://api.x.ai/v1/models", + responsesUrl: "https://api.x.ai/v1/responses", + clientId: "b1a00492-073a-47ea-816f-4c329264a828", + tokenUrl: "https://auth.x.ai/oauth2/token", + refreshUrl: "https://auth.x.ai/oauth2/token", + }, + models: [ + { 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", params: ["n","response_format"], kind: "image" }, + ], + serviceKinds: ["llm","imageToText","webSearch","image"], + imageConfig: { baseUrl: "https://api.x.ai/v1/images/generations", bodyFields: ["model","prompt","n","response_format"] }, + searchViaChat: { + defaultModel: "grok-4.20-reasoning", + endpoint: "https://api.x.ai/v1/responses", + pricingUrl: "https://x.ai/api#pricing", + }, +}; diff --git a/open-sse/providers/registry/xiaomi-mimo.js b/open-sse/providers/registry/xiaomi-mimo.js new file mode 100644 index 00000000..fcef7af8 --- /dev/null +++ b/open-sse/providers/registry/xiaomi-mimo.js @@ -0,0 +1,46 @@ +import { CLAUDE_API_HEADERS } from "../shared.js"; + +export default { + id: "xiaomi-mimo", + priority: 290, + alias: "xiaomi-mimo", + aliases: [ + "mimo", + ], + uiAlias: "mimo", + display: { + name: "Xiaomi MiMo", + icon: "smart_toy", + color: "#FF6900", + textIcon: "XM", + website: "https://xiaomimimo.com", + notice: { + apiKeyUrl: "https://xiaomimimo.com", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://api.xiaomimimo.com/v1/chat/completions", + validateUrl: "https://api.xiaomimimo.com/v1/models", + }, + // Multi-endpoint: pick the transport matching client sourceFormat to skip translation. + transports: [ + { + format: "openai", + baseUrl: "https://api.xiaomimimo.com/v1/chat/completions", + auth: { combined: true, header: "Authorization", scheme: "bearer" }, + }, + { + format: "claude", + baseUrl: "https://api.xiaomimimo.com/anthropic/v1/messages", + headers: { ...CLAUDE_API_HEADERS }, + auth: { combined: true, header: "x-api-key", scheme: "raw" }, + }, + ], + models: [ + { 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" }, + ], +}; diff --git a/open-sse/providers/registry/xiaomi-tokenplan.js b/open-sse/providers/registry/xiaomi-tokenplan.js new file mode 100644 index 00000000..55441434 --- /dev/null +++ b/open-sse/providers/registry/xiaomi-tokenplan.js @@ -0,0 +1,58 @@ +import { CLAUDE_API_HEADERS } from "../shared.js"; + +export default { + id: "xiaomi-tokenplan", + priority: 300, + alias: "xiaomi-tokenplan", + aliases: [ + "xmtp", + ], + uiAlias: "xmtp", + display: { + name: "Xiaomi MiMo (Token Plan)", + icon: "smart_toy", + color: "#FF6700", + textIcon: "XT", + website: "https://mimo.xiaomi.com", + notice: { + text: "Xiaomi MiMo Token Plan subscription (API key starts with tp-). Token Plan keys are cluster-specific — select the region matching your subscription.", + apiKeyUrl: "https://mimo.xiaomi.com", + }, + }, + category: "apikey", + hasProviderSpecificData: true, + defaultRegion: "sgp", + transport: { + baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1/chat/completions", + regions: { + sgp: "https://token-plan-sgp.xiaomimimo.com/v1", + cn: "https://token-plan-cn.xiaomimimo.com/v1", + ams: "https://token-plan-ams.xiaomimimo.com/v1", + }, + defaultRegion: "sgp", + }, + // Multi-endpoint: pick the transport matching client sourceFormat to skip translation. + // baseUrl omitted — region-dynamic, resolved in the executor's buildUrl. + transports: [ + { + format: "openai", + auth: { combined: true, header: "Authorization", scheme: "bearer" }, + }, + { + format: "claude", + headers: { ...CLAUDE_API_HEADERS }, + auth: { combined: true, header: "x-api-key", scheme: "raw" }, + }, + ], + models: [ + { 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" }, + ], +}; diff --git a/open-sse/providers/registry/youcom.js b/open-sse/providers/registry/youcom.js new file mode 100644 index 00000000..d090c638 --- /dev/null +++ b/open-sse/providers/registry/youcom.js @@ -0,0 +1,35 @@ +export default { + id: "youcom", + alias: "youcom", + display: { + name: "You.com Search", + icon: "search", + color: "#7C3AED", + textIcon: "YC", + website: "https://you.com", + notice: { + apiKeyUrl: "https://api.you.com" + } + }, + category: "apikey", + authType: "apikey", + serviceKinds: [ + "webSearch" + ], + searchConfig: { + baseUrl: "https://ydc-index.io/v1/search", + method: "GET", + authType: "apikey", + authHeader: "x-api-key", + costPerQuery: 0.005, + freeMonthlyQuota: 0, + searchTypes: [ + "web", + "news" + ], + defaultMaxResults: 5, + maxMaxResults: 100, + timeoutMs: 10000, + cacheTTLMs: 300000 + } +}; diff --git a/open-sse/providers/schema.js b/open-sse/providers/schema.js new file mode 100644 index 00000000..e8b5ddd1 --- /dev/null +++ b/open-sse/providers/schema.js @@ -0,0 +1,76 @@ +// Provider transport schema: shared defaults + endpoint defaults + resolver (skeleton, not wired) +import { DEFAULT_RETRY_CONFIG, FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js"; + +/** + * RegistryEntry shape — full contract for registry/{id}.js. See REGISTRY_TEMPLATE.js for a worked example. + * Only `id` + `category` are strictly required; everything else is optional/derived. + * + * @typedef {Object} RegistryEntry + * @property {string} id Unique provider id (kebab-case). REQUIRED. + * @property {string} [alias] Short key for PROVIDER_MODELS (defaults to id). + * @property {string[]}[aliases] Extra lookup tokens resolving to this provider. + * @property {string} [uiAlias] Token shown in UI badges. + * @property {string} category "apikey"|"oauth"|"freeTier"|... drives UI grouping. REQUIRED. + * @property {string} [authType] "apikey"|"oauth" auth hint. + * @property {string[]}[authModes] Allowed auth modes when provider supports both. + * @property {boolean} [hasOAuth] Provider exposes an OAuth flow. + * @property {boolean} [noAuth] Provider needs no credentials (local/free). + * @property {Object} [display] UI: {name,icon,color,textIcon,website,notice,deprecated,deprecationNotice,kindNotice,mediaPriority}. + * @property {Object} [transport] Runtime HTTP config (see TransportConfig below). Builds PROVIDERS[id]. + * @property {Object} [oauth] OAuth flow config (see OAuthConfig). Builds PROVIDER_OAUTH[id]. + * @property {Object} [media] Non-LLM services (see MediaConfig). Builds PROVIDER_MEDIA[id]. + * @property {Array} [models] Model list; omit = no model key, [] = explicit empty. + * @property {Object} [features] Feature flags, e.g. {usage:true}. + * @property {Object} [thinkingConfig] Reasoning UI: {options:[...],defaultMode}. + * @property {boolean} [passthroughModels] Forward client model id untouched. + * + * TransportConfig: { baseUrl, format, headers, auth, forceStream, urlSuffix, quirks, retry, timeoutMs, + * executor, clientId, clientSecret, tokenUrl, refreshUrl, usage, cliVersion, apiClient, regions, + * defaultRegion, modelsFetcher, validateUrl, responsesUrl } — clientId/clientSecret/tokenUrl are + * injected from `oauth` automatically (single source); declare them in `oauth`, not here. + * + * OAuthConfig: { clientId, authorizeUrl, tokenUrl, deviceCodeUrl, refreshUrl, scope|scopes, redirectUri, + * callbackPath, fixedPort, codeChallengeMethod, extraParams, refresh:{encoding,scope}, refreshLeadMs, + * userInfoUrl }. + * + * MediaConfig: { serviceKinds:[...], ttsConfig, sttConfig, embeddingConfig, imageConfig, + * searchViaChat:{defaultModel,pricingUrl}, hiddenKinds } — each *Config: {baseUrl,authType,authHeader, + * format,defaultModel,models:[{id,name,dimensions?}]}. + */ + +// Shared transport defaults — provider only overrides fields that differ. +// NOTE: runtime (index.js buildTransport) only re-applies `format`; the rest documents the contract +// and feeds the (currently unwired) resolveProvider(). Adding keys here does NOT change PROVIDERS. +export const PROVIDER_DEFAULTS = { + baseUrl: "", + format: "openai", + headers: {}, + auth: { header: "Authorization", scheme: "bearer", source: ["accessToken", "apiKey"] }, + forceStream: false, + urlSuffix: "", + quirks: {}, + passthroughModels: false, + retry: DEFAULT_RETRY_CONFIG, + timeoutMs: FETCH_CONNECT_TIMEOUT_MS, + executor: "default" +}; + +// Default endpoints per format (provider only overrides what differs) +export const ENDPOINT_DEFAULTS = { + openai: { chat: "/chat/completions", test: "/models", models: "/models" }, + claude: { chat: "/messages", test: "/models", countTokens: "/messages/count_tokens" }, + gemini: { chat: "/{model}:streamGenerateContent", models: "/models", test: "/models" } +}; + +// Deep-merge a provider entry over PROVIDER_DEFAULTS (defensive for missing transport) +export function resolveProvider(entry) { + const transport = (entry && entry.transport) || {}; + return { + ...PROVIDER_DEFAULTS, + ...transport, + headers: { ...PROVIDER_DEFAULTS.headers, ...transport.headers }, + auth: { ...PROVIDER_DEFAULTS.auth, ...transport.auth }, + quirks: { ...PROVIDER_DEFAULTS.quirks, ...transport.quirks }, + retry: { ...PROVIDER_DEFAULTS.retry, ...transport.retry } + }; +} diff --git a/open-sse/providers/shared.js b/open-sse/providers/shared.js new file mode 100644 index 00000000..32388584 --- /dev/null +++ b/open-sse/providers/shared.js @@ -0,0 +1,67 @@ +import { platform, arch } from "os"; + +// === OS/Arch helpers (Stainless fingerprint) === +export function mapStainlessOs() { + switch (platform()) { + case "darwin": return "MacOS"; + case "win32": return "Windows"; + case "linux": return "Linux"; + case "freebsd": return "FreeBSD"; + default: return `Other::${platform()}`; + } +} + +export function mapStainlessArch() { + switch (arch()) { + case "x64": return "x64"; + case "arm64": return "arm64"; + case "ia32": return "x86"; + default: return `other::${arch()}`; + } +} + +// Anthropic API version (single source — reused across claude-format providers/executors) +export const ANTHROPIC_API_VERSION = "2023-06-01"; + +// Shared Claude-compatible API headers (reused across claude-format providers) +export const CLAUDE_API_HEADERS = { + "Anthropic-Version": ANTHROPIC_API_VERSION, + "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) +export const CLAUDE_CLI_SPOOF_HEADERS = { + "Anthropic-Version": ANTHROPIC_API_VERSION, + "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 +export const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/v1/messages"; + +// Default base for dynamic compat providers (openai-compatible-* / anthropic-compatible-*) when user gives no baseUrl +export const OPENAI_COMPAT_BASE = "https://api.openai.com/v1"; +export const ANTHROPIC_COMPAT_BASE = "https://api.anthropic.com/v1"; + +// Antigravity OAuth client credentials (public CLI client — duplicated in usage.js + src/lib/oauth) +export const ANTIGRAVITY_OAUTH_CLIENT = { + clientId: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com", + clientSecret: "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" +}; + +// Gemini (Google) OAuth client credentials (public CLI client — shared by gemini, gemini-cli, src/lib/oauth) +export const GOOGLE_OAUTH_CLIENT = { + clientId: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com", + clientSecret: "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" +}; diff --git a/open-sse/rtk/caveman.js b/open-sse/rtk/caveman.js index 09cc8cfb..9c9a2065 100644 --- a/open-sse/rtk/caveman.js +++ b/open-sse/rtk/caveman.js @@ -1,100 +1,9 @@ // Caveman injector: appends a caveman-style instruction into the system message // of the final request body, just before it is dispatched to the provider executor. -// Dispatches by format so it works for both translated and native-passthrough flows. -import { FORMATS } from "../translator/formats.js"; +import { injectSystemPrompt } from "./systemInject.js"; import { CAVEMAN_PROMPTS } from "./cavemanPrompts.js"; -const SEP = "\n\n"; - export function injectCaveman(body, format, level) { - const prompt = CAVEMAN_PROMPTS[level]; - if (!body || !prompt) return; - - switch (format) { - case FORMATS.CLAUDE: - injectClaudeSystem(body, prompt); - return; - case FORMATS.GEMINI: - case FORMATS.GEMINI_CLI: - case FORMATS.VERTEX: - case FORMATS.ANTIGRAVITY: - // Antigravity wraps Gemini shape in body.request → injectGeminiSystem handles it - injectGeminiSystem(body, prompt); - return; - default: - // OpenAI and OpenAI-shaped formats (responses/codex/cursor/kiro/ollama) - injectMessagesSystem(body, prompt); - } -} - -// OpenAI-shaped: messages[] (chat) or input[] (responses) or instructions (responses string) -function injectMessagesSystem(body, prompt) { - // OpenAI Responses API: top-level string field - if (typeof body.instructions === "string") { - body.instructions = body.instructions - ? `${body.instructions}${SEP}${prompt}` - : prompt; - return; - } - - const arr = Array.isArray(body.messages) ? body.messages - : Array.isArray(body.input) ? body.input - : null; - if (!arr) return; - - const idx = arr.findIndex(m => m && (m.role === "system" || m.role === "developer")); - if (idx >= 0) { - appendToOpenAIMessage(arr[idx], prompt); - } else { - arr.unshift({ role: "system", content: prompt }); - } -} - -function appendToOpenAIMessage(msg, prompt) { - if (typeof msg.content === "string") { - msg.content = `${msg.content}${SEP}${prompt}`; - } else if (Array.isArray(msg.content)) { - // Responses-style array of parts {type:"input_text"|"text", text} - msg.content.push({ type: "input_text", text: prompt }); - } else { - msg.content = prompt; - } -} - -// Claude shape: body.system as string | array of {type:"text", text} -// Insert before the last cache_control block to keep caveman inside the cached prefix. -function injectClaudeSystem(body, prompt) { - if (typeof body.system === "string" && body.system.length > 0) { - body.system = `${body.system}${SEP}${prompt}`; - return; - } - if (Array.isArray(body.system)) { - const block = { type: "text", text: prompt }; - let lastCacheIdx = -1; - for (let i = body.system.length - 1; i >= 0; i--) { - if (body.system[i]?.cache_control) { lastCacheIdx = i; break; } - } - if (lastCacheIdx >= 0) { - body.system.splice(lastCacheIdx, 0, block); - } else { - body.system.push(block); - } - return; - } - body.system = prompt; -} - -// Gemini shape: body.system_instruction | body.systemInstruction | body.request.systemInstruction -// Each shape: { parts: [{ text }] } -function injectGeminiSystem(body, prompt) { - const target = body.request && typeof body.request === "object" ? body.request : body; - const useSnake = Object.prototype.hasOwnProperty.call(target, "system_instruction"); - const key = useSnake ? "system_instruction" : "systemInstruction"; - const sys = target[key]; - if (sys && Array.isArray(sys.parts)) { - sys.parts.push({ text: prompt }); - return; - } - target[key] = { parts: [{ text: prompt }] }; + injectSystemPrompt(body, format, CAVEMAN_PROMPTS[level]); } diff --git a/open-sse/rtk/constants.js b/open-sse/rtk/constants.js index b62740c1..752c2fee 100644 --- a/open-sse/rtk/constants.js +++ b/open-sse/rtk/constants.js @@ -19,8 +19,12 @@ export const STATUS_MAX_UNTRACKED = 10; // config::limits().status_max_un export const LS_EXT_SUMMARY_TOP = 5; // top-N extensions in summary export const LS_NOISE_DIRS = [ "node_modules", ".git", "target", "__pycache__", - ".next", "dist", "build", ".venv", "venv", - ".cache", ".idea", ".vscode", ".DS_Store" + ".next", "dist", "build", ".cache", ".turbo", + ".vercel", ".pytest_cache", ".mypy_cache", ".tox", + ".venv", "venv", + "env", // Python legacy virtualenv; .env (dotenv) intentionally excluded + "coverage", ".nyc_output", ".DS_Store", "Thumbs.db", + ".idea", ".vscode", ".vs", "*.egg-info", ".eggs" ]; // tree filter_tree_output cap (no rust cap, we add one to be safe) diff --git a/open-sse/rtk/filters/find.js b/open-sse/rtk/filters/find.js index 4f6ee239..5770a99b 100644 --- a/open-sse/rtk/filters/find.js +++ b/open-sse/rtk/filters/find.js @@ -31,16 +31,15 @@ export function find(input) { const showDirs = dirs.slice(0, FIND_TOTAL_DIR_MAX); for (const dir of showDirs) { const files = byDir.get(dir); - out += `${dir}/ (${files.length}):\n`; + out += `${dir}/ (${files.length})\n`; const showFiles = files.slice(0, FIND_PER_DIR_MAX); for (const f of showFiles) out += ` ${f}\n`; if (files.length > FIND_PER_DIR_MAX) { out += ` +${files.length - FIND_PER_DIR_MAX}\n`; } - out += "\n"; } if (dirs.length > FIND_TOTAL_DIR_MAX) { - out += `+${dirs.length - FIND_TOTAL_DIR_MAX} more dirs\n`; + out += `\n+${dirs.length - FIND_TOTAL_DIR_MAX} more dirs\n`; } return out; diff --git a/open-sse/rtk/headroom.js b/open-sse/rtk/headroom.js new file mode 100644 index 00000000..1dd7abea --- /dev/null +++ b/open-sse/rtk/headroom.js @@ -0,0 +1,203 @@ +import { claudeToOpenAIRequest } from "../translator/request/claude-to-openai.js"; +import { openaiToClaudeRequest } from "../translator/request/openai-to-claude.js"; +import { + openaiResponsesToOpenAIRequest, + openaiToOpenAIResponsesRequest, +} from "../translator/request/openai-responses.js"; + +const DEFAULT_TIMEOUT_MS = 3000; + +function jsonBytes(value) { + try { + return new TextEncoder().encode(JSON.stringify(value) || "").length; + } catch { + return 0; + } +} + +function messagePayload(body) { + if (Array.isArray(body?.messages)) return body.messages; + if (Array.isArray(body?.input)) return body.input; + return null; +} + +function captureSizeSnapshot(body) { + const messages = messagePayload(body); + return { + bodyBytes: jsonBytes(body), + messageBytes: messages ? jsonBytes(messages) : 0, + }; +} + +function setDiagnostic(diagnostics, reason) { + if (diagnostics && !diagnostics.reason) diagnostics.reason = reason; +} + +function scrubSensitiveUrlText(text) { + return String(text) + .replace(/\/\/[^/@\s]+@/g, "//") + .replace(/(https?:\/\/[^\s?#]+)[?#][^\s)]*/g, "$1"); +} + +function describeFetchError(error) { + const cause = error?.cause; + const code = cause?.code || error?.code; + const message = scrubSensitiveUrlText(cause?.message || error?.message || String(error)); + return code ? `${code}: ${message}` : message; +} + +function buildCompressEndpoint(url) { + try { + const parsed = new URL(url); + parsed.pathname = `${parsed.pathname.replace(/\/$/, "")}/v1/compress`; + parsed.hash = ""; + return parsed.toString(); + } catch { + const raw = String(url).replace(/#.*$/, ""); + const [base, query = ""] = raw.split("?", 2); + const endpoint = `${base.replace(/\/$/, "")}/v1/compress`; + return query ? `${endpoint}?${query}` : endpoint; + } +} + +function maskEndpoint(endpoint) { + try { + const parsed = new URL(endpoint); + parsed.username = ""; + parsed.password = ""; + parsed.search = ""; + parsed.hash = ""; + return parsed.toString(); + } catch { + return String(endpoint).replace(/\/\/[^/@\s]+@/, "//").replace(/[?#].*$/, ""); + } +} + +// POST messages to Headroom /v1/compress; returns compressed messages + stats or null. +async function callCompress(url, messages, model, timeoutMs, compressUserMessages, diagnostics) { + const endpoint = buildCompressEndpoint(url); + diagnostics.endpoint = maskEndpoint(endpoint); + const payload = { messages, model }; + if (compressUserMessages) payload.config = { compress_user_messages: true }; + let res; + try { + res = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(timeoutMs), + }); + } catch (error) { + setDiagnostic(diagnostics, `request failed: ${describeFetchError(error)}`); + return null; + } + if (!res.ok) { + setDiagnostic(diagnostics, `proxy returned HTTP ${res.status}`); + return null; + } + const data = await res.json(); + if (!Array.isArray(data?.messages)) { + setDiagnostic(diagnostics, "proxy response missing messages[]"); + return null; + } + return data; +} + +// Compress request body via Headroom proxy. Fail-open: returns null on any error. +// /v1/compress only understands OpenAI shape, so Claude bodies are translated +// to OpenAI, compressed, then translated back using 9Router's own translators. +export async function compressWithHeadroom(body, { enabled, url, model, format, compressUserMessages, timeoutMs = DEFAULT_TIMEOUT_MS, diagnostics = null } = {}) { + if (!enabled) { + setDiagnostic(diagnostics, "disabled"); + return null; + } + if (!url) { + setDiagnostic(diagnostics, "missing proxy URL"); + return null; + } + if (!body) { + setDiagnostic(diagnostics, "missing request body"); + return null; + } + + try { + if (diagnostics) diagnostics.before = captureSizeSnapshot(body); + + // Claude shape: translate → OpenAI → compress → translate back. + if (format === "claude") { + const oai = claudeToOpenAIRequest(model, body, false); + if (!Array.isArray(oai?.messages)) { + setDiagnostic(diagnostics, "Claude request did not translate to messages[]"); + return null; + } + const data = await callCompress(url, oai.messages, model, timeoutMs, compressUserMessages, diagnostics || {}); + if (!data) return null; + const claudeBody = openaiToClaudeRequest(model, { ...oai, messages: data.messages }, false); + if (Array.isArray(claudeBody?.messages)) body.messages = claudeBody.messages; + if (claudeBody?.system !== undefined) body.system = claudeBody.system; + if (diagnostics) diagnostics.after = captureSizeSnapshot(body); + return data; + } + + // OpenAI Responses shape (Codex): body.input holds Responses items, NOT OpenAI + // messages. Translate input -> OpenAI -> compress -> translate back to input so + // body.input keeps the Responses contract (the proxy only understands OpenAI). (#1998) + if (format === "openai-responses") { + const oai = openaiResponsesToOpenAIRequest(model, body, false); + if (!Array.isArray(oai?.messages)) return null; + const data = await callCompress(url, oai.messages, model, timeoutMs, compressUserMessages, diagnostics || {}); + if (!data) return null; + // input: undefined so the translator rebuilds input from the compressed + // messages instead of returning the original input unchanged. + const responsesBody = openaiToOpenAIResponsesRequest( + model, + { ...oai, input: undefined, messages: data.messages }, + false + ); + if (Array.isArray(responsesBody?.input)) body.input = responsesBody.input; + if (diagnostics) diagnostics.after = captureSizeSnapshot(body); + return data; + } + + // OpenAI shape: messages/input go straight to the proxy. + const key = Array.isArray(body.messages) ? "messages" + : Array.isArray(body.input) ? "input" + : null; + if (!key) { + setDiagnostic(diagnostics, `unsupported ${format || "unknown"} request shape`); + return null; + } + const data = await callCompress(url, body[key], model, timeoutMs, compressUserMessages, diagnostics || {}); + if (!data) return null; + body[key] = data.messages; + if (diagnostics) diagnostics.after = captureSizeSnapshot(body); + return data; + } catch (error) { + setDiagnostic(diagnostics, `unexpected error: ${error?.message || String(error)}`); + return null; + } +} + +export function formatHeadroomLog(stats) { + if (!stats) return null; + const before = stats.tokens_before || 0; + const after = stats.tokens_after || 0; + const delta = stats.tokens_saved || 0; + const pct = before > 0 ? ((delta / before) * 100).toFixed(1) : "0"; + return `reported token delta=${delta} before=${before}${after ? ` after=${after}` : ""} (${pct}%)`.trim(); +} + +export function formatHeadroomSizeLog(diagnostics) { + const before = diagnostics?.before; + const after = diagnostics?.after; + if (!before || !after) return ""; + return `body=${before.bodyBytes}B→${after.bodyBytes}B messages=${before.messageBytes}B→${after.messageBytes}B`; +} + +export function isHeadroomPhantomSavings(stats, diagnostics, minShrinkRatio = 0.05) { + if (!stats?.tokens_saved || stats.tokens_saved <= 0) return false; + const before = diagnostics?.before?.bodyBytes || 0; + const after = diagnostics?.after?.bodyBytes || 0; + if (before <= 0 || after <= 0) return false; + return after >= before * (1 - minShrinkRatio); +} diff --git a/open-sse/rtk/ponytail.js b/open-sse/rtk/ponytail.js new file mode 100644 index 00000000..1af6a1ab --- /dev/null +++ b/open-sse/rtk/ponytail.js @@ -0,0 +1,9 @@ +// Ponytail injector: appends the "lazy senior dev" instruction into the system +// message of the final request body, just before dispatch to the provider executor. + +import { injectSystemPrompt } from "./systemInject.js"; +import { PONYTAIL_PROMPTS } from "./ponytailPrompt.js"; + +export function injectPonytail(body, format, level) { + injectSystemPrompt(body, format, PONYTAIL_PROMPTS[level]); +} diff --git a/open-sse/rtk/ponytailPrompt.js b/open-sse/rtk/ponytailPrompt.js new file mode 100644 index 00000000..1de20663 --- /dev/null +++ b/open-sse/rtk/ponytailPrompt.js @@ -0,0 +1,52 @@ +// Ponytail intensity-level prompts injected into system message to bias toward minimal code. +// Adapted from ponytail skill (https://github.com/DietrichGebert/ponytail). + +export const PONYTAIL_LEVELS = { + LITE: "lite", + FULL: "full", + ULTRA: "ultra", +}; + +const SHARED_PERSONA = "You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written."; + +const SHARED_LADDER = "Before writing code, stop at the first rung that holds: 1) Does this need to exist at all? (YAGNI) 2) Stdlib does it? Use it. 3) Native platform feature covers it? Use it (CSS over JS, DB constraint over app code). 4) Already-installed dependency solves it? Use it; never add a new one for what a few lines can do. 5) Can it be one line? One line. 6) Only then: the minimum code that works."; + +const SHARED_RULES = "No unrequested abstractions (no interface with one implementation, no factory for one product, no config for a value that never changes). No boilerplate or scaffolding \"for later\". Deletion over addition. Boring over clever. Fewest files possible; shortest working diff wins. Two stdlib options the same size: take the edge-case-correct one. Mark deliberate simplifications with a `ponytail:` comment naming the ceiling and upgrade path."; + +const SHARED_OUTPUT = "Code first. Then at most three short lines: what was skipped, when to add it. No essays or design notes. Pattern: `[code] → skipped: [X], add when [Y].`"; + +const SHARED_NOT_LAZY = "Never simplify away: input validation at trust boundaries, error handling that prevents data loss, security, accessibility, anything explicitly requested. Non-trivial logic leaves ONE runnable check behind (an assert-based self-check or one small test file; no frameworks). Trivial one-liners need no test."; + +const SHARED_PERSISTENCE = "ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if unsure."; + +export const PONYTAIL_PROMPTS = { + [PONYTAIL_LEVELS.LITE]: [ + SHARED_PERSONA, + "Lite: build what's asked, but name the lazier alternative in one line. User picks.", + SHARED_LADDER, + SHARED_RULES, + SHARED_OUTPUT, + SHARED_NOT_LAZY, + SHARED_PERSISTENCE, + ].join(" "), + + [PONYTAIL_LEVELS.FULL]: [ + SHARED_PERSONA, + "Full: the ladder enforced. Stdlib and native first. Shortest diff, shortest explanation.", + SHARED_LADDER, + SHARED_RULES, + SHARED_OUTPUT, + SHARED_NOT_LAZY, + SHARED_PERSISTENCE, + ].join(" "), + + [PONYTAIL_LEVELS.ULTRA]: [ + SHARED_PERSONA, + "Ultra: YAGNI extremist. Deletion before addition. Ship the one-liner and challenge the rest of the requirement in the same response.", + SHARED_LADDER, + SHARED_RULES, + SHARED_OUTPUT, + SHARED_NOT_LAZY, + SHARED_PERSISTENCE, + ].join(" "), +}; diff --git a/open-sse/rtk/systemInject.js b/open-sse/rtk/systemInject.js new file mode 100644 index 00000000..0d5af728 --- /dev/null +++ b/open-sse/rtk/systemInject.js @@ -0,0 +1,98 @@ +// Shared system-prompt injector: appends an instruction into the system message of +// the final request body, dispatching by format so it works for translated and +// native-passthrough flows. Used by caveman.js and ponytail.js. + +import { FORMATS } from "../translator/formats.js"; + +const SEP = "\n\n"; + +export function injectSystemPrompt(body, format, prompt) { + if (!body || !prompt) return; + + switch (format) { + case FORMATS.CLAUDE: + injectClaudeSystem(body, prompt); + return; + case FORMATS.GEMINI: + case FORMATS.GEMINI_CLI: + case FORMATS.VERTEX: + case FORMATS.ANTIGRAVITY: + // Antigravity wraps Gemini shape in body.request → injectGeminiSystem handles it + injectGeminiSystem(body, prompt); + return; + default: + // OpenAI and OpenAI-shaped formats (responses/codex/cursor/kiro/ollama) + injectMessagesSystem(body, prompt); + } +} + +// OpenAI-shaped: messages[] (chat) or input[] (responses) or instructions (responses string) +function injectMessagesSystem(body, prompt) { + // OpenAI Responses API: top-level string field + if (typeof body.instructions === "string") { + body.instructions = body.instructions + ? `${body.instructions}${SEP}${prompt}` + : prompt; + return; + } + + const arr = Array.isArray(body.messages) ? body.messages + : Array.isArray(body.input) ? body.input + : null; + if (!arr) return; + + const idx = arr.findIndex(m => m && (m.role === "system" || m.role === "developer")); + if (idx >= 0) { + appendToOpenAIMessage(arr[idx], prompt); + } else { + arr.unshift({ role: "system", content: prompt }); + } +} + +function appendToOpenAIMessage(msg, prompt) { + if (typeof msg.content === "string") { + msg.content = `${msg.content}${SEP}${prompt}`; + } else if (Array.isArray(msg.content)) { + // Responses-style array of parts {type:"input_text"|"text", text} + msg.content.push({ type: "input_text", text: prompt }); + } else { + msg.content = prompt; + } +} + +// Claude shape: body.system as string | array of {type:"text", text} +// Insert before the last cache_control block to keep injection inside the cached prefix. +function injectClaudeSystem(body, prompt) { + if (typeof body.system === "string" && body.system.length > 0) { + body.system = `${body.system}${SEP}${prompt}`; + return; + } + if (Array.isArray(body.system)) { + const block = { type: "text", text: prompt }; + let lastCacheIdx = -1; + for (let i = body.system.length - 1; i >= 0; i--) { + if (body.system[i]?.cache_control) { lastCacheIdx = i; break; } + } + if (lastCacheIdx >= 0) { + body.system.splice(lastCacheIdx, 0, block); + } else { + body.system.push(block); + } + return; + } + body.system = prompt; +} + +// Gemini shape: body.system_instruction | body.systemInstruction | body.request.systemInstruction +// Each shape: { parts: [{ text }] } +function injectGeminiSystem(body, prompt) { + const target = body.request && typeof body.request === "object" ? body.request : body; + const useSnake = Object.prototype.hasOwnProperty.call(target, "system_instruction"); + const key = useSnake ? "system_instruction" : "systemInstruction"; + const sys = target[key]; + if (sys && Array.isArray(sys.parts)) { + sys.parts.push({ text: prompt }); + return; + } + target[key] = { parts: [{ text: prompt }] }; +} diff --git a/open-sse/services/combo.js b/open-sse/services/combo.js index 6a4b17df..9216ab2f 100644 --- a/open-sse/services/combo.js +++ b/open-sse/services/combo.js @@ -4,6 +4,82 @@ import { checkFallbackError, formatRetryAfter } from "./accountFallback.js"; import { unavailableResponse } from "../utils/error.js"; +import { getCapabilitiesForModel } from "../providers/capabilities.js"; +import { extractTextContent } from "../translator/formats/gemini.js"; + +// Hard capabilities = input modalities; missing one drops request data (e.g. image +// stripped). Must be prioritized. Soft (e.g. search) only degrades a feature. +const HARD_CAPS = new Set(["vision", "pdf", "audioInput", "videoInput"]); + +// Prefixes used when flattening tool turns into plain prose for panel models. +const TOOL_CALL_PREFIX = "[Called tools: "; +const TOOL_RESULT_PREFIX = "[Tool result: "; + +// Flatten tool turns into prose so panel models keep the context but can't loop +// on tools: drop the request's tools, turn tool/function results into assistant +// text, and inline assistant tool_calls names instead of the structured field. +function flattenToolHistory(messages) { + return messages + .filter((msg) => msg) + .map((msg) => { + if (msg.role === "tool" || msg.role === "function") { + return { role: "assistant", content: `${TOOL_RESULT_PREFIX}${extractTextContent(msg.content) || String(msg.content ?? "")}]` }; + } + if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) { + const { tool_calls, ...rest } = msg; + const names = tool_calls.map((c) => c?.function?.name || c?.name || "tool").join(", "); + const base = extractTextContent(rest.content) || (typeof rest.content === "string" ? rest.content : ""); + return { ...rest, content: `${base}${base ? "\n" : ""}${TOOL_CALL_PREFIX}${names}]` }; + } + if (Array.isArray(msg.content)) { + const hasToolUse = msg.content.some((c) => c.type === "tool_use"); + const hasToolResult = msg.content.some((c) => c.type === "tool_result"); + if (hasToolUse || hasToolResult) { + const textParts = []; + const toolNames = []; + const toolResults = []; + for (const block of msg.content) { + if (block.type === "text" && block.text) textParts.push(block.text); + if (block.type === "tool_use") toolNames.push(block.name || "tool"); + if (block.type === "tool_result") toolResults.push(extractTextContent(block.content) || String(block.content ?? "")); + } + const { ...rest } = msg; + let newContent = textParts.join("\n"); + if (toolNames.length > 0) { + newContent = `${newContent}${newContent ? "\n" : ""}${TOOL_CALL_PREFIX}${toolNames.join(", ")}]`; + } + if (toolResults.length > 0) { + newContent = `${newContent}${newContent ? "\n" : ""}${TOOL_RESULT_PREFIX}${toolResults.join("\n")}]`; + } + return { ...rest, content: newContent }; + } + } + return msg; + }); +} + +// Reorder combo models by capability fit. Stable; never drops a model (fallback intact). +// Tier 0: satisfies all hard + all soft. Tier 1: all hard only. Tier 2: rest. +export function reorderByCapabilities(models, required) { + if (!required || required.size === 0 || !Array.isArray(models) || models.length <= 1) return models; + const hard = [...required].filter((c) => HARD_CAPS.has(c)); + const soft = [...required].filter((c) => !HARD_CAPS.has(c)); + + const tierOf = (m) => { + const slash = typeof m === "string" ? m.indexOf("/") : -1; + const provider = slash > 0 ? m.slice(0, slash) : ""; + const model = slash > 0 ? m.slice(slash + 1) : m; + const caps = getCapabilitiesForModel(provider, model); + if (!hard.every((c) => caps[c] === true)) return 2; + return soft.every((c) => caps[c] === true) ? 0 : 1; + }; + + // Stable sort by tier (Array.prototype.sort is stable in modern engines). + return models + .map((m, i) => ({ m, i, t: tierOf(m) })) + .sort((a, b) => a.t - b.t || a.i - b.i) + .map((x) => x.m); +} /** * Track rotation state per combo (for round-robin strategy) @@ -11,6 +87,51 @@ import { unavailableResponse } from "../utils/error.js"; */ const comboRotationState = new Map(); +// Trailing run of items after the last assistant/model turn = the current user +// turn. It may span several messages (e.g. text + image split across blocks), +// so we return all of them. History media (older turns) must not pin the combo +// to a vision model — those get stripped + placeholdered downstream instead. +function trailingUserItems(arr) { + if (!Array.isArray(arr) || arr.length === 0) return []; + const isAssistant = (r) => r === "assistant" || r === "model"; + let i = arr.length - 1; + while (i >= 0 && !isAssistant(arr[i]?.role)) i--; + return arr.slice(i + 1); +} + +// Detect which capabilities a request needs. Modalities (vision/pdf) are scanned +// only on the current user turn; "search" is request-wide (lives in tools). +// Returns a Set of: "vision" | "pdf" | "search". +export function detectRequiredCapabilities(body) { + const required = new Set(); + if (!body || typeof body !== "object") return required; + + const scanBlock = (b) => { + if (!b || typeof b !== "object") return; + const t = b.type; + if (t === "image_url" || t === "image" || t === "input_image") required.add("vision"); + if (t === "file" || t === "document" || t === "input_file") required.add("pdf"); + // gemini parts: inlineData/fileData carry a mime + const mime = b.inlineData?.mimeType || b.fileData?.mimeType; + if (typeof mime === "string" && mime.startsWith("image/")) required.add("vision"); + if (mime === "application/pdf") required.add("pdf"); + }; + + const scanContent = (content) => { + if (Array.isArray(content)) for (const b of content) scanBlock(b); + }; + + // Modalities: current user turn only (trailing user run across each known shape). + for (const m of trailingUserItems(body.messages)) scanContent(m.content); // openai / claude + for (const it of trailingUserItems(body.input)) scanContent(it.content); // responses + const contents = body.contents || body.request?.contents; // gemini / antigravity + for (const c of trailingUserItems(contents)) scanContent(c.parts); + + // search: temporarily disabled in auto-switch (feature not wired yet). + + return required; +} + function normalizeStickyLimit(stickyLimit) { const parsed = Number.parseInt(stickyLimit, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : 1; @@ -105,9 +226,21 @@ export function getComboModelsFromData(modelStr, combosData) { * @param {number|string} [options.comboStickyLimit=1] - Requests per combo model before switching * @returns {Promise} */ -export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboStrategy, comboStickyLimit = 1 }) { +export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboStrategy, comboStickyLimit = 1, autoSwitch = true }) { // Apply rotation strategy if enabled - const rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit); + let rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit); + + // Auto-switch: float models that satisfy the request's required capabilities to the front. + if (autoSwitch) { + const required = detectRequiredCapabilities(body); + if (required.size > 0) { + const reordered = reorderByCapabilities(rotatedModels, required); + if (reordered[0] !== rotatedModels[0]) { + log.info("COMBO", `auto-switch for [${[...required].join(",")}] → ${reordered[0]}`); + } + rotatedModels = reordered; + } + } let lastError = null; let earliestRetryAfter = null; @@ -196,3 +329,243 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co { status, headers: { "Content-Type": "application/json" } } ); } + +/** + * Extract assistant text from a non-stream completion across formats + * (OpenAI chat, Claude messages, Gemini, OpenAI Responses). Returns "" if none. + * Panel responses are already translated to the client format by chatCore, so the + * leaf content→string step reuses the translator's own extractTextContent. + */ +function extractPanelText(json) { + if (!json || typeof json !== "object") return ""; + + // OpenAI chat completion + const choice = json.choices?.[0]; + if (choice) { + const msg = choice.message ?? choice.delta ?? {}; + const t = extractTextContent(msg.content); + if (t.trim()) return t; + if (typeof choice.text === "string" && choice.text.trim()) return choice.text; + } + + // Claude messages (text blocks share OpenAI's {type:"text"} shape) + const claudeText = extractTextContent(json.content); + if (claudeText.trim()) return claudeText; + + // Gemini (parts carry .text without a type discriminator) + const parts = json.candidates?.[0]?.content?.parts; + if (Array.isArray(parts)) { + const t = parts.map((p) => p?.text || "").join(""); + if (t.trim()) return t; + } + + // OpenAI Responses API + if (Array.isArray(json.output)) { + const t = json.output + .flatMap((o) => (Array.isArray(o.content) ? o.content.map((c) => c?.text || "") : [])) + .join(""); + if (t.trim()) return t; + } + + return ""; +} + +/** + * Append a synthesized user turn to whichever message array the request format uses. + * Preserves the original conversation + system prompt so the judge has full context. + */ +function appendUserTurn(body, text) { + const next = { ...body }; + if (Array.isArray(body.messages)) { + next.messages = [...body.messages, { role: "user", content: text }]; + } else if (Array.isArray(body.input)) { + next.input = [...body.input, { role: "user", content: text }]; + } else if (Array.isArray(body.contents)) { + next.contents = [...body.contents, { role: "user", parts: [{ text }] }]; + } else { + next.messages = [{ role: "user", content: text }]; + } + return next; +} + +/** + * Build the judge directive. Per OpenRouter's Fusion design, the judge does NOT + * merge — it analyzes (consensus / contradictions / partial coverage / unique + * insights / blind spots) then writes one answer grounded in that analysis. + * ~3/4 of fusion's quality lift comes from this synthesis step. + * + * Sources are anonymized ("Source N") so the judge weighs substance, not the + * reputation of a model brand. + */ +function buildJudgePrompt(answers) { + const panel = answers + .map((a, i) => `[Source ${i + 1}]\n${a.text}`) + .join("\n\n"); + + return [ + `You are the JUDGE in a model-fusion panel. ${answers.length} expert models independently answered the user's most recent request. Their responses are below, anonymized by source.`, + "", + "Do NOT mention that multiple models were used, and do NOT refer to the sources. Produce ONE authoritative final answer addressed directly to the user.", + "", + "First, internally analyze the panel along these dimensions: consensus (points most sources agree on — treat as higher-confidence), contradictions (where they disagree — resolve with your own judgment), partial coverage, unique insights only one source surfaced, and blind spots every source missed. Then write the best possible final answer grounded in that analysis — more complete and correct than any single response, with no filler.", + "", + "=== PANEL RESPONSES ===", + panel, + "=== END PANEL RESPONSES ===", + "", + "Now write the final answer to the user's original request.", + ].join("\n"); +} + +// Fusion tuning. Overridable per-combo via settings.comboStrategies[name]. +const FUSION_DEFAULTS = { + minPanel: 2, // answers needed before stragglers get a grace window + stragglerGraceMs: 8000, // wait this long for laggards once quorum is reached + panelHardTimeoutMs: 90000, // absolute cap so one hung model can't stall forever +}; + +// Resolve a Response (or {__error}) within ms; the loser keeps running but is ignored. +function withTimeout(promise, ms) { + return new Promise((resolve) => { + const t = setTimeout(() => resolve({ __timeout: true }), ms); + Promise.resolve(promise) + .then((v) => { clearTimeout(t); resolve(v); }) + .catch((e) => { clearTimeout(t); resolve({ __error: e }); }); + }); +} + +/** + * Collect panel responses with quorum-grace: as soon as `minPanel` calls succeed, + * start a short grace timer for the rest, then proceed with whatever arrived. This + * caps the straggler penalty (the slowest model otherwise dominates wall time) while + * still preferring a full panel when everyone is fast. Bounded by a hard timeout. + * Returns a sparse array aligned to `calls` (undefined = not yet / dropped). + */ +function collectPanel(calls, { minPanel, stragglerGraceMs, panelHardTimeoutMs }) { + return new Promise((resolve) => { + const out = new Array(calls.length); + let settled = 0; + let ok = 0; + let finished = false; + let graceTimer = null; + const finish = () => { + if (finished) return; + finished = true; + clearTimeout(hardTimer); + if (graceTimer) clearTimeout(graceTimer); + resolve(out); + }; + const hardTimer = setTimeout(finish, panelHardTimeoutMs); + calls.forEach((p, i) => { + Promise.resolve(p) + .then((v) => { out[i] = v; }) + .catch((e) => { out[i] = { __error: e }; }) + .finally(() => { + settled++; + if (out[i] && out[i].ok) ok++; + if (settled === calls.length) return finish(); + if (ok >= minPanel && !graceTimer) graceTimer = setTimeout(finish, stragglerGraceMs); + }); + }); + }); +} + +/** + * Handle a fusion combo: fan the prompt out to every panel model in parallel, + * then a judge model synthesizes one final answer from all panel responses. + * + * Panel calls are forced non-streaming with tools stripped (the judge needs + * complete prose to synthesize). The judge call keeps the client's original + * stream flag + tools, so streaming and downstream tool use still work. + * + * Speed: quorum-grace collection caps the straggler penalty. Quality: the judge + * runs the consensus/contradiction/blind-spot analysis before writing. + * + * Degrades gracefully: 0 panel answers -> 503, exactly 1 -> return it directly. + * + * @param {Object} options + * @param {Object} options.body - Request body (client format) + * @param {string[]} options.models - Panel model strings + * @param {Function} options.handleSingleModel - (body, modelStr) => Promise + * @param {Object} options.log - Logger + * @param {string} [options.comboName] - Combo name (logging) + * @param {string} [options.judgeModel] - Judge model; falls back to panel[0] + * @param {Object} [options.tuning] - Override FUSION_DEFAULTS (minPanel, grace, timeout) + * @returns {Promise} + */ +export async function handleFusionChat({ body, models, handleSingleModel, log, comboName, judgeModel, tuning }) { + const panel = Array.isArray(models) ? models.filter(Boolean) : []; + if (panel.length === 0) { + return new Response( + JSON.stringify({ error: { message: "Fusion combo has no models" } }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + + // A single-model fusion has nothing to fuse — just answer directly. + if (panel.length === 1) { + return handleSingleModel(body, panel[0]); + } + + const cfg = { ...FUSION_DEFAULTS, ...(tuning || {}) }; + const minPanel = Math.min(Math.max(2, cfg.minPanel), panel.length); + const judge = judgeModel && judgeModel.trim() ? judgeModel.trim() : panel[0]; + log.info("FUSION", `Combo "${comboName}" | panel=${panel.length} [${panel.join(", ")}] | judge=${judge} | quorum=${minPanel}`); + + // 1. Fan out to the panel in parallel: non-streaming, tools stripped (we want prose). + const { tools, tool_choice, ...rest } = body; + const panelBody = { ...rest, stream: false }; + + // Flatten tool turns to prose so panel models keep context without emitting tool_calls. + if (Array.isArray(panelBody.messages)) { + panelBody.messages = flattenToolHistory(panelBody.messages); + } else if (Array.isArray(panelBody.input)) { + panelBody.input = flattenToolHistory(panelBody.input); + } + + const t0 = Date.now(); + const calls = panel.map((m) => withTimeout(handleSingleModel(panelBody, m, true), cfg.panelHardTimeoutMs)); + const settled = await collectPanel(calls, { ...cfg, minPanel }); + log.info("FUSION", `fan-out collected in ${Date.now() - t0}ms`); + + // 2. Collect successful answers. + const answers = []; + for (let i = 0; i < settled.length; i++) { + const res = settled[i]; + const model = panel[i]; + if (!res) { log.warn("FUSION", `Panel ${model} dropped (straggler/timeout)`); continue; } + if (res.__timeout) { log.warn("FUSION", `Panel ${model} timed out`); continue; } + if (res.__error) { log.warn("FUSION", `Panel ${model} threw`, { error: res.__error?.message || String(res.__error) }); continue; } + if (!res.ok) { log.warn("FUSION", `Panel ${model} failed`, { status: res.status }); continue; } + try { + const json = await res.clone().json(); + const text = extractPanelText(json); + if (text) { + answers.push({ model, text }); + log.info("FUSION", `Panel ${model} ok (${text.length} chars)`); + } else { + log.warn("FUSION", `Panel ${model} returned empty content`); + } + } catch (e) { + log.warn("FUSION", `Panel ${model} unparseable`, { error: e.message || String(e) }); + } + } + + // 3. Degrade gracefully when the panel is too thin to fuse. + if (answers.length === 0) { + log.warn("FUSION", "All panel models failed"); + return new Response( + JSON.stringify({ error: { message: "All fusion panel models failed" } }), + { status: 503, headers: { "Content-Type": "application/json" } } + ); + } + if (answers.length === 1) { + log.info("FUSION", `Only ${answers[0].model} succeeded — answering directly (no fusion)`); + return handleSingleModel(body, answers[0].model); + } + + // 4. Judge analyzes + writes one final answer (streams to client if requested). + const judgeBody = appendUserTurn(body, buildJudgePrompt(answers)); + log.info("FUSION", `Judging ${answers.length} answers with ${judge}`); + return handleSingleModel(judgeBody, judge); +} diff --git a/open-sse/services/copilotModels.js b/open-sse/services/copilotModels.js new file mode 100644 index 00000000..940f8ade --- /dev/null +++ b/open-sse/services/copilotModels.js @@ -0,0 +1,155 @@ +/** + * GitHub Copilot model catalog fetcher. + * + * Calls Copilot's `GET /models` endpoint to get the live catalog for an + * authenticated account, so `/v1/models` reflects what the account can + * actually use (e.g. newly shipped `claude-opus-4.8`, `gpt-5.5`) instead of + * the hand-maintained static registry, which inevitably lags behind. + * + * Returns chat-capable models the account's policy allows. Embeddings and + * disabled models are filtered out. + */ + +import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { GITHUB_COPILOT } from "../config/appConstants.js"; +import { refreshCopilotToken } from "./tokenRefresh.js"; + +const MODELS_URL = "https://api.githubcopilot.com/models"; +const FETCH_TIMEOUT_MS = 10_000; +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes per credential + +/** @type {Map} */ +const catalogCache = new Map(); + +function cacheKey(credentials) { + return credentials?.providerSpecificData?.copilotToken + || credentials?.accessToken + || "copilot-anonymous"; +} + +function buildHeaders(token) { + return { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + "Copilot-Integration-Id": "vscode-chat", + "editor-version": `vscode/${GITHUB_COPILOT.VSCODE_VERSION}`, + "editor-plugin-version": `copilot-chat/${GITHUB_COPILOT.COPILOT_CHAT_VERSION}`, + "user-agent": GITHUB_COPILOT.USER_AGENT, + "x-github-api-version": GITHUB_COPILOT.API_VERSION, + }; +} + +async function fetchCatalogRaw(token, signal) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const response = await proxyAwareFetch(MODELS_URL, { + method: "GET", + headers: buildHeaders(token), + cache: "no-store", + signal: signal || controller.signal, + }); + if (!response.ok) { + const err = new Error(`Copilot /models returned ${response.status}`); + err.status = response.status; + throw err; + } + const data = await response.json(); + return Array.isArray(data?.data) ? data.data : []; + } finally { + clearTimeout(timeoutId); + } +} + +// Keep only chat models the account is allowed to use. The static registry +// surfaced disabled/embedding entries inconsistently; here we trust upstream. +function expandCatalog(raw) { + const seen = new Set(); + const models = []; + for (const m of raw) { + if (!m || typeof m !== "object") continue; + if (m.capabilities?.type !== "chat") continue; + if (m.policy && m.policy.state !== "enabled") continue; + const id = m.id; + if (!id || seen.has(id)) continue; + seen.add(id); + models.push({ id, name: m.name || id }); + } + return models; +} + +/** + * Resolve the live Copilot model catalog for a connection. + * + * @param {object} credentials Connection record (accessToken, refreshToken, + * providerSpecificData {copilotToken, copilotTokenExpiresAt}). + * @param {object} [options] + * @param {boolean} [options.forceRefresh] Bypass the per-credential cache. + * @param {object} [options.log] Logger. + * @param {function} [options.onCredentialsRefreshed] Persist a refreshed + * Copilot token back to your store. Called with `{ copilotToken, + * copilotTokenExpiresAt }` whenever a 401 triggers a refresh. + * @returns {Promise<{ models: object[] } | null>} + */ +export async function resolveCopilotModels(credentials, options = {}) { + const token = credentials?.providerSpecificData?.copilotToken || credentials?.accessToken; + if (!token) { + options.log?.debug?.("COPILOT_MODELS", "No copilotToken/accessToken; skipping live fetch"); + return null; + } + + const key = cacheKey(credentials); + const now = Date.now(); + if (!options.forceRefresh) { + const cached = catalogCache.get(key); + if (cached && cached.expiresAt > now) { + return { models: cached.models }; + } + } + + let raw; + try { + raw = await fetchCatalogRaw(token, options.signal); + } catch (err) { + // A 401/403 means the Copilot token is stale — refresh from the GitHub + // access token and retry once. + if (err && (err.status === 401 || err.status === 403) && credentials.accessToken) { + options.log?.info?.("COPILOT_MODELS", `Got ${err.status}; refreshing Copilot token`); + const refreshed = await refreshCopilotToken(credentials.accessToken); + if (refreshed?.token) { + if (typeof options.onCredentialsRefreshed === "function") { + try { + await options.onCredentialsRefreshed({ + copilotToken: refreshed.token, + copilotTokenExpiresAt: refreshed.expiresAt, + }); + } catch (e) { + options.log?.warn?.("COPILOT_MODELS", `onCredentialsRefreshed failed: ${e?.message || e}`); + } + } + try { + raw = await fetchCatalogRaw(refreshed.token, options.signal); + } catch (err2) { + options.log?.warn?.("COPILOT_MODELS", `Retry after refresh failed: ${err2?.message || err2}`); + return null; + } + } else { + options.log?.warn?.("COPILOT_MODELS", "Token refresh did not return a token"); + return null; + } + } else { + options.log?.warn?.("COPILOT_MODELS", `Live model fetch failed: ${err?.message || err}`); + return null; + } + } + + const models = expandCatalog(raw); + if (!models.length) return null; + + catalogCache.set(key, { expiresAt: now + CACHE_TTL_MS, models }); + return { models }; +} + +export function clearCopilotModelCache() { + catalogCache.clear(); +} diff --git a/open-sse/services/model.js b/open-sse/services/model.js index d2dd3c81..6558d707 100644 --- a/open-sse/services/model.js +++ b/open-sse/services/model.js @@ -1,147 +1,22 @@ -// Provider alias to ID mapping -const ALIAS_TO_PROVIDER_ID = { - cc: "claude", - cx: "codex", - gc: "gemini-cli", - qw: "qwen", - if: "iflow", - ag: "antigravity", - gh: "github", - kr: "kiro", - cu: "cursor", - kc: "kilocode", - kmc: "kimi-coding", - cl: "cline", - oc: "opencode", - ocg: "opencode-go", - qd: "qoder", - qoder: "qoder", - // TTS providers +import REGISTRY from "../providers/registry/index.js"; + +// Alias→id derived from registry single-source: id→id, alias→id, aliases[]→id. +// Media-only providers without a registry transport entry keep explicit aliases here. +const MEDIA_ONLY_ALIASES = { el: "elevenlabs", - // API Key providers - openai: "openai", - vercel: "vercel-ai-gateway", - "vercel-ai-gateway": "vercel-ai-gateway", - anthropic: "anthropic", - gemini: "gemini", - openrouter: "openrouter", - glm: "glm", - kimi: "kimi", - minimax: "minimax", - "minimax-cn": "minimax-cn", - hf: "huggingface", - huggingface: "huggingface", - ds: "deepseek", - deepseek: "deepseek", - cmc: "commandcode", - commandcode: "commandcode", - groq: "groq", - xai: "xai", - mistral: "mistral", - pplx: "perplexity", - perplexity: "perplexity", - together: "together", - fireworks: "fireworks", - cerebras: "cerebras", - cohere: "cohere", - nvidia: "nvidia", - nebius: "nebius", - siliconflow: "siliconflow", - hyp: "hyperbolic", - hyperbolic: "hyperbolic", - dg: "deepgram", - deepgram: "deepgram", - aai: "assemblyai", - assemblyai: "assemblyai", - nb: "nanobanana", - nanobanana: "nanobanana", - ch: "chutes", - chutes: "chutes", - ark: "volcengine-ark", - "volcengine-ark": "volcengine-ark", - byteplus: "byteplus", - bpm: "byteplus", - cursor: "cursor", - vx: "vertex", - vertex: "vertex", - vxp: "vertex-partner", - "vertex-partner": "vertex-partner", - // Web cookie providers - gw: "grok-web", - "grok-web": "grok-web", - pw: "perplexity-web", - "perplexity-web": "perplexity-web", - mimo: "xiaomi-mimo", - "xiaomi-mimo": "xiaomi-mimo", - xmtp: "xiaomi-tokenplan", - "xiaomi-tokenplan": "xiaomi-tokenplan", - cf: "cloudflare-ai", - "cloudflare-ai": "cloudflare-ai", - // Image/video providers - fal: "fal-ai", - "fal-ai": "fal-ai", - stability: "stability-ai", - "stability-ai": "stability-ai", - bfl: "black-forest-labs", - "black-forest-labs": "black-forest-labs", - recraft: "recraft", - topaz: "topaz", - runway: "runwayml", - runwayml: "runwayml", - // Embedding/rerank jina: "jina-ai", "jina-ai": "jina-ai", - // TTS polly: "aws-polly", "aws-polly": "aws-polly", - // Free-tier providers (synced from OmniRoute) - agentrouter: "agentrouter", - aimlapi: "aimlapi", - aiml: "aimlapi", - novita: "novita", - modal: "modal", - mdl: "modal", - reka: "reka", - nlpcloud: "nlpcloud", - nlpc: "nlpcloud", - bazaarlink: "bazaarlink", - bzl: "bazaarlink", - completions: "completions", - cpl: "completions", - enally: "enally", - enly: "enally", - freetheai: "freetheai", - fta: "freetheai", - llm7: "llm7", - lepton: "lepton", - kluster: "kluster", - ai21: "ai21", - "inference-net": "inference-net", - inet: "inference-net", - predibase: "predibase", - bytez: "bytez", - morph: "morph", - longcat: "longcat", - lc: "longcat", - puter: "puter", - pu: "puter", - uncloseai: "uncloseai", - unc: "uncloseai", - scaleway: "scaleway", - scw: "scaleway", - deepinfra: "deepinfra", - sambanova: "sambanova", - samba: "sambanova", - nscale: "nscale", - baseten: "baseten", - publicai: "publicai", - "nous-research": "nous-research", - nous: "nous-research", - glhf: "glhf", - bb: "blackbox", - blackbox: "blackbox", }; +const ALIAS_TO_PROVIDER_ID = { ...MEDIA_ONLY_ALIASES }; +for (const entry of REGISTRY) { + ALIAS_TO_PROVIDER_ID[entry.id] = entry.id; + if (entry.alias) ALIAS_TO_PROVIDER_ID[entry.alias] = entry.id; + for (const a of entry.aliases || []) ALIAS_TO_PROVIDER_ID[a] = entry.id; +} + /** * Resolve provider alias to provider ID */ @@ -241,6 +116,15 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) { }; } +// Config-driven prefix → provider inference (first match wins, fallback "openai"). +const MODEL_PREFIX_PROVIDERS = [ + [/^claude-/, "anthropic"], + [/^gemini-/, "gemini"], + [/^gpt-/, "openai"], + [/^o[134]/, "openai"], + [/^deepseek-/, "openrouter"], +]; + /** * Infer provider from model name prefix * Used as fallback when no provider prefix or alias is given @@ -248,12 +132,5 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) { function inferProviderFromModelName(modelName) { if (!modelName) return "openai"; const m = modelName.toLowerCase(); - if (m.startsWith("claude-")) return "anthropic"; - if (m.startsWith("gemini-")) return "gemini"; - if (m.startsWith("gpt-")) return "openai"; - if (m.startsWith("o1") || m.startsWith("o3") || m.startsWith("o4")) - return "openai"; - if (m.startsWith("deepseek-")) return "openrouter"; - // Default fallback - return "openai"; + return MODEL_PREFIX_PROVIDERS.find(([re]) => re.test(m))?.[1] || "openai"; } diff --git a/open-sse/services/oauthCredentialManager.js b/open-sse/services/oauthCredentialManager.js index 466fcda9..75bebd50 100644 --- a/open-sse/services/oauthCredentialManager.js +++ b/open-sse/services/oauthCredentialManager.js @@ -3,8 +3,10 @@ import { isUnrecoverableRefreshError, refreshTokenByProvider, } from "./tokenRefresh.js"; +import { PROVIDER_OAUTH } from "../providers/index.js"; -export const CODEX_MAX_REFRESH_AGE_MS = 8 * 24 * 60 * 60 * 1000; +// Single source: codex.oauth.maxRefreshAgeMs (8 days) — proactive refresh window +export const CODEX_MAX_REFRESH_AGE_MS = PROVIDER_OAUTH["codex"]?.maxRefreshAgeMs; const refreshLocks = new Map(); @@ -35,9 +37,9 @@ export function getCredentialLastRefreshMs(credentials) { ); } -export function isCodexRefreshStale(credentials, nowMs = Date.now()) { +export function isCodexRefreshStale(credentials, nowMs = Date.now(), maxAgeMs = CODEX_MAX_REFRESH_AGE_MS) { const lastRefreshMs = getCredentialLastRefreshMs(credentials); - return !lastRefreshMs || nowMs - lastRefreshMs >= CODEX_MAX_REFRESH_AGE_MS; + return !lastRefreshMs || nowMs - lastRefreshMs >= maxAgeMs; } export function shouldRefreshCredentials(provider, credentials, nowMs = Date.now()) { @@ -48,7 +50,9 @@ export function shouldRefreshCredentials(provider, credentials, nowMs = Date.now return true; } - if (provider === "codex" && credentials.refreshToken && isCodexRefreshStale(credentials, nowMs)) { + // Proactive stale refresh for providers declaring oauth.maxRefreshAgeMs (e.g. codex) + const maxAgeMs = PROVIDER_OAUTH[provider]?.maxRefreshAgeMs; + if (maxAgeMs && credentials.refreshToken && isCodexRefreshStale(credentials, nowMs, maxAgeMs)) { return true; } @@ -101,8 +105,9 @@ export function mergeRefreshedCredentials(provider, currentCredentials, refreshe next.copilotTokenExpiresAt = refreshedCredentials.copilotTokenExpiresAt; } + // trackRefreshAt providers (e.g. codex) always stamp lastRefreshAt for staleness tracking if ( - provider === "codex" || + PROVIDER_OAUTH[provider]?.trackRefreshAt || next.accessToken || next.apiKey || next.token || diff --git a/open-sse/services/provider.js b/open-sse/services/provider.js index 286ed441..1b02cd83 100644 --- a/open-sse/services/provider.js +++ b/open-sse/services/provider.js @@ -1,14 +1,14 @@ import { PROVIDERS } from "../config/providers.js"; -import { buildClineHeaders } from "../../src/shared/utils/clineAuth.js"; +import { OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.js"; const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-"; const OPENAI_COMPATIBLE_DEFAULTS = { - baseUrl: "https://api.openai.com/v1", + baseUrl: OPENAI_COMPAT_BASE, }; const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-"; const ANTHROPIC_COMPATIBLE_DEFAULTS = { - baseUrl: "https://api.anthropic.com/v1", + baseUrl: ANTHROPIC_COMPAT_BASE, }; function isOpenAICompatible(provider) { @@ -24,27 +24,6 @@ function getOpenAICompatibleType(provider) { return provider.includes("responses") ? "responses" : "chat"; } -function buildOpenAICompatibleUrl(baseUrl, apiType) { - const normalized = baseUrl.replace(/\/$/, ""); - const path = apiType === "responses" ? "/responses" : "/chat/completions"; - return `${normalized}${path}`; -} - -function buildAnthropicCompatibleUrl(baseUrl) { - const normalized = baseUrl.replace(/\/$/, ""); - return `${normalized}/messages`; -} - -function buildQwenBaseUrl(resourceUrl, fallbackBaseUrl) { - const fallback = (fallbackBaseUrl || "").replace(/\/chat\/completions$/, ""); - const raw = typeof resourceUrl === "string" ? resourceUrl.trim() : ""; - if (!raw) return fallback; - if (raw.startsWith("http://") || raw.startsWith("https://")) { - return raw.replace(/\/$/, ""); - } - return `https://${raw.replace(/\/$/, "")}/v1`; -} - // Detect request format from body structure export function detectFormat(body) { // OpenAI Responses API: has input (array or string) instead of messages[] @@ -125,8 +104,8 @@ export function detectFormat(body) { return "openai"; } -// Get provider config -export function getProviderConfig(provider) { +// Get provider config (internal — no external runtime consumer) +function getProviderConfig(provider) { if (isOpenAICompatible(provider)) { const apiType = getOpenAICompatibleType(provider); return { @@ -145,180 +124,6 @@ export function getProviderConfig(provider) { return PROVIDERS[provider] || PROVIDERS.openai; } -// Get number of fallback URLs for provider (for retry logic) -export function getProviderFallbackCount(provider) { - const config = getProviderConfig(provider); - return config.baseUrls?.length || 1; -} - -// Build provider URL -export function buildProviderUrl(provider, model, stream = true, options = {}) { - if (isOpenAICompatible(provider)) { - const apiType = getOpenAICompatibleType(provider); - const baseUrl = options?.baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl; - return buildOpenAICompatibleUrl(baseUrl, apiType); - } - if (isAnthropicCompatible(provider)) { - const baseUrl = options?.baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl; - return buildAnthropicCompatibleUrl(baseUrl); - } - const config = getProviderConfig(provider); - - switch (provider) { - case "claude": - return `${config.baseUrl}?beta=true`; - - case "gemini": { - const action = stream ? "streamGenerateContent?alt=sse" : "generateContent"; - return `${config.baseUrl}/${model}:${action}`; - } - - case "gemini-cli": { - const action = stream ? "streamGenerateContent?alt=sse" : "generateContent"; - return `${config.baseUrl}:${action}`; - } - - case "antigravity": { - // Use baseUrlIndex from options or default to 0 - const urlIndex = options?.baseUrlIndex || 0; - const baseUrl = config.baseUrls[urlIndex] || config.baseUrls[0]; - const path = stream ? "/v1internal:streamGenerateContent?alt=sse" : "/v1internal:generateContent"; - return `${baseUrl}${path}`; - } - - case "codex": - return config.baseUrl; - - case "qwen": { - const baseUrl = buildQwenBaseUrl(options?.qwenResourceUrl, config.baseUrl); - return `${baseUrl}/chat/completions`; - } - - case "github": - return config.baseUrl; - - case "glm": - case "kimi": - case "minimax": - // Claude-compatible providers - return `${config.baseUrl}?beta=true`; - - default: - return config.baseUrl; - } -} - -// Build provider headers -export function buildProviderHeaders(provider, credentials, stream = true, body = null) { - const config = getProviderConfig(provider); - const headers = { - "Content-Type": "application/json", - ...config.headers - }; - - // Add auth header - // Specific override for Anthropic Compatible - if (isAnthropicCompatible(provider)) { - if (credentials.apiKey) { - headers["x-api-key"] = credentials.apiKey; - // Do NOT send Authorization header when apiKey is present for Anthropic Compatible - // as it causes issues with some providers (e.g. opencode.ai) - } else if (credentials.accessToken) { - headers["Authorization"] = `Bearer ${credentials.accessToken}`; - } - // Add default Anthropic version if not present (some proxies require it) - if (!headers["anthropic-version"]) { - headers["anthropic-version"] = "2023-06-01"; - } - } else { - switch (provider) { - case "gemini": - if (credentials.apiKey) { - headers["x-goog-api-key"] = credentials.apiKey; - } else if (credentials.accessToken) { - headers["Authorization"] = `Bearer ${credentials.accessToken}`; - } - break; - - case "antigravity": - case "gemini-cli": - // Antigravity and Gemini CLI use OAuth access token - headers["Authorization"] = `Bearer ${credentials.accessToken}`; - break; - - case "claude": - // Claude uses x-api-key header for API key, or Authorization for OAuth - if (credentials.apiKey) { - headers["x-api-key"] = credentials.apiKey; - } else if (credentials.accessToken) { - headers["Authorization"] = `Bearer ${credentials.accessToken}`; - } - break; - - case "github": { - // GitHub Copilot requires special headers to mimic VSCode - // Prioritize copilotToken from providerSpecificData, fallback to accessToken - const githubToken = credentials.copilotToken || credentials.accessToken; - // Add headers in exact same order as test endpoint - headers["Authorization"] = `Bearer ${githubToken}`; - headers["Content-Type"] = "application/json"; - headers["copilot-integration-id"] = "vscode-chat"; - headers["editor-version"] = "vscode/1.107.1"; - headers["editor-plugin-version"] = "copilot-chat/0.26.7"; - headers["user-agent"] = "GitHubCopilotChat/0.26.7"; - headers["openai-intent"] = "conversation-panel"; - headers["x-github-api-version"] = "2025-04-01"; - // Generate a UUID for x-request-id (Cloudflare Workers compatible) - headers["x-request-id"] = crypto.randomUUID ? crypto.randomUUID() : - 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = Math.random() * 16 | 0; - const v = c == 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); - headers["x-vscode-user-agent-library-version"] = "electron-fetch"; - headers["X-Initiator"] = "user"; - headers["Accept"] = "application/json"; - break; - } - - case "codex": - case "qwen": - case "openai": - case "openrouter": - headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`; - break; - - case "cline": - Object.assign(headers, buildClineHeaders(credentials.apiKey || credentials.accessToken)); - break; - - case "glm": - case "kimi": - case "minimax": - // Claude-compatible API providers use x-api-key - headers["x-api-key"] = credentials.apiKey; - break; - - case "vertex": - case "vertex-partner": - // Vertex uses async token minting — headers are set by VertexExecutor._buildHeadersAsync() - // Do NOT set Authorization here; it would leak the raw SA JSON as Bearer token - break; - - default: - headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`; - break; - } - } - - // Stream accept header - if (stream) { - headers["Accept"] = "text/event-stream"; - } - - return headers; -} - // Get target format for provider export function getTargetFormat(provider) { if (isOpenAICompatible(provider)) { @@ -331,6 +136,16 @@ export function getTargetFormat(provider) { return config.format || "openai"; } +// Resolve which transport to use for a provider given the client sourceFormat. +// Multi-endpoint providers (transport.transports[]) pick the entry matching sourceFormat +// to avoid lossy translation; falls back to the default transport when no match. +export function resolveTransport(provider, sourceFormat) { + const config = PROVIDERS[provider]; + const transports = config?.transports; + if (!Array.isArray(transports) || !transports.length) return null; + return transports.find(t => t.format === sourceFormat) || null; +} + // Check if last message is from user export function isLastMessageFromUser(body) { const messages = body.messages || body.contents; @@ -344,12 +159,10 @@ export function hasThinkingConfig(body) { return !!(body.reasoning_effort || body.thinking?.type === "enabled"); } -// Normalize thinking config based on last message role -// - If lastMessage is not user → remove thinking config -// - If lastMessage is user AND has thinking config → keep it (force enable) +// Normalize provider-native thinking config based on last message role. +// OpenAI reasoning_effort is request-level and must survive tool-result turns. export function normalizeThinkingConfig(body) { if (!isLastMessageFromUser(body)) { - delete body.reasoning_effort; delete body.thinking; } return body; diff --git a/open-sse/services/qoderModels.js b/open-sse/services/qoderModels.js index dea92be6..01e6fb13 100644 --- a/open-sse/services/qoderModels.js +++ b/open-sse/services/qoderModels.js @@ -15,10 +15,10 @@ import { createHash } from "crypto"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; -import { buildCosyHeaders } from "@/lib/qoder/cosy.js"; +import { buildCosyHeaders } from "../shared/qoder/cosy.js"; import { QODER_MODEL_LIST_URL, -} from "@/lib/qoder/constants.js"; +} from "../shared/qoder/constants.js"; const FETCH_TIMEOUT_MS = 15_000; const CACHE_TTL_MS = 60 * 60 * 1000; // 1h, same as the Kiro catalog diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index a9e69444..f759493e 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -1,73 +1,37 @@ import { PROVIDERS } from "../config/providers.js"; -import { OAUTH_ENDPOINTS, GITHUB_COPILOT, REFRESH_LEAD_MS } from "../config/appConstants.js"; -import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { OAUTH_ENDPOINTS, REFRESH_LEAD_MS } from "../config/appConstants.js"; +import { + refreshXaiToken, + refreshAccessToken, + refreshClaudeOAuthToken, + refreshGoogleToken, + refreshQwenToken, + refreshCodexToken, + refreshKiroToken, + refreshIflowToken, + refreshGitHubToken, + refreshCopilotToken, + refreshCodebuddyToken, + classifyOAuthRefreshError, +} from "./tokenRefresh/providers.js"; -// xAI refresh — wraps the class method from src/lib/oauth/services/xai.js so -// the token-refresh switches below can stay flat (one function per provider). -let _xaiServiceSingleton = null; -async function refreshXaiToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("xai", refreshToken, async () => { - try { - if (!_xaiServiceSingleton) { - const mod = await import("../../src/lib/oauth/services/xai.js"); - _xaiServiceSingleton = new mod.XaiService(); - } - const tokens = await _xaiServiceSingleton.refreshAccessToken(refreshToken); - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - idToken: tokens.id_token, - }; - } catch (e) { - log?.warn?.("TOKEN_REFRESH", `xai refresh failed: ${e?.message || e}`); - const msg = String(e?.message || ""); - if (msg.includes("invalid_grant") || msg.includes("invalid_request")) { - return { error: "invalid_grant" }; - } - return null; - } - }, log); -} +// Re-export all provider refresh functions (preserves public API for all consumers) +export { + refreshAccessToken, + refreshClaudeOAuthToken, + refreshGoogleToken, + refreshQwenToken, + refreshCodexToken, + refreshKiroToken, + refreshIflowToken, + refreshGitHubToken, + refreshCopilotToken, + refreshCodebuddyToken, + classifyOAuthRefreshError, +}; -// Default token expiry buffer (refresh if expires within 5 minutes) export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; -// Dedup: cache in-flight promise + recent result to prevent refresh_token_reused (Auth0 family revoke) -const REFRESH_RESULT_TTL_MS = 10_000; -const refreshDedupCache = new Map(); - -async function dedupRefresh(provider, oldToken, fn, log) { - if (!oldToken) return fn(); - const key = `${provider}:${oldToken}`; - const hit = refreshDedupCache.get(key); - if (hit) { - if (hit.promise) { - log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`); - return hit.promise; - } - if (hit.expiresAt > Date.now()) { - log?.info?.("TOKEN_REFRESH", `Reusing recent refresh result for ${provider}`); - return hit.result; - } - refreshDedupCache.delete(key); - } - const promise = (async () => { - try { - const result = await fn(); - refreshDedupCache.set(key, { result, expiresAt: Date.now() + REFRESH_RESULT_TTL_MS }); - return result; - } catch (err) { - refreshDedupCache.delete(key); - throw err; - } - })(); - refreshDedupCache.set(key, { promise }); - return promise; -} - -// Check if refresh result indicates unrecoverable error (caller should stop retry, force re-auth) export function isUnrecoverableRefreshError(result) { return ( result && @@ -79,652 +43,123 @@ export function isUnrecoverableRefreshError(result) { ); } -// Get provider-specific refresh lead time, falls back to default buffer export function getRefreshLeadMs(provider) { return REFRESH_LEAD_MS[provider] || TOKEN_EXPIRY_BUFFER_MS; } -/** - * Refresh OAuth access token using refresh token - */ -export async function refreshAccessToken(provider, refreshToken, credentials, log) { - const config = PROVIDERS[provider]; - - if (!config || !config.refreshUrl) { - log?.warn?.("TOKEN_REFRESH", `No refresh URL configured for provider: ${provider}`); - return null; - } - - if (!refreshToken) { - log?.warn?.("TOKEN_REFRESH", `No refresh token available for provider: ${provider}`); - return null; - } - - return dedupRefresh(provider, refreshToken, async () => { +export function parseVertexSaJson(apiKey) { + if (typeof apiKey !== "string") return null; try { - const response = await fetch(config.refreshUrl, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: config.clientId, - client_secret: config.clientSecret, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", `Failed to refresh token for ${provider}`, { - status: response.status, - error: errorText, - }); - return null; + const parsed = JSON.parse(apiKey); + if (parsed.type === "service_account" && parsed.client_email && parsed.private_key && parsed.project_id) { + return parsed; } - - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", `Successfully refreshed token for ${provider}`, { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, { - error: error.message, - }); return null; - } - }, log); -} - -/** - * Specialized refresh for Claude OAuth tokens - */ -export async function refreshClaudeOAuthToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("claude", refreshToken, async () => { - try { - const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.claude.clientId, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", { status: response.status, error: errorText }); - return null; - } - - const tokens = await response.json(); - log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in }); - return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`); - return null; - } - }, log); -} - -/** - * Specialized refresh for Google providers (Gemini, Antigravity) - */ -export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) { - if (!refreshToken) return null; - return dedupRefresh(`google:${clientId}`, refreshToken, async () => { - try { - const response = await fetch(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: clientId, - client_secret: clientSecret, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh Google token", { status: response.status, error: errorText }); - return null; - } - - const tokens = await response.json(); - log?.info?.("TOKEN_REFRESH", "Successfully refreshed Google token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in }); - return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Network error refreshing Google token: ${error.message}`); - return null; - } - }, log); -} - -/** - * Specialized refresh for Qwen OAuth tokens - */ -export async function refreshQwenToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("qwen", refreshToken, async () => { - const endpoint = OAUTH_ENDPOINTS.qwen.token; - - try { - const response = await fetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.qwen.clientId, - }), - }); - - if (response.status === 200) { - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qwen token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - providerSpecificData: tokens.resource_url - ? { resourceUrl: tokens.resource_url } - : undefined, - }; - } else { - const errorText = await response.text().catch(() => ""); - log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, { - status: response.status, - error: errorText, - }); - } - } catch (error) { - log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, { - error: error.message, - }); - } - - log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token"); - return null; - }, log); -} - -export function classifyOAuthRefreshError(errorText = "", status = 0) { - let parsed = null; - try { - parsed = errorText ? JSON.parse(errorText) : null; } catch { - parsed = null; - } - - const code = parsed?.error?.code || parsed?.error || parsed?.error_code || ""; - const description = parsed?.error_description || parsed?.message || errorText || ""; - const combined = `${code} ${description}`.toLowerCase(); - const permanent = [ - "refresh_token_expired", - "refresh_token_reused", - "refresh_token_invalidated", - "invalid_grant", - ].some((marker) => combined.includes(marker)); - - return { status, code, description, permanent }; -} - -/** - * Specialized refresh for Codex (OpenAI) OAuth tokens. - * OpenAI uses rotating (one-time-use) refresh tokens. - * Returns { error: 'unrecoverable_refresh_error' } when token already consumed/invalid, - * so callers stop retrying and request re-authentication. - */ -export async function refreshCodexToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("codex", refreshToken, async () => { - try { - const response = await fetch(OAUTH_ENDPOINTS.openai.token, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - client_id: PROVIDERS.codex.clientId, - grant_type: "refresh_token", - refresh_token: refreshToken, - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - const failure = classifyOAuthRefreshError(errorText, response.status); - if (failure.permanent) { - log?.error?.("TOKEN_REFRESH", "Codex refresh token already used or invalid. Re-auth required.", { - status: response.status, - code: failure.code, - }); - return { error: "unrecoverable_refresh_error", code: failure.code }; - } - - log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", { - status: response.status, - error: errorText, - code: failure.code, - permanent: failure.permanent, - }); - return null; - } - - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - hasIdToken: !!tokens.id_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - idToken: tokens.id_token, - expiresIn: tokens.expires_in, - }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`); - return null; - } - }, log); -} - -/** - * Specialized refresh for Kiro (AWS CodeWhisperer) tokens - * Supports both AWS SSO OIDC (Builder ID/IDC) and Social Auth (Google/GitHub) - */ -// Backfill missing Kiro profileArn on refresh so existing IDC connections self-heal -async function resolveKiroProfileArnPatch(providerSpecificData, accessToken, refreshedArn) { - if (providerSpecificData?.profileArn) return {}; - let profileArn = refreshedArn?.trim?.() || null; - if (!profileArn) { - const { fetchKiroProfileArn } = await import("../../src/lib/oauth/providers.js"); - profileArn = await fetchKiroProfileArn(accessToken); - } - return profileArn ? { providerSpecificData: { profileArn } } : {}; -} - -export async function refreshKiroToken(refreshToken, providerSpecificData, log, proxyOptions = null) { - if (!refreshToken) return null; - return dedupRefresh("kiro", refreshToken, async () => { - const authMethod = providerSpecificData?.authMethod; - const clientId = providerSpecificData?.clientId; - const clientSecret = providerSpecificData?.clientSecret; - const region = providerSpecificData?.region; - - // AWS SSO OIDC (Builder ID or IDC) - // If clientId and clientSecret exist, assume AWS SSO OIDC (default to builder-id if authMethod not specified) - if (clientId && clientSecret) { - const isIDC = authMethod === "idc"; - const endpoint = isIDC && region - ? `https://oidc.${region}.amazonaws.com/token` - : "https://oidc.us-east-1.amazonaws.com/token"; - - const response = await proxyAwareFetch(endpoint, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - clientId: clientId, - clientSecret: clientSecret, - refreshToken: refreshToken, - grantType: "refresh_token", - }), - }, proxyOptions); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro AWS token", { - status: response.status, - error: errorText, - }); - return null; - } - - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro AWS token", { - hasNewAccessToken: !!tokens.accessToken, - expiresIn: tokens.expiresIn, - }); - - return { - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken || refreshToken, - expiresIn: tokens.expiresIn, - ...(await resolveKiroProfileArnPatch(providerSpecificData, tokens.accessToken, tokens.profileArn)), - }; - } - - // Social Auth (Google/GitHub) - use Kiro's refresh endpoint - const response = await proxyAwareFetch(PROVIDERS.kiro.tokenUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - "User-Agent": "kiro-cli/1.0.0", - }, - body: JSON.stringify({ - refreshToken: refreshToken, - }), - }, proxyOptions); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro social token", { - status: response.status, - error: errorText, - }); return null; } - - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro social token", { - hasNewAccessToken: !!tokens.accessToken, - expiresIn: tokens.expiresIn, - }); - - return { - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken || refreshToken, - expiresIn: tokens.expiresIn, - ...(await resolveKiroProfileArnPatch(providerSpecificData, tokens.accessToken, tokens.profileArn)), - }; - }, log); } -/** - * Specialized refresh for iFlow OAuth tokens - */ -export async function refreshIflowToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("iflow", refreshToken, async () => { - const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`); +// Cache Vertex tokens keyed by service account email { token, expiresAt } +const vertexTokenCache = new Map(); - const response = await fetch(OAUTH_ENDPOINTS.iflow.token, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - Authorization: `Basic ${basicAuth}`, - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.iflow.clientId, - client_secret: PROVIDERS.iflow.clientSecret, - }), - }); +export async function refreshVertexToken(saJson, log) { + const cacheKey = saJson.client_email; + const cached = vertexTokenCache.get(cacheKey); - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh iFlow token", { - status: response.status, - error: errorText, - }); - return null; + if (cached && cached.expiresAt - Date.now() > 5 * 60 * 1000) { + return { accessToken: cached.token, expiresAt: cached.expiresAt }; } - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed iFlow token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - }; - }, log); -} - -/** - * Specialized refresh for GitHub Copilot OAuth tokens - */ -export async function refreshGitHubToken(refreshToken, log) { - if (!refreshToken) return null; - return dedupRefresh("github", refreshToken, async () => { - const params = { - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: PROVIDERS.github.clientId, - }; - if (PROVIDERS.github.clientSecret) { - params.client_secret = PROVIDERS.github.clientSecret; - } - - const response = await fetch(OAUTH_ENDPOINTS.github.token, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - }, - body: new URLSearchParams(params), - }); - - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh GitHub token", { - status: response.status, - error: errorText, - }); - return null; - } - - const tokens = await response.json(); - - log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitHub token", { - hasNewAccessToken: !!tokens.access_token, - hasNewRefreshToken: !!tokens.refresh_token, - expiresIn: tokens.expires_in, - }); - - return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || refreshToken, - expiresIn: tokens.expires_in, - }; - }, log); -} - -/** - * Refresh GitHub Copilot token using GitHub access token - */ -export async function refreshCopilotToken(githubAccessToken, log) { - if (!githubAccessToken) return null; - return dedupRefresh("copilot", githubAccessToken, async () => { try { - const response = await fetch("https://api.github.com/copilot_internal/v2/token", { - headers: { - "Authorization": `token ${githubAccessToken}`, - "User-Agent": GITHUB_COPILOT.USER_AGENT, - "Editor-Version": `vscode/${GITHUB_COPILOT.VSCODE_VERSION}`, - "Editor-Plugin-Version": `copilot-chat/${GITHUB_COPILOT.COPILOT_CHAT_VERSION}`, - "Accept": "application/json", - "x-github-api-version": GITHUB_COPILOT.API_VERSION - } + const { SignJWT, importPKCS8 } = await import("jose"); + log?.debug?.("TOKEN_REFRESH", `Vertex minting token for ${saJson.client_email}`); + const privateKey = await importPKCS8(saJson.private_key.replace(/\\n/g, "\n"), "RS256"); + const now = Math.floor(Date.now() / 1000); + + const jwt = await new SignJWT({ scope: "https://www.googleapis.com/auth/cloud-platform" }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer(saJson.client_email) + .setAudience(OAUTH_ENDPOINTS.google.token) + .setIssuedAt(now) + .setExpirationTime(now + 3600) + .sign(privateKey); + + const res = await fetch(OAUTH_ENDPOINTS.google.token, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: jwt, + }), }); - if (!response.ok) { - const errorText = await response.text(); - log?.error?.("TOKEN_REFRESH", "Failed to refresh Copilot token", { - status: response.status, - error: errorText - }); + if (!res.ok) { + const err = await res.text(); + log?.error?.("TOKEN_REFRESH", `Vertex token mint failed: ${err}`); return null; } - const data = await response.json(); + const { access_token, expires_in } = await res.json(); + const expiresAt = Date.now() + (expires_in ?? 3600) * 1000; - log?.info?.("TOKEN_REFRESH", "Successfully refreshed Copilot token", { - hasToken: !!data.token, - expiresAt: data.expires_at - }); + vertexTokenCache.set(cacheKey, { token: access_token, expiresAt }); + log?.info?.("TOKEN_REFRESH", `Vertex token minted for ${saJson.client_email}`); - return { - token: data.token, - expiresAt: data.expires_at - }; + return { accessToken: access_token, expiresAt }; } catch (error) { - log?.error?.("TOKEN_REFRESH", "Error refreshing Copilot token", { - error: error.message - }); + log?.error?.("TOKEN_REFRESH", `Vertex token error: ${error.message}`); return null; } - }, log); } -/** - * Get access token for a specific provider (with in-flight dedup). - * If a refresh is already in-flight for same provider+token, share the promise - * to prevent parallel OAuth requests → Auth0 'refresh_token_reused' family revoke. - */ +function vertexRefreshHandler(c, log) { + const saJson = parseVertexSaJson(c.apiKey); + if (!saJson) return null; + return refreshVertexToken(saJson, log); +} + +const REFRESH_HANDLERS = { + "gemini-cli": (c, log) => refreshGoogleToken(c.refreshToken, PROVIDERS["gemini-cli"].clientId, PROVIDERS["gemini-cli"].clientSecret, log), + antigravity: (c, log) => refreshGoogleToken(c.refreshToken, PROVIDERS.antigravity.clientId, PROVIDERS.antigravity.clientSecret, log), + claude: (c, log) => refreshClaudeOAuthToken(c.refreshToken, log), + codex: (c, log) => refreshCodexToken(c.refreshToken, log), + qwen: (c, log) => refreshQwenToken(c.refreshToken, log), + iflow: (c, log) => refreshIflowToken(c.refreshToken, log), + github: (c, log) => refreshGitHubToken(c.refreshToken, log), + kiro: (c, log) => refreshKiroToken(c.refreshToken, c.providerSpecificData, log), + xai: (c, log) => refreshXaiToken(c.refreshToken, log), + "codebuddy-cn": (c, log) => refreshCodebuddyToken(c.refreshToken, log), + vertex: vertexRefreshHandler, + "vertex-partner": vertexRefreshHandler +}; + export async function getAccessToken(provider, credentials, log) { if (!credentials || !credentials.refreshToken || typeof credentials.refreshToken !== "string") { log?.warn?.("TOKEN_REFRESH", `No valid refresh token available for provider: ${provider}`); return null; } - // Dedup is handled inside each refreshXxxToken function return _getAccessTokenInternal(provider, credentials, log); } async function _getAccessTokenInternal(provider, credentials, log) { - switch (provider) { - case "gemini": - case "gemini-cli": - case "antigravity": - return await refreshGoogleToken( - credentials.refreshToken, - PROVIDERS[provider].clientId, - PROVIDERS[provider].clientSecret, - log - ); - - case "claude": - return await refreshClaudeOAuthToken(credentials.refreshToken, log); - - case "codex": - return await refreshCodexToken(credentials.refreshToken, log); - - case "qwen": - return await refreshQwenToken(credentials.refreshToken, log); - - case "iflow": - return await refreshIflowToken(credentials.refreshToken, log); - - case "github": - return await refreshGitHubToken(credentials.refreshToken, log); - - case "kiro": - return await refreshKiroToken( - credentials.refreshToken, - credentials.providerSpecificData, - log - ); - - case "xai": - return await refreshXaiToken(credentials.refreshToken, log); - - case "vertex": - case "vertex-partner": { - const saJson = parseVertexSaJson(credentials.apiKey); - if (!saJson) return null; - return await refreshVertexToken(saJson, log); - } - - default: - log?.warn?.("TOKEN_REFRESH", `Unsupported provider for token refresh: ${provider}`); - return null; + if (provider === "gemini") { + return refreshGoogleToken(credentials.refreshToken, PROVIDERS.gemini.clientId, PROVIDERS.gemini.clientSecret, log); } + const handler = REFRESH_HANDLERS[provider]; + if (!handler) { + log?.warn?.("TOKEN_REFRESH", `Unsupported provider for token refresh: ${provider}`); + return null; + } + return handler(credentials, log); } -/** - * Refresh token by provider type (helper for handlers) - */ export async function refreshTokenByProvider(provider, credentials, log) { if (!credentials.refreshToken) return null; - - switch (provider) { - case "gemini-cli": - case "antigravity": - return refreshGoogleToken( - credentials.refreshToken, - PROVIDERS[provider].clientId, - PROVIDERS[provider].clientSecret, - log - ); - case "claude": - return refreshClaudeOAuthToken(credentials.refreshToken, log); - case "codex": - return refreshCodexToken(credentials.refreshToken, log); - case "qwen": - return refreshQwenToken(credentials.refreshToken, log); - case "iflow": - return refreshIflowToken(credentials.refreshToken, log); - case "github": - return refreshGitHubToken(credentials.refreshToken, log); - case "kiro": - return refreshKiroToken( - credentials.refreshToken, - credentials.providerSpecificData, - log - ); - case "xai": - return refreshXaiToken(credentials.refreshToken, log); - case "vertex": - case "vertex-partner": { - const saJson = parseVertexSaJson(credentials.apiKey); - if (!saJson) return null; - return refreshVertexToken(saJson, log); - } - default: - return refreshAccessToken(provider, credentials.refreshToken, credentials, log); - } + const handler = REFRESH_HANDLERS[provider]; + return handler ? handler(credentials, log) : refreshAccessToken(provider, credentials.refreshToken, credentials, log); } -/** - * Format credentials for provider - */ export function formatProviderCredentials(provider, credentials, log) { const config = PROVIDERS[provider]; if (!config) { @@ -774,9 +209,6 @@ export function formatProviderCredentials(provider, credentials, log) { } } -/** - * Get all access tokens for a user - */ export async function getAllAccessTokens(userInfo, log) { const results = {}; @@ -797,89 +229,6 @@ export async function getAllAccessTokens(userInfo, log) { return results; } -/** - * Parse Vertex AI Service Account JSON from apiKey string - */ -export function parseVertexSaJson(apiKey) { - if (typeof apiKey !== "string") return null; - try { - const parsed = JSON.parse(apiKey); - if (parsed.type === "service_account" && parsed.client_email && parsed.private_key && parsed.project_id) { - return parsed; - } - return null; - } catch { - return null; - } -} - -// Cache Vertex tokens keyed by service account email { token, expiresAt } -const vertexTokenCache = new Map(); - -/** - * Mint a short-lived OAuth2 Bearer token for Google Cloud Vertex AI - * using Service Account JSON + jose (RS256 JWT assertion flow). - * Token is cached until 5 minutes before expiry. - */ -export async function refreshVertexToken(saJson, log) { - const cacheKey = saJson.client_email; - const cached = vertexTokenCache.get(cacheKey); - - // Return cached token if still valid (5-min buffer) - if (cached && cached.expiresAt - Date.now() > 5 * 60 * 1000) { - return { accessToken: cached.token, expiresAt: cached.expiresAt }; - } - - try { - const { SignJWT, importPKCS8 } = await import("jose"); - log?.debug?.("TOKEN_REFRESH", `Vertex minting token for ${saJson.client_email}`); - const privateKey = await importPKCS8(saJson.private_key.replace(/\\n/g, "\n"), "RS256"); - const now = Math.floor(Date.now() / 1000); - - const jwt = await new SignJWT({ scope: "https://www.googleapis.com/auth/cloud-platform" }) - .setProtectedHeader({ alg: "RS256" }) - .setIssuer(saJson.client_email) - .setAudience("https://oauth2.googleapis.com/token") - .setIssuedAt(now) - .setExpirationTime(now + 3600) - .sign(privateKey); - - const res = await fetch("https://oauth2.googleapis.com/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", - assertion: jwt, - }), - }); - - if (!res.ok) { - const err = await res.text(); - log?.error?.("TOKEN_REFRESH", `Vertex token mint failed: ${err}`); - return null; - } - - const { access_token, expires_in } = await res.json(); - const expiresAt = Date.now() + (expires_in ?? 3600) * 1000; - - vertexTokenCache.set(cacheKey, { token: access_token, expiresAt }); - log?.info?.("TOKEN_REFRESH", `Vertex token minted for ${saJson.client_email}`); - - return { accessToken: access_token, expiresAt }; - } catch (error) { - log?.error?.("TOKEN_REFRESH", `Vertex token error: ${error.message}`); - return null; - } -} - -/** - * Refresh token with retry and exponential backoff - * Retries on failure with increasing delay: 1s, 2s, 3s... - * @param {function} refreshFn - Async function that returns token or null - * @param {number} maxRetries - Max retry attempts (default 3) - * @param {object} log - Logger instance (optional) - * @returns {Promise} Token result or null if all retries fail - */ export async function refreshWithRetry(refreshFn, maxRetries = 3, log = null) { for (let attempt = 0; attempt < maxRetries; attempt++) { if (attempt > 0) { diff --git a/open-sse/services/tokenRefresh/dedup.js b/open-sse/services/tokenRefresh/dedup.js new file mode 100644 index 00000000..ea4666c4 --- /dev/null +++ b/open-sse/services/tokenRefresh/dedup.js @@ -0,0 +1,31 @@ +const REFRESH_RESULT_TTL_MS = 10_000; +const refreshDedupCache = new Map(); + +export async function dedupRefresh(provider, oldToken, fn, log) { + if (!oldToken) return fn(); + const key = `${provider}:${oldToken}`; + const hit = refreshDedupCache.get(key); + if (hit) { + if (hit.promise) { + log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`); + return hit.promise; + } + if (hit.expiresAt > Date.now()) { + log?.info?.("TOKEN_REFRESH", `Reusing recent refresh result for ${provider}`); + return hit.result; + } + refreshDedupCache.delete(key); + } + const promise = (async () => { + try { + const result = await fn(); + refreshDedupCache.set(key, { result, expiresAt: Date.now() + REFRESH_RESULT_TTL_MS }); + return result; + } catch (err) { + refreshDedupCache.delete(key); + throw err; + } + })(); + refreshDedupCache.set(key, { promise }); + return promise; +} diff --git a/open-sse/services/tokenRefresh/providers.js b/open-sse/services/tokenRefresh/providers.js new file mode 100644 index 00000000..54c2fa0b --- /dev/null +++ b/open-sse/services/tokenRefresh/providers.js @@ -0,0 +1,624 @@ +import { PROVIDERS, PROVIDER_OAUTH } from "../../config/providers.js"; +import { OAUTH_ENDPOINTS, GITHUB_COPILOT } from "../../config/appConstants.js"; +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { dedupRefresh } from "./dedup.js"; +import { buildExternalIdpRefreshParams } from "../../../src/lib/oauth/kiroExternalIdp.js"; + +let _xaiServiceSingleton = null; +export async function refreshXaiToken(refreshToken, log) { + if (!refreshToken) return null; + return dedupRefresh("xai", refreshToken, async () => { + try { + if (!_xaiServiceSingleton) { + const mod = await import("../../../src/lib/oauth/services/xai.js"); + _xaiServiceSingleton = new mod.XaiService(); + } + const tokens = await _xaiServiceSingleton.refreshAccessToken(refreshToken); + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || refreshToken, + expiresIn: tokens.expires_in, + idToken: tokens.id_token, + }; + } catch (e) { + log?.warn?.("TOKEN_REFRESH", `xai refresh failed: ${e?.message || e}`); + const msg = String(e?.message || ""); + if (msg.includes("invalid_grant") || msg.includes("invalid_request")) { + return { error: "invalid_grant" }; + } + return null; + } + }, log); +} + +export async function refreshAccessToken(provider, refreshToken, credentials, log) { + const config = PROVIDERS[provider]; + + if (!config || !config.refreshUrl) { + log?.warn?.("TOKEN_REFRESH", `No refresh URL configured for provider: ${provider}`); + return null; + } + + if (!refreshToken) { + log?.warn?.("TOKEN_REFRESH", `No refresh token available for provider: ${provider}`); + return null; + } + + return dedupRefresh(provider, refreshToken, async () => { + try { + const response = await fetch(config.refreshUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: config.clientId, + client_secret: config.clientSecret, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", `Failed to refresh token for ${provider}`, { + status: response.status, + error: errorText, + }); + return null; + } + + const tokens = await response.json(); + + log?.info?.("TOKEN_REFRESH", `Successfully refreshed token for ${provider}`, { + hasNewAccessToken: !!tokens.access_token, + hasNewRefreshToken: !!tokens.refresh_token, + expiresIn: tokens.expires_in, + }); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || refreshToken, + expiresIn: tokens.expires_in, + }; + } catch (error) { + log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, { + error: error.message, + }); + return null; + } + }, log); +} + +export async function refreshClaudeOAuthToken(refreshToken, log) { + if (!refreshToken) return null; + return dedupRefresh("claude", refreshToken, async () => { + try { + const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: PROVIDERS.claude.clientId, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", { status: response.status, error: errorText }); + return null; + } + + const tokens = await response.json(); + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in }); + return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in }; + } catch (error) { + log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`); + return null; + } + }, log); +} + +export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) { + if (!refreshToken) return null; + return dedupRefresh(`google:${clientId}`, refreshToken, async () => { + try { + const response = await fetch(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: clientId, + client_secret: clientSecret, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh Google token", { status: response.status, error: errorText }); + return null; + } + + const tokens = await response.json(); + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Google token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in }); + return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in }; + } catch (error) { + log?.error?.("TOKEN_REFRESH", `Network error refreshing Google token: ${error.message}`); + return null; + } + }, log); +} + +export async function refreshQwenToken(refreshToken, log) { + if (!refreshToken) return null; + return dedupRefresh("qwen", refreshToken, async () => { + const endpoint = OAUTH_ENDPOINTS.qwen.token; + + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: PROVIDERS.qwen.clientId, + }), + }); + + if (response.status === 200) { + const tokens = await response.json(); + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qwen token", { + hasNewAccessToken: !!tokens.access_token, + hasNewRefreshToken: !!tokens.refresh_token, + expiresIn: tokens.expires_in, + }); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || refreshToken, + expiresIn: tokens.expires_in, + providerSpecificData: tokens.resource_url + ? { resourceUrl: tokens.resource_url } + : undefined, + }; + } else { + const errorText = await response.text().catch(() => ""); + log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, { + status: response.status, + error: errorText, + }); + } + } catch (error) { + log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, { + error: error.message, + }); + } + + log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token"); + return null; + }, log); +} + +export function classifyOAuthRefreshError(errorText = "", status = 0) { + let parsed = null; + try { + parsed = errorText ? JSON.parse(errorText) : null; + } catch { + parsed = null; + } + + const code = parsed?.error?.code || parsed?.error || parsed?.error_code || ""; + const description = parsed?.error_description || parsed?.message || errorText || ""; + const combined = `${code} ${description}`.toLowerCase(); + const permanent = [ + "refresh_token_expired", + "refresh_token_reused", + "refresh_token_invalidated", + "invalid_grant", + ].some((marker) => combined.includes(marker)); + + return { status, code, description, permanent }; +} + +export async function refreshCodexToken(refreshToken, log) { + if (!refreshToken) return null; + return dedupRefresh("codex", refreshToken, async () => { + try { + const response = await fetch(OAUTH_ENDPOINTS.openai.token, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + client_id: PROVIDERS.codex.clientId, + grant_type: "refresh_token", + refresh_token: refreshToken, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + const failure = classifyOAuthRefreshError(errorText, response.status); + if (failure.permanent) { + log?.error?.("TOKEN_REFRESH", "Codex refresh token already used or invalid. Re-auth required.", { + status: response.status, + code: failure.code, + }); + return { error: "unrecoverable_refresh_error", code: failure.code }; + } + + log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", { + status: response.status, + error: errorText, + code: failure.code, + permanent: failure.permanent, + }); + return null; + } + + const tokens = await response.json(); + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", { + hasNewAccessToken: !!tokens.access_token, + hasNewRefreshToken: !!tokens.refresh_token, + hasIdToken: !!tokens.id_token, + expiresIn: tokens.expires_in, + }); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || refreshToken, + idToken: tokens.id_token, + expiresIn: tokens.expires_in, + }; + } catch (error) { + log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`); + return null; + } + }, log); +} + +async function resolveKiroProfileArnPatch(providerSpecificData, accessToken, refreshedArn) { + if (providerSpecificData?.profileArn) return {}; + let profileArn = refreshedArn?.trim?.() || null; + if (!profileArn) { + const { fetchKiroProfileArn } = await import("../../../src/lib/oauth/providers.js"); + profileArn = await fetchKiroProfileArn(accessToken); + } + return profileArn ? { providerSpecificData: { profileArn } } : {}; +} + +export async function refreshKiroToken(refreshToken, providerSpecificData, log, proxyOptions = null) { + if (!refreshToken) return null; + return dedupRefresh("kiro", refreshToken, async () => { + const authMethod = providerSpecificData?.authMethod; + const clientId = providerSpecificData?.clientId; + const clientSecret = providerSpecificData?.clientSecret; + const region = providerSpecificData?.region; + + if (authMethod === "external_idp") { + let refreshRequest; + try { + refreshRequest = buildExternalIdpRefreshParams(refreshToken, providerSpecificData); + } catch (error) { + log?.warn?.("TOKEN_REFRESH", `Invalid Kiro external_idp refresh config: ${error.message}`); + return null; + } + + const response = await proxyAwareFetch(refreshRequest.tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: refreshRequest.body, + }, proxyOptions); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro external_idp token", { + status: response.status, + error: errorText, + }); + return null; + } + + const tokens = await response.json(); + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro external_idp token", { + hasNewAccessToken: !!tokens.access_token, + hasNewRefreshToken: !!tokens.refresh_token, + expiresIn: tokens.expires_in, + }); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || refreshToken, + expiresIn: tokens.expires_in, + providerSpecificData: refreshRequest.providerSpecificData, + }; + } + + if (clientId && clientSecret) { + const isIDC = authMethod === "idc"; + const endpoint = isIDC && region + ? `https://oidc.${region}.amazonaws.com/token` + : "https://oidc.us-east-1.amazonaws.com/token"; + + const response = await proxyAwareFetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + clientId: clientId, + clientSecret: clientSecret, + refreshToken: refreshToken, + grantType: "refresh_token", + }), + }, proxyOptions); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro AWS token", { + status: response.status, + error: errorText, + }); + return null; + } + + const tokens = await response.json(); + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro AWS token", { + hasNewAccessToken: !!tokens.accessToken, + expiresIn: tokens.expiresIn, + }); + + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken || refreshToken, + expiresIn: tokens.expiresIn, + ...(await resolveKiroProfileArnPatch(providerSpecificData, tokens.accessToken, tokens.profileArn)), + }; + } + + const response = await proxyAwareFetch(PROVIDERS.kiro.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": "kiro-cli/1.0.0", + }, + body: JSON.stringify({ + refreshToken: refreshToken, + }), + }, proxyOptions); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro social token", { + status: response.status, + error: errorText, + }); + return null; + } + + const tokens = await response.json(); + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro social token", { + hasNewAccessToken: !!tokens.accessToken, + expiresIn: tokens.expiresIn, + }); + + return { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken || refreshToken, + expiresIn: tokens.expiresIn, + ...(await resolveKiroProfileArnPatch(providerSpecificData, tokens.accessToken, tokens.profileArn)), + }; + }, log); +} + +export async function refreshIflowToken(refreshToken, log) { + if (!refreshToken) return null; + return dedupRefresh("iflow", refreshToken, async () => { + const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`); + + const response = await fetch(OAUTH_ENDPOINTS.iflow.token, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + Authorization: `Basic ${basicAuth}`, + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: PROVIDERS.iflow.clientId, + client_secret: PROVIDERS.iflow.clientSecret, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh iFlow token", { + status: response.status, + error: errorText, + }); + return null; + } + + const tokens = await response.json(); + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed iFlow token", { + hasNewAccessToken: !!tokens.access_token, + hasNewRefreshToken: !!tokens.refresh_token, + expiresIn: tokens.expires_in, + }); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || refreshToken, + expiresIn: tokens.expires_in, + }; + }, log); +} + +export async function refreshGitHubToken(refreshToken, log) { + if (!refreshToken) return null; + return dedupRefresh("github", refreshToken, async () => { + const params = { + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: PROVIDERS.github.clientId, + }; + if (PROVIDERS.github.clientSecret) { + params.client_secret = PROVIDERS.github.clientSecret; + } + + const response = await fetch(OAUTH_ENDPOINTS.github.token, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams(params), + }); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh GitHub token", { + status: response.status, + error: errorText, + }); + return null; + } + + const tokens = await response.json(); + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitHub token", { + hasNewAccessToken: !!tokens.access_token, + hasNewRefreshToken: !!tokens.refresh_token, + expiresIn: tokens.expires_in, + }); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || refreshToken, + expiresIn: tokens.expires_in, + }; + }, log); +} + +export async function refreshCopilotToken(githubAccessToken, log) { + if (!githubAccessToken) return null; + return dedupRefresh("copilot", githubAccessToken, async () => { + try { + const response = await fetch(PROVIDER_OAUTH["github"]?.copilotTokenUrl, { + headers: { + "Authorization": `token ${githubAccessToken}`, + "User-Agent": GITHUB_COPILOT.USER_AGENT, + "Editor-Version": `vscode/${GITHUB_COPILOT.VSCODE_VERSION}`, + "Editor-Plugin-Version": `copilot-chat/${GITHUB_COPILOT.COPILOT_CHAT_VERSION}`, + "Accept": "application/json", + "x-github-api-version": GITHUB_COPILOT.API_VERSION + } + }); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh Copilot token", { + status: response.status, + error: errorText + }); + return null; + } + + const data = await response.json(); + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Copilot token", { + hasToken: !!data.token, + expiresAt: data.expires_at + }); + + return { + token: data.token, + expiresAt: data.expires_at + }; + } catch (error) { + log?.error?.("TOKEN_REFRESH", "Error refreshing Copilot token", { + error: error.message + }); + return null; + } + }, log); +} + +// CodeBuddy (Tencent) refresh — POST /v2/plugin/auth/token/refresh with the +// refresh token carried in the X-Refresh-Token header (not a form body), +// matching the official CodeBuddy CLI. Response: { code: 0, data: }. +export async function refreshCodebuddyToken(refreshToken, log) { + if (!refreshToken) return null; + return dedupRefresh("codebuddy-cn", refreshToken, async () => { + const oauth = PROVIDER_OAUTH["codebuddy-cn"] || {}; + const response = await fetch(oauth.refreshUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": oauth.userAgent, + "X-Requested-With": "XMLHttpRequest", + "X-Domain": "copilot.tencent.com", + "X-Refresh-Token": refreshToken, + "X-Auth-Refresh-Source": "plugin", + "X-Product": "SaaS", + }, + body: "{}", + }); + + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN_REFRESH", "Failed to refresh CodeBuddy token", { + status: response.status, + error: errorText, + }); + return null; + } + + const data = await response.json(); + if (data.code !== 0 || !data.data?.accessToken) { + log?.error?.("TOKEN_REFRESH", "CodeBuddy token refresh returned no token", { + code: data.code, + msg: data.msg, + }); + return null; + } + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed CodeBuddy token", { + hasNewAccessToken: !!data.data.accessToken, + hasNewRefreshToken: !!data.data.refreshToken, + expiresIn: data.data.expiresIn, + }); + + return { + accessToken: data.data.accessToken, + refreshToken: data.data.refreshToken || refreshToken, + expiresIn: data.data.expiresIn, + }; + }, log); +} diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 95c4be7c..a4cf972a 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -2,67 +2,49 @@ * Usage Fetcher - Get usage data from provider APIs */ -import { CLIENT_METADATA, getPlatformUserAgent } from "../config/appConstants.js"; -import { proxyAwareFetch } from "../utils/proxyFetch.js"; -import { resolveDefaultProfileArn } from "../config/kiroConstants.js"; +import { getGitHubUsage } from "./usage/github.js"; +import { getGeminiUsage, getAntigravityUsage } from "./usage/google.js"; +import { getClaudeUsage } from "./usage/claude.js"; +import { getCodexUsage, consumeCodexRateLimitResetCredit } from "./usage/codex.js"; -// GitHub API config -const GITHUB_CONFIG = { - apiVersion: "2022-11-28", - userAgent: "GitHubCopilotChat/0.26.7", -}; - -// GLM quota endpoints (region-aware) -const GLM_QUOTA_URLS = { - international: "https://api.z.ai/api/monitor/usage/quota/limit", - china: "https://open.bigmodel.cn/api/monitor/usage/quota/limit", -}; - -// MiniMax usage endpoints (try in order, fallback on transient errors) -const MINIMAX_USAGE_URLS = { - minimax: [ - "https://www.minimax.io/v1/token_plan/remains", - "https://api.minimax.io/v1/api/openplatform/coding_plan/remains", - ], - "minimax-cn": [ - "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains", - "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains", - ], -}; - -// Vercel AI Gateway credits endpoint -// Returns { balance: "95.50", total_used: "4.50" } (USD as decimal strings). -// Docs: https://vercel.com/docs/ai-gateway/usage -const VERCEL_AI_GATEWAY_CREDITS_URL = "https://ai-gateway.vercel.sh/v1/credits"; - -// Antigravity API config (from Quotio) -const ANTIGRAVITY_CONFIG = { - 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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", - userAgent: getPlatformUserAgent(), -}; - -// Codex (OpenAI) API config -const CODEX_CONFIG = { - usageUrl: "https://chatgpt.com/backend-api/wham/usage", -}; - -// Claude API config -const CLAUDE_CONFIG = { - oauthUsageUrl: "https://api.anthropic.com/api/oauth/usage", - usageUrl: "https://api.anthropic.com/v1/organizations/{org_id}/usage", - settingsUrl: "https://api.anthropic.com/v1/settings", - apiVersion: "2023-06-01", -}; +export { consumeCodexRateLimitResetCredit }; +import { getKiroUsage } from "./usage/kiro.js"; +import { getMiniMaxUsage } from "./usage/minimax.js"; +import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.js"; +import { + getQwenUsage, + getIflowUsage, + getOllamaUsage, + getGlmUsage, + getVercelAiGatewayUsage, + getQoderUsage, +} from "./usage/misc.js"; /** * Get usage data for a provider connection * @param {Object} connection - Provider connection with accessToken * @returns {Object} Usage data with quotas */ +// provider → usage handler (ctx carries every arg each handler needs) +const USAGE_HANDLERS = { + github: (c) => getGitHubUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), + "gemini-cli": (c) => getGeminiUsage(c.accessToken, c.providerDataWithProjectId, c.proxyOptions), + antigravity: (c) => getAntigravityUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), + claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions), + codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions), + kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), + qoder: (c) => getQoderUsage(c.accessToken, c.proxyOptions), + qwen: (c) => getQwenUsage(c.accessToken, c.providerSpecificData), + iflow: (c) => getIflowUsage(c.accessToken), + ollama: (c) => getOllamaUsage(c.accessToken), + glm: (c) => getGlmUsage(c.apiKey, c.provider, c.proxyOptions), + "glm-cn": (c) => getGlmUsage(c.apiKey, c.provider, c.proxyOptions), + minimax: (c) => getMiniMaxUsage(c.apiKey, c.provider, c.proxyOptions), + "minimax-cn": (c) => getMiniMaxUsage(c.apiKey, c.provider, c.proxyOptions), + "vercel-ai-gateway": (c) => getVercelAiGatewayUsage(c.apiKey, c.proxyOptions), + "codebuddy-cn": (c) => getCodeBuddyCnUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions), +}; + export async function getUsageForProvider(connection, proxyOptions = null) { const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection; const providerDataWithProjectId = { @@ -70,1287 +52,7 @@ export async function getUsageForProvider(connection, proxyOptions = null) { ...(projectId ? { projectId } : {}), }; - switch (provider) { - case "github": - return await getGitHubUsage(accessToken, providerSpecificData, proxyOptions); - case "gemini-cli": - return await getGeminiUsage(accessToken, providerDataWithProjectId, proxyOptions); - case "antigravity": - return await getAntigravityUsage(accessToken, providerSpecificData, proxyOptions); - case "claude": - return await getClaudeUsage(accessToken, proxyOptions); - case "codex": - return await getCodexUsage(accessToken, proxyOptions); - case "kiro": - return await getKiroUsage(accessToken, providerSpecificData, proxyOptions); - case "qoder": - return await getQoderUsage(accessToken, proxyOptions); - case "qwen": - return await getQwenUsage(accessToken, providerSpecificData); - case "iflow": - return await getIflowUsage(accessToken); - case "ollama": - return await getOllamaUsage(accessToken); - case "glm": - case "glm-cn": - return await getGlmUsage(apiKey, provider, proxyOptions); - case "minimax": - case "minimax-cn": - return await getMiniMaxUsage(apiKey, provider, proxyOptions); - case "vercel-ai-gateway": - return await getVercelAiGatewayUsage(apiKey, proxyOptions); - default: - return { message: `Usage API not implemented for ${provider}` }; - } -} - -/** - * Parse reset date/time to ISO string - * Handles multiple formats: Unix timestamp (ms), ISO date string, etc. - */ -function parseResetTime(resetValue) { - if (!resetValue) return null; - - try { - // If it's already a Date object - if (resetValue instanceof Date) { - return resetValue.toISOString(); - } - - // Unix timestamps from provider APIs may be seconds or milliseconds. - if (typeof resetValue === 'number') { - return new Date(resetValue < 1e12 ? resetValue * 1000 : resetValue).toISOString(); - } - - // If it's a numeric string, treat it like a Unix timestamp too. - if (typeof resetValue === 'string') { - if (/^\d+$/.test(resetValue)) { - const timestamp = Number(resetValue); - return new Date(timestamp < 1e12 ? timestamp * 1000 : timestamp).toISOString(); - } - return new Date(resetValue).toISOString(); - } - - return null; - } catch (error) { - console.warn(`Failed to parse reset time: ${resetValue}`, error); - return null; - } -} - -/** - * GitHub Copilot Usage - * Uses GitHub accessToken (not copilotToken) to call copilot_internal/user API - */ -async function getGitHubUsage(accessToken, providerSpecificData, proxyOptions = null) { - try { - if (!accessToken) { - throw new Error("No GitHub access token available. Please re-authorize the connection."); - } - - // copilot_internal/user API requires GitHub OAuth token, not copilotToken - const response = await proxyAwareFetch("https://api.github.com/copilot_internal/user", { - headers: { - "Authorization": `token ${accessToken}`, - "Accept": "application/json", - "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, - "User-Agent": GITHUB_CONFIG.userAgent, - "Editor-Version": "vscode/1.100.0", - "Editor-Plugin-Version": "copilot-chat/0.26.7", - }, - }, proxyOptions); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`GitHub API error: ${error}`); - } - - const data = await response.json(); - - // Handle different response formats (paid vs free) - if (data.quota_snapshots) { - // Paid plan format - const snapshots = data.quota_snapshots; - const resetAt = parseResetTime(data.quota_reset_date); - - return { - plan: data.copilot_plan, - resetDate: data.quota_reset_date, - quotas: { - chat: { ...formatGitHubQuotaSnapshot(snapshots.chat), resetAt }, - completions: { ...formatGitHubQuotaSnapshot(snapshots.completions), resetAt }, - premium_interactions: { ...formatGitHubQuotaSnapshot(snapshots.premium_interactions), resetAt }, - }, - }; - } else if (data.monthly_quotas || data.limited_user_quotas) { - // Free/limited plan format - const monthlyQuotas = data.monthly_quotas || {}; - const usedQuotas = data.limited_user_quotas || {}; - const resetAt = parseResetTime(data.limited_user_reset_date); - - return { - plan: data.copilot_plan || data.access_type_sku, - resetDate: data.limited_user_reset_date, - quotas: { - chat: { - used: usedQuotas.chat || 0, - total: monthlyQuotas.chat || 0, - unlimited: false, - resetAt, - }, - completions: { - used: usedQuotas.completions || 0, - total: monthlyQuotas.completions || 0, - unlimited: false, - resetAt, - }, - }, - }; - } - - return { message: "GitHub Copilot connected. Unable to parse quota data." }; - } catch (error) { - throw new Error(`Failed to fetch GitHub usage: ${error.message}`); - } -} - -function formatGitHubQuotaSnapshot(quota) { - if (!quota) return { used: 0, total: 0, unlimited: true }; - - return { - used: quota.entitlement - quota.remaining, - total: quota.entitlement, - remaining: quota.remaining, - unlimited: quota.unlimited || false, - }; -} - -/** - * Gemini CLI Usage — fetch per-model quota via Cloud Code Assist API. - * Uses retrieveUserQuota (same endpoint as `gemini /stats`) returning - * per-model buckets with remainingFraction + resetTime. - */ -async function getGeminiUsage(accessToken, providerSpecificData, proxyOptions = null) { - if (!accessToken) { - return { plan: "Free", message: "Gemini CLI access token not available." }; - } - - try { - // Resolve project id: prefer connection-stored id, else loadCodeAssist lookup. - // #1271: OAuth save stores projectId on the connection, not providerSpecificData. - let projectId = normalizeCloudCodeProjectId(providerSpecificData?.projectId); - let plan = "Free"; - - if (!projectId) { - const subInfo = await getGeminiSubscriptionInfo(accessToken, proxyOptions); - projectId = normalizeCloudCodeProjectId(subInfo?.cloudaicompanionProject); - plan = subInfo?.currentTier?.name || plan; - } - - if (!projectId) { - return { - plan, - message: "Gemini CLI project ID not available. Reconnect Gemini CLI, or configure a Google Cloud project with Gemini Code Assist access before checking quota.", - }; - } - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - let response; - try { - response = await proxyAwareFetch( - "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", - { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ project: projectId }), - signal: controller.signal, - }, - proxyOptions - ); - } finally { - clearTimeout(timeoutId); - } - - if (!response.ok) { - return { plan, message: `Gemini CLI quota error (${response.status}).` }; - } - - const data = await response.json(); - const quotas = {}; - - if (Array.isArray(data.buckets)) { - for (const bucket of data.buckets) { - if (!bucket.modelId || bucket.remainingFraction == null) continue; - - const remainingFraction = Number(bucket.remainingFraction) || 0; - const total = 1000; // Normalized base, matches antigravity convention - const remaining = Math.round(total * remainingFraction); - const used = Math.max(0, total - remaining); - - quotas[bucket.modelId] = { - used, - total, - resetAt: parseResetTime(bucket.resetTime), - remainingPercentage: remainingFraction * 100, - unlimited: false, - }; - } - } - - return { plan, quotas }; - } catch (error) { - return { message: `Gemini CLI error: ${error.message}` }; - } -} - -function normalizeCloudCodeProjectId(project) { - if (typeof project === "string") return project.trim() || null; - if (project && typeof project === "object" && typeof project.id === "string") { - return project.id.trim() || null; - } - return null; -} - -/** - * Get Gemini CLI subscription info via loadCodeAssist - */ -async function getGeminiSubscriptionInfo(accessToken, proxyOptions = null) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); - try { - const response = await proxyAwareFetch( - "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", - { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - metadata: CLIENT_METADATA, - }), - signal: controller.signal, - }, - proxyOptions - ); - if (!response.ok) return null; - return await response.json(); - } catch { - return null; - } finally { - clearTimeout(timeoutId); - } -} - -/** - * Antigravity Usage - Fetch quota from Google Cloud Code API - */ -async function getAntigravityUsage(accessToken, providerSpecificData, proxyOptions = null) { - try { - // Fetch subscription info once — reuse for both projectId and plan - const subscriptionInfo = await getAntigravitySubscriptionInfo(accessToken, proxyOptions); - const projectId = subscriptionInfo?.cloudaicompanionProject || null; - - // Fetch quota data with timeout - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout - - let response; - try { - response = await proxyAwareFetch(ANTIGRAVITY_CONFIG.quotaApiUrl, { - method: "POST", - headers: { - "Authorization": `Bearer ${accessToken}`, - "User-Agent": ANTIGRAVITY_CONFIG.userAgent, - "Content-Type": "application/json", - "X-Client-Name": "antigravity", - "X-Client-Version": "1.107.0", - "x-request-source": "local", // MITM bypass - }, - body: JSON.stringify({ - ...(projectId ? { project: projectId } : {}) - }), - signal: controller.signal, - }, proxyOptions); - } finally { - clearTimeout(timeoutId); - } - - if (response.status === 403) { - return { - message: "Antigravity quota API access forbidden. Chat may still work.", - quotas: {} - }; - } - - if (response.status === 401) { - return { - message: "Antigravity quota API authentication expired. Chat may still work.", - quotas: {} - }; - } - - if (!response.ok) { - throw new Error(`Antigravity API error: ${response.status}`); - } - - const data = await response.json(); - const quotas = {}; - - // Parse model quotas (inspired by vscode-antigravity-cockpit) - if (data.models) { - // Filter only recommended/important models (must match PROVIDER_MODELS ag ids) - const importantModels = [ - 'gemini-3-flash-agent', - 'gemini-3.5-flash-low', - 'gemini-3.5-flash-extra-low', - 'gemini-pro-agent', - 'gemini-3.1-pro-low', - 'claude-sonnet-4-6', - 'claude-opus-4-6-thinking', - 'gpt-oss-120b-medium', - 'gemini-3-flash', - ]; - - for (const [modelKey, info] of Object.entries(data.models)) { - // Skip models without quota info - if (!info.quotaInfo) { - continue; - } - - // Skip internal models and non-important models - if (info.isInternal || !importantModels.includes(modelKey)) { - continue; - } - - const remainingFraction = info.quotaInfo.remainingFraction || 0; - const remainingPercentage = remainingFraction * 100; - - // Convert percentage to used/total for UI compatibility - const total = 1000; // Normalized base - const remaining = Math.round(total * remainingFraction); - const used = total - remaining; - - // Use modelKey as key (matches PROVIDER_MODELS id) - quotas[modelKey] = { - used, - total, - resetAt: parseResetTime(info.quotaInfo.resetTime), - remainingPercentage, - unlimited: false, - displayName: info.displayName || modelKey, - }; - } - } - - return { - plan: subscriptionInfo?.currentTier?.name || "Unknown", - quotas, - subscriptionInfo, - }; - } catch (error) { - console.error("[Antigravity Usage] Error:", error.message, error.cause); - return { message: `Antigravity error: ${error.message}` }; - } -} - -/** - * Get Antigravity project ID from subscription info - */ -async function getAntigravityProjectId(accessToken) { - try { - const info = await getAntigravitySubscriptionInfo(accessToken); - return info?.cloudaicompanionProject || null; - } catch { - return null; - } -} - -/** - * Get Antigravity subscription info - */ -async function getAntigravitySubscriptionInfo(accessToken, proxyOptions = null) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout - try { - const response = await proxyAwareFetch(ANTIGRAVITY_CONFIG.loadProjectApiUrl, { - method: "POST", - headers: { - "Authorization": `Bearer ${accessToken}`, - "User-Agent": ANTIGRAVITY_CONFIG.userAgent, - "Content-Type": "application/json", - "x-request-source": "local", // MITM bypass - }, - body: JSON.stringify({ metadata: CLIENT_METADATA, mode: 1 }), - signal: controller.signal, - }, proxyOptions); - - if (!response.ok) return null; - return await response.json(); - } catch (error) { - console.error("[Antigravity Subscription] Error:", error.message); - return null; - } finally { - clearTimeout(timeoutId); - } -} - -/** - * Claude Usage - Primary: OAuth endpoint, Fallback: legacy settings/org endpoint - */ -async function getClaudeUsage(accessToken, proxyOptions = null) { - try { - // Primary: OAuth usage endpoint (Claude Code consumer OAuth tokens) - const oauthResponse = await proxyAwareFetch(CLAUDE_CONFIG.oauthUsageUrl, { - method: "GET", - headers: { - "Authorization": `Bearer ${accessToken}`, - "anthropic-beta": "oauth-2025-04-20", - "anthropic-version": CLAUDE_CONFIG.apiVersion, - }, - }, proxyOptions); - - if (oauthResponse.ok) { - const data = await oauthResponse.json(); - const quotas = {}; - - // utilization = % USED (e.g. 87 means 87% used, 13% remaining) - const hasUtilization = (window) => - window && typeof window === "object" && typeof window.utilization === "number"; - - const createQuotaObject = (window) => { - const used = window.utilization; - const remaining = Math.max(0, 100 - used); - return { - used, - total: 100, - remaining, - remainingPercentage: remaining, - resetAt: parseResetTime(window.resets_at), - unlimited: false, - }; - }; - - if (hasUtilization(data.five_hour)) { - quotas["session (5h)"] = createQuotaObject(data.five_hour); - } - - if (hasUtilization(data.seven_day)) { - quotas["weekly (7d)"] = createQuotaObject(data.seven_day); - } - - // Parse model-specific weekly windows (e.g. seven_day_sonnet, seven_day_opus) - for (const [key, value] of Object.entries(data)) { - if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(value)) { - const modelName = key.replace("seven_day_", ""); - quotas[`weekly ${modelName} (7d)`] = createQuotaObject(value); - } - } - - return { - plan: "Claude Code", - extraUsage: data.extra_usage ?? null, - quotas, - }; - } - - // Fallback: legacy settings + org usage endpoint - console.warn(`[Claude Usage] OAuth endpoint returned ${oauthResponse.status}, falling back to legacy`); - return await getClaudeUsageLegacy(accessToken, proxyOptions); - } catch (error) { - return { message: `Claude connected. Unable to fetch usage: ${error.message}` }; - } -} - -/** - * Legacy Claude usage for API key / org admin users - */ -async function getClaudeUsageLegacy(accessToken, proxyOptions = null) { - try { - const settingsResponse = await proxyAwareFetch(CLAUDE_CONFIG.settingsUrl, { - method: "GET", - headers: { - "Authorization": `Bearer ${accessToken}`, - "anthropic-version": CLAUDE_CONFIG.apiVersion, - }, - }, proxyOptions); - - if (settingsResponse.ok) { - const settings = await settingsResponse.json(); - - if (settings.organization_id) { - const usageResponse = await proxyAwareFetch( - CLAUDE_CONFIG.usageUrl.replace("{org_id}", settings.organization_id), - { - method: "GET", - headers: { - "Authorization": `Bearer ${accessToken}`, - "anthropic-version": CLAUDE_CONFIG.apiVersion, - }, - }, - proxyOptions - ); - - if (usageResponse.ok) { - const usage = await usageResponse.json(); - return { - plan: settings.plan || "Unknown", - organization: settings.organization_name, - quotas: usage, - }; - } - } - - return { - plan: settings.plan || "Unknown", - organization: settings.organization_name, - message: "Claude connected. Usage details require admin access.", - }; - } - - return { message: "Claude connected. Usage API requires admin permissions." }; - } catch (error) { - return { message: `Claude connected. Unable to fetch usage: ${error.message}` }; - } -} - -/** - * Codex (OpenAI) Usage - Fetch from ChatGPT backend API - */ -function toFiniteNumber(value, fallback = 0) { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string" && value.trim()) { - const parsed = Number(value); - if (Number.isFinite(parsed)) return parsed; - } - return fallback; -} - -function getCodexRateLimitBody(snapshot) { - if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return null; - return snapshot.rate_limit && typeof snapshot.rate_limit === "object" - ? snapshot.rate_limit - : snapshot; -} - -function formatCodexWindow(window) { - const used = Math.max(0, Math.min(100, toFiniteNumber(window?.used_percent ?? window?.percent_used, 0))); - return { - used, - total: 100, - remaining: Math.max(0, 100 - used), - resetAt: parseResetTime(window?.reset_at ?? window?.resets_at ?? window?.resetAt ?? null), - unlimited: false, - }; -} - -function appendCodexQuotaWindows(quotas, prefix, snapshot) { - const rateLimit = getCodexRateLimitBody(snapshot); - if (!rateLimit) return false; - - const primary = rateLimit.primary_window || rateLimit.primary || snapshot.primary_window || snapshot.primary; - const secondary = rateLimit.secondary_window || rateLimit.secondary || snapshot.secondary_window || snapshot.secondary; - let added = false; - - if (primary) { - quotas[prefix ? `${prefix}_session` : "session"] = formatCodexWindow(primary); - added = true; - } - if (secondary) { - quotas[prefix ? `${prefix}_weekly` : "weekly"] = formatCodexWindow(secondary); - added = true; - } - - return added; -} - -function getCodexReviewRateLimit(data) { - if (data.code_review_rate_limit || data.review_rate_limit) { - return data.code_review_rate_limit || data.review_rate_limit; - } - - const byLimitId = data.rate_limits_by_limit_id; - if (byLimitId && typeof byLimitId === "object" && !Array.isArray(byLimitId)) { - return byLimitId.code_review || byLimitId.codex_review || byLimitId.review || null; - } - - const additional = Array.isArray(data.additional_rate_limits) ? data.additional_rate_limits : []; - return additional.find((entry) => { - const id = String(entry?.limit_name || entry?.metered_feature || entry?.id || "").toLowerCase(); - return id === "code_review" || id === "codex_review" || id === "review" || id.includes("review"); - }) || null; -} - -async function getCodexUsage(accessToken, proxyOptions = null) { - try { - const response = await proxyAwareFetch(CODEX_CONFIG.usageUrl, { - method: "GET", - headers: { - "Authorization": `Bearer ${accessToken}`, - "Accept": "application/json", - }, - }, proxyOptions); - - if (!response.ok) { - return { message: `Codex connected. Usage API temporarily unavailable (${response.status}).` }; - } - - const data = await response.json(); - const normalRateLimit = data.rate_limit || data.rate_limits || data.rate_limits_by_limit_id?.codex || {}; - const reviewRateLimit = getCodexReviewRateLimit(data); - const quotas = {}; - - appendCodexQuotaWindows(quotas, "", normalRateLimit); - appendCodexQuotaWindows(quotas, "review", reviewRateLimit); - - return { - plan: data.plan_type || data.summary?.plan || "unknown", - limitReached: getCodexRateLimitBody(normalRateLimit)?.limit_reached || false, - reviewLimitReached: getCodexRateLimitBody(reviewRateLimit)?.limit_reached || false, - quotas, - }; - } catch (error) { - throw new Error(`Failed to fetch Codex usage: ${error.message}`); - } -} - -/** - * Kiro (AWS CodeWhisperer) Usage - */ -function parseKiroQuotaData(data) { - const usageList = data.usageBreakdownList || []; - const quotaInfo = {}; - const resetAt = parseResetTime(data.nextDateReset || data.resetDate); - - usageList.forEach((breakdown) => { - const resourceType = breakdown.resourceType?.toLowerCase() || "unknown"; - const used = breakdown.currentUsageWithPrecision || 0; - const total = breakdown.usageLimitWithPrecision || 0; - - quotaInfo[resourceType] = { - used, - total, - remaining: total - used, - resetAt, - unlimited: false, - }; - - // Add free trial if available - if (breakdown.freeTrialInfo) { - const freeUsed = breakdown.freeTrialInfo.currentUsageWithPrecision || 0; - const freeTotal = breakdown.freeTrialInfo.usageLimitWithPrecision || 0; - - quotaInfo[`${resourceType}_freetrial`] = { - used: freeUsed, - total: freeTotal, - remaining: freeTotal - freeUsed, - resetAt: parseResetTime(breakdown.freeTrialInfo.freeTrialExpiry || resetAt), - unlimited: false, - }; - } - }); - - return { - plan: data.subscriptionInfo?.subscriptionTitle || "Kiro", - quotas: quotaInfo, - }; -} - -async function getKiroUsage(accessToken, providerSpecificData, proxyOptions = null) { - const authMethod = providerSpecificData?.authMethod || "builder-id"; - const profileArn = providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod); - - const getUsageParams = new URLSearchParams({ - isEmailRequired: "true", - origin: "AI_EDITOR", - resourceType: "AGENTIC_REQUEST", - }); - - // For compatibility, try multiple known Kiro usage endpoints - const attempts = [ - { - name: "codewhisperer-get", - run: async () => proxyAwareFetch( - `https://codewhisperer.us-east-1.amazonaws.com/getUsageLimits?${getUsageParams.toString()}`, - { - method: "GET", - headers: { - "Authorization": `Bearer ${accessToken}`, - "Accept": "application/json", - "x-amz-user-agent": "aws-sdk-js/1.0.0 KiroIDE", - "user-agent": "aws-sdk-js/1.0.0 KiroIDE", - }, - }, - proxyOptions - ), - }, - { - name: "codewhisperer-post", - run: async () => proxyAwareFetch("https://codewhisperer.us-east-1.amazonaws.com", { - method: "POST", - headers: { - "Authorization": `Bearer ${accessToken}`, - "Content-Type": "application/x-amz-json-1.0", - "x-amz-target": "AmazonCodeWhispererService.GetUsageLimits", - "Accept": "application/json", - }, - body: JSON.stringify({ - origin: "AI_EDITOR", - profileArn, - resourceType: "AGENTIC_REQUEST", - }), - }, proxyOptions), - }, - { - name: "q-get", - run: async () => { - const params = new URLSearchParams({ - origin: "AI_EDITOR", - profileArn, - resourceType: "AGENTIC_REQUEST", - }); - return proxyAwareFetch(`https://q.us-east-1.amazonaws.com/getUsageLimits?${params}`, { - method: "GET", - headers: { - "Authorization": `Bearer ${accessToken}`, - "Accept": "application/json", - }, - }, proxyOptions); - }, - }, - ]; - - let sawAuthError = false; - const errors = []; - - for (const attempt of attempts) { - try { - const response = await attempt.run(); - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - if (response.status === 401 || response.status === 403) { - sawAuthError = true; - } - errors.push(`${attempt.name}:${response.status}${errorText ? `:${errorText}` : ""}`); - continue; - } - - const data = await response.json(); - return parseKiroQuotaData(data); - } catch (error) { - errors.push(`${attempt.name}:${error.message}`); - } - } - - if (sawAuthError && authMethod === "idc") { - return { - message: "Kiro quota API is unavailable for the current AWS IAM Identity Center session. Chat may still work. If this persists after renewing your session, reconnect Kiro.", - quotas: {}, - }; - } - - // Social auth (Google/GitHub) - these use a different token format that may not work with AWS CodeWhisperer quota APIs - if (sawAuthError && (authMethod === "google" || authMethod === "github")) { - return { - message: "Kiro quota API authentication expired. Chat may still work.", - quotas: {}, - }; - } - - if (sawAuthError) { - return { - message: "Kiro quota API rejected the current token. Chat may still work.", - quotas: {}, - }; - } - - const fallbackMessage = - errors.length > 0 - ? `Unable to fetch Kiro usage right now. (${errors[errors.length - 1]})` - : "Unable to fetch Kiro usage right now."; - - return { - message: fallbackMessage, - quotas: {}, - }; -} - -/** - * Qwen Usage - */ -async function getQwenUsage(accessToken, providerSpecificData) { - try { - const resourceUrl = providerSpecificData?.resourceUrl; - if (!resourceUrl) { - return { message: "Qwen connected. No resource URL available." }; - } - - // Qwen may have usage endpoint at resource URL - return { message: "Qwen connected. Usage tracked per request." }; - } catch (error) { - return { message: "Unable to fetch Qwen usage." }; - } -} - -/** - * iFlow Usage - */ -async function getIflowUsage(accessToken) { - try { - // iFlow may have usage endpoint - return { message: "iFlow connected. Usage tracked per request." }; - } catch (error) { - return { message: "Unable to fetch iFlow usage." }; - } -} - -/** - * Ollama Cloud Usage - * Ollama Cloud uses an API key from ollama.com/settings/keys - * and has no public usage API — free tier has light usage limits (resets every 5h & 7d). - * This returns an informational message with the plan details. - */ -async function getOllamaUsage(accessToken, providerSpecificData) { - try { - // Ollama Cloud does not expose a public quota/usage API. - // The provider is configured as noAuth with a notice explaining limits. - // We return a graceful message so the UI shows a friendly state instead of an error. - const plan = providerSpecificData?.plan || "Free"; - return { - plan, - message: "Ollama Cloud uses a free tier with light usage limits (resets every 5h & 7d). For detailed usage tracking, visit ollama.com/settings/keys.", - quotas: [], - }; - } catch (error) { - return { message: "Unable to fetch Ollama Cloud usage." }; - } -} - -/** - * GLM Coding Plan usage (international + China regions) - */ -async function getGlmUsage(apiKey, provider, proxyOptions = null) { - if (!apiKey) { - return { message: "GLM API key not available." }; - } - - const region = provider === "glm-cn" ? "china" : "international"; - const quotaUrl = GLM_QUOTA_URLS[region]; - - try { - const response = await proxyAwareFetch(quotaUrl, { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json", - }, - }, proxyOptions); - - if (!response.ok) { - if (response.status === 401) { - return { message: "GLM API key invalid or expired." }; - } - return { message: `GLM quota API error (${response.status}).` }; - } - - const json = await response.json(); - const data = json?.data && typeof json.data === "object" ? json.data : {}; - const limits = Array.isArray(data.limits) ? data.limits : []; - const quotas = {}; - - for (const limit of limits) { - if (!limit || limit.type !== "TOKENS_LIMIT") continue; - const usedPercent = Number(limit.percentage) || 0; - const resetMs = Number(limit.nextResetTime) || 0; - const remaining = Math.max(0, 100 - usedPercent); - - quotas["session"] = { - used: usedPercent, - total: 100, - remaining, - remainingPercentage: remaining, - resetAt: resetMs > 0 ? new Date(resetMs).toISOString() : null, - unlimited: false, - }; - } - - const levelRaw = typeof data.level === "string" ? data.level : ""; - const plan = levelRaw - ? levelRaw.charAt(0).toUpperCase() + levelRaw.slice(1).toLowerCase() - : "Unknown"; - - return { plan, quotas }; - } catch (error) { - return { message: `GLM error: ${error.message}` }; - } -} - -// ── MiniMax helpers ────────────────────────────────────────────────────── -function getMiniMaxField(model, snakeKey, camelKey) { - if (!model || typeof model !== "object") return null; - return model[snakeKey] ?? model[camelKey] ?? null; -} - -function getMiniMaxModelName(model) { - return String(getMiniMaxField(model, "model_name", "modelName") || "").trim(); -} - -function formatMiniMaxQuotaName(model) { - const rawName = getMiniMaxModelName(model); - if (!rawName) return "MiniMax"; - - // M3+ shared quota pool: MiniMax reports M-series as a single wildcard - // bucket ("MiniMax-M*"). Newer responses rename it to plain "general". - // Render both as a friendly series label rather than leaking the - // asterisk or the vague "general" word to the UI. - if (rawName === "MiniMax-M*" || rawName === "general") return "M-series"; - - return rawName - .replace(/[_-]+/g, " ") - .replace(/\s+/g, " ") - .trim() - .replace(/\b\w/g, (ch) => ch.toUpperCase()) - .replace(/\bTo\b/g, "to") - .replace(/\bTts\b/g, "TTS") - .replace(/\bHd\b/g, "HD"); -} - -function getMiniMaxProvidedPercent(model, snakeKey, camelKey) { - if (!model || typeof model !== "object") return null; - const raw = model[snakeKey] ?? model[camelKey]; - if (raw === null || raw === undefined) return null; - const num = Number(raw); - if (!Number.isFinite(num)) return null; - return Math.max(0, Math.min(100, num)); -} - -function getMiniMaxSessionTotal(model) { - return Math.max(0, Number(getMiniMaxField(model, "current_interval_total_count", "currentIntervalTotalCount")) || 0); -} - -function getMiniMaxWeeklyTotal(model) { - return Math.max(0, Number(getMiniMaxField(model, "current_weekly_total_count", "currentWeeklyTotalCount")) || 0); -} - -function hasMiniMaxQuota(model) { - // Old format has real count totals; M3-era M-series buckets ship percent-only - // (count fields are 0) so accept those too. - if (getMiniMaxSessionTotal(model) > 0 || getMiniMaxWeeklyTotal(model) > 0) return true; - if (getMiniMaxProvidedPercent(model, "current_interval_remaining_percent", "currentIntervalRemainingPercent") !== null) return true; - if (getMiniMaxProvidedPercent(model, "current_weekly_remaining_percent", "currentWeeklyRemainingPercent") !== null) return true; - return false; -} - -function getMiniMaxResetAt(model, capturedAtMs, remainsSnake, remainsCamel, endSnake, endCamel) { - const remainsMs = Number(getMiniMaxField(model, remainsSnake, remainsCamel)) || 0; - if (remainsMs > 0) return new Date(capturedAtMs + remainsMs).toISOString(); - return parseResetTime(getMiniMaxField(model, endSnake, endCamel)); -} - -function buildMiniMaxQuota(total, count, resetAt, countMeansRemaining, providedPercent = null) { - const safeTotal = Math.max(0, total); - const used = countMeansRemaining ? Math.max(safeTotal - count, 0) : Math.min(Math.max(0, count), safeTotal); - const remaining = Math.max(safeTotal - used, 0); - // M-series buckets ship percent-only (count = 0). Prefer the upstream value - // when present, otherwise fall back to the computed percentage. When the - // quota is unbounded (no count) and no upstream percent is available, surface - // the percent anyway as long as it is defined. - const remainingPercentage = providedPercentage(providedPercent, remaining, safeTotal); - return { - used, - total: safeTotal, - remaining, - remainingPercentage, - resetAt, - unlimited: false, - }; -} - -function providedPercentage(provided, remaining, total) { - if (provided !== null && provided !== undefined && Number.isFinite(provided)) { - return Math.max(0, Math.min(100, provided)); - } - return total > 0 ? Math.max(0, Math.min(100, (remaining / total) * 100)) : 0; -} - -function addMiniMaxQuota(quotas, key, model, getTotal, countSnake, countCamel, percentSnake, percentCamel, resetArgs, countMeansRemaining) { - const total = getTotal(model); - const providedPercent = getMiniMaxProvidedPercent(model, percentSnake, percentCamel); - if (total <= 0 && providedPercent === null) return; - - const count = Math.max(0, Number(getMiniMaxField(model, countSnake, countCamel)) || 0); - let effectiveTotal = total; - let effectiveCount = count; - if (total <= 0) { - // M-series bucket: API only ships *_remaining_percent (count = 0). Normalize - // to total=100. The downstream buildMiniMaxQuota treats the count as - // "used" or "remaining" depending on countMeansRemaining, so the synthetic - // count has to match that semantic — otherwise the UI flips the percentage. - effectiveTotal = 100; - const pct = providedPercent; - effectiveCount = countMeansRemaining - ? Math.round(effectiveTotal * (pct / 100)) - : Math.round(effectiveTotal * (1 - pct / 100)); - } - quotas[key] = buildMiniMaxQuota( - effectiveTotal, - effectiveCount, - getMiniMaxResetAt(model, ...resetArgs), - countMeansRemaining, - providedPercent - ); -} - -/** - * MiniMax Token Plan / Coding Plan usage - */ -async function getMiniMaxUsage(apiKey, provider, proxyOptions = null) { - if (!apiKey) { - return { message: "MiniMax API key not available." }; - } - - const usageUrls = MINIMAX_USAGE_URLS[provider] || []; - let lastErrorMessage = ""; - - for (let index = 0; index < usageUrls.length; index += 1) { - const usageUrl = usageUrls[index]; - const canFallback = index < usageUrls.length - 1; - - try { - const response = await proxyAwareFetch(usageUrl, { - method: "GET", - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json", - "Content-Type": "application/json", - }, - }, proxyOptions); - - const rawText = await response.text(); - let payload = {}; - if (rawText) { - try { payload = JSON.parse(rawText); } catch { payload = {}; } - } - - const baseResp = (payload?.base_resp ?? payload?.baseResp) || {}; - const apiStatusCode = Number(baseResp.status_code ?? baseResp.statusCode) || 0; - const apiStatusMessage = String(baseResp.status_msg ?? baseResp.statusMsg ?? "").trim(); - const combined = `${apiStatusMessage} ${rawText}`.trim(); - const authLike = /token plan|coding plan|invalid api key|invalid key|unauthorized|inactive/i; - - if (response.status === 401 || response.status === 403 || apiStatusCode === 1004 || authLike.test(combined)) { - return { message: "MiniMax API key invalid or inactive. Use an active Token/Coding Plan key." }; - } - - if (!response.ok) { - lastErrorMessage = `MiniMax usage endpoint error (${response.status})`; - if ((response.status === 404 || response.status === 405 || response.status >= 500) && canFallback) continue; - return { message: `MiniMax connected. ${lastErrorMessage}` }; - } - - if (apiStatusCode !== 0) { - return { message: `MiniMax connected. ${apiStatusMessage || "Upstream quota API error"}` }; - } - - const modelRemains = payload?.model_remains ?? payload?.modelRemains; - const allModels = Array.isArray(modelRemains) ? modelRemains : []; - const quotaModels = allModels.filter(hasMiniMaxQuota); - - if (quotaModels.length === 0) { - return { message: "MiniMax connected. No quota data was returned." }; - } - - const capturedAtMs = Date.now(); - const countMeansRemaining = usageUrl.includes("/coding_plan/remains"); - const quotas = {}; - - for (const model of quotaModels) { - const displayName = formatMiniMaxQuotaName(model); - addMiniMaxQuota( - quotas, - `${displayName} (5h)`, - model, - getMiniMaxSessionTotal, - "current_interval_usage_count", - "currentIntervalUsageCount", - "current_interval_remaining_percent", - "currentIntervalRemainingPercent", - [capturedAtMs, "remains_time", "remainsTime", "end_time", "endTime"], - countMeansRemaining - ); - - addMiniMaxQuota( - quotas, - `${displayName} (7d)`, - model, - getMiniMaxWeeklyTotal, - "current_weekly_usage_count", - "currentWeeklyUsageCount", - "current_weekly_remaining_percent", - "currentWeeklyRemainingPercent", - [capturedAtMs, "weekly_remains_time", "weeklyRemainsTime", "weekly_end_time", "weeklyEndTime"], - countMeansRemaining - ); - } - - if (Object.keys(quotas).length === 0) { - return { message: "MiniMax connected. Unable to extract quota usage." }; - } - - return { quotas }; - } catch (error) { - lastErrorMessage = error.message; - if (!canFallback) break; - } - } - - return { message: lastErrorMessage ? `MiniMax connected. Unable to fetch usage: ${lastErrorMessage}` : "MiniMax connected. Unable to fetch usage." }; -} - - -/** - * Vercel AI Gateway usage — credit balance for the API key - * - * Calls GET /v1/credits which returns: - * { "balance": "95.50", "total_used": "4.50" } (USD as decimal strings) - * - * We surface this as a single "Balance ($)" quota row so the existing - * QuotaTable / progress-bar UI can render it. used = total_used, - * total = balance + total_used (the original credit allotment), so the - * remaining percentage equals balance / total. - * - * Docs: https://vercel.com/docs/ai-gateway/usage - */ -async function getVercelAiGatewayUsage(apiKey, proxyOptions = null) { - if (!apiKey) { - return { message: "Vercel AI Gateway API key not available." }; - } - - try { - const response = await proxyAwareFetch(VERCEL_AI_GATEWAY_CREDITS_URL, { - method: "GET", - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json", - }, - }, proxyOptions); - - if (response.status === 401 || response.status === 403) { - return { message: "Vercel AI Gateway API key invalid or expired." }; - } - - if (!response.ok) { - const errorText = await response.text().catch(() => ""); - const trimmed = errorText ? `: ${errorText.slice(0, 200)}` : ""; - return { message: `Vercel AI Gateway credits API error (${response.status})${trimmed}` }; - } - - const data = await response.json(); - - // Vercel returns numeric strings; coerce safely. - const balance = Number(data?.balance) || 0; - const totalUsed = Number(data?.total_used) || 0; - - // Vercel gives $5/month free credit. The API doesn't return the - // monthly allocation so we use the known constant as the denominator. - const MONTHLY_CREDIT = 5; - const remainingPercentage = (balance / MONTHLY_CREDIT) * 100; - - if (balance <= 0 && totalUsed <= 0) { - return { - plan: "Pay-as-you-go", - message: "Vercel AI Gateway connected. No credit allocation found (BYOK or unfunded account).", - quotas: {}, - }; - } - - // "Used (USD)": how much has been spent this month (no fixed cap → unlimited). - // "Remaining (USD)": balance remaining out of the $5 monthly allocation. - return { - plan: "Pay-as-you-go", - quotas: { - "Used (USD)": { - used: totalUsed, - total: 0, - remaining: 0, - remainingPercentage: 100, - unlimited: true, - }, - "Remaining (USD)": { - used: balance, - total: MONTHLY_CREDIT, - remaining: balance, - remainingPercentage, - unlimited: false, - }, - }, - }; - } catch (error) { - return { message: `Vercel AI Gateway error: ${error.message}` }; - } -} - -async function getQoderUsage(accessToken, proxyOptions = null) { - if (!accessToken) { - return { message: "Qoder usage unavailable: no access token" }; - } - try { - const response = await proxyAwareFetch( - "https://openapi.qoder.sh/api/v2/quota/usage", - { - method: "GET", - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", - }, - }, - proxyOptions, - ); - if (!response.ok) { - return { message: `Qoder connected. Usage fetch returned ${response.status}.` }; - } - const body = await response.json().catch(() => null); - if (!body) { - return { message: "Qoder connected. Usage response was not JSON." }; - } - // Quota records live under `quotas`; scalar metadata - // (totalUsagePercentage, isQuotaExceeded, expiresAt) are surfaced as - // siblings so the dashboard parser doesn't try to render them as rows. - const userQuota = body.userQuota || {}; - const orgQuota = body.orgResourcePackage || {}; - // Qoder publishes a single absolute reset timestamp (`expiresAt` in ms); - // surface it on every quota record as ISO so the table can render - // "resets at" alongside used/total. - const expiresAtMs = Number.isFinite(Number(body.expiresAt)) && Number(body.expiresAt) > 0 - ? Number(body.expiresAt) - : null; - const resetAt = expiresAtMs ? new Date(expiresAtMs).toISOString() : null; - const quotas = { - user: { - total: Number(userQuota.total) || 0, - used: Number(userQuota.used) || 0, - remaining: Number(userQuota.remaining) || 0, - unit: userQuota.unit || "credits", - resetAt, - }, - organization: { - total: Number(orgQuota.total) || 0, - used: Number(orgQuota.used) || 0, - remaining: Number(orgQuota.remaining) || 0, - unit: orgQuota.unit || "credits", - resetAt, - }, - }; - return { - quotas, - totalUsagePercentage: Number(body.totalUsagePercentage) || 0, - isQuotaExceeded: !!body.isQuotaExceeded, - expiresAt: expiresAtMs, - }; - } catch (error) { - return { message: `Qoder connected. Unable to fetch usage: ${error.message}` }; - } + const handler = USAGE_HANDLERS[provider]; + if (!handler) return { message: `Usage API not implemented for ${provider}` }; + return await handler({ provider, accessToken, apiKey, providerSpecificData, providerDataWithProjectId, proxyOptions }); } diff --git a/open-sse/services/usage/claude.js b/open-sse/services/usage/claude.js new file mode 100644 index 00000000..85ab8e6f --- /dev/null +++ b/open-sse/services/usage/claude.js @@ -0,0 +1,147 @@ +/** + * Claude usage handler + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { ANTHROPIC_API_VERSION } from "../../providers/shared.js"; +import { U, parseResetTime } from "./shared.js"; + +// Claude API config (urls from registry, apiVersion is header logic kept here) +const CLAUDE_CONFIG = { + oauthUsageUrl: U("claude").oauthUrl, + usageUrl: U("claude").orgUrl, + settingsUrl: U("claude").settingsUrl, + apiVersion: ANTHROPIC_API_VERSION, +}; + +// OAuth usage endpoint rate-limits (429); cool down per-token to stop hammering it. +// Only the quota endpoint is affected — chat with the same token still works. +const OAUTH_429_COOLDOWN_MS = 180000; +const oauthCooldown = new Map(); + +export async function getClaudeUsage(accessToken, proxyOptions = null) { + try { + // Skip OAuth usage call while this token is cooling down from a recent 429 + const cooldownUntil = oauthCooldown.get(accessToken); + if (cooldownUntil && Date.now() < cooldownUntil) { + return await getClaudeUsageLegacy(accessToken, proxyOptions); + } + + // Primary: OAuth usage endpoint (Claude Code consumer OAuth tokens) + const oauthResponse = await proxyAwareFetch(CLAUDE_CONFIG.oauthUsageUrl, { + method: "GET", + headers: { + "Authorization": `Bearer ${accessToken}`, + "anthropic-beta": "oauth-2025-04-20", + "anthropic-version": CLAUDE_CONFIG.apiVersion, + }, + }, proxyOptions); + + if (oauthResponse.ok) { + const data = await oauthResponse.json(); + const quotas = {}; + + // utilization = % USED (e.g. 87 means 87% used, 13% remaining) + const hasUtilization = (window) => + window && typeof window === "object" && typeof window.utilization === "number"; + + const createQuotaObject = (window) => { + const used = window.utilization; + const remaining = Math.max(0, 100 - used); + return { + used, + total: 100, + remaining, + remainingPercentage: remaining, + resetAt: parseResetTime(window.resets_at), + unlimited: false, + }; + }; + + if (hasUtilization(data.five_hour)) { + quotas["session (5h)"] = createQuotaObject(data.five_hour); + } + + if (hasUtilization(data.seven_day)) { + quotas["weekly (7d)"] = createQuotaObject(data.seven_day); + } + + // Parse model-specific weekly windows (e.g. seven_day_sonnet, seven_day_opus) + for (const [key, value] of Object.entries(data)) { + if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(value)) { + const modelName = key.replace("seven_day_", ""); + quotas[`weekly ${modelName} (7d)`] = createQuotaObject(value); + } + } + + return { + plan: "Claude Code", + extraUsage: data.extra_usage ?? null, + quotas, + }; + } + + // Cool down OAuth usage polling after a 429 (quota endpoint only) + if (oauthResponse.status === 429) { + oauthCooldown.set(accessToken, Date.now() + OAUTH_429_COOLDOWN_MS); + } + + // Fallback: legacy settings + org usage endpoint + console.warn(`[Claude Usage] OAuth endpoint returned ${oauthResponse.status}, falling back to legacy`); + return await getClaudeUsageLegacy(accessToken, proxyOptions); + } catch (error) { + return { message: `Claude connected. Unable to fetch usage: ${error.message}` }; + } +} + +/** + * Legacy Claude usage for API key / org admin users + */ +async function getClaudeUsageLegacy(accessToken, proxyOptions = null) { + try { + const settingsResponse = await proxyAwareFetch(CLAUDE_CONFIG.settingsUrl, { + method: "GET", + headers: { + "Authorization": `Bearer ${accessToken}`, + "anthropic-version": CLAUDE_CONFIG.apiVersion, + }, + }, proxyOptions); + + if (settingsResponse.ok) { + const settings = await settingsResponse.json(); + + if (settings.organization_id) { + const usageResponse = await proxyAwareFetch( + CLAUDE_CONFIG.usageUrl.replace("{org_id}", settings.organization_id), + { + method: "GET", + headers: { + "Authorization": `Bearer ${accessToken}`, + "anthropic-version": CLAUDE_CONFIG.apiVersion, + }, + }, + proxyOptions + ); + + if (usageResponse.ok) { + const usage = await usageResponse.json(); + return { + plan: settings.plan || "Unknown", + organization: settings.organization_name, + quotas: usage, + }; + } + } + + return { + plan: settings.plan || "Unknown", + organization: settings.organization_name, + message: "Claude connected. Usage details require admin access.", + }; + } + + return { message: "Claude connected. Usage API requires admin permissions." }; + } catch (error) { + return { message: `Claude connected. Unable to fetch usage: ${error.message}` }; + } +} diff --git a/open-sse/services/usage/codebuddy-cn.js b/open-sse/services/usage/codebuddy-cn.js new file mode 100644 index 00000000..32d15c2c --- /dev/null +++ b/open-sse/services/usage/codebuddy-cn.js @@ -0,0 +1,131 @@ +/** + * CodeBuddy CN usage handler + * + * Scoped to the "codebuddy-cn" provider specifically — a future "codebuddy-intl" + * variant would get its own handler/endpoint, so keep this CN-only. + * + * Quota lives behind a Tencent billing endpoint (POST, payload wrapped twice + * under data.Response.Data). It mixes two credit types that must NOT be merged: + * + * - Refill / base ("基础体验包"): a recurring allowance whose cycle resets long + * before the resource itself expires (CycleEndTime << DeductionEndTime). The + * live numbers live in the *Cycle* fields (e.g. CycleCapacityUsed 6.54 / 500) + * and resetAt is the next monthly refresh. + * - Bonus ("活动赠送包"): one-shot credits that run a single cycle and then + * expire for good (CycleEndTime == DeductionEndTime). Numbers live in the + * plain Capacity fields. + * + * We surface one quota row per package — a cadence label (Monthly/Weekly/Daily) + * for refill packs, "Bonus Pack N" for bonus packs (soonest-expiring first). + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { PROVIDERS } from "../../providers/index.js"; +import { U, parseResetTime } from "./shared.js"; + +const PROVIDER_ID = "codebuddy-cn"; + +// Prefer the *Precise string fields (exact), fall back to the numeric ones. +function num(precise, plain) { + const n = Number(precise ?? plain); + return Number.isFinite(n) ? n : 0; +} + +// Label a refill pack by its cycle length (Monthly is the common CodeBuddy case). +function refillCadence(acc) { + const start = parseResetTime(acc.CycleStartTime); + const end = parseResetTime(acc.CycleEndTime); + if (start && end) { + const days = (new Date(end).getTime() - new Date(start).getTime()) / 86400000; + if (days <= 1.5) return "Daily"; + if (days <= 10) return "Weekly"; + } + return "Monthly"; +} + +export async function getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificData, proxyOptions = null) { + const token = accessToken || apiKey; + if (!token) { + return { message: "CodeBuddy CN credential not available." }; + } + + try { + const response = await proxyAwareFetch(U(PROVIDER_ID).url, { + method: "POST", + headers: { + ...(PROVIDERS[PROVIDER_ID]?.headers || {}), + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: "{}", + }, proxyOptions); + + if (response.status === 401 || response.status === 403) { + return { message: "CodeBuddy CN credential invalid or expired." }; + } + if (!response.ok) { + return { message: `CodeBuddy CN quota API error (${response.status}).` }; + } + + const json = await response.json(); + if (json?.code !== 0) { + return { message: `CodeBuddy CN quota error: ${json?.msg || "unknown"}` }; + } + + const data = json?.data?.Response?.Data || {}; + const accounts = Array.isArray(data.Accounts) ? data.Accounts : []; + if (accounts.length === 0) { + return { message: "CodeBuddy CN connected. No credit package found." }; + } + + const cycleEndMs = (acc) => { + const r = parseResetTime(acc.CycleEndTime); + return r ? new Date(r).getTime() : Number.POSITIVE_INFINITY; + }; + // Refill packs roll into a new cycle before the resource expires; bonus packs + // end exactly at expiry. >2d gap between cycle end and validity end = refill. + const REFILL_GAP_MS = 2 * 24 * 60 * 60 * 1000; + const isRefill = (acc) => { + const ce = cycleEndMs(acc); + const de = Number(acc.DeductionEndTime); + return Number.isFinite(ce) && Number.isFinite(de) && de - ce > REFILL_GAP_MS; + }; + const byExpiry = (a, b) => cycleEndMs(a) - cycleEndMs(b); + + const refills = accounts.filter(isRefill).sort(byExpiry); + const bonuses = accounts.filter((a) => !isRefill(a)).sort(byExpiry); + + const quotas = {}; + // Refill packs first: cadence-labelled, using the *Cycle* balance and + // resetting at the next refresh. + const seenRefill = {}; + refills.forEach((acc) => { + const base = refillCadence(acc); + seenRefill[base] = (seenRefill[base] || 0) + 1; + const name = seenRefill[base] > 1 ? `${base} ${seenRefill[base]}` : base; + quotas[name] = { + used: num(acc.CycleCapacityUsedPrecise, acc.CycleCapacityUsed), + total: num(acc.CycleCapacitySizePrecise, acc.CycleCapacitySize), + resetAt: parseResetTime(acc.CycleEndTime), + unlimited: false, + }; + }); + // Bonus packs: use the lifetime Capacity balance; resetAt is the expiry. + bonuses.forEach((acc, i) => { + quotas[`Bonus Pack ${i + 1}`] = { + used: num(acc.CapacityUsedPrecise, acc.CapacityUsed), + total: num(acc.CapacitySizePrecise, acc.CapacitySize), + resetAt: parseResetTime(acc.CycleEndTime), + unlimited: false, + }; + }); + + const basePkg = refills[0] || accounts[0] || {}; + const plan = basePkg.PackageName || basePkg.SubProductName || "CodeBuddy CN"; + + return { plan, quotas }; + } catch (error) { + return { message: `CodeBuddy CN error: ${error.message}` }; + } +} diff --git a/open-sse/services/usage/codex.js b/open-sse/services/usage/codex.js new file mode 100644 index 00000000..cfcd5931 --- /dev/null +++ b/open-sse/services/usage/codex.js @@ -0,0 +1,145 @@ +/** + * Codex (OpenAI) usage handler + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { U, parseResetTime, toFiniteNumber } from "./shared.js"; + +// Codex (OpenAI) API config +const CODEX_CONFIG = { + usageUrl: U("codex").url, + resetCreditsConsumeUrl: U("codex").resetCreditsConsumeUrl, +}; + +function getCodexRateLimitBody(snapshot) { + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return null; + return snapshot.rate_limit && typeof snapshot.rate_limit === "object" + ? snapshot.rate_limit + : snapshot; +} + +function formatCodexWindow(window) { + const used = Math.max(0, Math.min(100, toFiniteNumber(window?.used_percent ?? window?.percent_used, 0))); + return { + used, + total: 100, + remaining: Math.max(0, 100 - used), + resetAt: parseResetTime(window?.reset_at ?? window?.resets_at ?? window?.resetAt ?? null), + unlimited: false, + }; +} + +function appendCodexQuotaWindows(quotas, prefix, snapshot) { + const rateLimit = getCodexRateLimitBody(snapshot); + if (!rateLimit) return false; + + const primary = rateLimit.primary_window || rateLimit.primary || snapshot.primary_window || snapshot.primary; + const secondary = rateLimit.secondary_window || rateLimit.secondary || snapshot.secondary_window || snapshot.secondary; + let added = false; + + if (primary) { + quotas[prefix ? `${prefix}_session` : "session"] = formatCodexWindow(primary); + added = true; + } + if (secondary) { + quotas[prefix ? `${prefix}_weekly` : "weekly"] = formatCodexWindow(secondary); + added = true; + } + + return added; +} + +function getCodexReviewRateLimit(data) { + if (data.code_review_rate_limit || data.review_rate_limit) { + return data.code_review_rate_limit || data.review_rate_limit; + } + + const byLimitId = data.rate_limits_by_limit_id; + if (byLimitId && typeof byLimitId === "object" && !Array.isArray(byLimitId)) { + return byLimitId.code_review || byLimitId.codex_review || byLimitId.review || null; + } + + const additional = Array.isArray(data.additional_rate_limits) ? data.additional_rate_limits : []; + return additional.find((entry) => { + const id = String(entry?.limit_name || entry?.metered_feature || entry?.id || "").toLowerCase(); + return id === "code_review" || id === "codex_review" || id === "review" || id.includes("review"); + }) || null; +} + +export async function getCodexUsage(accessToken, proxyOptions = null) { + try { + const response = await proxyAwareFetch(CODEX_CONFIG.usageUrl, { + method: "GET", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Accept": "application/json", + }, + }, proxyOptions); + + if (!response.ok) { + return { message: `Codex connected. Usage API temporarily unavailable (${response.status}).` }; + } + + const data = await response.json(); + const normalRateLimit = data.rate_limit || data.rate_limits || data.rate_limits_by_limit_id?.codex || {}; + const reviewRateLimit = getCodexReviewRateLimit(data); + const availableResetCredits = Math.max(0, toFiniteNumber(data.rate_limit_reset_credits?.available_count, 0)); + const quotas = {}; + + appendCodexQuotaWindows(quotas, "", normalRateLimit); + appendCodexQuotaWindows(quotas, "review", reviewRateLimit); + + return { + plan: data.plan_type || data.summary?.plan || "unknown", + limitReached: getCodexRateLimitBody(normalRateLimit)?.limit_reached || false, + reviewLimitReached: getCodexRateLimitBody(reviewRateLimit)?.limit_reached || false, + resetCredits: { availableCount: availableResetCredits }, + quotas, + }; + } catch (error) { + throw new Error(`Failed to fetch Codex usage: ${error.message}`); + } +} + +// Consume one Codex rate-limit reset credit (irreversible, spends 1 credit) +export async function consumeCodexRateLimitResetCredit(accessToken, redeemRequestId, proxyOptions = null) { + if (!accessToken) { + throw new Error("No Codex access token available. Please re-authorize the connection."); + } + if (!redeemRequestId || typeof redeemRequestId !== "string") { + throw new Error("A redeem request id is required to consume a Codex reset credit."); + } + + let response; + let data = null; + try { + response = await proxyAwareFetch(CODEX_CONFIG.resetCreditsConsumeUrl, { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Accept": "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ redeem_request_id: redeemRequestId }), + }, proxyOptions); + + const text = await response.text(); + data = text ? JSON.parse(text) : null; + } catch (error) { + throw new Error(`Failed to consume Codex reset credit: ${error.message}`); + } + + const code = data?.code || null; + const windowsReset = toFiniteNumber(data?.windows_reset, 0); + const success = response.ok && (code === "reset" || windowsReset > 0); + + return { + ok: success, + noCredit: response.ok && code === "no_credit", + status: response.status, + code, + windowsReset, + message: data?.message || null, + raw: data, + }; +} diff --git a/open-sse/services/usage/github.js b/open-sse/services/usage/github.js new file mode 100644 index 00000000..8eec3b60 --- /dev/null +++ b/open-sse/services/usage/github.js @@ -0,0 +1,100 @@ +/** + * GitHub Copilot usage handler + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { PROVIDER_OAUTH } from "../../providers/index.js"; +import { U, parseResetTime } from "./shared.js"; + +// GitHub API config — single source from registry oauth block +const GITHUB_CONFIG = { + apiVersion: PROVIDER_OAUTH.github?.apiVersion, + userAgent: PROVIDER_OAUTH.github?.userAgent, +}; + +/** + * GitHub Copilot Usage + * Uses GitHub accessToken (not copilotToken) to call copilot_internal/user API + */ +export async function getGitHubUsage(accessToken, providerSpecificData, proxyOptions = null) { + try { + if (!accessToken) { + throw new Error("No GitHub access token available. Please re-authorize the connection."); + } + + // copilot_internal/user API requires GitHub OAuth token, not copilotToken + const response = await proxyAwareFetch(U("github").url, { + headers: { + "Authorization": `token ${accessToken}`, + "Accept": "application/json", + "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, + "User-Agent": GITHUB_CONFIG.userAgent, + "Editor-Version": "vscode/1.100.0", + "Editor-Plugin-Version": "copilot-chat/0.26.7", + }, + }, proxyOptions); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`GitHub API error: ${error}`); + } + + const data = await response.json(); + + // Handle different response formats (paid vs free) + if (data.quota_snapshots) { + // Paid plan format + const snapshots = data.quota_snapshots; + const resetAt = parseResetTime(data.quota_reset_date); + + return { + plan: data.copilot_plan, + resetDate: data.quota_reset_date, + quotas: { + chat: { ...formatGitHubQuotaSnapshot(snapshots.chat), resetAt }, + completions: { ...formatGitHubQuotaSnapshot(snapshots.completions), resetAt }, + premium_interactions: { ...formatGitHubQuotaSnapshot(snapshots.premium_interactions), resetAt }, + }, + }; + } else if (data.monthly_quotas || data.limited_user_quotas) { + // Free/limited plan format + const monthlyQuotas = data.monthly_quotas || {}; + const usedQuotas = data.limited_user_quotas || {}; + const resetAt = parseResetTime(data.limited_user_reset_date); + + return { + plan: data.copilot_plan || data.access_type_sku, + resetDate: data.limited_user_reset_date, + quotas: { + chat: { + used: usedQuotas.chat || 0, + total: monthlyQuotas.chat || 0, + unlimited: false, + resetAt, + }, + completions: { + used: usedQuotas.completions || 0, + total: monthlyQuotas.completions || 0, + unlimited: false, + resetAt, + }, + }, + }; + } + + return { message: "GitHub Copilot connected. Unable to parse quota data." }; + } catch (error) { + throw new Error(`Failed to fetch GitHub usage: ${error.message}`); + } +} + +function formatGitHubQuotaSnapshot(quota) { + if (!quota) return { used: 0, total: 0, unlimited: true }; + + return { + used: quota.entitlement - quota.remaining, + total: quota.entitlement, + remaining: quota.remaining, + unlimited: quota.unlimited || false, + }; +} diff --git a/open-sse/services/usage/google.js b/open-sse/services/usage/google.js new file mode 100644 index 00000000..71c53e89 --- /dev/null +++ b/open-sse/services/usage/google.js @@ -0,0 +1,243 @@ +/** + * Google usage handlers (Gemini CLI + Antigravity) + */ + +import { CLIENT_METADATA, getPlatformUserAgent } from "../../config/appConstants.js"; +import { ANTIGRAVITY_OAUTH_CLIENT } from "../../providers/shared.js"; +import { U, parseResetTime, normalizeCloudCodeProjectId, fetchWithTimeout } from "./shared.js"; + +// Antigravity API config (from Quotio) — urls from registry, oauth client + dynamic UA kept here +const ANTIGRAVITY_CONFIG = { + ...U("antigravity"), + ...ANTIGRAVITY_OAUTH_CLIENT, + userAgent: getPlatformUserAgent(), +}; + +/** + * Gemini CLI Usage — fetch per-model quota via Cloud Code Assist API. + * Uses retrieveUserQuota (same endpoint as `gemini /stats`) returning + * per-model buckets with remainingFraction + resetTime. + */ +export async function getGeminiUsage(accessToken, providerSpecificData, proxyOptions = null) { + if (!accessToken) { + return { plan: "Free", message: "Gemini CLI access token not available." }; + } + + try { + // Resolve project id: prefer connection-stored id, else loadCodeAssist lookup. + // #1271: OAuth save stores projectId on the connection, not providerSpecificData. + let projectId = normalizeCloudCodeProjectId(providerSpecificData?.projectId); + let plan = "Free"; + + if (!projectId) { + const subInfo = await getGeminiSubscriptionInfo(accessToken, proxyOptions); + projectId = normalizeCloudCodeProjectId(subInfo?.cloudaicompanionProject); + plan = subInfo?.currentTier?.name || plan; + } + + if (!projectId) { + return { + plan, + message: "Gemini CLI project ID not available. Reconnect Gemini CLI, or configure a Google Cloud project with Gemini Code Assist access before checking quota.", + }; + } + + const response = await fetchWithTimeout( + U("gemini-cli").quotaUrl, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ project: projectId }), + }, + 10000, + proxyOptions + ); + + if (!response.ok) { + return { plan, message: `Gemini CLI quota error (${response.status}).` }; + } + + const data = await response.json(); + const quotas = {}; + + if (Array.isArray(data.buckets)) { + for (const bucket of data.buckets) { + if (!bucket.modelId || bucket.remainingFraction == null) continue; + + const remainingFraction = Number(bucket.remainingFraction) || 0; + const total = 1000; // Normalized base, matches antigravity convention + const remaining = Math.round(total * remainingFraction); + const used = Math.max(0, total - remaining); + + quotas[bucket.modelId] = { + used, + total, + resetAt: parseResetTime(bucket.resetTime), + remainingPercentage: remainingFraction * 100, + unlimited: false, + }; + } + } + + return { plan, quotas }; + } catch (error) { + return { message: `Gemini CLI error: ${error.message}` }; + } +} + +/** + * Get Gemini CLI subscription info via loadCodeAssist + */ +async function getGeminiSubscriptionInfo(accessToken, proxyOptions = null) { + try { + const response = await fetchWithTimeout( + U("gemini-cli").loadCodeAssistUrl, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ metadata: CLIENT_METADATA }), + }, + 10000, + proxyOptions + ); + if (!response.ok) return null; + return await response.json(); + } catch { + return null; + } +} + +/** + * Antigravity Usage - Fetch quota from Google Cloud Code API + */ +export async function getAntigravityUsage(accessToken, providerSpecificData, proxyOptions = null) { + try { + // Fetch subscription info once — reuse for both projectId and plan + const subscriptionInfo = await getAntigravitySubscriptionInfo(accessToken, proxyOptions); + const projectId = subscriptionInfo?.cloudaicompanionProject || null; + + const response = await fetchWithTimeout(ANTIGRAVITY_CONFIG.quotaApiUrl, { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "User-Agent": ANTIGRAVITY_CONFIG.userAgent, + "Content-Type": "application/json", + "X-Client-Name": "antigravity", + "X-Client-Version": "1.107.0", + "x-request-source": "local", // MITM bypass + }, + body: JSON.stringify({ + ...(projectId ? { project: projectId } : {}) + }), + }, 10000, proxyOptions); + + if (response.status === 403) { + return { + message: "Antigravity quota API access forbidden. Chat may still work.", + quotas: {} + }; + } + + if (response.status === 401) { + return { + message: "Antigravity quota API authentication expired. Chat may still work.", + quotas: {} + }; + } + + if (!response.ok) { + throw new Error(`Antigravity API error: ${response.status}`); + } + + const data = await response.json(); + const quotas = {}; + + // Parse model quotas (inspired by vscode-antigravity-cockpit) + if (data.models) { + // Filter only recommended/important models (must match PROVIDER_MODELS ag ids) + const importantModels = [ + 'gemini-3-flash-agent', + 'gemini-3.5-flash-low', + 'gemini-3.5-flash-extra-low', + 'gemini-pro-agent', + 'gemini-3.1-pro-low', + 'claude-sonnet-4-6', + 'claude-opus-4-6-thinking', + 'gpt-oss-120b-medium', + 'gemini-3-flash', + // Image generation models + 'gemini-3.1-flash-image', + 'gemini-3-pro-image', + ]; + + for (const [modelKey, info] of Object.entries(data.models)) { + // Skip models without quota info + if (!info.quotaInfo) { + continue; + } + + // Skip internal models and non-important models + if (info.isInternal || !importantModels.includes(modelKey)) { + continue; + } + + const remainingFraction = info.quotaInfo.remainingFraction || 0; + const remainingPercentage = remainingFraction * 100; + + // Convert percentage to used/total for UI compatibility + const total = 1000; // Normalized base + const remaining = Math.round(total * remainingFraction); + const used = total - remaining; + + // Use modelKey as key (matches PROVIDER_MODELS id) + quotas[modelKey] = { + used, + total, + resetAt: parseResetTime(info.quotaInfo.resetTime), + remainingPercentage, + unlimited: false, + displayName: info.displayName || modelKey, + }; + } + } + + return { + plan: subscriptionInfo?.currentTier?.name || "Unknown", + quotas, + subscriptionInfo, + }; + } catch (error) { + console.error("[Antigravity Usage] Error:", error.message, error.cause); + return { message: `Antigravity error: ${error.message}` }; + } +} + +/** + * Get Antigravity subscription info + */ +async function getAntigravitySubscriptionInfo(accessToken, proxyOptions = null) { + try { + const response = await fetchWithTimeout(ANTIGRAVITY_CONFIG.loadProjectApiUrl, { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "User-Agent": ANTIGRAVITY_CONFIG.userAgent, + "Content-Type": "application/json", + "x-request-source": "local", // MITM bypass + }, + body: JSON.stringify({ metadata: CLIENT_METADATA, mode: 1 }), + }, 10000, proxyOptions); + + if (!response.ok) return null; + return await response.json(); + } catch (error) { + console.error("[Antigravity Subscription] Error:", error.message); + return null; + } +} diff --git a/open-sse/services/usage/kiro.js b/open-sse/services/usage/kiro.js new file mode 100644 index 00000000..f517d0cd --- /dev/null +++ b/open-sse/services/usage/kiro.js @@ -0,0 +1,188 @@ +/** + * Kiro (AWS CodeWhisperer) usage handler + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { resolveDefaultProfileArn } from "../../config/kiroConstants.js"; +import { U, parseResetTime } from "./shared.js"; + +/** + * Kiro (AWS CodeWhisperer) Usage + */ +function parseKiroQuotaData(data) { + const usageList = data.usageBreakdownList || []; + const quotaInfo = {}; + const resetAt = parseResetTime(data.nextDateReset || data.resetDate); + + usageList.forEach((breakdown) => { + const resourceType = breakdown.resourceType?.toLowerCase() || "unknown"; + const used = breakdown.currentUsageWithPrecision || 0; + const total = breakdown.usageLimitWithPrecision || 0; + + quotaInfo[resourceType] = { + used, + total, + remaining: total - used, + resetAt, + unlimited: false, + }; + + // Add free trial if available + if (breakdown.freeTrialInfo) { + const freeUsed = breakdown.freeTrialInfo.currentUsageWithPrecision || 0; + const freeTotal = breakdown.freeTrialInfo.usageLimitWithPrecision || 0; + + quotaInfo[`${resourceType}_freetrial`] = { + used: freeUsed, + total: freeTotal, + remaining: freeTotal - freeUsed, + resetAt: parseResetTime(breakdown.freeTrialInfo.freeTrialExpiry || resetAt), + unlimited: false, + }; + } + }); + + return { + plan: data.subscriptionInfo?.subscriptionTitle || "Kiro", + quotas: quotaInfo, + }; +} + +export async function getKiroUsage(accessToken, providerSpecificData, proxyOptions = null) { + const authMethod = providerSpecificData?.authMethod || "builder-id"; + // API-key Kiro connections authenticate the quota API the same way the chat + // executor does: a bearer token plus a `tokentype: API_KEY` header so + // CodeWhisperer treats it as a long-lived API key rather than an OIDC token. + // Without this header the GetUsageLimits call is rejected (401/403). + const isApiKey = authMethod === "api_key"; + const isExternalIdp = authMethod === "external_idp"; + const apiKeyHeaders = isApiKey ? { tokentype: "API_KEY" } : {}; + const externalIdpHeaders = isExternalIdp ? { TokenType: "EXTERNAL_IDP" } : {}; + + // For api-key auth, never inject the shared default placeholder profileArn — + // CodeWhisperer 403s a request whose profileArn isn't owned by the key's + // account. Only send a profileArn actually resolved for this connection. + const profileArn = isApiKey + ? (providerSpecificData?.profileArn || "") + : (providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod)); + + const getUsageParams = new URLSearchParams({ + isEmailRequired: "true", + origin: "AI_EDITOR", + resourceType: "AGENTIC_REQUEST", + }); + + // For compatibility, try multiple known Kiro usage endpoints + const attempts = [ + { + name: "codewhisperer-get", + run: async () => proxyAwareFetch( + `${U("kiro").cwHost}${U("kiro").limitsPath}?${getUsageParams.toString()}`, + { + method: "GET", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Accept": "application/json", + "x-amz-user-agent": "aws-sdk-js/1.0.0 KiroIDE", + "user-agent": "aws-sdk-js/1.0.0 KiroIDE", + ...apiKeyHeaders, + ...externalIdpHeaders, + }, + }, + proxyOptions + ), + }, + { + name: "codewhisperer-post", + run: async () => proxyAwareFetch(U("kiro").cwHost, { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/x-amz-json-1.0", + "x-amz-target": "AmazonCodeWhispererService.GetUsageLimits", + "Accept": "application/json", + ...apiKeyHeaders, + ...externalIdpHeaders, + }, + body: JSON.stringify({ + origin: "AI_EDITOR", + ...(profileArn ? { profileArn } : {}), + resourceType: "AGENTIC_REQUEST", + }), + }, proxyOptions), + }, + { + name: "q-get", + run: async () => { + const params = new URLSearchParams({ + origin: "AI_EDITOR", + ...(profileArn ? { profileArn } : {}), + resourceType: "AGENTIC_REQUEST", + }); + return proxyAwareFetch(`${U("kiro").qHost}${U("kiro").limitsPath}?${params}`, { + method: "GET", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Accept": "application/json", + ...apiKeyHeaders, + ...externalIdpHeaders, + }, + }, proxyOptions); + }, + }, + ]; + + let sawAuthError = false; + const errors = []; + + for (const attempt of attempts) { + try { + const response = await attempt.run(); + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + if (response.status === 401 || response.status === 403) { + sawAuthError = true; + } + errors.push(`${attempt.name}:${response.status}${errorText ? `:${errorText}` : ""}`); + continue; + } + + const data = await response.json(); + return parseKiroQuotaData(data); + } catch (error) { + errors.push(`${attempt.name}:${error.message}`); + } + } + + if (sawAuthError && authMethod === "idc") { + return { + message: "Kiro quota API is unavailable for the current AWS IAM Identity Center session. Chat may still work. If this persists after renewing your session, reconnect Kiro.", + quotas: {}, + }; + } + + // Social auth (Google/GitHub) - these use a different token format that may not work with AWS CodeWhisperer quota APIs + if (sawAuthError && (authMethod === "google" || authMethod === "github")) { + return { + message: "Kiro quota API authentication expired. Chat may still work.", + quotas: {}, + }; + } + + if (sawAuthError) { + return { + message: "Kiro quota API rejected the current token. Chat may still work.", + quotas: {}, + }; + } + + const fallbackMessage = + errors.length > 0 + ? `Unable to fetch Kiro usage right now. (${errors[errors.length - 1]})` + : "Unable to fetch Kiro usage right now."; + + return { + message: fallbackMessage, + quotas: {}, + }; +} diff --git a/open-sse/services/usage/minimax.js b/open-sse/services/usage/minimax.js new file mode 100644 index 00000000..81a6087f --- /dev/null +++ b/open-sse/services/usage/minimax.js @@ -0,0 +1,234 @@ +/** + * MiniMax usage handler + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { U, parseResetTime } from "./shared.js"; + +// MiniMax usage endpoints (try in order, fallback on transient errors) +const MINIMAX_USAGE_URLS = { + minimax: U("minimax").urls, + "minimax-cn": U("minimax-cn").urls, +}; + +// ── MiniMax helpers ────────────────────────────────────────────────────── +function getMiniMaxField(model, snakeKey, camelKey) { + if (!model || typeof model !== "object") return null; + return model[snakeKey] ?? model[camelKey] ?? null; +} + +function getMiniMaxModelName(model) { + return String(getMiniMaxField(model, "model_name", "modelName") || "").trim(); +} + +function formatMiniMaxQuotaName(model) { + const rawName = getMiniMaxModelName(model); + if (!rawName) return "MiniMax"; + + // M3+ shared quota pool: MiniMax reports M-series as a single wildcard + // bucket ("MiniMax-M*"). Newer responses rename it to plain "general". + // Render both as a friendly series label rather than leaking the + // asterisk or the vague "general" word to the UI. + if (rawName === "MiniMax-M*" || rawName === "general") return "M-series"; + + return rawName + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .replace(/\b\w/g, (ch) => ch.toUpperCase()) + .replace(/\bTo\b/g, "to") + .replace(/\bTts\b/g, "TTS") + .replace(/\bHd\b/g, "HD"); +} + +function getMiniMaxProvidedPercent(model, snakeKey, camelKey) { + if (!model || typeof model !== "object") return null; + const raw = model[snakeKey] ?? model[camelKey]; + if (raw === null || raw === undefined) return null; + const num = Number(raw); + if (!Number.isFinite(num)) return null; + return Math.max(0, Math.min(100, num)); +} + +function getMiniMaxSessionTotal(model) { + return Math.max(0, Number(getMiniMaxField(model, "current_interval_total_count", "currentIntervalTotalCount")) || 0); +} + +function getMiniMaxWeeklyTotal(model) { + return Math.max(0, Number(getMiniMaxField(model, "current_weekly_total_count", "currentWeeklyTotalCount")) || 0); +} + +function hasMiniMaxQuota(model) { + // Old format has real count totals; M3-era M-series buckets ship percent-only + // (count fields are 0) so accept those too. + if (getMiniMaxSessionTotal(model) > 0 || getMiniMaxWeeklyTotal(model) > 0) return true; + if (getMiniMaxProvidedPercent(model, "current_interval_remaining_percent", "currentIntervalRemainingPercent") !== null) return true; + if (getMiniMaxProvidedPercent(model, "current_weekly_remaining_percent", "currentWeeklyRemainingPercent") !== null) return true; + return false; +} + +function getMiniMaxResetAt(model, capturedAtMs, remainsSnake, remainsCamel, endSnake, endCamel) { + const remainsMs = Number(getMiniMaxField(model, remainsSnake, remainsCamel)) || 0; + if (remainsMs > 0) return new Date(capturedAtMs + remainsMs).toISOString(); + return parseResetTime(getMiniMaxField(model, endSnake, endCamel)); +} + +function buildMiniMaxQuota(total, count, resetAt, countMeansRemaining, providedPercent = null) { + const safeTotal = Math.max(0, total); + const used = countMeansRemaining ? Math.max(safeTotal - count, 0) : Math.min(Math.max(0, count), safeTotal); + const remaining = Math.max(safeTotal - used, 0); + // M-series buckets ship percent-only (count = 0). Prefer the upstream value + // when present, otherwise fall back to the computed percentage. When the + // quota is unbounded (no count) and no upstream percent is available, surface + // the percent anyway as long as it is defined. + const remainingPercentage = providedPercentage(providedPercent, remaining, safeTotal); + return { + used, + total: safeTotal, + remaining, + remainingPercentage, + resetAt, + unlimited: false, + }; +} + +function providedPercentage(provided, remaining, total) { + if (provided !== null && provided !== undefined && Number.isFinite(provided)) { + return Math.max(0, Math.min(100, provided)); + } + return total > 0 ? Math.max(0, Math.min(100, (remaining / total) * 100)) : 0; +} + +function addMiniMaxQuota(quotas, key, model, getTotal, countSnake, countCamel, percentSnake, percentCamel, resetArgs, countMeansRemaining) { + const total = getTotal(model); + const providedPercent = getMiniMaxProvidedPercent(model, percentSnake, percentCamel); + if (total <= 0 && providedPercent === null) return; + + const count = Math.max(0, Number(getMiniMaxField(model, countSnake, countCamel)) || 0); + let effectiveTotal = total; + let effectiveCount = count; + if (total <= 0) { + // M-series bucket: API only ships *_remaining_percent (count = 0). Normalize + // to total=100. The downstream buildMiniMaxQuota treats the count as + // "used" or "remaining" depending on countMeansRemaining, so the synthetic + // count has to match that semantic — otherwise the UI flips the percentage. + effectiveTotal = 100; + const pct = providedPercent; + effectiveCount = countMeansRemaining + ? Math.round(effectiveTotal * (pct / 100)) + : Math.round(effectiveTotal * (1 - pct / 100)); + } + quotas[key] = buildMiniMaxQuota( + effectiveTotal, + effectiveCount, + getMiniMaxResetAt(model, ...resetArgs), + countMeansRemaining, + providedPercent + ); +} + +/** + * MiniMax Token Plan / Coding Plan usage + */ +export async function getMiniMaxUsage(apiKey, provider, proxyOptions = null) { + if (!apiKey) { + return { message: "MiniMax API key not available." }; + } + + const usageUrls = MINIMAX_USAGE_URLS[provider] || []; + let lastErrorMessage = ""; + + for (let index = 0; index < usageUrls.length; index += 1) { + const usageUrl = usageUrls[index]; + const canFallback = index < usageUrls.length - 1; + + try { + const response = await proxyAwareFetch(usageUrl, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + "Content-Type": "application/json", + }, + }, proxyOptions); + + const rawText = await response.text(); + let payload = {}; + if (rawText) { + try { payload = JSON.parse(rawText); } catch { payload = {}; } + } + + const baseResp = (payload?.base_resp ?? payload?.baseResp) || {}; + const apiStatusCode = Number(baseResp.status_code ?? baseResp.statusCode) || 0; + const apiStatusMessage = String(baseResp.status_msg ?? baseResp.statusMsg ?? "").trim(); + const combined = `${apiStatusMessage} ${rawText}`.trim(); + const authLike = /token plan|coding plan|invalid api key|invalid key|unauthorized|inactive/i; + + if (response.status === 401 || response.status === 403 || apiStatusCode === 1004 || authLike.test(combined)) { + return { message: "MiniMax API key invalid or inactive. Use an active Token/Coding Plan key." }; + } + + if (!response.ok) { + lastErrorMessage = `MiniMax usage endpoint error (${response.status})`; + if ((response.status === 404 || response.status === 405 || response.status >= 500) && canFallback) continue; + return { message: `MiniMax connected. ${lastErrorMessage}` }; + } + + if (apiStatusCode !== 0) { + return { message: `MiniMax connected. ${apiStatusMessage || "Upstream quota API error"}` }; + } + + const modelRemains = payload?.model_remains ?? payload?.modelRemains; + const allModels = Array.isArray(modelRemains) ? modelRemains : []; + const quotaModels = allModels.filter(hasMiniMaxQuota); + + if (quotaModels.length === 0) { + return { message: "MiniMax connected. No quota data was returned." }; + } + + const capturedAtMs = Date.now(); + const countMeansRemaining = usageUrl.includes("/coding_plan/remains"); + const quotas = {}; + + for (const model of quotaModels) { + const displayName = formatMiniMaxQuotaName(model); + addMiniMaxQuota( + quotas, + `${displayName} (5h)`, + model, + getMiniMaxSessionTotal, + "current_interval_usage_count", + "currentIntervalUsageCount", + "current_interval_remaining_percent", + "currentIntervalRemainingPercent", + [capturedAtMs, "remains_time", "remainsTime", "end_time", "endTime"], + countMeansRemaining + ); + + addMiniMaxQuota( + quotas, + `${displayName} (7d)`, + model, + getMiniMaxWeeklyTotal, + "current_weekly_usage_count", + "currentWeeklyUsageCount", + "current_weekly_remaining_percent", + "currentWeeklyRemainingPercent", + [capturedAtMs, "weekly_remains_time", "weeklyRemainsTime", "weekly_end_time", "weeklyEndTime"], + countMeansRemaining + ); + } + + if (Object.keys(quotas).length === 0) { + return { message: "MiniMax connected. Unable to extract quota usage." }; + } + + return { quotas }; + } catch (error) { + lastErrorMessage = error.message; + if (!canFallback) break; + } + } + + return { message: lastErrorMessage ? `MiniMax connected. Unable to fetch usage: ${lastErrorMessage}` : "MiniMax connected. Unable to fetch usage." }; +} diff --git a/open-sse/services/usage/misc.js b/open-sse/services/usage/misc.js new file mode 100644 index 00000000..6ce012fa --- /dev/null +++ b/open-sse/services/usage/misc.js @@ -0,0 +1,269 @@ +/** + * Misc usage handlers (Qwen, iFlow, Ollama, GLM, Vercel AI Gateway, Qoder) + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { U } from "./shared.js"; + +// GLM quota endpoints (region-aware) — url from registry transport.usage +const GLM_QUOTA_URLS = { + international: U("glm").url, + china: U("glm-cn").url, +}; + +// Vercel AI Gateway credits endpoint +// Returns { balance: "95.50", total_used: "4.50" } (USD as decimal strings). +const VERCEL_AI_GATEWAY_CREDITS_URL = U("vercel-ai-gateway").url; + +/** + * Qwen Usage + */ +export async function getQwenUsage(accessToken, providerSpecificData) { + try { + const resourceUrl = providerSpecificData?.resourceUrl; + if (!resourceUrl) { + return { message: "Qwen connected. No resource URL available." }; + } + + // Qwen may have usage endpoint at resource URL + return { message: "Qwen connected. Usage tracked per request." }; + } catch (error) { + return { message: "Unable to fetch Qwen usage." }; + } +} + +/** + * iFlow Usage + */ +export async function getIflowUsage(accessToken) { + try { + // iFlow may have usage endpoint + return { message: "iFlow connected. Usage tracked per request." }; + } catch (error) { + return { message: "Unable to fetch iFlow usage." }; + } +} + +/** + * Ollama Cloud Usage + * Ollama Cloud uses an API key from ollama.com/settings/keys + * and has no public usage API — free tier has light usage limits (resets every 5h & 7d). + * This returns an informational message with the plan details. + */ +export async function getOllamaUsage(accessToken, providerSpecificData) { + try { + // Ollama Cloud does not expose a public quota/usage API. + // The provider is configured as noAuth with a notice explaining limits. + // We return a graceful message so the UI shows a friendly state instead of an error. + const plan = providerSpecificData?.plan || "Free"; + return { + plan, + message: "Ollama Cloud uses a free tier with light usage limits (resets every 5h & 7d). For detailed usage tracking, visit ollama.com/settings/keys.", + quotas: [], + }; + } catch (error) { + return { message: "Unable to fetch Ollama Cloud usage." }; + } +} + +/** + * GLM Coding Plan usage (international + China regions) + */ +export async function getGlmUsage(apiKey, provider, proxyOptions = null) { + if (!apiKey) { + return { message: "GLM API key not available." }; + } + + const region = provider === "glm-cn" ? "china" : "international"; + const quotaUrl = GLM_QUOTA_URLS[region]; + + try { + const response = await proxyAwareFetch(quotaUrl, { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + }, proxyOptions); + + if (!response.ok) { + if (response.status === 401) { + return { message: "GLM API key invalid or expired." }; + } + return { message: `GLM quota API error (${response.status}).` }; + } + + const json = await response.json(); + const data = json?.data && typeof json.data === "object" ? json.data : {}; + const limits = Array.isArray(data.limits) ? data.limits : []; + const quotas = {}; + + for (const limit of limits) { + if (!limit || limit.type !== "TOKENS_LIMIT") continue; + const usedPercent = Number(limit.percentage) || 0; + const resetMs = Number(limit.nextResetTime) || 0; + const remaining = Math.max(0, 100 - usedPercent); + + quotas["session"] = { + used: usedPercent, + total: 100, + remaining, + remainingPercentage: remaining, + resetAt: resetMs > 0 ? new Date(resetMs).toISOString() : null, + unlimited: false, + }; + } + + const levelRaw = typeof data.level === "string" ? data.level : ""; + const plan = levelRaw + ? levelRaw.charAt(0).toUpperCase() + levelRaw.slice(1).toLowerCase() + : "Unknown"; + + return { plan, quotas }; + } catch (error) { + return { message: `GLM error: ${error.message}` }; + } +} + +/** + * Vercel AI Gateway usage — credit balance for the API key + * + * Calls GET /v1/credits which returns: + * { "balance": "95.50", "total_used": "4.50" } (USD as decimal strings) + * + * We surface this as a single "Balance ($)" quota row so the existing + * QuotaTable / progress-bar UI can render it. used = total_used, + * total = balance + total_used (the original credit allotment), so the + * remaining percentage equals balance / total. + * + * Docs: https://vercel.com/docs/ai-gateway/usage + */ +export async function getVercelAiGatewayUsage(apiKey, proxyOptions = null) { + if (!apiKey) { + return { message: "Vercel AI Gateway API key not available." }; + } + + try { + const response = await proxyAwareFetch(VERCEL_AI_GATEWAY_CREDITS_URL, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + }, proxyOptions); + + if (response.status === 401 || response.status === 403) { + return { message: "Vercel AI Gateway API key invalid or expired." }; + } + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + const trimmed = errorText ? `: ${errorText.slice(0, 200)}` : ""; + return { message: `Vercel AI Gateway credits API error (${response.status})${trimmed}` }; + } + + const data = await response.json(); + + // Vercel returns numeric strings; coerce safely. + const balance = Number(data?.balance) || 0; + const totalUsed = Number(data?.total_used) || 0; + + // Vercel gives $5/month free credit. The API doesn't return the + // monthly allocation so we use the known constant as the denominator. + const MONTHLY_CREDIT = 5; + const remainingPercentage = (balance / MONTHLY_CREDIT) * 100; + + if (balance <= 0 && totalUsed <= 0) { + return { + plan: "Pay-as-you-go", + message: "Vercel AI Gateway connected. No credit allocation found (BYOK or unfunded account).", + quotas: {}, + }; + } + + // "Used (USD)": how much has been spent this month (no fixed cap → unlimited). + // "Remaining (USD)": balance remaining out of the $5 monthly allocation. + return { + plan: "Pay-as-you-go", + quotas: { + "Used (USD)": { + used: totalUsed, + total: 0, + remaining: 0, + remainingPercentage: 100, + unlimited: true, + }, + "Remaining (USD)": { + used: balance, + total: MONTHLY_CREDIT, + remaining: balance, + remainingPercentage, + unlimited: false, + }, + }, + }; + } catch (error) { + return { message: `Vercel AI Gateway error: ${error.message}` }; + } +} + +export async function getQoderUsage(accessToken, proxyOptions = null) { + if (!accessToken) { + return { message: "Qoder usage unavailable: no access token" }; + } + try { + const response = await proxyAwareFetch( + U("qoder").url, + { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }, + proxyOptions, + ); + if (!response.ok) { + return { message: `Qoder connected. Usage fetch returned ${response.status}.` }; + } + const body = await response.json().catch(() => null); + if (!body) { + return { message: "Qoder connected. Usage response was not JSON." }; + } + // Quota records live under `quotas`; scalar metadata + // (totalUsagePercentage, isQuotaExceeded, expiresAt) are surfaced as + // siblings so the dashboard parser doesn't try to render them as rows. + const userQuota = body.userQuota || {}; + const orgQuota = body.orgResourcePackage || {}; + // Qoder publishes a single absolute reset timestamp (`expiresAt` in ms); + // surface it on every quota record as ISO so the table can render + // "resets at" alongside used/total. + const expiresAtMs = Number.isFinite(Number(body.expiresAt)) && Number(body.expiresAt) > 0 + ? Number(body.expiresAt) + : null; + const resetAt = expiresAtMs ? new Date(expiresAtMs).toISOString() : null; + const quotas = { + user: { + total: Number(userQuota.total) || 0, + used: Number(userQuota.used) || 0, + remaining: Number(userQuota.remaining) || 0, + unit: userQuota.unit || "credits", + resetAt, + }, + organization: { + total: Number(orgQuota.total) || 0, + used: Number(orgQuota.used) || 0, + remaining: Number(orgQuota.remaining) || 0, + unit: orgQuota.unit || "credits", + resetAt, + }, + }; + return { + quotas, + totalUsagePercentage: Number(body.totalUsagePercentage) || 0, + isQuotaExceeded: !!body.isQuotaExceeded, + expiresAt: expiresAtMs, + }; + } catch (error) { + return { message: `Qoder connected. Unable to fetch usage: ${error.message}` }; + } +} diff --git a/open-sse/services/usage/shared.js b/open-sse/services/usage/shared.js new file mode 100644 index 00000000..7ca59f77 --- /dev/null +++ b/open-sse/services/usage/shared.js @@ -0,0 +1,70 @@ +/** + * Shared usage helpers (cross-provider) + */ + +import { PROVIDERS } from "../../providers/index.js"; +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; + +// usage endpoints: single source from registry transport.usage +export const U = (id) => PROVIDERS[id]?.usage || {}; + +/** + * Parse reset date/time to ISO string + * Handles multiple formats: Unix timestamp (ms), ISO date string, etc. + */ +export function parseResetTime(resetValue) { + if (!resetValue) return null; + + try { + // If it's already a Date object + if (resetValue instanceof Date) { + return resetValue.toISOString(); + } + + // Unix timestamps from provider APIs may be seconds or milliseconds. + if (typeof resetValue === 'number') { + return new Date(resetValue < 1e12 ? resetValue * 1000 : resetValue).toISOString(); + } + + // If it's a numeric string, treat it like a Unix timestamp too. + if (typeof resetValue === 'string') { + if (/^\d+$/.test(resetValue)) { + const timestamp = Number(resetValue); + return new Date(timestamp < 1e12 ? timestamp * 1000 : timestamp).toISOString(); + } + return new Date(resetValue).toISOString(); + } + + return null; + } catch (error) { + console.warn(`Failed to parse reset time: ${resetValue}`, error); + return null; + } +} + +export function toFiniteNumber(value, fallback = 0) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return fallback; +} + +export function normalizeCloudCodeProjectId(project) { + if (typeof project === "string") return project.trim() || null; + if (project && typeof project === "object" && typeof project.id === "string") { + return project.id.trim() || null; + } + return null; +} + +export async function fetchWithTimeout(url, opts, ms = 10000, proxyOptions = null) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), ms); + try { + return await proxyAwareFetch(url, { ...opts, signal: controller.signal }, proxyOptions); + } finally { + clearTimeout(timeoutId); + } +} diff --git a/open-sse/shared/clineAuth.js b/open-sse/shared/clineAuth.js new file mode 100644 index 00000000..1b2b7df6 --- /dev/null +++ b/open-sse/shared/clineAuth.js @@ -0,0 +1,37 @@ +import pkg from "../../package.json" with { type: "json" }; + +const APP_VERSION = pkg.version || "0.0.0"; + +export function getClineAccessToken(token) { + if (typeof token !== "string") return ""; + const trimmed = token.trim(); + if (!trimmed) return ""; + return trimmed.startsWith("workos:") ? trimmed : `workos:${trimmed}`; +} + +export function getClineAuthorizationHeader(token) { + const accessToken = getClineAccessToken(token); + return accessToken ? `Bearer ${accessToken}` : ""; +} + +export function buildClineHeaders(token, extraHeaders = {}) { + const authorization = getClineAuthorizationHeader(token); + const headers = { + "HTTP-Referer": "https://cline.bot", + "X-Title": "Cline", + "User-Agent": `9Router/${APP_VERSION}`, + "X-PLATFORM": process.platform || "unknown", + "X-PLATFORM-VERSION": process.version || "unknown", + "X-CLIENT-TYPE": "9router", + "X-CLIENT-VERSION": APP_VERSION, + "X-CORE-VERSION": APP_VERSION, + "X-IS-MULTIROOT": "false", + ...extraHeaders, + }; + + if (authorization) { + headers.Authorization = authorization; + } + + return headers; +} diff --git a/open-sse/shared/machineId.js b/open-sse/shared/machineId.js new file mode 100644 index 00000000..76c2a8d3 --- /dev/null +++ b/open-sse/shared/machineId.js @@ -0,0 +1,19 @@ +import { machineIdSync } from "node-machine-id"; +import crypto from "node:crypto"; + +let cachedRawId = null; + +function loadRawMachineId() { + if (cachedRawId) return cachedRawId; + try { + cachedRawId = machineIdSync(); + } catch { + cachedRawId = crypto.randomUUID(); + } + return cachedRawId; +} + +export async function getConsistentMachineId(salt = "endpoint-proxy-salt") { + const rawId = loadRawMachineId(); + return crypto.createHash("sha256").update(rawId + salt).digest("hex").substring(0, 16); +} diff --git a/open-sse/shared/qoder/constants.js b/open-sse/shared/qoder/constants.js new file mode 100644 index 00000000..1d9ce303 --- /dev/null +++ b/open-sse/shared/qoder/constants.js @@ -0,0 +1,64 @@ +/** + * Qoder API constants ported from CLIProxyAPIPlus qoder-provider branch. + * + * Endpoint set: + * openapi.qoder.sh - device flow + userinfo + quota usage + * center.qoder.sh - token refresh (best-effort, currently 403 for device tokens) + * api3.qoder.sh - inference (chat) + model list, requires COSY signing + * qoder.com/device - browser landing page for device authorization + */ + +export const QODER_OPENAPI_BASE = "https://openapi.qoder.sh"; +export const QODER_CENTER_BASE = "https://center.qoder.sh"; +export const QODER_CHAT_BASE = "https://api3.qoder.sh"; + +export const QODER_LOGIN_URL = "https://qoder.com/device/selectAccounts"; + +// Device flow endpoints +export const QODER_DEVICE_TOKEN_URL = `${QODER_OPENAPI_BASE}/api/v1/deviceToken/poll`; +export const QODER_USERINFO_URL = `${QODER_OPENAPI_BASE}/api/v1/userinfo`; +export const QODER_QUOTA_USAGE_URL = `${QODER_OPENAPI_BASE}/api/v2/quota/usage`; +export const QODER_REFRESH_TOKEN_URL = `${QODER_CENTER_BASE}/algo/api/v3/user/refresh_token`; + +// Inference endpoints (under /algo on api3.qoder.sh, all COSY-signed) +export const QODER_CHAT_SIG_PATH = "/api/v2/service/pro/sse/agent_chat_generation"; +export const QODER_CHAT_URL = `${QODER_CHAT_BASE}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common`; +export const QODER_CHAT_URL_ENCODED = `${QODER_CHAT_URL}&Encode=1`; +export const QODER_MODEL_LIST_URL = `${QODER_CHAT_BASE}/algo/api/v2/model/list`; + +// COSY header constants. These are not arbitrary — the upstream signature +// validation matches them against the values used at signing time. +export const QODER_IDE_VERSION = "1.0.0"; +export const QODER_CLIENT_TYPE = "5"; +export const QODER_DATA_POLICY = "disagree"; +export const QODER_LOGIN_VERSION = "v2"; +export const QODER_MACHINE_OS = "x86_64_windows"; +export const QODER_MACHINE_TYPE = "5"; + +// Canonical model identifiers. Identity map — keep as a map so callers can +// cheaply test "is this a known qoder model?" before sending the request. +export const QODER_MODEL_MAP = { + // Tier models + auto: "auto", + ultimate: "ultimate", + performance: "performance", + efficient: "efficient", + lite: "lite", + // Frontier models + qmodel: "qmodel", + qmodel_latest: "qmodel_latest", + dmodel: "dmodel", + dfmodel: "dfmodel", + gm51model: "gm51model", + kmodel: "kmodel", + mmodel: "mmodel", +}; + +// RSA public key for COSY encryption (extracted from Qoder IDE v0.9). +// Matches the CLIProxyAPIPlus branch and live qodercli traffic. +export const QODER_RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc +4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l +6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17 +XcW+ML9FoCI6AOvOzwIDAQAB +-----END PUBLIC KEY-----`; diff --git a/open-sse/shared/qoder/cosy.js b/open-sse/shared/qoder/cosy.js new file mode 100644 index 00000000..d5d59af7 --- /dev/null +++ b/open-sse/shared/qoder/cosy.js @@ -0,0 +1,175 @@ +/** + * Qoder COSY (hybrid RSA+AES+MD5) signing, ported from CLIProxyAPIPlus + * qoder-provider branch (internal/auth/qoder/cosy.go). + * + * Every signed request carries: + * - an AES-128-CBC payload of the user info, the AES key wrapped in RSA + * - an MD5 signature over `payload || cosyKey || timestamp || body || sigPath` + * - the body's MD5 hash + length so the server can validate integrity + * - 17 Cosy-* / X-* headers fingerprinting the client (machine id, IDE + * version, organization id, etc.) + * + * The on-the-wire header keys use the same casing as qodercli: + * Cosy-Machineid, not Cosy-MachineID. + */ + +import crypto from "crypto"; +import { v4 as uuidv4 } from "uuid"; + +import { + QODER_CLIENT_TYPE, + QODER_DATA_POLICY, + QODER_IDE_VERSION, + QODER_LOGIN_VERSION, + QODER_MACHINE_OS, + QODER_MACHINE_TYPE, + QODER_RSA_PUBLIC_KEY, +} from "./constants.js"; + +// AES-128 wants a 16-byte key. Match qodercli/Veria: take the first 16 chars +// of a fresh UUID's canonical string (hyphens included). The key is fresh +// per request so even though the IV reuses the key bytes, each request still +// has a unique IV. +function generateAesKey() { + return uuidv4().slice(0, 16); +} + +function pkcs7Pad(data, blockSize) { + const padding = blockSize - (data.length % blockSize); + const padded = Buffer.alloc(data.length + padding, padding); + data.copy(padded, 0); + return padded; +} + +function aesEncryptCbcBase64(plaintext, keyStr) { + const keyBytes = Buffer.from(keyStr, "utf8"); + if (keyBytes.length !== 16) { + throw new Error(`aes key must be 16 bytes, got ${keyBytes.length}`); + } + const iv = keyBytes.subarray(0, 16); + const cipher = crypto.createCipheriv("aes-128-cbc", keyBytes, iv); + cipher.setAutoPadding(false); + const padded = pkcs7Pad(Buffer.from(plaintext, "utf8"), 16); + const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]); + return encrypted.toString("base64"); +} + +function rsaEncryptBase64(data) { + const encrypted = crypto.publicEncrypt( + { key: QODER_RSA_PUBLIC_KEY, padding: crypto.constants.RSA_PKCS1_PADDING }, + Buffer.from(data, "utf8"), + ); + return encrypted.toString("base64"); +} + +function encryptUserInfo(userInfo) { + const aesKey = generateAesKey(); + const plaintext = JSON.stringify(userInfo); + const infoB64 = aesEncryptCbcBase64(plaintext, aesKey); + const cosyKeyB64 = rsaEncryptBase64(aesKey); + return { cosyKey: cosyKeyB64, info: infoB64 }; +} + +function md5Hex(input) { + return crypto.createHash("md5").update(input).digest("hex"); +} + +/** + * Strip the leading "/algo" prefix from the request path. Matches qodercli + * convention. Empty input returns "". + */ +function computeSigPath(requestUrl) { + let pathname; + try { + pathname = new URL(requestUrl).pathname || ""; + } catch { + return ""; + } + if (pathname.startsWith("/algo")) { + return pathname.slice("/algo".length); + } + return pathname; +} + +/** + * Generate a fresh machine UUID. Persisted on the connection record so + * every request from the same auth carries the same machineId. + */ +export function generateMachineId() { + return uuidv4(); +} + +/** + * Build the full Cosy-* header set for a single Qoder request. + * + * @param {Buffer|Uint8Array|string} body The exact bytes that will be sent. + * For GET requests pass an empty Buffer / "". + * @param {string} requestUrl Full request URL (used for sigPath). + * @param {object} creds + * @param {string} creds.userId Stable Qoder user id. + * @param {string} creds.authToken Device access token (`dt-...`). + * @param {string} [creds.name] Display name (optional). + * @param {string} [creds.email] Email (optional, can be empty). + * @param {string} [creds.machineId] Persisted machine UUID. + * @returns {Record} Header map ready to merge onto fetch(). + */ +export function buildCosyHeaders(body, requestUrl, creds) { + if (!creds?.userId) throw new Error("cosy: user id is empty"); + if (!creds?.authToken) throw new Error("cosy: auth token is empty"); + + const bodyBuf = Buffer.isBuffer(body) + ? body + : typeof body === "string" + ? Buffer.from(body, "latin1") + : Buffer.from(body || []); + + const { cosyKey, info } = encryptUserInfo({ + uid: creds.userId, + security_oauth_token: creds.authToken, + name: creds.name || "", + aid: "", + email: creds.email || "", + }); + + const timestamp = String(Math.floor(Date.now() / 1000)); + const requestId = uuidv4(); + + const payloadJson = JSON.stringify({ + version: "v1", + requestId, + info, + cosyVersion: QODER_IDE_VERSION, + ideVersion: "", + }); + const payloadB64 = Buffer.from(payloadJson, "utf8").toString("base64"); + + const sigPath = computeSigPath(requestUrl); + const sigInput = `${payloadB64}\n${cosyKey}\n${timestamp}\n${bodyBuf.toString("latin1")}\n${sigPath}`; + const sig = md5Hex(Buffer.from(sigInput, "latin1")); + + const machineId = creds.machineId || generateMachineId(); + const bodyHash = md5Hex(bodyBuf); + const bodyLength = String(bodyBuf.length); + + return { + Authorization: `Bearer COSY.${payloadB64}.${sig}`, + "Cosy-Key": cosyKey, + "Cosy-User": creds.userId, + "Cosy-Date": timestamp, + "Cosy-Version": QODER_IDE_VERSION, + "Cosy-Machineid": machineId, + "Cosy-Machinetoken": machineId, + "Cosy-Machinetype": QODER_MACHINE_TYPE, + "Cosy-Machineos": QODER_MACHINE_OS, + "Cosy-Clienttype": QODER_CLIENT_TYPE, + "Cosy-Clientip": "127.0.0.1", + "Cosy-Bodyhash": bodyHash, + "Cosy-Bodylength": bodyLength, + "Cosy-Sigpath": sigPath, + "Cosy-Data-Policy": QODER_DATA_POLICY, + "Cosy-Organization-Id": "", + "Cosy-Organization-Tags": "", + "Login-Version": QODER_LOGIN_VERSION, + "X-Request-Id": uuidv4(), + }; +} diff --git a/open-sse/shared/qoder/encoding.js b/open-sse/shared/qoder/encoding.js new file mode 100644 index 00000000..31449e85 --- /dev/null +++ b/open-sse/shared/qoder/encoding.js @@ -0,0 +1,55 @@ +/** + * Qoder body encoding ported from qoder2api's QoderEncoding.java (via the + * CLIProxyAPIPlus qoder-provider branch). + * + * Algorithm: + * 1. base64-encode the plaintext bytes (standard alphabet). + * 2. Rearrange: split into thirds, reorder as [tail][mid][head]. + * 3. Substitute each character via a custom alphabet mapping. + * + * The encoded body must be sent with `&Encode=1` appended to the URL so the + * server decodes in reverse. The obfuscation prevents Alibaba Cloud WAF from + * pattern-matching the plaintext request body. + */ + +const QODER_STD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +const QODER_CUSTOM_ALPHABET = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!"; + +const QODER_S2C = (() => { + const table = new Int16Array(128).fill(-1); + for (let i = 0; i < 64; i++) { + table[QODER_STD_ALPHABET.charCodeAt(i)] = QODER_CUSTOM_ALPHABET.charCodeAt(i); + } + table["=".charCodeAt(0)] = "$".charCodeAt(0); + return table; +})(); + +/** + * Encode plaintext bytes/string using Qoder's WAF-bypass scheme. + * @param {Buffer|Uint8Array|string} plaintext + * @returns {string} encoded string + */ +export function qoderEncodeBody(plaintext) { + const buf = Buffer.isBuffer(plaintext) + ? plaintext + : typeof plaintext === "string" + ? Buffer.from(plaintext, "utf8") + : Buffer.from(plaintext); + + const std = buf.toString("base64"); + const n = std.length; + const a = Math.floor(n / 3); + // [tail][mid][head] + const rearranged = std.slice(n - a) + std.slice(a, n - a) + std.slice(0, a); + + const out = Buffer.alloc(n); + for (let i = 0; i < n; i++) { + const c = rearranged.charCodeAt(i); + if (c < 128 && QODER_S2C[c] >= 0) { + out[i] = QODER_S2C[c]; + } else { + out[i] = c; + } + } + return out.toString("latin1"); +} diff --git a/open-sse/translator/concerns/chunk.js b/open-sse/translator/concerns/chunk.js new file mode 100644 index 00000000..35d97531 --- /dev/null +++ b/open-sse/translator/concerns/chunk.js @@ -0,0 +1,11 @@ +// Build OpenAI chat.completion.chunk. Caller supplies id/created/model so each +// translator keeps its exact id-generation + created semantics (no Date.now here). +export function buildChunk({ id, created, model }, delta, finishReason = null) { + return { + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta, finish_reason: finishReason }], + }; +} diff --git a/open-sse/translator/concerns/finishReason.js b/open-sse/translator/concerns/finishReason.js new file mode 100644 index 00000000..684a7001 --- /dev/null +++ b/open-sse/translator/concerns/finishReason.js @@ -0,0 +1,63 @@ +// Concern #6: finish_reason / stop_reason mapping. +// One entry per direction; switch by special format, default handles common providers. +import { OPENAI_FINISH, CLAUDE_STOP, GEMINI_FINISH } from "../schema/finishReasons.js"; + +// upstream finish/stop reason → OpenAI finish_reason +export function toOpenAIFinish(reason, format) { + switch (format) { + case "claude": + switch (reason) { + case CLAUDE_STOP.END_TURN: return OPENAI_FINISH.STOP; + case CLAUDE_STOP.MAX_TOKENS: return OPENAI_FINISH.LENGTH; + case CLAUDE_STOP.TOOL_USE: return OPENAI_FINISH.TOOL_CALLS; + case CLAUDE_STOP.STOP_SEQUENCE: return OPENAI_FINISH.STOP; + default: return OPENAI_FINISH.STOP; + } + case "commandcode": + switch (reason) { + case "stop": return OPENAI_FINISH.STOP; + case "length": return OPENAI_FINISH.LENGTH; + case "tool-calls": + case "tool_use": return OPENAI_FINISH.TOOL_CALLS; + case "content-filter": return OPENAI_FINISH.CONTENT_FILTER; + case "error": return OPENAI_FINISH.STOP; + default: return reason || OPENAI_FINISH.STOP; + } + case "gemini": + switch (String(reason).toUpperCase()) { + case GEMINI_FINISH.STOP: return OPENAI_FINISH.STOP; + case GEMINI_FINISH.MAX_TOKENS: return OPENAI_FINISH.LENGTH; + case GEMINI_FINISH.SAFETY: + case GEMINI_FINISH.RECITATION: + case GEMINI_FINISH.BLOCKLIST: + case GEMINI_FINISH.PROHIBITED_CONTENT: return OPENAI_FINISH.CONTENT_FILTER; + default: return OPENAI_FINISH.STOP; + } + case "kiro": + case "ollama": + switch (reason) { + case "tool_calls": + case "tool_use": return OPENAI_FINISH.TOOL_CALLS; + case "length": + case "max_tokens": return OPENAI_FINISH.LENGTH; + default: return OPENAI_FINISH.STOP; + } + default: + return reason || OPENAI_FINISH.STOP; + } +} + +// OpenAI finish_reason → upstream stop reason +export function fromOpenAIFinish(reason, format) { + switch (format) { + case "claude": + switch (reason) { + case OPENAI_FINISH.STOP: return CLAUDE_STOP.END_TURN; + case OPENAI_FINISH.LENGTH: return CLAUDE_STOP.MAX_TOKENS; + case OPENAI_FINISH.TOOL_CALLS: return CLAUDE_STOP.TOOL_USE; + default: return CLAUDE_STOP.END_TURN; + } + default: + return reason; + } +} diff --git a/open-sse/translator/concerns/image.js b/open-sse/translator/concerns/image.js new file mode 100644 index 00000000..73864b5b --- /dev/null +++ b/open-sse/translator/concerns/image.js @@ -0,0 +1,124 @@ +// Build a base64 data URI from mime + base64 payload +export function encodeDataUri(mimeType, base64) { + return `data:${mimeType};base64,${base64}`; +} + +// Parse a base64 data URI → { mimeType, base64 }, or null if not a data URI. +// [\s\S] tolerates newlines inside the base64 payload. +const DATA_URI_RE = /^data:([^;]+);base64,([\s\S]+)$/; +export function parseDataUri(url) { + if (typeof url !== "string") return null; + const m = url.match(DATA_URI_RE); + return m ? { mimeType: m[1], base64: m[2] } : null; +} + +import { lookup } from "node:dns/promises"; +import { Agent } from "undici"; +import { MAX_IMAGE_BYTES, FETCH_TIMEOUT_MS, IMAGE_SIGNATURES, BLOCKED_HOSTS } from "../../config/mediaConfig.js"; + +// True if an IPv4/IPv6 address is private/reserved (SSRF target). +function isPrivateIp(ip) { + if (!ip) return true; + // IPv6 loopback / unique-local / link-local + if (ip === "::1" || ip.startsWith("fc") || ip.startsWith("fd") || ip.startsWith("fe80")) return true; + // IPv4-mapped IPv6 (::ffff:a.b.c.d) -> extract tail + const v4 = ip.includes(".") ? ip.split(":").pop() : ip; + const parts = v4.split(".").map((n) => Number.parseInt(n, 10)); + if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return ip.includes(":") ? false : true; + const [a, b] = parts; + if (a === 10 || a === 127 || a === 0) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 169 && b === 254) return true; // link-local + cloud metadata + if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT + return false; +} + +// Resolve host once and return only public IPs (SSRF guard). +// Rejects if any resolved record is private/reserved (defeats multi-A tricks). +async function resolvePinnedIps(hostname) { + if (!hostname || BLOCKED_HOSTS.has(hostname.toLowerCase())) return null; + try { + const records = await lookup(hostname, { all: true }); + if (!records.length || records.some((r) => isPrivateIp(r.address))) return null; + return records; + } catch { + return null; + } +} + +// Verify buffer magic bytes match a known image signature; return its mime or null. +function detectImageMime(buf) { + for (const { sig, offset, mime, verifyWebp } of IMAGE_SIGNATURES) { + if (buf.length < offset + sig.length) continue; + let match = true; + for (let i = 0; i < sig.length; i++) { + if (buf[offset + i] !== sig[i]) { match = false; break; } + } + if (!match) continue; + // WEBP: RIFF....WEBP — bytes 8..11 must be "WEBP". + if (verifyWebp && !(buf.length >= 12 && buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50)) continue; + return mime; + } + return null; +} + +/** + * Fetch a remote image URL and return it as a base64 data URI. + * Hardened against SSRF (private/metadata IPs), memory DoS (size cap), + * and disguised non-image payloads (magic-byte verification). + * Returns null on any failure or rejection. + * + * @param {string} imageUrl - HTTP(S) URL of the image + * @param {object} options - { signal, timeoutMs, maxBytes } + * @returns {Promise<{url: string, mimeType: string}|null>} + */ +export async function fetchImageAsBase64(imageUrl, options = {}) { + const { signal, timeoutMs = FETCH_TIMEOUT_MS, maxBytes = MAX_IMAGE_BYTES } = options; + if (!imageUrl || (!imageUrl.startsWith("http://") && !imageUrl.startsWith("https://"))) { + return null; + } + + let url; + try { url = new URL(imageUrl); } catch { return null; } + const pinnedIps = await resolvePinnedIps(url.hostname); + if (!pinnedIps) return null; + + const controller = new AbortController(); + const timeout = signal ? null : setTimeout(() => controller.abort(), timeoutMs); + const fetchSignal = signal || controller.signal; + + // Pin connect to the validated IP so no second DNS resolution can rebind (TOCTOU fix). + const dispatcher = new Agent({ + connect: { lookup: (_h, _o, cb) => cb(null, [{ address: pinnedIps[0].address, family: pinnedIps[0].family }]) }, + }); + + try { + // redirect:"manual" prevents a public URL redirecting to a private one (SSRF bypass). + const response = await fetch(imageUrl, { signal: fetchSignal, redirect: "manual", dispatcher }); + if (!response.ok || !response.body) return null; + + // Stream-read with a hard byte cap to avoid loading huge payloads into memory. + const reader = response.body.getReader(); + const chunks = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.length; + if (total > maxBytes) { try { await reader.cancel(); } catch { /* ignore */ } return null; } + chunks.push(value); + } + + const buf = Buffer.concat(chunks.map((c) => Buffer.from(c))); + const mimeType = detectImageMime(buf); + if (!mimeType) return null; // not a recognized image — reject disguised payloads + + return { url: `data:${mimeType};base64,${buf.toString("base64")}`, mimeType }; + } catch { + return null; + } finally { + if (timeout) clearTimeout(timeout); + dispatcher.close().catch(() => {}); + } +} diff --git a/open-sse/translator/concerns/json.js b/open-sse/translator/concerns/json.js new file mode 100644 index 00000000..36c1b71b --- /dev/null +++ b/open-sse/translator/concerns/json.js @@ -0,0 +1,5 @@ +// Safe JSON.parse: non-string passthrough; on parse error return caller-chosen `fallback`. +export function safeParseJSON(str, fallback) { + if (typeof str !== "string") return str; + try { return JSON.parse(str); } catch { return fallback; } +} diff --git a/open-sse/translator/concerns/message.js b/open-sse/translator/concerns/message.js new file mode 100644 index 00000000..169aeaf0 --- /dev/null +++ b/open-sse/translator/concerns/message.js @@ -0,0 +1,7 @@ +import { OPENAI_BLOCK } from "../schema/index.js"; + +// Collapse an OpenAI content-part array: a lone text part becomes a plain string, +// otherwise the array is returned as-is. Matches existing translator behavior. +export function collapseTextParts(parts) { + return parts.length === 1 && parts[0].type === OPENAI_BLOCK.TEXT ? parts[0].text : parts; +} diff --git a/open-sse/translator/concerns/modality.js b/open-sse/translator/concerns/modality.js new file mode 100644 index 00000000..b3bba2de --- /dev/null +++ b/open-sse/translator/concerns/modality.js @@ -0,0 +1,155 @@ +// Strip multimodal content blocks a model cannot read, BEFORE translation. +// Driven by getCapabilitiesForModel: vision/audioInput/pdf. Replaces removed +// media with a short text placeholder so messages never become empty. +import { FORMATS } from "../formats.js"; + +// Placeholder text inserted where a media block was removed. +// Current turn: explain the active model can't read what the user just sent. +const PLACEHOLDER_CURRENT = { + vision: "[image omitted: model has no vision support]", + audioInput: "[audio omitted: model has no audio support]", + pdf: "[file omitted: model has no document support]", +}; +// Earlier turns: neutral (a combo may route to a different model each turn). +const PLACEHOLDER_PREV = { + vision: "[Previous image omitted from context.]", + audioInput: "[Previous audio omitted from context.]", + pdf: "[Previous file omitted from context.]", +}; +const ph = (cap, isLast) => (isLast ? PLACEHOLDER_CURRENT : PLACEHOLDER_PREV)[cap]; + +// Map gemini inlineData/fileData mime prefix -> capability it requires. +function capForMime(mime) { + if (typeof mime !== "string") return null; + if (mime.startsWith("image/")) return "vision"; + if (mime.startsWith("audio/")) return "audioInput"; + if (mime === "application/pdf") return "pdf"; + return null; +} + +// OpenAI chat content block -> required capability (null = plain text/other, keep). +function capForOpenAIBlock(block) { + const t = block?.type; + if (t === "image_url" || t === "image") return "vision"; + if (t === "input_audio" || t === "audio_url") return "audioInput"; + if (t === "file") return "pdf"; + return null; +} + +// Claude content block -> required capability. +function capForClaudeBlock(block) { + const t = block?.type; + if (t === "image") return "vision"; + if (t === "document") return "pdf"; + return null; +} + +// Filter an array of content blocks; drop unsupported, inject one placeholder per kind. +// isLast = block belongs to the current user turn (picks the explanatory placeholder). +function filterBlocks(blocks, capOf, caps, removed, isLast) { + const out = []; + for (const block of blocks) { + const cap = capOf(block); + if (cap && caps[cap] === false) { removed.add(cap); continue; } + out.push(block); + } + for (const cap of removed) out.push({ type: "text", text: ph(cap, isLast) }); + return out; +} + +// OpenAI / OpenAI-compatible chat messages[].content[]. +function stripOpenAI(body, caps) { + if (!Array.isArray(body.messages)) return; + const last = body.messages.length - 1; + body.messages.forEach((msg, i) => { + if (!Array.isArray(msg.content)) return; + const removed = new Set(); + msg.content = filterBlocks(msg.content, capForOpenAIBlock, caps, removed, i === last); + }); +} + +// Claude messages[].content[]. +function stripClaude(body, caps) { + if (!Array.isArray(body.messages)) return; + const last = body.messages.length - 1; + body.messages.forEach((msg, i) => { + if (!Array.isArray(msg.content)) return; + const removed = new Set(); + msg.content = filterBlocks(msg.content, capForClaudeBlock, caps, removed, i === last); + }); +} + +// OpenAI Responses input[].content[] (input_image / input_file). +function stripResponses(body, caps) { + if (!Array.isArray(body.input)) return; + const last = body.input.length - 1; + body.input.forEach((item, i) => { + if (!Array.isArray(item.content)) return; + const removed = new Set(); + item.content = item.content.filter((b) => { + const cap = b?.type === "input_image" ? "vision" : b?.type === "input_file" ? "pdf" : null; + if (cap && caps[cap] === false) { removed.add(cap); return false; } + return true; + }); + for (const cap of removed) item.content.push({ type: "input_text", text: ph(cap, i === last) }); + }); +} + +// Gemini / gemini-cli contents[].parts[] (inlineData / fileData by mime). +function stripGeminiParts(contents, caps) { + if (!Array.isArray(contents)) return; + const last = contents.length - 1; + contents.forEach((c, i) => { + if (!Array.isArray(c.parts)) return; + const removed = new Set(); + c.parts = c.parts.filter((p) => { + const mime = p?.inlineData?.mimeType || p?.fileData?.mimeType; + const cap = capForMime(mime); + if (cap && caps[cap] === false) { removed.add(cap); return false; } + return true; + }); + for (const cap of removed) c.parts.push({ text: ph(cap, i === last) }); + }); +} + +/** + * Remove media blocks the model can't read, in-place on the source-format body. + * @param {object} body - request body (source format) + * @param {string} sourceFormat - one of FORMATS + * @param {object} caps - capabilities from getCapabilitiesForModel + * @returns {boolean} true if anything was stripped-eligible (cap false for some modality) + */ +export function stripUnsupportedModalities(body, sourceFormat, caps) { + if (!body || !caps) return false; + // Fast exit: model supports everything we'd strip. + if (caps.vision !== false && caps.audioInput !== false && caps.pdf !== false) return false; + + switch (sourceFormat) { + case FORMATS.OPENAI: + case FORMATS.OLLAMA: + case FORMATS.KIRO: + case FORMATS.CURSOR: + case FORMATS.COMMANDCODE: + stripOpenAI(body, caps); + break; + case FORMATS.CLAUDE: + stripClaude(body, caps); + break; + case FORMATS.OPENAI_RESPONSES: + case FORMATS.OPENAI_RESPONSE: + case FORMATS.CODEX: + stripResponses(body, caps); + break; + case FORMATS.GEMINI: + case FORMATS.GEMINI_CLI: + case FORMATS.VERTEX: + stripGeminiParts(body.contents, caps); + break; + case FORMATS.ANTIGRAVITY: + stripGeminiParts(body?.request?.contents, caps); + break; + default: + stripOpenAI(body, caps); + } + return true; +} diff --git a/open-sse/translator/concerns/paramSupport.js b/open-sse/translator/concerns/paramSupport.js new file mode 100644 index 00000000..dc030194 --- /dev/null +++ b/open-sse/translator/concerns/paramSupport.js @@ -0,0 +1,44 @@ +// Strip request params a given provider/model rejects upstream (e.g. HTTP 400). +// Config-driven: add a rule instead of scattering `delete body.x` across executors. + +// Each rule: optional provider, regex match on model, list of params to drop. +// A param is removed only when it is present (!== undefined). +const STRIP_RULES = [ + // claude-opus-4 series: temperature is deprecated (Anthropic 400). #1748 + { match: /claude-opus-4/i, drop: ["temperature"] }, + // GitHub Copilot gpt-5.4: temperature unsupported. + { provider: "github", match: /gpt-5\.4/i, drop: ["temperature"] }, + // GitHub Copilot Claude (except opus/sonnet 4.6): thinking + reasoning_effort rejected. #713 + { provider: "github", match: (m) => /claude/i.test(m) && !/claude.*(opus|sonnet).*4\.6/i.test(m), drop: ["thinking", "reasoning_effort"] }, + // Cloudflare Workers AI: content must be plain string, rejects OpenAI content-part array (#1926) + { provider: "cloudflare-ai", flattenContent: true }, +]; + +// Test a rule's match (regex or predicate) against the model id. +function matches(rule, model) { + if (!rule.match) return true; + return typeof rule.match === "function" ? rule.match(model) : rule.match.test(model); +} + +// Remove unsupported params from body in place; returns body. +export function stripUnsupportedParams(provider, model, body) { + if (!model || !body || typeof body !== "object") return body; + for (const rule of STRIP_RULES) { + if (rule.provider && rule.provider !== provider) continue; + if (!matches(rule, model)) continue; + for (const key of rule.drop || []) { + if (body[key] !== undefined) delete body[key]; + } + // CF Workers AI oneOf root schema only accepts content as plain string (#1926) + if (rule.flattenContent && Array.isArray(body.messages)) { + for (const msg of body.messages) { + if (msg && Array.isArray(msg.content)) { + msg.content = msg.content + .map(b => (b?.type === "text" && typeof b.text === "string") ? b.text : "") + .join(""); + } + } + } + } + return body; +} diff --git a/open-sse/translator/concerns/prefetch.js b/open-sse/translator/concerns/prefetch.js new file mode 100644 index 00000000..5055be3c --- /dev/null +++ b/open-sse/translator/concerns/prefetch.js @@ -0,0 +1,96 @@ +// Pre-fetch remote image URLs into base64 BEFORE translation, for target +// formats whose upstream providers cannot fetch remote URLs themselves +// (they require inline base64). Runs on the source-format body. +import { FORMATS } from "../formats.js"; +import { fetchImageAsBase64, parseDataUri } from "./image.js"; + +// Targets that require inline base64 images (cannot accept remote URLs). +const TARGETS_NEED_BASE64 = new Set([ + FORMATS.GEMINI, FORMATS.GEMINI_CLI, FORMATS.VERTEX, + FORMATS.ANTIGRAVITY, FORMATS.OLLAMA, FORMATS.KIRO, +]); + +function isRemoteUrl(url) { + return typeof url === "string" && (url.startsWith("http://") || url.startsWith("https://")); +} + +// Collect {get,set} accessors for every remote image URL in a source body. +function collectImageRefs(body, sourceFormat) { + const refs = []; + const pushOpenAI = (messages) => { + for (const msg of messages || []) { + if (!Array.isArray(msg.content)) continue; + for (const block of msg.content) { + if (block?.type === "image_url") { + const url = typeof block.image_url === "string" ? block.image_url : block.image_url?.url; + if (isRemoteUrl(url)) refs.push({ get: () => url, set: (v) => { + if (typeof block.image_url === "string") block.image_url = v; else block.image_url.url = v; + } }); + } + } + } + }; + const pushGemini = (contents) => { + for (const c of contents || []) { + for (const p of c.parts || []) { + const uri = p?.fileData?.fileUri; + if (isRemoteUrl(uri)) refs.push({ get: () => uri, part: p }); + } + } + }; + + switch (sourceFormat) { + case FORMATS.OPENAI: + case FORMATS.OLLAMA: + case FORMATS.KIRO: + case FORMATS.CURSOR: + case FORMATS.COMMANDCODE: + pushOpenAI(body.messages); + break; + case FORMATS.CLAUDE: + for (const msg of body.messages || []) { + if (!Array.isArray(msg.content)) continue; + for (const block of msg.content) { + if (block?.type === "image" && block.source?.type === "url" && isRemoteUrl(block.source.url)) { + refs.push({ get: () => block.source.url, claudeBlock: block }); + } + } + } + break; + case FORMATS.GEMINI: + case FORMATS.GEMINI_CLI: + case FORMATS.VERTEX: + pushGemini(body.contents); + break; + case FORMATS.ANTIGRAVITY: + pushGemini(body?.request?.contents); + break; + default: + pushOpenAI(body.messages); + } + return refs; +} + +/** + * Replace remote image URLs with base64 data when the target needs inline data. + * No-op when target accepts remote URLs (e.g. openai, claude) or body has none. + * @returns {Promise} count of images converted + */ +export async function prefetchRemoteImages(body, sourceFormat, targetFormat, options = {}) { + if (!body || !TARGETS_NEED_BASE64.has(targetFormat)) return 0; + const refs = collectImageRefs(body, sourceFormat); + if (!refs.length) return 0; + + let converted = 0; + for (const ref of refs) { + const url = ref.get(); + if (parseDataUri(url)) continue; // already inline + const fetched = await fetchImageAsBase64(url, options); + if (!fetched) continue; + if (ref.set) ref.set(fetched.url); + else if (ref.part) { delete ref.part.fileData; ref.part.inlineData = { mimeType: fetched.mimeType, data: fetched.url.split(",")[1] }; } + else if (ref.claudeBlock) ref.claudeBlock.source = { type: "base64", media_type: fetched.mimeType, data: fetched.url.split(",")[1] }; + converted++; + } + return converted; +} diff --git a/open-sse/translator/concerns/reasoning.js b/open-sse/translator/concerns/reasoning.js new file mode 100644 index 00000000..f4855eb7 --- /dev/null +++ b/open-sse/translator/concerns/reasoning.js @@ -0,0 +1,24 @@ +import { ROLE } from "../schema/index.js"; + +// Build OpenAI delta carrying reasoning_content (optional leading assistant role) +export function reasoningDelta(text, withRole = false) { + return withRole + ? { role: ROLE.ASSISTANT, reasoning_content: text } + : { reasoning_content: text }; +} + +// Extract reasoning text from a streamed OpenAI-compatible delta across vendor shapes: +// - reasoning_content (GLM, Qwen, DeepSeek, Kimi, Step, Hunyuan) +// - reasoning (some compat layers) +// - reasoning_details[] (MiniMax reasoning_split=true): [{ text|content }] +// Returns concatenated reasoning string, or "" when none. +export function extractReasoningText(delta) { + if (!delta || typeof delta !== "object") return ""; + if (typeof delta.reasoning_content === "string" && delta.reasoning_content) return delta.reasoning_content; + if (typeof delta.reasoning === "string" && delta.reasoning) return delta.reasoning; + const details = delta.reasoning_details; + if (Array.isArray(details)) { + return details.map((d) => (typeof d === "string" ? d : d?.text || d?.content || "")).join(""); + } + return ""; +} diff --git a/open-sse/translator/concerns/thinking.js b/open-sse/translator/concerns/thinking.js new file mode 100644 index 00000000..3db892c4 --- /dev/null +++ b/open-sse/translator/concerns/thinking.js @@ -0,0 +1,53 @@ +// Concern: reasoning_effort ↔ provider-native thinking config. +// Central source of truth for level↔budget maps (web-standard values). +// Provider-specific application lives in thinkingUnified.js; this file is maps-only. + +// Discrete effort levels, ordered low→high. +export const EFFORT_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"]; + +// Web-standard level → budget_tokens (Anthropic/Gemini docs). +export const LEVEL_TO_BUDGET = { + none: 0, + minimal: 512, + low: 1024, + medium: 8192, + high: 24576, + xhigh: 32768, + max: 128000, +}; + +// Returns budget_tokens for an effort level, or undefined if unknown. +// 0 means "no thinking"; undefined means "effort not recognized". +export function effortToBudget(effort) { + if (!effort) return undefined; + return LEVEL_TO_BUDGET[String(effort).toLowerCase()]; +} + +// OpenAI reasoning_effort → Gemini thinkingLevel (gemini-3 enum: minimal|low|medium|high). +// Gemini 3 cannot fully disable thinking; "none"/"off" map to "minimal". +export function effortToThinkingLevel(effort) { + const e = String(effort).toLowerCase().trim(); + if (e === "none" || e === "off") return "minimal"; + if (e === "xhigh" || e === "max") return "high"; + return e; +} + +// Numeric budget → nearest discrete level (reverse map via thresholds). +// Returns null when budget <= 0 (no reasoning). +export function budgetToLevel(budget) { + const b = Number(budget); + if (!b || b <= 0) return null; + if (b <= 768) return "minimal"; + if (b <= 4096) return "low"; + if (b <= 16384) return "medium"; + if (b <= 28672) return "high"; + return "xhigh"; +} + +// Gemini thinkingBudget (numeric) → OpenAI reasoning_effort (antigravity reverse map). +export function budgetToEffort(budget) { + if (!budget || budget <= 0) return null; + if (budget <= 2048) return "low"; + if (budget <= 16384) return "medium"; + return "high"; +} diff --git a/open-sse/translator/concerns/thinkingUnified.js b/open-sse/translator/concerns/thinkingUnified.js new file mode 100644 index 00000000..1cf44384 --- /dev/null +++ b/open-sse/translator/concerns/thinkingUnified.js @@ -0,0 +1,271 @@ +// Unified thinking normalization: extract client intent → apply provider-native format. +// Config-driven: thinking format/limits come from capabilities.js + registry transport, +// never hardcoded per-model here. See .docs/thinking/plan.md MATRIX VI-A. + +import { getCapabilitiesForModel } from "../../providers/capabilities.js"; +import { PROVIDERS } from "../../providers/index.js"; +import { LEVEL_TO_BUDGET, budgetToLevel, effortToBudget, effortToThinkingLevel } from "./thinking.js"; + +// Map a target wire-format to its native thinking format (when capability has none). +const FORMAT_TO_NATIVE = { + openai: "openai", + "openai-responses": "openai", + "openai-response": "openai", + codex: "openai", + claude: "claude-budget", + gemini: "gemini-budget", + "gemini-cli": "gemini-budget", + vertex: "gemini-budget", + antigravity: "gemini-budget", + kiro: "kiro", +}; + +// Parse model-name suffix "model(value)" → { cleanModel, override }. +// value: level name (high) | number (8192) | auto | none. null override when absent. +export function parseSuffix(model) { + if (typeof model !== "string") return { cleanModel: model, override: null }; + const m = model.match(/^(.*)\(([^()]+)\)\s*$/); + if (!m) return { cleanModel: model, override: null }; + const cleanModel = m[1].trim(); + const raw = m[2].trim().toLowerCase(); + if (raw === "none" || raw === "off") return { cleanModel, override: { mode: "none" } }; + if (raw === "auto") return { cleanModel, override: { mode: "auto" } }; + if (/^\d+$/.test(raw)) return { cleanModel, override: { mode: "budget", budget: Number(raw) } }; + if (LEVEL_TO_BUDGET[raw] !== undefined) return { cleanModel, override: { mode: "level", level: raw } }; + return { cleanModel, override: null }; +} + +// Extract unified thinking intent from a request body (post-translation, mixed shapes). +// Returns { mode, budget?, level? } or null when no thinking intent present. +export function extractThinking(body) { + if (!body || typeof body !== "object") return null; + + // Claude output_config.effort (explicit) — priority over adaptive thinking + const oc = body.output_config?.effort; + if (typeof oc === "string" && oc) { + const e = oc.toLowerCase(); + if (e === "none" || e === "off") return { mode: "none" }; + if (e === "auto") return { mode: "auto" }; + return { mode: "level", level: e }; + } + + // Claude shape + const t = body.thinking; + if (t && typeof t === "object") { + if (t.type === "disabled") return { mode: "none" }; + if (t.type === "adaptive" || t.type === "enabled") { + const budget = Number(t.budget_tokens); + if (Number.isFinite(budget) && budget > 0) return { mode: "budget", budget }; + return { mode: "auto" }; + } + } + + // OpenAI chat / Responses shape + const effort = body.reasoning_effort ?? (typeof body.reasoning === "object" ? body.reasoning?.effort : null); + if (typeof effort === "string" && effort) { + const e = effort.toLowerCase(); + if (e === "none" || e === "off") return { mode: "none" }; + if (e === "auto") return { mode: "auto" }; + return { mode: "level", level: e }; + } + + // Gemini shape (top-level, generationConfig, or request envelope) + const tc = body.thinkingConfig || body.generationConfig?.thinkingConfig || body.request?.generationConfig?.thinkingConfig; + if (tc && typeof tc === "object") { + if (typeof tc.thinkingLevel === "string") return { mode: "level", level: tc.thinkingLevel.toLowerCase() }; + const tb = Number(tc.thinkingBudget); + if (Number.isFinite(tb)) { + if (tb === 0) return { mode: "none" }; + if (tb < 0) return { mode: "auto" }; + return { mode: "budget", budget: tb }; + } + } + + // Qwen shape + if (body.enable_thinking === false) return { mode: "none" }; + if (body.enable_thinking === true) { + const tb = Number(body.thinking_budget); + if (Number.isFinite(tb) && tb > 0) return { mode: "budget", budget: tb }; + return { mode: "auto" }; + } + + return null; +} + +// Capture thinking intent from a body. Alias of extractThinking, named for clarity +// at the call-site where intent is snapshotted before format translation. +export const captureThinking = extractThinking; + +// Resolve thinking format: provider override > capability > derive(targetFormat). +function resolveFormat(targetFormat, model, provider) { + const providerFmt = provider ? PROVIDERS[provider]?.thinkingFormat : null; + if (providerFmt) return providerFmt; + const caps = getCapabilitiesForModel(provider, model); + if (caps.thinkingFormat) return caps.thinkingFormat; + return FORMAT_TO_NATIVE[targetFormat] || "openai"; +} + +// Convert unified config to a budget number (for budget-based formats). +function toBudget(cfg, range) { + let budget; + if (cfg.mode === "budget") budget = cfg.budget; + else if (cfg.mode === "level") budget = effortToBudget(cfg.level); + else if (cfg.mode === "auto") return -1; + if (!Number.isFinite(budget)) return undefined; + if (range) { + if (range.min != null && budget < range.min) budget = range.min; + if (range.max != null && budget > range.max) budget = range.max; + } + return budget; +} + +// Convert unified config to a discrete level string. +function toLevel(cfg) { + if (cfg.mode === "level") return cfg.level; + if (cfg.mode === "budget") return budgetToLevel(cfg.budget) || "medium"; + if (cfg.mode === "auto") return "auto"; + return null; +} + +function toGeminiThinkingLevel(cfg) { + const raw = cfg.mode === "auto" ? "high" : (toLevel(cfg) || "high"); + return effortToThinkingLevel(raw); +} + +// Gemini nests thinkingConfig under generationConfig. gemini-cli / antigravity wrap +// the whole request in a { request: { generationConfig } } envelope — target the +// envelope's generationConfig when present, else the top-level one. +function setGeminiThinking(body, tc) { + const gc = body.request?.generationConfig + ? body.request.generationConfig + : (body.generationConfig && typeof body.generationConfig === "object" + ? body.generationConfig + : (body.generationConfig = {})); + gc.thinkingConfig = tc; +} + +// Strip every known thinking field from a body (used before re-applying / when unsupported). +function stripAll(body) { + delete body.thinking; + delete body.reasoning_effort; + delete body.reasoning; + delete body.thinkingConfig; + delete body.enable_thinking; + delete body.thinking_budget; + delete body.output_config; + if (body.generationConfig) delete body.generationConfig.thinkingConfig; + if (body.request?.generationConfig) delete body.request.generationConfig.thinkingConfig; +} + +// Apply unified thinking config to body in the resolved provider-native format. +function applyFormat(fmt, body, cfg, caps) { + const none = cfg.mode === "none"; + const canDisable = caps.thinkingCanDisable !== false; + // Model cannot disable thinking → clamp "none" to minimal effort instead. + const eff = none && !canDisable ? { mode: "level", level: "minimal" } : cfg; + + switch (fmt) { + case "openai": { + if (none && canDisable) { body.reasoning_effort = "none"; break; } + const level = toLevel(eff); + if (level) body.reasoning_effort = level; + break; + } + case "claude-adaptive": { + if (none && canDisable) { body.thinking = { type: "disabled" }; break; } + const level = toLevel(eff); + body.output_config = { effort: level === "xhigh" ? "high" : level }; + break; + } + case "claude-budget": { + if (none && canDisable) { body.thinking = { type: "disabled" }; break; } + const budget = toBudget(eff, caps.thinkingRange); + body.thinking = budget === -1 ? { type: "enabled" } : { type: "enabled", budget_tokens: budget || 8192 }; + break; + } + case "gemini-level": { + const level = none ? "minimal" : toGeminiThinkingLevel(eff); + setGeminiThinking(body, { thinkingLevel: level, includeThoughts: level !== "minimal" }); + break; + } + case "gemini-budget": { + if (none && canDisable) { setGeminiThinking(body, { thinkingBudget: 0, includeThoughts: false }); break; } + const budget = toBudget(eff, caps.thinkingRange); + setGeminiThinking(body, { thinkingBudget: budget ?? -1, includeThoughts: true }); + break; + } + case "zai": { + // Z.ai ignores thinking.disabled → must use enable_thinking:false to turn off. + if (none && canDisable) { body.enable_thinking = false; delete body.thinking; break; } + body.thinking = { type: "enabled" }; + break; + } + case "qwen": { + if (none && canDisable) { body.enable_thinking = false; break; } + body.enable_thinking = true; + const budget = toBudget(eff, caps.thinkingRange); + if (Number.isFinite(budget) && budget > 0) body.thinking_budget = budget; + break; + } + case "deepseek": { + if (none && canDisable) { body.thinking = { type: "disabled" }; break; } + body.thinking = { type: "enabled" }; + // DeepSeek: low/medium→high, xhigh/max→max. + const level = toLevel(eff); + body.reasoning_effort = level === "xhigh" || level === "max" ? "max" : "high"; + break; + } + case "kimi": { + if (none && canDisable) { body.thinking = { type: "disabled" }; break; } + const level = toLevel(eff); + if (level) body.reasoning_effort = level === "max" ? "high" : level; + break; + } + case "minimax": { + // M3 adaptive; M2.x cannot disable (handled via canDisable clamp). + body.thinking = { type: none && canDisable ? "disabled" : "adaptive" }; + break; + } + case "hunyuan": { + if (none && canDisable) { body.thinking = { type: "disabled" }; break; } + const budget = toBudget(eff, caps.thinkingRange); + body.thinking = budget === -1 ? { type: "enabled" } : { type: "enabled", budget_tokens: budget || 8192 }; + break; + } + case "step": { + if (none && canDisable) break; + const level = toLevel(eff); + if (level) body.reasoning_effort = level === "xhigh" || level === "max" ? "high" : level; + break; + } + case "kiro": + // Kiro thinking handled via system-tag injection in openai-to-kiro.js; no body field here. + break; + default: + break; + } +} + +// Public entry: normalize thinking for the resolved target format. +// Mutates and returns body. No-op when model has no reasoning capability. +// `intent` is a pre-captured config (from captureThinking on the original body); +// falls back to extracting from the current body when omitted. +export function applyThinking(targetFormat, model, body, provider = null, intent = undefined) { + if (!body || typeof body !== "object") return body; + + const { cleanModel, override } = parseSuffix(model); + const cfg = override || intent || extractThinking(body); + const caps = getCapabilitiesForModel(provider, cleanModel); + + // Model cannot reason → strip any stray thinking fields. + if (!caps.reasoning) { + stripAll(body); + return body; + } + if (!cfg) return body; + + const fmt = resolveFormat(targetFormat, cleanModel, provider); + stripAll(body); + applyFormat(fmt, body, cfg, caps); + return body; +} diff --git a/open-sse/translator/helpers/toolCallHelper.js b/open-sse/translator/concerns/toolCall.js similarity index 96% rename from open-sse/translator/helpers/toolCallHelper.js rename to open-sse/translator/concerns/toolCall.js index a80f5939..8de82f71 100644 --- a/open-sse/translator/helpers/toolCallHelper.js +++ b/open-sse/translator/concerns/toolCall.js @@ -3,6 +3,11 @@ // Anthropic tool_use.id must match: ^[a-zA-Z0-9_-]+$ const TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; +// Fallback streaming tool_call id when provider omits one (index optional) +export function fallbackToolCallId(index) { + return index === undefined ? `call_${Date.now()}` : `call_${index}_${Date.now()}`; +} + // Generate deterministic tool call ID from position + tool name (cache-friendly) export function generateToolCallId(msgIndex = 0, tcIndex = 0, toolName = "") { const name = toolName ? `_${toolName.replace(/[^a-zA-Z0-9_-]/g, "")}` : ""; diff --git a/open-sse/translator/concerns/usage.js b/open-sse/translator/concerns/usage.js new file mode 100644 index 00000000..44622901 --- /dev/null +++ b/open-sse/translator/concerns/usage.js @@ -0,0 +1,60 @@ +// Build OpenAI usage object. Caller computes prompt/completion/total (provider math). +// Optional details added only when > 0 (matches existing claude/gemini/codex behavior). +export function buildUsage({ promptTokens, completionTokens, totalTokens, cachedTokens = 0, cacheCreationTokens = 0, reasoningTokens = 0 }) { + const usage = { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: totalTokens }; + if (cachedTokens > 0 || cacheCreationTokens > 0) { + usage.prompt_tokens_details = {}; + if (cachedTokens > 0) usage.prompt_tokens_details.cached_tokens = cachedTokens; + if (cacheCreationTokens > 0) usage.prompt_tokens_details.cache_creation_tokens = cacheCreationTokens; + } + if (reasoningTokens > 0) { + usage.completion_tokens_details = { reasoning_tokens: reasoningTokens }; + } + return usage; +} + +const n = (v) => (typeof v === "number" ? v : 0); + +// Per-provider raw token field-map + math. Returns buildUsage() args (NOT the usage object). +// Keeps each provider's exact semantics: claude/gemini fold cache+reasoning, others don't. +const USAGE_EXTRACTORS = { + claude(raw) { + const input = n(raw.input_tokens), output = n(raw.output_tokens); + const cacheRead = n(raw.cache_read_input_tokens), cacheCreate = n(raw.cache_creation_input_tokens); + const prompt = input + cacheRead + cacheCreate; + return { promptTokens: prompt, completionTokens: output, totalTokens: prompt + output, cachedTokens: cacheRead, cacheCreationTokens: cacheCreate }; + }, + gemini(raw) { + const cached = n(raw.cachedContentTokenCount); + const prompt = n(raw.promptTokenCount); + const thoughts = n(raw.thoughtsTokenCount); + const total = n(raw.totalTokenCount); + let candidates = n(raw.candidatesTokenCount); + // Fallback: derive candidates from total when upstream omits it + if (candidates === 0 && total > 0) { + candidates = total - prompt - thoughts; + if (candidates < 0) candidates = 0; + } + return { promptTokens: prompt, completionTokens: candidates + thoughts, totalTokens: total, cachedTokens: cached, reasoningTokens: thoughts }; + }, + kiro(raw) { + const input = n(raw.inputTokens), output = n(raw.outputTokens); + return { promptTokens: input, completionTokens: output, totalTokens: input + output }; + }, + ollama(raw) { + const input = n(raw.prompt_eval_count), output = n(raw.eval_count); + return { promptTokens: input, completionTokens: output, totalTokens: input + output }; + }, + commandcode(raw) { + const input = n(raw.inputTokens), output = n(raw.outputTokens); + const total = typeof raw.totalTokens === "number" ? raw.totalTokens : input + output; + return { promptTokens: input, completionTokens: output, totalTokens: total }; + }, +}; + +// Convert provider-native usage object → OpenAI usage. Returns null if no extractor/raw. +export function toOpenAIUsage(raw, kind) { + const extract = USAGE_EXTRACTORS[kind]; + if (!extract || !raw || typeof raw !== "object") return null; + return buildUsage(extract(raw)); +} diff --git a/open-sse/translator/helpers/claudeHelper.js b/open-sse/translator/formats/claude.js similarity index 59% rename from open-sse/translator/helpers/claudeHelper.js rename to open-sse/translator/formats/claude.js index 3bdd31db..62ce7b13 100644 --- a/open-sse/translator/helpers/claudeHelper.js +++ b/open-sse/translator/formats/claude.js @@ -1,17 +1,22 @@ // Claude helper functions for translator import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.js"; -import { adjustMaxTokens } from "./maxTokensHelper.js"; +import { ROLE, CLAUDE_BLOCK } from "../schema/index.js"; +import { adjustMaxTokens } from "./maxTokens.js"; import { applyCloaking } from "../../utils/claudeCloaking.js"; -import { deriveSessionId } from "../../utils/sessionManager.js"; +import { resolveSessionId } from "../../utils/sessionManager.js"; +import { isValidClaudeSignature } from "../../utils/claudeSignature.js"; +import { PROVIDERS } from "../../providers/index.js"; +import { getCapabilitiesForModel } from "../../providers/capabilities.js"; +import { DEFAULT_MAX_TOKENS } from "../../config/runtimeConfig.js"; // Check if message has valid non-empty content export function hasValidContent(msg) { if (typeof msg.content === "string" && msg.content.trim()) return true; if (Array.isArray(msg.content)) { return msg.content.some(block => - (block.type === "text" && block.text?.trim()) || - block.type === "tool_use" || - block.type === "tool_result" + (block.type === CLAUDE_BLOCK.TEXT && block.text?.trim()) || + block.type === CLAUDE_BLOCK.TOOL_USE || + block.type === CLAUDE_BLOCK.TOOL_RESULT ); } return false; @@ -25,18 +30,18 @@ export function fixToolUseOrdering(messages) { // Pass 1: Fix assistant messages with tool_use - remove text after tool_use for (const msg of messages) { - if (msg.role === "assistant" && Array.isArray(msg.content)) { - const hasToolUse = msg.content.some(b => b.type === "tool_use"); + if (msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) { + const hasToolUse = msg.content.some(b => b.type === CLAUDE_BLOCK.TOOL_USE); if (hasToolUse) { // Keep only: thinking blocks + tool_use blocks (remove text blocks after tool_use) const newContent = []; let foundToolUse = false; for (const block of msg.content) { - if (block.type === "tool_use") { + if (block.type === CLAUDE_BLOCK.TOOL_USE) { foundToolUse = true; newContent.push(block); - } else if (block.type === "thinking" || block.type === "redacted_thinking") { + } else if (block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING) { newContent.push(block); } else if (!foundToolUse) { // Keep text blocks BEFORE tool_use @@ -58,17 +63,17 @@ export function fixToolUseOrdering(messages) { if (last && last.role === msg.role) { // Merge content arrays - const lastContent = Array.isArray(last.content) ? last.content : [{ type: "text", text: last.content }]; - const msgContent = Array.isArray(msg.content) ? msg.content : [{ type: "text", text: msg.content }]; + const lastContent = Array.isArray(last.content) ? last.content : [{ type: CLAUDE_BLOCK.TEXT, text: last.content }]; + const msgContent = Array.isArray(msg.content) ? msg.content : [{ type: CLAUDE_BLOCK.TEXT, text: msg.content }]; // Put tool_result first, then other content - const toolResults = [...lastContent.filter(b => b.type === "tool_result"), ...msgContent.filter(b => b.type === "tool_result")]; - const otherContent = [...lastContent.filter(b => b.type !== "tool_result"), ...msgContent.filter(b => b.type !== "tool_result")]; + const toolResults = [...lastContent.filter(b => b.type === CLAUDE_BLOCK.TOOL_RESULT), ...msgContent.filter(b => b.type === CLAUDE_BLOCK.TOOL_RESULT)]; + const otherContent = [...lastContent.filter(b => b.type !== CLAUDE_BLOCK.TOOL_RESULT), ...msgContent.filter(b => b.type !== CLAUDE_BLOCK.TOOL_RESULT)]; last.content = [...toolResults, ...otherContent]; } else { // Ensure content is array - const content = Array.isArray(msg.content) ? msg.content : [{ type: "text", text: msg.content }]; + const content = Array.isArray(msg.content) ? msg.content : [{ type: CLAUDE_BLOCK.TEXT, text: msg.content }]; merged.push({ role: msg.role, content: [...content] }); } } @@ -76,13 +81,33 @@ export function fixToolUseOrdering(messages) { return merged; } -// Models that reject thinking.type "adaptive" (only Sonnet/Opus support it) +// Models that reject thinking.type "adaptive" + output_config.effort (Opus 4.5+/Sonnet 4.6+ only) const ADAPTIVE_THINKING_UNSUPPORTED = /haiku/i; +function handlesThinkingBlocks(provider) { + return provider === "claude" || provider?.startsWith("anthropic-compatible") || provider === "deepseek"; +} + +function buildThinkingPlaceholder(provider) { + const block = { + type: CLAUDE_BLOCK.THINKING, + thinking: ".", + }; + + // DeepSeek's Anthropic-compatible endpoint requires a thinking block in + // thinking mode, but it does not need Anthropic's signed-thinking fallback. + if (provider !== "deepseek") { + block.signature = DEFAULT_THINKING_CLAUDE_SIGNATURE; + } + + return block; +} + // Normalize a native Claude passthrough body to match Anthropic Messages API spec. // Newer Cowork/Claude Code clients emit beta-only shapes that OAuth endpoints reject: // 1. thinking.type "adaptive" → unsupported on Haiku -// 2. role "system" messages (mid-conversation-system beta) → only top-level system is allowed +// 2. output_config.effort → unsupported on Haiku +// 3. role "system" messages (mid-conversation-system beta) → only top-level system is allowed export function normalizeClaudePassthrough(body, model = "") { if (!body || typeof body !== "object") return body; @@ -91,18 +116,24 @@ export function normalizeClaudePassthrough(body, model = "") { body.thinking = { type: "enabled", budget_tokens: 10000 }; } + // 2. Strip effort param for models that don't support it (keep other output_config fields) + if (ADAPTIVE_THINKING_UNSUPPORTED.test(model) && body.output_config?.effort != null) { + delete body.output_config.effort; + if (Object.keys(body.output_config).length === 0) delete body.output_config; + } + // 2. Hoist mid-conversation system messages into the top-level system field if (Array.isArray(body.messages)) { const systemBlocks = []; const messages = []; for (const msg of body.messages) { - if (msg.role === "system") { + if (msg.role === ROLE.SYSTEM) { const text = typeof msg.content === "string" ? msg.content : Array.isArray(msg.content) ? msg.content.map(b => (typeof b === "string" ? b : b?.text || "")).join("\n") : ""; - if (text.trim()) systemBlocks.push({ type: "text", text }); + if (text.trim()) systemBlocks.push({ type: CLAUDE_BLOCK.TEXT, text }); continue; } messages.push(msg); @@ -122,21 +153,24 @@ export function normalizeClaudePassthrough(body, model = "") { return body; } -const CLAUDE_FORMAT_PROVIDERS_WITHOUT_OUTPUT_CONFIG = new Set(["minimax", "minimax-cn"]); - // Prepare request for Claude format endpoints // - Cleanup cache_control // - Filter empty messages // - Add thinking block for Anthropic endpoint (provider === "claude") // - Fix tool_use/tool_result ordering // - Apply cloaking (billing header + fake user ID) for OAuth tokens -export function prepareClaudeRequest(body, provider = null, apiKey = null, connectionId = null) { - // MiniMax exposes a Claude-compatible endpoint but rejects Anthropic's extended - // structured output parameter with a generic 400 "invalid params" response. - if (CLAUDE_FORMAT_PROVIDERS_WITHOUT_OUTPUT_CONFIG.has(provider)) { +export function prepareClaudeRequest(body, provider = null, apiKey = null, connectionId = null, rawHeaders = null, sessionId = null) { + // quirk: MiniMax's Claude-compatible endpoint rejects Anthropic's output_config (400 invalid params) + if (PROVIDERS[provider]?.quirks?.dropOutputConfig) { delete body.output_config; } + // Clamp max_tokens to the model output ceiling (never above DEFAULT_MAX_TOKENS) + if (body.max_tokens) { + const ceiling = Math.min(getCapabilitiesForModel(provider, body.model).maxOutput, DEFAULT_MAX_TOKENS); + if (body.max_tokens > ceiling) body.max_tokens = ceiling; + } + // 1. System: remove all cache_control, add only to last block with ttl 1h if (body.system && Array.isArray(body.system)) { body.system = body.system.map((block, i) => { @@ -193,7 +227,7 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne if (!lastAssistantProcessed && msg.content.length > 0) { for (let j = msg.content.length - 1; j >= 0; j--) { const block = msg.content[j]; - if (block.type !== "thinking" && block.type !== "redacted_thinking") { + if (block.type !== CLAUDE_BLOCK.THINKING && block.type !== CLAUDE_BLOCK.REDACTED_THINKING) { block.cache_control = { type: "ephemeral" }; break; } @@ -201,27 +235,43 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne lastAssistantProcessed = true; } - // Handle thinking blocks for Anthropic endpoint only - if (provider === "claude" || provider?.startsWith("anthropic-compatible")) { + // Handle thinking blocks for Anthropic-compatible endpoints. + if (handlesThinkingBlocks(provider)) { let hasToolUse = false; - let hasThinking = false; + let hasKeptThinking = false; - // Always replace signature for all thinking blocks + // Claude native: preserve valid signatures, drop invalid blocks. + // anthropic-compatible: replace with default (safe fallback for lenient upstreams). + // DeepSeek: keep existing thinking as-is; add an unsigned placeholder only if missing. + const isClaudeNative = provider === "claude"; + const isDeepSeek = provider === "deepseek"; + const kept = []; for (const block of msg.content) { - if (block.type === "thinking" || block.type === "redacted_thinking") { - block.signature = DEFAULT_THINKING_CLAUDE_SIGNATURE; - hasThinking = true; + const isThinking = block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING; + if (isThinking) { + if (isClaudeNative) { + if (isValidClaudeSignature(block.signature)) { + hasKeptThinking = true; + kept.push(block); + } + } else if (isDeepSeek) { + hasKeptThinking = true; + kept.push(block); + } else { + block.signature = DEFAULT_THINKING_CLAUDE_SIGNATURE; + hasKeptThinking = true; + kept.push(block); + } + continue; } - if (block.type === "tool_use") hasToolUse = true; + if (block.type === CLAUDE_BLOCK.TOOL_USE) hasToolUse = true; + kept.push(block); } + msg.content = kept; // Add thinking block if thinking enabled + has tool_use but no thinking - if (thinkingEnabled && !hasThinking && hasToolUse) { - msg.content.unshift({ - type: "thinking", - thinking: ".", - signature: DEFAULT_THINKING_CLAUDE_SIGNATURE - }); + if (thinkingEnabled && !hasKeptThinking && hasToolUse) { + msg.content.unshift(buildThinkingPlaceholder(provider)); } } } @@ -230,9 +280,22 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne // 3. Tools: filter built-in tools for non-Anthropic providers, then handle cache_control if (body.tools && Array.isArray(body.tools)) { - // Strip built-in tools (e.g. web_search_20250305) for providers that don't support them + // Strip built-in tools (e.g. web_search_20250305) and normalize to Anthropic-native shape + // (drop `type` field, fold `function.{name,description,parameters}`) for non-Anthropic providers if (provider !== "claude") { - body.tools = body.tools.filter(tool => !tool.type || tool.type === "function"); + body.tools = body.tools + .filter(tool => !tool.type || tool.type === "function") + .map(tool => { + if (tool.function) { + return { + name: tool.function.name, + description: tool.function.description, + input_schema: tool.function.parameters, + }; + } + const { type, ...rest } = tool; + return rest; + }); } body.tools = body.tools.map((tool, i) => { @@ -253,10 +316,9 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne // Apply cloaking for OAuth tokens (billing header + fake user ID) // session_id in user_id must match X-Claude-Code-Session-Id for fingerprint consistency if ((provider === "claude" || provider?.startsWith("anthropic-compatible")) && apiKey) { - const sessionId = connectionId ? deriveSessionId(connectionId) : null; - body = applyCloaking(body, apiKey, sessionId); + const sid = sessionId || resolveSessionId({ headers: rawHeaders, body, connectionId, scope: "claude" }); + body = applyCloaking(body, apiKey, sid); } return body; } - diff --git a/open-sse/translator/helpers/geminiHelper.js b/open-sse/translator/formats/gemini.js similarity index 88% rename from open-sse/translator/helpers/geminiHelper.js rename to open-sse/translator/formats/gemini.js index b39b3f3a..3fe4eb7b 100644 --- a/open-sse/translator/helpers/geminiHelper.js +++ b/open-sse/translator/formats/gemini.js @@ -1,10 +1,13 @@ // Gemini helper functions for translator +import { safeParseJSON } from "../concerns/json.js"; +import { OPENAI_BLOCK } from "../schema/index.js"; + // Unsupported JSON Schema constraints that should be removed for Antigravity export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [ // Basic constraints (not supported by Gemini API) "minLength", "maxLength", "exclusiveMinimum", "exclusiveMaximum", - "pattern", "minItems", "maxItems", "format", + "minItems", "maxItems", "format", // Claude rejects these in VALIDATED mode "default", "examples", // JSON Schema meta keywords @@ -16,7 +19,7 @@ export const UNSUPPORTED_SCHEMA_CONSTRAINTS = [ // Dependency keywords (not supported) "dependencies", "dependentSchemas", "dependentRequired", // Other unsupported keywords - "title", "if", "then", "else", "contentMediaType", "contentEncoding", + "title", "optional", "if", "then", "else", "contentMediaType", "contentEncoding", // UI/Styling properties (from Cursor tools - NOT JSON Schema standard) "cornerRadius", "fillColor", "fontFamily", "fontSize", "fontWeight", "gap", "padding", "strokeColor", "strokeThickness", "textColor" @@ -39,9 +42,9 @@ export function convertOpenAIContentToParts(content) { parts.push({ text: content }); } else if (Array.isArray(content)) { for (const item of content) { - if (item.type === "text") { + if (item.type === OPENAI_BLOCK.TEXT) { parts.push({ text: item.text }); - } else if (item.type === "image_url" && item.image_url?.url?.startsWith("data:")) { + } else if (item.type === OPENAI_BLOCK.IMAGE_URL && item.image_url?.url?.startsWith("data:")) { const url = item.image_url.url; const commaIndex = url.indexOf(","); if (commaIndex !== -1) { @@ -53,17 +56,17 @@ export function convertOpenAIContentToParts(content) { inlineData: { mime_type: mimeType, data: data } }); } - } else if (item.type === "image_url" && item.image_url?.url && (item.image_url.url.startsWith("http://") || item.image_url.url.startsWith("https://"))) { + } else if (item.type === OPENAI_BLOCK.IMAGE_URL && item.image_url?.url && (item.image_url.url.startsWith("http://") || item.image_url.url.startsWith("https://"))) { parts.push({ fileData: { fileUri: item.image_url.url, mimeType: "image/*" } }); - } else if (item.type === "input_audio" && item.input_audio?.data) { + } else if (item.type === OPENAI_BLOCK.INPUT_AUDIO && item.input_audio?.data) { const format = item.input_audio.format || "wav"; const mimeType = format === "mp3" ? "audio/mpeg" : `audio/${format}`; parts.push({ inlineData: { mime_type: mimeType, data: item.input_audio.data } }); - } else if (item.type === "audio_url" && item.audio_url?.url?.startsWith("data:")) { + } else if (item.type === OPENAI_BLOCK.AUDIO_URL && item.audio_url?.url?.startsWith("data:")) { const url = item.audio_url.url; const commaIndex = url.indexOf(","); if (commaIndex !== -1) { @@ -74,6 +77,14 @@ export function convertOpenAIContentToParts(content) { inlineData: { mime_type: mimeType, data: data } }); } + } else if (item.type === OPENAI_BLOCK.FILE && item.file?.file_data?.startsWith("data:")) { + const url = item.file.file_data; + const commaIndex = url.indexOf(","); + if (commaIndex !== -1) { + const mimeType = url.substring(5, commaIndex).split(";")[0]; + const data = url.substring(commaIndex + 1); + parts.push({ inlineData: { mime_type: mimeType, data: data } }); + } } } } @@ -82,22 +93,17 @@ export function convertOpenAIContentToParts(content) { } // Extract text content from OpenAI content -export function extractTextContent(content) { +export function extractTextContent(content, separator = "") { if (typeof content === "string") return content; if (Array.isArray(content)) { - return content.filter(c => c.type === "text").map(c => c.text).join(""); + return content.filter(c => c.type === OPENAI_BLOCK.TEXT).map(c => c.text).join(separator); } return ""; } -// Try parse JSON safely +// Try parse JSON safely (null fallback on parse error; re-export keeps legacy API) export function tryParseJSON(str) { - if (typeof str !== "string") return str; - try { - return JSON.parse(str); - } catch { - return null; - } + return safeParseJSON(str, null); } // Generate request ID diff --git a/open-sse/translator/helpers/maxTokensHelper.js b/open-sse/translator/formats/maxTokens.js similarity index 87% rename from open-sse/translator/helpers/maxTokensHelper.js rename to open-sse/translator/formats/maxTokens.js index 294d9c55..0e5b36f2 100644 --- a/open-sse/translator/helpers/maxTokensHelper.js +++ b/open-sse/translator/formats/maxTokens.js @@ -8,7 +8,7 @@ import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../config/runtimeConf export function adjustMaxTokens(body) { let maxTokens = body.max_tokens || DEFAULT_MAX_TOKENS; - // Auto-increase for tool calling to prevent truncated arguments + // Auto-increase for tool calling to prevent truncated arguments (min never above max) if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) { if (maxTokens < DEFAULT_MIN_TOKENS) { maxTokens = DEFAULT_MIN_TOKENS; @@ -22,6 +22,9 @@ export function adjustMaxTokens(body) { maxTokens = body.thinking.budget_tokens + 1024; } + // Never exceed the global ceiling + if (maxTokens > DEFAULT_MAX_TOKENS) maxTokens = DEFAULT_MAX_TOKENS; + return maxTokens; } diff --git a/open-sse/translator/helpers/openaiHelper.js b/open-sse/translator/formats/openai.js similarity index 74% rename from open-sse/translator/helpers/openaiHelper.js rename to open-sse/translator/formats/openai.js index a4c3f8a3..d6c850c4 100644 --- a/open-sse/translator/helpers/openaiHelper.js +++ b/open-sse/translator/formats/openai.js @@ -1,8 +1,8 @@ // OpenAI helper functions for translator +import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK, VALID_OPENAI_CONTENT_TYPES, VALID_OPENAI_MESSAGE_TYPES } from "../schema/index.js"; -// Valid OpenAI content block types -export const VALID_OPENAI_CONTENT_TYPES = ["text", "image_url", "image", "input_audio", "audio_url"]; -export const VALID_OPENAI_MESSAGE_TYPES = ["text", "image_url", "image", "tool_calls", "tool_result"]; +// Re-export valid-type lists (moved to schema/blocks.js) to keep existing importers working. +export { VALID_OPENAI_CONTENT_TYPES, VALID_OPENAI_MESSAGE_TYPES }; // Filter messages to OpenAI standard format // Remove: thinking, redacted_thinking, signature, and other non-OpenAI blocks @@ -11,13 +11,13 @@ export function filterToOpenAIFormat(body) { body.messages = body.messages.map(msg => { // Normalize developer role to system (many providers don't support developer) - if (msg.role === "developer") msg = { ...msg, role: "system" }; + if (msg.role === ROLE.DEVELOPER) msg = { ...msg, role: ROLE.SYSTEM }; // Keep tool messages as-is (OpenAI format) - if (msg.role === "tool") return msg; + if (msg.role === ROLE.TOOL) return msg; // Keep assistant messages with tool_calls as-is - if (msg.role === "assistant" && msg.tool_calls) return msg; + if (msg.role === ROLE.ASSISTANT && msg.tool_calls) return msg; // Handle string content if (typeof msg.content === "string") return msg; @@ -28,17 +28,17 @@ export function filterToOpenAIFormat(body) { for (const block of msg.content) { // Skip thinking blocks - if (block.type === "thinking" || block.type === "redacted_thinking") continue; + if (block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING) continue; // Only keep valid OpenAI content types if (VALID_OPENAI_CONTENT_TYPES.includes(block.type)) { // Remove signature field if exists const { signature, cache_control, ...cleanBlock } = block; filteredContent.push(cleanBlock); - } else if (block.type === "tool_use") { + } else if (block.type === CLAUDE_BLOCK.TOOL_USE) { // Convert tool_use to tool_calls format (handled separately) continue; - } else if (block.type === "tool_result") { + } else if (block.type === CLAUDE_BLOCK.TOOL_RESULT) { // Keep tool_result but clean it const { signature, cache_control, ...cleanBlock } = block; filteredContent.push(cleanBlock); @@ -47,7 +47,7 @@ export function filterToOpenAIFormat(body) { // If all content was filtered, add empty text if (filteredContent.length === 0) { - filteredContent.push({ type: "text", text: "" }); + filteredContent.push({ type: OPENAI_BLOCK.TEXT, text: "" }); } return { ...msg, content: filteredContent }; @@ -59,15 +59,15 @@ export function filterToOpenAIFormat(body) { // Filter out messages with only empty text (but NEVER filter tool messages) body.messages = body.messages.filter(msg => { // Always keep tool messages - if (msg.role === "tool") return true; + if (msg.role === ROLE.TOOL) return true; // Always keep assistant messages with tool_calls - if (msg.role === "assistant" && msg.tool_calls) return true; + if (msg.role === ROLE.ASSISTANT && msg.tool_calls) return true; if (typeof msg.content === "string") return msg.content.trim() !== ""; if (Array.isArray(msg.content)) { return msg.content.some(b => - (b.type === "text" && b.text?.trim()) || - b.type !== "text" + (b.type === OPENAI_BLOCK.TEXT && b.text?.trim()) || + b.type !== OPENAI_BLOCK.TEXT ); } return true; @@ -82,12 +82,12 @@ export function filterToOpenAIFormat(body) { if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) { body.tools = body.tools.map(tool => { // Already OpenAI format - if (tool.type === "function" && tool.function) return tool; + if (tool.type === OPENAI_BLOCK.FUNCTION && tool.function) return tool; // Claude format: {name, description, input_schema} if (tool.name && (tool.input_schema || tool.description)) { return { - type: "function", + type: OPENAI_BLOCK.FUNCTION, function: { name: tool.name, description: String(tool.description || ""), @@ -99,7 +99,7 @@ export function filterToOpenAIFormat(body) { // Gemini format: {functionDeclarations: [{name, description, parameters}]} if (tool.functionDeclarations && Array.isArray(tool.functionDeclarations)) { return tool.functionDeclarations.map(fn => ({ - type: "function", + type: OPENAI_BLOCK.FUNCTION, function: { name: fn.name, description: String(fn.description || ""), @@ -121,7 +121,7 @@ export function filterToOpenAIFormat(body) { } else if (choice.type === "any") { body.tool_choice = "required"; } else if (choice.type === "tool" && choice.name) { - body.tool_choice = { type: "function", function: { name: choice.name } }; + body.tool_choice = { type: OPENAI_BLOCK.FUNCTION, function: { name: choice.name } }; } } diff --git a/open-sse/translator/helpers/responsesApiHelper.js b/open-sse/translator/formats/responsesApi.js similarity index 76% rename from open-sse/translator/helpers/responsesApiHelper.js rename to open-sse/translator/formats/responsesApi.js index 8bb87b8b..c41ee470 100644 --- a/open-sse/translator/helpers/responsesApiHelper.js +++ b/open-sse/translator/formats/responsesApi.js @@ -1,3 +1,5 @@ +import { ROLE, OPENAI_BLOCK, RESPONSES_ITEM } from "../schema/index.js"; + /** * Normalize Responses API input to array format. * Accepts string or array, returns array of message items. @@ -9,12 +11,12 @@ export function normalizeResponsesInput(input) { if (typeof input === "string") { const text = input.trim() === "" ? "..." : input; - return [{ type: "message", role: "user", content: [{ type: "input_text", text }] }]; + return [{ type: RESPONSES_ITEM.MESSAGE, role: ROLE.USER, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text }] }]; } if (Array.isArray(input)) { // Empty input[] would produce messages:[] which all providers reject (#389) if (input.length === 0) { - return [{ type: "message", role: "user", content: [{ type: "input_text", text: "..." }] }]; + return [{ type: RESPONSES_ITEM.MESSAGE, role: ROLE.USER, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: "..." }] }]; } return input; } @@ -34,7 +36,7 @@ export function convertResponsesApiFormat(body) { // Convert instructions to system message if (body.instructions) { - result.messages.push({ role: "system", content: body.instructions }); + result.messages.push({ role: ROLE.SYSTEM, content: body.instructions }); } // Group items by conversation turn @@ -48,9 +50,9 @@ export function convertResponsesApiFormat(body) { for (const item of inputItems) { // Determine item type - Droid CLI sends role-based items without 'type' field // Fallback: if no type but has role property, treat as message - const itemType = item.type || (item.role ? "message" : null); + const itemType = item.type || (item.role ? RESPONSES_ITEM.MESSAGE : null); - if (itemType === "message") { + if (itemType === RESPONSES_ITEM.MESSAGE) { // Flush any pending assistant message with tool calls if (currentAssistantMsg) { result.messages.push(currentAssistantMsg); @@ -67,22 +69,22 @@ export function convertResponsesApiFormat(body) { // Convert content: input_text → text, output_text → text, input_image → image_url const content = Array.isArray(item.content) ? item.content.map(c => { - if (c.type === "input_text") return { type: "text", text: c.text }; - if (c.type === "output_text") return { type: "text", text: c.text }; - if (c.type === "input_image") { + if (c.type === RESPONSES_ITEM.INPUT_TEXT) return { type: OPENAI_BLOCK.TEXT, text: c.text }; + if (c.type === RESPONSES_ITEM.OUTPUT_TEXT) return { type: OPENAI_BLOCK.TEXT, text: c.text }; + if (c.type === RESPONSES_ITEM.INPUT_IMAGE) { const url = c.image_url || c.file_id || ""; - return { type: "image_url", image_url: { url, detail: c.detail || "auto" } }; + return { type: OPENAI_BLOCK.IMAGE_URL, image_url: { url, detail: c.detail || "auto" } }; } return c; }) : item.content; result.messages.push({ role: item.role, content }); } - else if (itemType === "function_call") { + else if (itemType === RESPONSES_ITEM.FUNCTION_CALL) { // Start or append to assistant message with tool_calls if (!currentAssistantMsg) { currentAssistantMsg = { - role: "assistant", + role: ROLE.ASSISTANT, content: null, tool_calls: [] }; @@ -91,14 +93,14 @@ export function convertResponsesApiFormat(body) { if (!item.name || typeof item.name !== "string" || item.name.trim() === "") continue; currentAssistantMsg.tool_calls.push({ id: item.call_id, - type: "function", + type: OPENAI_BLOCK.FUNCTION, function: { name: item.name, arguments: item.arguments } }); } - else if (itemType === "function_call_output") { + else if (itemType === RESPONSES_ITEM.FUNCTION_CALL_OUTPUT) { // Flush assistant message first if exists if (currentAssistantMsg) { result.messages.push(currentAssistantMsg); @@ -106,12 +108,12 @@ export function convertResponsesApiFormat(body) { } // Add tool result pendingToolResults.push({ - role: "tool", + role: ROLE.TOOL, tool_call_id: item.call_id, content: typeof item.output === "string" ? item.output : JSON.stringify(item.output) }); } - else if (itemType === "reasoning") { + else if (itemType === RESPONSES_ITEM.REASONING) { // Skip reasoning items - they are for display only continue; } diff --git a/open-sse/translator/helpers/imageHelper.js b/open-sse/translator/helpers/imageHelper.js deleted file mode 100644 index 1df04d8d..00000000 --- a/open-sse/translator/helpers/imageHelper.js +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Fetch a remote image URL and return it as a base64 data URI. - * Used when upstream providers (Codex, etc.) require inline base64 images - * instead of remote URLs they cannot fetch. - * Returns null if fetch fails. - * - * @param {string} imageUrl - HTTP(S) URL of the image - * @param {object} options - { signal, timeoutMs } - * @returns {Promise<{url: string, mimeType: string}|null>} - */ -export async function fetchImageAsBase64(imageUrl, options = {}) { - const { signal, timeoutMs = 10000 } = options; - if (!imageUrl || (!imageUrl.startsWith("http://") && !imageUrl.startsWith("https://"))) { - return null; - } - - const controller = new AbortController(); - const timeout = signal ? null : setTimeout(() => controller.abort(), timeoutMs); - const fetchSignal = signal || controller.signal; - - try { - const response = await fetch(imageUrl, { signal: fetchSignal }); - if (!response.ok) return null; - - const mimeType = response.headers.get("Content-Type") || "image/jpeg"; - const arrayBuffer = await response.arrayBuffer(); - const base64 = Buffer.from(arrayBuffer).toString("base64"); - return { url: `data:${mimeType};base64,${base64}`, mimeType }; - } catch { - return null; - } finally { - if (timeout) clearTimeout(timeout); - } -} diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index d581bd7e..8c25d486 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -1,20 +1,24 @@ import { FORMATS } from "./formats.js"; -import { ensureToolCallIds, fixMissingToolResponses } from "./helpers/toolCallHelper.js"; -import { prepareClaudeRequest } from "./helpers/claudeHelper.js"; +import { ensureToolCallIds, fixMissingToolResponses } from "./concerns/toolCall.js"; +import { prepareClaudeRequest } from "./formats/claude.js"; import { cloakClaudeTools } from "../utils/claudeCloaking.js"; -import { filterToOpenAIFormat } from "./helpers/openaiHelper.js"; +import { filterToOpenAIFormat } from "./formats/openai.js"; import { normalizeThinkingConfig } from "../services/provider.js"; +import { applyThinking, captureThinking } from "./concerns/thinkingUnified.js"; +import { captureSessionId } from "../utils/sessionManager.js"; import { AntigravityExecutor } from "../executors/antigravity.js"; +import { PROVIDERS } from "../providers/index.js"; -// Registry for translators -const requestRegistry = new Map(); -const responseRegistry = new Map(); - -// Track initialization state -let initialized = false; +// Registry for translators. Lazy-init guards against circular-import order: +// translator modules call register() (side-effect) before this module's body runs. +// var (not let): hoisted as undefined so register() can run during circular import (no TDZ). +var requestRegistry; +var responseRegistry; // Register translator export function register(from, to, requestFn, responseFn) { + requestRegistry ??= new Map(); + responseRegistry ??= new Map(); const key = `${from}:${to}`; if (requestFn) { requestRegistry.set(key, requestFn); @@ -24,35 +28,8 @@ export function register(from, to, requestFn, responseFn) { } } -// Lazy load translators (called once on first use) -function ensureInitialized() { - if (initialized) return; - initialized = true; - - // Request translators - sync require pattern for bundler - require("./request/claude-to-openai.js"); - require("./request/openai-to-claude.js"); - require("./request/gemini-to-openai.js"); - require("./request/openai-to-gemini.js"); - require("./request/openai-to-vertex.js"); - require("./request/antigravity-to-openai.js"); - require("./request/openai-responses.js"); - require("./request/openai-to-kiro.js"); - require("./request/openai-to-cursor.js"); - require("./request/openai-to-ollama.js"); - require("./request/openai-to-commandcode.js"); - - // Response translators - require("./response/claude-to-openai.js"); - require("./response/openai-to-claude.js"); - require("./response/gemini-to-openai.js"); - require("./response/openai-to-antigravity.js"); - require("./response/openai-responses.js"); - require("./response/kiro-to-openai.js"); - require("./response/cursor-to-openai.js"); - require("./response/ollama-to-openai.js"); - require("./response/commandcode-to-openai.js"); -} +// No-op: translators self-register via the static imports at the bottom of this file. +function ensureInitialized() {} // Strip specific content types from messages (explicit opt-in via strip[] in PROVIDER_MODELS) function stripContentTypes(body, stripList = []) { @@ -88,27 +65,47 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream // Fix missing tool responses (insert empty tool_result if needed) fixMissingToolResponses(result); + // Capture thinking intent from the original (pre-translation) body, before any + // format conversion strips/renames the fields. Applied after translation. + const thinkingIntent = captureThinking(result); + + // Capture session id from the original body (envelope still intact, e.g. antigravity request.sessionId) + const clientSessionId = captureSessionId(result, credentials, connectionId, targetFormat); + // Expose to downstream translators (gemini-cli/antigravity envelopes) that run after envelope is stripped + if (credentials) credentials._clientSessionId = clientSessionId; + // If same format, skip translation steps if (sourceFormat !== targetFormat) { - // Step 1: source -> openai (if source is not openai) - if (sourceFormat !== FORMATS.OPENAI) { - const toOpenAI = requestRegistry.get(`${sourceFormat}:${FORMATS.OPENAI}`); - if (toOpenAI) { - result = toOpenAI(model, result, stream, credentials); - // Log OpenAI intermediate format - reqLogger?.logOpenAIRequest?.(result); + // Direct route: if a translator is registered for this exact source:target + // pair, use it instead of pivoting through OpenAI. This is lossless for + // pairs like claude:kiro (avoids the claude->openai->kiro double-hop). + const directFn = requestRegistry.get(`${sourceFormat}:${targetFormat}`); + if (directFn) { + result = directFn(model, result, stream, credentials); + } else { + // Step 1: source -> openai (if source is not openai) + if (sourceFormat !== FORMATS.OPENAI) { + const toOpenAI = requestRegistry.get(`${sourceFormat}:${FORMATS.OPENAI}`); + if (toOpenAI) { + result = toOpenAI(model, result, stream, credentials); + // Log OpenAI intermediate format + reqLogger?.logOpenAIRequest?.(result); + } } - } - // Step 2: openai -> target (if target is not openai) - if (targetFormat !== FORMATS.OPENAI) { - const fromOpenAI = requestRegistry.get(`${FORMATS.OPENAI}:${targetFormat}`); - if (fromOpenAI) { - result = fromOpenAI(model, result, stream, credentials); + // Step 2: openai -> target (if target is not openai) + if (targetFormat !== FORMATS.OPENAI) { + const fromOpenAI = requestRegistry.get(`${FORMATS.OPENAI}:${targetFormat}`); + if (fromOpenAI) { + result = fromOpenAI(model, result, stream, credentials); + } } } } + // Normalize thinking to the target provider-native format (config-driven, capability-aware) + applyThinking(targetFormat, model, result, provider, thinkingIntent); + // Always normalize to clean OpenAI format when target is OpenAI // This handles hybrid requests (e.g., OpenAI messages + Claude tools) if (targetFormat === FORMATS.OPENAI) { @@ -118,12 +115,12 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream // Final step: prepare request for Claude format endpoints if (targetFormat === FORMATS.CLAUDE) { const apiKey = credentials?.accessToken || credentials?.apiKey || null; - result = prepareClaudeRequest(result, provider, apiKey, connectionId); + result = prepareClaudeRequest(result, provider, apiKey, connectionId, credentials?.rawHeaders, clientSessionId); } // Claude cloaking: rename client tools with _cc suffix (anti-ban) - // Only for claude provider (not anthropic-compatible-*) with OAuth token - if (provider === "claude") { + // quirk: only providers flagged cloakToolsOnOAuth, and only with an OAuth token + if (PROVIDERS[provider]?.quirks?.cloakToolsOnOAuth) { const apiKey = credentials?.accessToken || credentials?.apiKey || null; if (apiKey?.includes("sk-ant-oat")) { const { body: cloakedBody, toolNameMap } = cloakClaudeTools(result); @@ -157,6 +154,16 @@ export function translateResponse(targetFormat, sourceFormat, chunk, state) { let results = [chunk]; let openaiResults = null; // Store OpenAI intermediate results + // Direct route: if a response translator is registered for this exact + // target:source pair, use it instead of pivoting through OpenAI. Mirrors the + // request-side direct route (e.g. kiro:claude — KiroExecutor already emits + // OpenAI-shaped chunks, so this converts them straight to Claude SSE). + const directFn = responseRegistry.get(`${targetFormat}:${sourceFormat}`); + if (directFn) { + const converted = directFn(chunk, state); + return converted ? (Array.isArray(converted) ? converted : [converted]) : []; + } + // Step 1: target -> openai (if target is not openai) if (targetFormat !== FORMATS.OPENAI) { const toOpenAI = responseRegistry.get(`${targetFormat}:${FORMATS.OPENAI}`); @@ -245,7 +252,31 @@ export function initState(sourceFormat) { return base; } -// Initialize all translators (kept for backward compatibility) +// Kept for backward compatibility; translators are already registered at import time. export function initTranslators() { ensureInitialized(); } + +// Static side-effect imports: each module calls register() at load (works in ESM + bundler). +import "./request/claude-to-openai.js"; +import "./request/openai-to-claude.js"; +import "./request/gemini-to-openai.js"; +import "./request/openai-to-gemini.js"; +import "./request/openai-to-vertex.js"; +import "./request/antigravity-to-openai.js"; +import "./request/openai-responses.js"; +import "./request/openai-to-kiro.js"; +import "./request/openai-to-cursor.js"; +import "./request/openai-to-ollama.js"; +import "./request/openai-to-commandcode.js"; +import "./request/claude-to-kiro.js"; +import "./response/claude-to-openai.js"; +import "./response/openai-to-claude.js"; +import "./response/gemini-to-openai.js"; +import "./response/openai-to-antigravity.js"; +import "./response/openai-responses.js"; +import "./response/kiro-to-openai.js"; +import "./response/cursor-to-openai.js"; +import "./response/ollama-to-openai.js"; +import "./response/commandcode-to-openai.js"; +import "./response/kiro-to-claude.js"; diff --git a/open-sse/translator/request/antigravity-to-openai.js b/open-sse/translator/request/antigravity-to-openai.js index 9a3b5d21..b1dbd7bf 100644 --- a/open-sse/translator/request/antigravity-to-openai.js +++ b/open-sse/translator/request/antigravity-to-openai.js @@ -1,6 +1,10 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; -import { adjustMaxTokens } from "../helpers/maxTokensHelper.js"; +import { adjustMaxTokens } from "../formats/maxTokens.js"; +import { encodeDataUri } from "../concerns/image.js"; +import { ROLE, GEMINI_ROLE, OPENAI_BLOCK } from "../schema/index.js"; +import { budgetToEffort } from "../concerns/thinking.js"; +import { collapseTextParts } from "../concerns/message.js"; // Convert Antigravity request to OpenAI format // Antigravity body: { project, model, userAgent, requestType, requestId, request: { contents, systemInstruction, tools, toolConfig, generationConfig, sessionId } } @@ -31,16 +35,8 @@ export function antigravityToOpenAIRequest(model, body, stream) { // Thinking config → reasoning_effort if (config.thinkingConfig) { - const budget = config.thinkingConfig.thinkingBudget || 0; - if (budget > 0) { - if (budget <= 2048) { - result.reasoning_effort = "low"; - } else if (budget <= 16384) { - result.reasoning_effort = "medium"; - } else { - result.reasoning_effort = "high"; - } - } + const effort = budgetToEffort(config.thinkingConfig.thinkingBudget || 0); + if (effort) result.reasoning_effort = effort; } } @@ -48,7 +44,7 @@ export function antigravityToOpenAIRequest(model, body, stream) { if (req.systemInstruction) { const systemText = extractText(req.systemInstruction); if (systemText) { - result.messages.push({ role: "system", content: systemText }); + result.messages.push({ role: ROLE.SYSTEM, content: systemText }); } } @@ -73,7 +69,7 @@ export function antigravityToOpenAIRequest(model, body, stream) { if (tool.functionDeclarations) { for (const func of tool.functionDeclarations) { result.tools.push({ - type: "function", + type: OPENAI_BLOCK.FUNCTION, function: { name: func.name, description: func.description || "", @@ -122,7 +118,7 @@ function normalizeSchemaTypes(schema) { // Convert Antigravity content to OpenAI message // Handles: text, thought, thoughtSignature, functionCall, functionResponse, inlineData function convertContent(content) { - const role = content.role === "model" ? "assistant" : content.role === "user" ? "user" : content.role; + const role = content.role === GEMINI_ROLE.MODEL ? ROLE.ASSISTANT : content.role === GEMINI_ROLE.USER ? ROLE.USER : content.role; if (!content.parts || !Array.isArray(content.parts)) { return null; @@ -142,21 +138,21 @@ function convertContent(content) { // Text with thoughtSignature = regular text after thinking if (part.thoughtSignature && part.text !== undefined) { - textParts.push({ type: "text", text: part.text }); + textParts.push({ type: OPENAI_BLOCK.TEXT, text: part.text }); continue; } // Regular text if (part.text !== undefined) { - textParts.push({ type: "text", text: part.text }); + textParts.push({ type: OPENAI_BLOCK.TEXT, text: part.text }); } // Inline data (images) if (part.inlineData) { textParts.push({ - type: "image_url", + type: OPENAI_BLOCK.IMAGE_URL, image_url: { - url: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}` + url: encodeDataUri(part.inlineData.mimeType, part.inlineData.data) } }); } @@ -164,8 +160,9 @@ function convertContent(content) { // Function call if (part.functionCall) { toolCalls.push({ - id: part.functionCall.id || `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, - type: "function", + // Deterministic id from name so the matching functionResponse pairs correctly. + id: part.functionCall.id || `call_${part.functionCall.name}`, + type: OPENAI_BLOCK.FUNCTION, function: { name: part.functionCall.name, arguments: JSON.stringify(part.functionCall.args || {}) @@ -176,8 +173,8 @@ function convertContent(content) { // Function response → collect all, each becomes a separate tool message if (part.functionResponse) { toolResults.push({ - role: "tool", - tool_call_id: part.functionResponse.id || part.functionResponse.name, + role: ROLE.TOOL, + tool_call_id: part.functionResponse.id || `call_${part.functionResponse.name}`, content: JSON.stringify(part.functionResponse.response?.result || part.functionResponse.response || {}) }); } @@ -190,9 +187,9 @@ function convertContent(content) { // Assistant with tool calls if (toolCalls.length > 0) { - const msg = { role: "assistant" }; + const msg = { role: ROLE.ASSISTANT }; if (textParts.length > 0) { - msg.content = textParts.length === 1 && textParts[0].type === "text" ? textParts[0].text : textParts; + msg.content = collapseTextParts(textParts); } if (reasoningContent) { msg.reasoning_content = reasoningContent; @@ -205,7 +202,7 @@ function convertContent(content) { if (textParts.length > 0 || reasoningContent) { const msg = { role }; if (textParts.length > 0) { - msg.content = textParts.length === 1 && textParts[0].type === "text" ? textParts[0].text : textParts; + msg.content = collapseTextParts(textParts); } if (reasoningContent) { msg.reasoning_content = reasoningContent; diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js new file mode 100644 index 00000000..8a38e4a9 --- /dev/null +++ b/open-sse/translator/request/claude-to-kiro.js @@ -0,0 +1,463 @@ +/** + * Claude → Kiro Request Translator (DIRECT route, no OpenAI pivot) + * + * Converts Anthropic Messages API requests straight to Kiro / AWS + * CodeWhisperer `GenerateAssistantResponse` payloads. This is the function the + * direct `claude:kiro` route in ../index.js uses; it is NOT reached through the + * claude→openai→kiro pivot. + * + * It reproduces the two 400-guards that live in openai-to-kiro.js so that a + * Claude client which omits the `tools` array on a follow-up turn (typical + * after client-side compaction) does not trip Kiro's schema validator and get + * "Improperly formed request" (HTTP 400): + * + * 1. flattenClaudeToolInteractions — when the client sent NO tools, collapse + * every tool_use / tool_result block to plain text so no structured tool + * reference survives to trigger the "tools required" rule. + * 2. reconcileOrphanedToolResults — when tools ARE present, fold any + * tool_result whose tool_use_id has no matching tool_use back into the + * user text instead of leaving a dangling structured reference. + * + * It also handles the 9router-synthetic `-agentic` / `-thinking` suffixes and + * the `enabled` reasoning trigger, matching + * buildKiroPayload. + */ +import { register } from "../index.js"; +import { FORMATS } from "../formats.js"; +import { v4 as uuidv4 } from "uuid"; +import { + resolveKiroModel, + resolveKiroThinkingBudget, + buildThinkingSystemPrefix, + KIRO_AGENTIC_SYSTEM_PROMPT, + resolveDefaultProfileArn, +} from "../../config/kiroConstants.js"; +import { DEFAULT_IMAGE_MIME } from "../schema/index.js"; +import { ROLE, CLAUDE_BLOCK } from "../schema/index.js"; + +/** Stringify a tool_use input as a readable line. */ +function toolUseToText(name, input) { + let argStr; + try { + argStr = typeof input === "string" ? input : JSON.stringify(input ?? {}); + } catch { + argStr = "{}"; + } + return `[Tool call: ${name || "unknown"}(${argStr})]`; +} + +/** Render a Claude tool_result block's content as a readable line. */ +function toolResultBlockToText(content) { + let text = ""; + if (typeof content === "string") { + text = content; + } else if (Array.isArray(content)) { + text = content + .map((c) => (typeof c === "string" ? c : c?.text || "")) + .filter(Boolean) + .join("\n"); + } else if (content) { + try { + text = JSON.stringify(content); + } catch { + text = ""; + } + } + return `[Tool result: ${text}]`; +} + +/** + * When the client sent no tools, rewrite every tool_use (assistant) and + * tool_result (user) content block into plain text. Keeps text + images. + * Returns a new messages array; never mutates the input. + */ +function flattenClaudeToolInteractions(messages) { + const out = []; + for (const msg of messages) { + if (!msg) continue; + + if (msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) { + const parts = []; + for (const block of msg.content) { + if (block.type === CLAUDE_BLOCK.TEXT && block.text) { + parts.push(block.text); + } else if (block.type === CLAUDE_BLOCK.TOOL_USE) { + parts.push(toolUseToText(block.name, block.input)); + } + } + out.push({ ...msg, content: parts.join("\n") }); + continue; + } + + if (msg.role === ROLE.USER && Array.isArray(msg.content)) { + const newContent = msg.content.map((block) => + block.type === CLAUDE_BLOCK.TOOL_RESULT + ? { type: CLAUDE_BLOCK.TEXT, text: toolResultBlockToText(block.content) } + : block + ); + out.push({ ...msg, content: newContent }); + continue; + } + + out.push(msg); + } + return out; +} + +/** + * Convert Claude messages to Kiro history + currentMessage. + * Kiro requires alternating user/assistant turns; consecutive same-role + * messages are merged. + */ +function convertClaudeMessagesToKiro(messages, tools, model) { + const history = []; + let currentMessage = null; + + let pendingUserContent = []; + let pendingAssistantContent = []; + let pendingToolResults = []; + let pendingImages = []; + let currentRole = null; + let toolsInjected = false; + + const clientProvidedTools = Array.isArray(tools) && tools.length > 0; + + const buildToolSpecs = () => + tools.map((t) => { + const name = t.name; + const description = t.description || `Tool: ${name}`; + const schema = t.input_schema || {}; + const normalizedSchema = + Object.keys(schema).length === 0 + ? { type: "object", properties: {}, required: [] } + : { ...schema, required: schema.required ?? [] }; + return { + toolSpecification: { + name, + description, + inputSchema: { json: normalizedSchema }, + }, + }; + }); + + const flushPending = () => { + if (currentRole === ROLE.USER) { + const content = pendingUserContent.join("\n\n").trim() || "continue"; + const userMsg = { userInputMessage: { content, modelId: model } }; + + if (pendingImages.length > 0) { + userMsg.userInputMessage.images = pendingImages; + } + if (pendingToolResults.length > 0) { + userMsg.userInputMessage.userInputMessageContext = { + toolResults: pendingToolResults, + }; + } + // Attach tools to the first user turn only. + if (clientProvidedTools && !toolsInjected) { + if (!userMsg.userInputMessage.userInputMessageContext) { + userMsg.userInputMessage.userInputMessageContext = {}; + } + userMsg.userInputMessage.userInputMessageContext.tools = buildToolSpecs(); + toolsInjected = true; + } + + history.push(userMsg); + currentMessage = userMsg; + pendingUserContent = []; + pendingToolResults = []; + pendingImages = []; + } else if (currentRole === ROLE.ASSISTANT) { + const content = pendingAssistantContent.join("\n\n").trim() || "..."; + history.push({ assistantResponseMessage: { content } }); + pendingAssistantContent = []; + } + }; + + for (const msg of messages) { + const role = msg.role; + if (role !== currentRole && currentRole !== null) flushPending(); + currentRole = role; + + if (role === ROLE.USER) { + if (typeof msg.content === "string") { + pendingUserContent.push(msg.content); + } else if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === CLAUDE_BLOCK.TEXT) { + pendingUserContent.push(block.text); + } else if (block.type === CLAUDE_BLOCK.IMAGE && block.source?.type === "base64") { + const mediaType = block.source.media_type || DEFAULT_IMAGE_MIME; + const format = mediaType.split("/")[1] || mediaType; + pendingImages.push({ format, source: { bytes: block.source.data } }); + } else if (block.type === CLAUDE_BLOCK.TOOL_RESULT) { + let resultContent = ""; + if (typeof block.content === "string") { + resultContent = block.content; + } else if (Array.isArray(block.content)) { + resultContent = + block.content + .filter((c) => c.type === CLAUDE_BLOCK.TEXT) + .map((c) => c.text) + .join("\n") || JSON.stringify(block.content); + } else if (block.content) { + resultContent = JSON.stringify(block.content); + } + pendingToolResults.push({ + toolUseId: block.tool_use_id, + status: "success", + content: [{ text: resultContent }], + }); + } + } + } + } else if (role === ROLE.ASSISTANT) { + let textContent = ""; + const toolUses = []; + if (typeof msg.content === "string") { + textContent = msg.content; + } else if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === CLAUDE_BLOCK.TEXT) { + textContent += block.text; + } else if (block.type === CLAUDE_BLOCK.TOOL_USE) { + toolUses.push({ + toolUseId: block.id, + name: block.name, + input: block.input || {}, + }); + } + } + } + if (textContent) pendingAssistantContent.push(textContent); + + if (toolUses.length > 0) { + flushPending(); + const lastMsg = history[history.length - 1]; + if (lastMsg?.assistantResponseMessage) { + lastMsg.assistantResponseMessage.toolUses = toolUses; + } + currentRole = null; + } + } + } + + if (currentRole !== null) flushPending(); + + // Pop the last user turn as currentMessage (skip trailing assistant turns). + for (let i = history.length - 1; i >= 0; i--) { + if (history[i].userInputMessage) { + currentMessage = history.splice(i, 1)[0]; + break; + } + } + + // Grab tools from the first history user turn before cleanup strips them. + const firstHistoryTools = + history[0]?.userInputMessage?.userInputMessageContext?.tools; + + history.forEach((item) => { + if (item.userInputMessage?.userInputMessageContext?.tools) { + delete item.userInputMessage.userInputMessageContext.tools; + } + if ( + item.userInputMessage?.userInputMessageContext && + Object.keys(item.userInputMessage.userInputMessageContext).length === 0 + ) { + delete item.userInputMessage.userInputMessageContext; + } + if (item.userInputMessage && !item.userInputMessage.modelId) { + item.userInputMessage.modelId = model; + } + }); + + // Merge consecutive user turns (Kiro requires alternating roles). + const mergedHistory = []; + for (const current of history) { + const prev = mergedHistory[mergedHistory.length - 1]; + if (current.userInputMessage && prev?.userInputMessage) { + prev.userInputMessage.content += "\n\n" + current.userInputMessage.content; + const prevCtx = prev.userInputMessage.userInputMessageContext; + const curCtx = current.userInputMessage.userInputMessageContext; + if (curCtx) { + if (!prevCtx) { + prev.userInputMessage.userInputMessageContext = curCtx; + } else { + if (curCtx.toolResults?.length > 0) { + prevCtx.toolResults = [ + ...(prevCtx.toolResults || []), + ...curCtx.toolResults, + ]; + } + if (curCtx.tools?.length > 0) { + prevCtx.tools = [...(prevCtx.tools || []), ...curCtx.tools]; + } + } + } + } else { + mergedHistory.push(current); + } + } + + if (!currentMessage) { + currentMessage = { userInputMessage: { content: "", modelId: model } }; + } + + // Inject tools into currentMessage after cleanup if not already present. + if ( + firstHistoryTools?.length > 0 && + !currentMessage.userInputMessage.userInputMessageContext?.tools + ) { + if (!currentMessage.userInputMessage.userInputMessageContext) { + currentMessage.userInputMessage.userInputMessageContext = {}; + } + currentMessage.userInputMessage.userInputMessageContext.tools = + firstHistoryTools; + } + + return { history: mergedHistory, currentMessage }; +} + +/** + * Fold orphaned toolResults (those whose toolUseId has no matching toolUse in + * any assistant turn) back into the user text, removing the dangling + * structured reference that makes Kiro 400. + */ +function reconcileOrphanedToolResults(history, currentMessage) { + const validIds = new Set(); + for (const h of history) { + const arm = h.assistantResponseMessage; + if (!arm) continue; + for (const tu of arm.toolUses || []) { + if (tu.toolUseId) validIds.add(tu.toolUseId); + } + } + + const carriers = currentMessage ? [...history, currentMessage] : history; + for (const item of carriers) { + const uim = item.userInputMessage; + const ctx = uim?.userInputMessageContext; + if (!ctx?.toolResults?.length) continue; + + const kept = []; + const salvaged = []; + for (const tr of ctx.toolResults) { + if (validIds.has(tr.toolUseId)) { + kept.push(tr); + } else { + const text = Array.isArray(tr.content) + ? tr.content.map((c) => c?.text || "").join("\n") + : ""; + salvaged.push(`[Tool result: ${text}]`); + } + } + + if (salvaged.length === 0) continue; + + const extra = salvaged.join("\n"); + uim.content = uim.content ? `${uim.content}\n\n${extra}` : extra; + ctx.toolResults = kept; + if (kept.length === 0 && !ctx.tools?.length) { + delete uim.userInputMessageContext; + } + } +} + +/** + * Build a Kiro payload directly from a Claude Messages API request body. + */ +export function claudeToKiroRequest(model, body, stream, credentials) { + let messages = Array.isArray(body.messages) ? body.messages : []; + const tools = Array.isArray(body.tools) ? body.tools : []; + const clientProvidedTools = tools.length > 0; + const maxTokens = body.max_tokens || 32000; + const temperature = body.temperature; + const topP = body.top_p; + + const { upstream: upstreamModel, agentic } = resolveKiroModel(model); + const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model); + + // Guard 1: no client tools → flatten all tool interactions to text. + if (!clientProvidedTools) { + messages = flattenClaudeToolInteractions(messages); + } + + const { history, currentMessage } = convertClaudeMessagesToKiro( + messages, + tools, + upstreamModel + ); + + // Guard 2: tools present → reconcile dangling tool_results. + if (clientProvidedTools) { + reconcileOrphanedToolResults(history, currentMessage); + } + + // API-key auth must never use the shared default ARN (403); OAuth/social fall back to it. + const authMethod = credentials?.providerSpecificData?.authMethod; + const profileArn = authMethod === "api_key" + ? (credentials?.providerSpecificData?.profileArn || "") + : (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod)); + + let finalContent = currentMessage?.userInputMessage?.content || ""; + + // System prompt → prepend to the user content. + if (body.system) { + let systemText = ""; + if (typeof body.system === "string") { + systemText = body.system; + } else if (Array.isArray(body.system)) { + systemText = body.system.map((s) => s.text || "").join("\n"); + } + if (systemText) finalContent = `${systemText}\n\n${finalContent}`; + } + + // Prefix order: thinking_mode tag, timestamp marker, then agentic prompt. + const timestamp = new Date().toISOString(); + const prefixParts = []; + if (thinkingBudget !== null) prefixParts.push(buildThinkingSystemPrefix(thinkingBudget)); + prefixParts.push(`[Context: Current time is ${timestamp}]`); + if (agentic) prefixParts.push(KIRO_AGENTIC_SYSTEM_PROMPT); + finalContent = `${prefixParts.join("\n\n")}\n\n${finalContent}`; + + const payload = { + conversationState: { + chatTriggerType: "MANUAL", + conversationId: uuidv4(), + currentMessage: { + userInputMessage: { + content: finalContent, + modelId: upstreamModel, + origin: "AI_EDITOR", + ...(currentMessage?.userInputMessage?.userInputMessageContext && { + userInputMessageContext: + currentMessage.userInputMessage.userInputMessageContext, + }), + ...(currentMessage?.userInputMessage?.images && { + images: currentMessage.userInputMessage.images, + }), + }, + }, + history, + }, + }; + + if (profileArn) payload.profileArn = profileArn; + + if (maxTokens || temperature !== undefined || topP !== undefined) { + payload.inferenceConfig = {}; + if (maxTokens) payload.inferenceConfig.maxTokens = maxTokens; + if (temperature !== undefined) payload.inferenceConfig.temperature = temperature; + if (topP !== undefined) payload.inferenceConfig.topP = topP; + } + + // Non-enumerable hint so the executor can route the upstream model id. + Object.defineProperty(payload, "_kiroUpstreamModel", { + value: upstreamModel, + enumerable: false, + }); + + return payload; +} + +register(FORMATS.CLAUDE, FORMATS.KIRO, claudeToKiroRequest, null); diff --git a/open-sse/translator/request/claude-to-openai.js b/open-sse/translator/request/claude-to-openai.js index 0f8e7bb5..dc54bac5 100644 --- a/open-sse/translator/request/claude-to-openai.js +++ b/open-sse/translator/request/claude-to-openai.js @@ -1,6 +1,9 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; -import { adjustMaxTokens } from "../helpers/maxTokensHelper.js"; +import { adjustMaxTokens } from "../formats/maxTokens.js"; +import { encodeDataUri } from "../concerns/image.js"; +import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js"; +import { collapseTextParts } from "../concerns/message.js"; function stripAnthropicBillingHeader(text) { if (typeof text !== "string") return ""; @@ -33,7 +36,7 @@ export function claudeToOpenAIRequest(model, body, stream) { if (systemContent) { result.messages.push({ - role: "system", + role: ROLE.SYSTEM, content: systemContent }); } @@ -55,13 +58,15 @@ export function claudeToOpenAIRequest(model, body, stream) { } } - // Fix missing tool responses - OpenAI requires every tool_call to have a response - fixMissingToolResponses(result.messages); + // Fix missing tool responses - OpenAI requires every tool_call to have a response. + // Local variant: scans contiguous tool replies + inserts "[No response received]" + // (distinct from the global immediate-next check in concerns/toolCall, runs on the openai leg). + fixMissingToolResponsesOpenAI(result.messages); // Tools if (body.tools && Array.isArray(body.tools)) { result.tools = body.tools.map(tool => ({ - type: "function", + type: OPENAI_BLOCK.FUNCTION, function: { name: tool.name, description: String(tool.description || ""), @@ -75,14 +80,24 @@ export function claudeToOpenAIRequest(model, body, stream) { result.tool_choice = convertToolChoice(body.tool_choice); } + if (body.reasoning_effort !== undefined) { + result.reasoning_effort = body.reasoning_effort; + } else if (body.reasoning?.effort !== undefined) { + result.reasoning_effort = body.reasoning.effort; + } + + if (body.reasoning !== undefined) { + result.reasoning = body.reasoning; + } + return result; } // Fix missing tool responses - add empty responses for tool_calls without responses -function fixMissingToolResponses(messages) { +function fixMissingToolResponsesOpenAI(messages) { for (let i = 0; i < messages.length; i++) { const msg = messages[i]; - if (msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0) { + if (msg.role === ROLE.ASSISTANT && msg.tool_calls && msg.tool_calls.length > 0) { const toolCallIds = msg.tool_calls.map(tc => tc.id); // Collect all tool response IDs that IMMEDIATELY follow this assistant message @@ -90,7 +105,7 @@ function fixMissingToolResponses(messages) { let insertPosition = i + 1; for (let j = i + 1; j < messages.length; j++) { const nextMsg = messages[j]; - if (nextMsg.role === "tool" && nextMsg.tool_call_id) { + if (nextMsg.role === ROLE.TOOL && nextMsg.tool_call_id) { respondedIds.add(nextMsg.tool_call_id); insertPosition = j + 1; } else { @@ -103,7 +118,7 @@ function fixMissingToolResponses(messages) { if (missingIds.length > 0) { const missingResponses = missingIds.map(id => ({ - role: "tool", + role: ROLE.TOOL, tool_call_id: id, content: "[No response received]" })); @@ -116,7 +131,7 @@ function fixMissingToolResponses(messages) { // Convert single Claude message - returns single message or array of messages function convertClaudeMessage(msg) { - const role = msg.role === "user" || msg.role === "tool" ? "user" : "assistant"; + const role = msg.role === ROLE.USER || msg.role === ROLE.TOOL ? ROLE.USER : ROLE.ASSISTANT; // Simple string content if (typeof msg.content === "string") { @@ -131,25 +146,25 @@ function convertClaudeMessage(msg) { for (const block of msg.content) { switch (block.type) { - case "text": - parts.push({ type: "text", text: block.text }); + case CLAUDE_BLOCK.TEXT: + parts.push({ type: OPENAI_BLOCK.TEXT, text: block.text }); break; - case "image": + case CLAUDE_BLOCK.IMAGE: if (block.source?.type === "base64") { parts.push({ - type: "image_url", + type: OPENAI_BLOCK.IMAGE_URL, image_url: { - url: `data:${block.source.media_type};base64,${block.source.data}` + url: encodeDataUri(block.source.media_type, block.source.data) } }); } break; - case "tool_use": + case CLAUDE_BLOCK.TOOL_USE: toolCalls.push({ id: block.id, - type: "function", + type: OPENAI_BLOCK.FUNCTION, function: { name: block.name, arguments: JSON.stringify(block.input || {}) @@ -157,13 +172,13 @@ function convertClaudeMessage(msg) { }); break; - case "tool_result": + case CLAUDE_BLOCK.TOOL_RESULT: let resultContent = ""; if (typeof block.content === "string") { resultContent = block.content; } else if (Array.isArray(block.content)) { resultContent = block.content - .filter(c => c.type === "text") + .filter(c => c.type === CLAUDE_BLOCK.TEXT) .map(c => c.text) .join("\n") || JSON.stringify(block.content); } else if (block.content) { @@ -171,7 +186,7 @@ function convertClaudeMessage(msg) { } toolResults.push({ - role: "tool", + role: ROLE.TOOL, tool_call_id: block.tool_use_id, content: resultContent }); @@ -182,21 +197,16 @@ function convertClaudeMessage(msg) { // If has tool results, return array of tool messages if (toolResults.length > 0) { if (parts.length > 0) { - const textContent = parts.length === 1 && parts[0].type === "text" - ? parts[0].text - : parts; - return [...toolResults, { role: "user", content: textContent }]; + return [...toolResults, { role: ROLE.USER, content: collapseTextParts(parts) }]; } return toolResults; } // If has tool calls, return assistant message with tool_calls if (toolCalls.length > 0) { - const result = { role: "assistant" }; + const result = { role: ROLE.ASSISTANT }; if (parts.length > 0) { - result.content = parts.length === 1 && parts[0].type === "text" - ? parts[0].text - : parts; + result.content = collapseTextParts(parts); } result.tool_calls = toolCalls; return result; @@ -206,7 +216,7 @@ function convertClaudeMessage(msg) { if (parts.length > 0) { return { role, - content: parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts + content: collapseTextParts(parts) }; } @@ -227,7 +237,7 @@ function convertToolChoice(choice) { switch (choice.type) { case "auto": return "auto"; case "any": return "required"; - case "tool": return { type: "function", function: { name: choice.name } }; + case "tool": return { type: OPENAI_BLOCK.FUNCTION, function: { name: choice.name } }; default: return "auto"; } } diff --git a/open-sse/translator/request/gemini-to-openai.js b/open-sse/translator/request/gemini-to-openai.js index 5b3d0f01..161ea9be 100644 --- a/open-sse/translator/request/gemini-to-openai.js +++ b/open-sse/translator/request/gemini-to-openai.js @@ -1,6 +1,9 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; -import { adjustMaxTokens } from "../helpers/maxTokensHelper.js"; +import { adjustMaxTokens } from "../formats/maxTokens.js"; +import { encodeDataUri } from "../concerns/image.js"; +import { collapseTextParts } from "../concerns/message.js"; +import { ROLE, GEMINI_ROLE, OPENAI_BLOCK } from "../schema/index.js"; // Convert Gemini request to OpenAI format export function geminiToOpenAIRequest(model, body, stream) { @@ -30,7 +33,7 @@ export function geminiToOpenAIRequest(model, body, stream) { const systemText = extractGeminiText(body.systemInstruction); if (systemText) { result.messages.push({ - role: "system", + role: ROLE.SYSTEM, content: systemText }); } @@ -53,7 +56,7 @@ export function geminiToOpenAIRequest(model, body, stream) { if (tool.functionDeclarations) { for (const func of tool.functionDeclarations) { result.tools.push({ - type: "function", + type: OPENAI_BLOCK.FUNCTION, function: { name: func.name, description: func.description || "", @@ -70,7 +73,7 @@ export function geminiToOpenAIRequest(model, body, stream) { // Convert Gemini content to OpenAI message function convertGeminiContent(content) { - const role = content.role === "user" ? "user" : "assistant"; + const role = content.role === GEMINI_ROLE.USER ? ROLE.USER : ROLE.ASSISTANT; if (!content.parts || !Array.isArray(content.parts)) { return null; @@ -81,22 +84,24 @@ function convertGeminiContent(content) { for (const part of content.parts) { if (part.text !== undefined) { - parts.push({ type: "text", text: part.text }); + parts.push({ type: OPENAI_BLOCK.TEXT, text: part.text }); } if (part.inlineData) { parts.push({ - type: "image_url", + type: OPENAI_BLOCK.IMAGE_URL, image_url: { - url: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}` + url: encodeDataUri(part.inlineData.mimeType, part.inlineData.data) } }); } if (part.functionCall) { + // Gemini lacks a native call id; derive a deterministic one from the name so the + // matching functionResponse maps to the same tool_call_id (providers require pairing). toolCalls.push({ - id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, - type: "function", + id: part.functionCall.id || `call_${part.functionCall.name}`, + type: OPENAI_BLOCK.FUNCTION, function: { name: part.functionCall.name, arguments: JSON.stringify(part.functionCall.args || {}) @@ -106,15 +111,15 @@ function convertGeminiContent(content) { if (part.functionResponse) { return { - role: "tool", - tool_call_id: part.functionResponse.id || part.functionResponse.name, + role: ROLE.TOOL, + tool_call_id: part.functionResponse.id || `call_${part.functionResponse.name}`, content: JSON.stringify(part.functionResponse.response?.result || part.functionResponse.response || {}) }; } } if (toolCalls.length > 0) { - const result = { role: "assistant" }; + const result = { role: ROLE.ASSISTANT }; if (parts.length > 0) { result.content = parts.length === 1 ? parts[0].text : parts; } @@ -125,7 +130,7 @@ function convertGeminiContent(content) { if (parts.length > 0) { return { role, - content: parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts + content: collapseTextParts(parts) }; } diff --git a/open-sse/translator/request/openai-responses.js b/open-sse/translator/request/openai-responses.js index 2c329d6e..98c516cd 100644 --- a/open-sse/translator/request/openai-responses.js +++ b/open-sse/translator/request/openai-responses.js @@ -6,7 +6,8 @@ */ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; -import { normalizeResponsesInput } from "../helpers/responsesApiHelper.js"; +import { normalizeResponsesInput } from "../formats/responsesApi.js"; +import { ROLE, OPENAI_BLOCK, RESPONSES_ITEM } from "../schema/index.js"; // Responses API enforces max 64 chars on call_id (#393) const MAX_CALL_ID_LEN = 64; @@ -23,7 +24,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) // Convert instructions to system message if (body.instructions) { - result.messages.push({ role: "system", content: body.instructions }); + result.messages.push({ role: ROLE.SYSTEM, content: body.instructions }); } // Group items by conversation turn @@ -50,9 +51,9 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) for (const item of inputItems) { // Determine item type - Droid CLI sends role-based items without 'type' field // Fallback: if no type but has role property, treat as message - const itemType = item.type || (item.role ? "message" : null); + const itemType = item.type || (item.role ? RESPONSES_ITEM.MESSAGE : null); - if (itemType === "message") { + if (itemType === RESPONSES_ITEM.MESSAGE) { // Flush any pending assistant message with tool calls if (currentAssistantMsg) { result.messages.push(currentAssistantMsg); @@ -69,28 +70,28 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) // Convert content: input_text → text, output_text → text, input_image → image_url const content = Array.isArray(item.content) ? item.content.map(c => { - if (c.type === "input_text") return { type: "text", text: c.text }; - if (c.type === "output_text") return { type: "text", text: c.text }; - if (c.type === "input_image") { + if (c.type === RESPONSES_ITEM.INPUT_TEXT) return { type: OPENAI_BLOCK.TEXT, text: c.text }; + if (c.type === RESPONSES_ITEM.OUTPUT_TEXT) return { type: OPENAI_BLOCK.TEXT, text: c.text }; + if (c.type === RESPONSES_ITEM.INPUT_IMAGE) { const url = c.image_url || c.file_id || ""; - return { type: "image_url", image_url: { url, detail: c.detail || "auto" } }; + return { type: OPENAI_BLOCK.IMAGE_URL, image_url: { url, detail: c.detail || "auto" } }; } return c; }) : item.content; const msg = { role: item.role, content }; // Attach buffered reasoning to assistant turn (required by xiaomi-mimo thinking mode) - if (item.role === "assistant" && pendingReasoning) { + if (item.role === ROLE.ASSISTANT && pendingReasoning) { msg.reasoning_content = pendingReasoning; } pendingReasoning = ""; result.messages.push(msg); } - else if (itemType === "function_call") { + else if (itemType === RESPONSES_ITEM.FUNCTION_CALL) { // Start or append to assistant message with tool_calls if (!currentAssistantMsg) { currentAssistantMsg = { - role: "assistant", + role: ROLE.ASSISTANT, content: null, tool_calls: [] }; @@ -103,14 +104,14 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) if (!item.name || typeof item.name !== "string" || item.name.trim() === "") continue; currentAssistantMsg.tool_calls.push({ id: item.call_id, - type: "function", + type: OPENAI_BLOCK.FUNCTION, function: { name: item.name, arguments: item.arguments } }); } - else if (itemType === "function_call_output") { + else if (itemType === RESPONSES_ITEM.FUNCTION_CALL_OUTPUT) { // Flush assistant message first if exists if (currentAssistantMsg) { result.messages.push(currentAssistantMsg); @@ -125,12 +126,12 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) } // Add tool result immediately result.messages.push({ - role: "tool", + role: ROLE.TOOL, tool_call_id: item.call_id, content: typeof item.output === "string" ? item.output : JSON.stringify(item.output) }); } - else if (itemType === "reasoning") { + else if (itemType === RESPONSES_ITEM.REASONING) { // Buffer reasoning text; attached to next assistant message/function_call const txt = extractReasoningText(item); if (txt) pendingReasoning = pendingReasoning ? `${pendingReasoning}\n${txt}` : txt; @@ -163,7 +164,7 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) const name = tool.name; if (!name || typeof name !== "string" || name.trim() === "") return null; return { - type: "function", + type: OPENAI_BLOCK.FUNCTION, function: { name, description: String(tool.description || ""), @@ -176,6 +177,12 @@ export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) } // Cleanup Responses API specific fields + // Map Responses-only max_output_tokens to Chat max_tokens (avoid leaking unknown field upstream) + if (result.max_output_tokens !== undefined) { + if (result.max_tokens === undefined) result.max_tokens = result.max_output_tokens; + delete result.max_output_tokens; + } + delete result.input; delete result.instructions; delete result.include; @@ -214,7 +221,7 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) const messages = body.messages || []; for (const msg of messages) { - if (msg.role === "system") { + if (msg.role === ROLE.SYSTEM) { // Use first system message as instructions if (!hasSystemMessage) { result.instructions = typeof msg.content === "string" ? msg.content : ""; @@ -224,21 +231,21 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) } // Convert user/assistant messages to input items - if (msg.role === "user" || msg.role === "assistant") { - const contentType = msg.role === "user" ? "input_text" : "output_text"; + if (msg.role === ROLE.USER || msg.role === ROLE.ASSISTANT) { + const contentType = msg.role === ROLE.USER ? RESPONSES_ITEM.INPUT_TEXT : RESPONSES_ITEM.OUTPUT_TEXT; const content = typeof msg.content === "string" ? [{ type: contentType, text: msg.content }] : Array.isArray(msg.content) ? msg.content.map(c => { - if (c.type === "text") return { type: contentType, text: c.text }; + if (c.type === OPENAI_BLOCK.TEXT) return { type: contentType, text: c.text }; // Convert Chat Completions image_url → Responses API input_image // Responses API expects: { type: "input_image", image_url: "" } // Chat Completions sends: { type: "image_url", image_url: { url: "...", detail: "..." } } - if (c.type === "image_url") { + if (c.type === OPENAI_BLOCK.IMAGE_URL) { const url = typeof c.image_url === "string" ? c.image_url : c.image_url?.url; - return { type: "input_image", image_url: url, detail: c.image_url?.detail || "auto" }; + return { type: RESPONSES_ITEM.INPUT_IMAGE, image_url: url, detail: c.image_url?.detail || "auto" }; } - if (c.type === "input_image") return c; + if (c.type === RESPONSES_ITEM.INPUT_IMAGE) return c; // Serialize any unknown type (tool_use, tool_result, thinking, etc.) as text const text = c.text || c.content || JSON.stringify(c); return { type: contentType, text: typeof text === "string" ? text : JSON.stringify(text) }; @@ -250,7 +257,7 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) // message block in that case; the tool_calls are pushed separately below. if (content.length > 0) { result.input.push({ - type: "message", + type: RESPONSES_ITEM.MESSAGE, role: msg.role, content }); @@ -258,10 +265,10 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) } // Convert tool calls - if (msg.role === "assistant" && msg.tool_calls) { + if (msg.role === ROLE.ASSISTANT && msg.tool_calls) { for (const tc of msg.tool_calls) { result.input.push({ - type: "function_call", + type: RESPONSES_ITEM.FUNCTION_CALL, call_id: clampCallId(tc.id), name: tc.function?.name || "_unknown", arguments: tc.function?.arguments || "{}" @@ -270,14 +277,14 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) } // Convert tool results - output must be a string for Responses API - if (msg.role === "tool") { + if (msg.role === ROLE.TOOL) { const output = typeof msg.content === "string" ? msg.content : Array.isArray(msg.content) ? msg.content.map(c => c.text || JSON.stringify(c)).join("") : JSON.stringify(msg.content); result.input.push({ - type: "function_call_output", + type: RESPONSES_ITEM.FUNCTION_CALL_OUTPUT, call_id: clampCallId(msg.tool_call_id), output }); @@ -292,9 +299,9 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) // Convert tools format if (body.tools && Array.isArray(body.tools)) { result.tools = body.tools.map(tool => { - if (tool.type === "function") { + if (tool.type === OPENAI_BLOCK.FUNCTION) { return { - type: "function", + type: OPENAI_BLOCK.FUNCTION, name: tool.function.name, description: String(tool.function.description || ""), parameters: normalizeToolParameters(tool.function.parameters), @@ -309,6 +316,8 @@ export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) if (body.temperature !== undefined) result.temperature = body.temperature; if (body.max_tokens !== undefined) result.max_tokens = body.max_tokens; if (body.top_p !== undefined) result.top_p = body.top_p; + if (body.reasoning !== undefined) result.reasoning = body.reasoning; + if (body.reasoning_effort !== undefined) result.reasoning = { effort: body.reasoning_effort, summary: "auto" }; return result; } diff --git a/open-sse/translator/request/openai-to-claude.js b/open-sse/translator/request/openai-to-claude.js index 9988c769..bc73149b 100644 --- a/open-sse/translator/request/openai-to-claude.js +++ b/open-sse/translator/request/openai-to-claude.js @@ -1,7 +1,11 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; import { CLAUDE_SYSTEM_PROMPT } from "../../config/appConstants.js"; -import { adjustMaxTokens } from "../helpers/maxTokensHelper.js"; +import { adjustMaxTokens } from "../formats/maxTokens.js"; +import { safeParseJSON } from "../concerns/json.js"; +import { parseDataUri } from "../concerns/image.js"; +import { extractTextContent } from "../formats/gemini.js"; +import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js"; // Empty prefix matches real Claude Code behavior (no tool name prefix). // Previously "proxy_" was used but this is a detectable fingerprint difference. @@ -29,13 +33,13 @@ export function openaiToClaudeRequest(model, body, stream) { if (body.messages && Array.isArray(body.messages)) { // Extract system messages for (const msg of body.messages) { - if (msg.role === "system") { - systemParts.push(typeof msg.content === "string" ? msg.content : extractTextContent(msg.content)); + if (msg.role === ROLE.SYSTEM) { + systemParts.push(typeof msg.content === "string" ? msg.content : extractTextContent(msg.content, "\n")); } } // Filter out system messages for separate processing - const nonSystemMessages = body.messages.filter(m => m.role !== "system"); + const nonSystemMessages = body.messages.filter(m => m.role !== ROLE.SYSTEM); // Process messages with merging logic // CRITICAL: tool_result must be in separate message immediately after tool_use @@ -50,20 +54,20 @@ export function openaiToClaudeRequest(model, body, stream) { }; for (const msg of nonSystemMessages) { - const newRole = (msg.role === "user" || msg.role === "tool") ? "user" : "assistant"; + const newRole = (msg.role === ROLE.USER || msg.role === ROLE.TOOL) ? ROLE.USER : ROLE.ASSISTANT; const blocks = getContentBlocksFromMessage(msg, toolNameMap); - const hasToolUse = blocks.some(b => b.type === "tool_use"); - const hasToolResult = blocks.some(b => b.type === "tool_result"); + const hasToolUse = blocks.some(b => b.type === CLAUDE_BLOCK.TOOL_USE); + const hasToolResult = blocks.some(b => b.type === CLAUDE_BLOCK.TOOL_RESULT); // Separate tool_result from other content if (hasToolResult) { - const toolResultBlocks = blocks.filter(b => b.type === "tool_result"); - const otherBlocks = blocks.filter(b => b.type !== "tool_result"); + const toolResultBlocks = blocks.filter(b => b.type === CLAUDE_BLOCK.TOOL_RESULT); + const otherBlocks = blocks.filter(b => b.type !== CLAUDE_BLOCK.TOOL_RESULT); flushCurrentMessage(); if (toolResultBlocks.length > 0) { - result.messages.push({ role: "user", content: toolResultBlocks }); + result.messages.push({ role: ROLE.USER, content: toolResultBlocks }); } if (otherBlocks.length > 0) { @@ -90,9 +94,9 @@ export function openaiToClaudeRequest(model, body, stream) { // Add cache_control to last assistant message for (let i = result.messages.length - 1; i >= 0; i--) { const message = result.messages[i]; - if (message.role === "assistant" && Array.isArray(message.content) && message.content.length > 0) { + if (message.role === ROLE.ASSISTANT && Array.isArray(message.content) && message.content.length > 0) { // Find the last block that can have cache_control (not thinking blocks) - const validBlockTypes = ["text", "tool_use", "tool_result", "image"]; + const validBlockTypes = [CLAUDE_BLOCK.TEXT, CLAUDE_BLOCK.TOOL_USE, CLAUDE_BLOCK.TOOL_RESULT, CLAUDE_BLOCK.IMAGE]; for (let j = message.content.length - 1; j >= 0; j--) { const block = message.content[j]; if (validBlockTypes.includes(block.type)) { @@ -121,13 +125,13 @@ Respond ONLY with the JSON object, no other text.`); } // System with Claude Code prompt and cache_control - const claudeCodePrompt = { type: "text", text: CLAUDE_SYSTEM_PROMPT }; + const claudeCodePrompt = { type: CLAUDE_BLOCK.TEXT, text: CLAUDE_SYSTEM_PROMPT }; if (systemParts.length > 0) { const systemText = systemParts.join("\n"); result.system = [ claudeCodePrompt, - { type: "text", text: systemText, cache_control: { type: "ephemeral", ttl: "1h" } } + { type: CLAUDE_BLOCK.TEXT, text: systemText, cache_control: { type: "ephemeral", ttl: "1h" } } ]; } else { result.system = [claudeCodePrompt]; @@ -139,12 +143,12 @@ Respond ONLY with the JSON object, no other text.`); for (const tool of body.tools) { // Pass-through built-in tools (e.g. web_search_20250305) without prefix or conversion const toolType = tool.type; - if (toolType && toolType !== "function") { + if (toolType && toolType !== OPENAI_BLOCK.FUNCTION) { result.tools.push(tool); continue; } - const toolData = toolType === "function" && tool.function ? tool.function : tool; + const toolData = toolType === OPENAI_BLOCK.FUNCTION && tool.function ? tool.function : tool; const originalName = toolData.name; // Claude OAuth requires prefixed tool names to avoid conflicts @@ -170,33 +174,7 @@ Respond ONLY with the JSON object, no other text.`); result.tool_choice = convertOpenAIToolChoice(body.tool_choice); } - // Thinking configuration - if (body.thinking) { - result.thinking = { - type: body.thinking.type || "enabled", - ...(body.thinking.budget_tokens && { budget_tokens: body.thinking.budget_tokens }), - ...(body.thinking.max_tokens && { max_tokens: body.thinking.max_tokens }) - }; - } - - // Map OpenAI reasoning_effort → Claude thinking.budget_tokens - // When client sends reasoning_effort (OpenAI format) but no explicit thinking block, - // translate to Claude's native format. - if (body.reasoning_effort && !result.thinking) { - const effortToBudget = { - none: 0, - low: 4096, - medium: 8192, - high: 16384, - xhigh: 32768, - }; - const budget = effortToBudget[body.reasoning_effort.toLowerCase()]; - if (budget === 0) { - // none → no thinking - } else if (budget) { - result.thinking = { type: "enabled", budget_tokens: budget }; - } - } + // Thinking is normalized centrally by applyThinking (thinkingUnified.js) after translation. // Attach toolNameMap to result for response translation if (toolNameMap.size > 0) { @@ -210,78 +188,88 @@ Respond ONLY with the JSON object, no other text.`); function getContentBlocksFromMessage(msg, toolNameMap = new Map()) { const blocks = []; - if (msg.role === "tool") { + if (msg.role === ROLE.TOOL) { blocks.push({ - type: "tool_result", + type: CLAUDE_BLOCK.TOOL_RESULT, tool_use_id: msg.tool_call_id, content: msg.content }); - } else if (msg.role === "user") { + } else if (msg.role === ROLE.USER) { if (typeof msg.content === "string") { if (msg.content) { - blocks.push({ type: "text", text: msg.content }); + blocks.push({ type: CLAUDE_BLOCK.TEXT, text: msg.content }); } } else if (Array.isArray(msg.content)) { for (const part of msg.content) { - if (part.type === "text" && part.text) { - blocks.push({ type: "text", text: part.text }); - } else if (part.type === "tool_result") { + if (part.type === OPENAI_BLOCK.TEXT && part.text) { + blocks.push({ type: CLAUDE_BLOCK.TEXT, text: part.text }); + } else if (part.type === CLAUDE_BLOCK.TOOL_RESULT) { blocks.push({ - type: "tool_result", + type: CLAUDE_BLOCK.TOOL_RESULT, tool_use_id: part.tool_use_id, content: part.content, ...(part.is_error && { is_error: part.is_error }) }); - } else if (part.type === "image_url") { + } else if (part.type === OPENAI_BLOCK.IMAGE_URL) { const url = part.image_url.url; - const match = url.match(/^data:([^;]+);base64,(.+)$/); - if (match) { + const parsed = parseDataUri(url); + if (parsed) { blocks.push({ - type: "image", - source: { type: "base64", media_type: match[1], data: match[2] } + type: CLAUDE_BLOCK.IMAGE, + source: { type: "base64", media_type: parsed.mimeType, data: parsed.base64 } }); } else if (url.startsWith("http://") || url.startsWith("https://")) { blocks.push({ - type: "image", + type: CLAUDE_BLOCK.IMAGE, source: { type: "url", url } }); } - } else if (part.type === "image" && part.source) { - blocks.push({ type: "image", source: part.source }); + } else if (part.type === OPENAI_BLOCK.IMAGE && part.source) { + blocks.push({ type: CLAUDE_BLOCK.IMAGE, source: part.source }); + } else if (part.type === OPENAI_BLOCK.FILE && part.file) { + // OpenAI file block -> Claude document (PDF only; Claude rejects other mimes). + const fileData = part.file.file_data; + const parsed = parseDataUri(fileData); + if (parsed && parsed.mimeType === "application/pdf") { + blocks.push({ + type: CLAUDE_BLOCK.DOCUMENT, + source: { type: "base64", media_type: parsed.mimeType, data: parsed.base64 } + }); + } } } } - } else if (msg.role === "assistant") { + } else if (msg.role === ROLE.ASSISTANT) { if (Array.isArray(msg.content)) { for (const part of msg.content) { - if (part.type === "text" && part.text) { - blocks.push({ type: "text", text: part.text }); - } else if (part.type === "tool_use") { + if (part.type === OPENAI_BLOCK.TEXT && part.text) { + blocks.push({ type: CLAUDE_BLOCK.TEXT, text: part.text }); + } else if (part.type === CLAUDE_BLOCK.TOOL_USE) { // Tool name already has prefix from tool declarations, keep as-is - blocks.push({ type: "tool_use", id: part.id, name: part.name, input: part.input }); - } else if (part.type === "thinking") { + blocks.push({ type: CLAUDE_BLOCK.TOOL_USE, id: part.id, name: part.name, input: part.input }); + } else if (part.type === CLAUDE_BLOCK.THINKING) { // Include thinking block but strip cache_control (not allowed on thinking blocks) const { cache_control, ...thinkingBlock } = part; blocks.push(thinkingBlock); } } } else if (msg.content) { - const text = typeof msg.content === "string" ? msg.content : extractTextContent(msg.content); + const text = typeof msg.content === "string" ? msg.content : extractTextContent(msg.content, "\n"); if (text) { - blocks.push({ type: "text", text }); + blocks.push({ type: CLAUDE_BLOCK.TEXT, text }); } } if (msg.tool_calls && Array.isArray(msg.tool_calls)) { for (const tc of msg.tool_calls) { - if (tc.type === "function") { + if (tc.type === OPENAI_BLOCK.FUNCTION) { // Apply prefix to tool name const toolName = CLAUDE_OAUTH_TOOL_PREFIX + tc.function.name; blocks.push({ - type: "tool_use", + type: CLAUDE_BLOCK.TOOL_USE, id: tc.id, name: toolName, - input: tryParseJSON(tc.function.arguments) + input: safeParseJSON(tc.function.arguments, tc.function.arguments) }); } } @@ -323,25 +311,6 @@ function convertOpenAIToolChoice(choice) { return { type: "auto" }; } -// Extract text from content -function extractTextContent(content) { - if (typeof content === "string") return content; - if (Array.isArray(content)) { - return content.filter(c => c.type === "text").map(c => c.text).join("\n"); - } - return ""; -} - -// Try parse JSON -function tryParseJSON(str) { - if (typeof str !== "string") return str; - try { - return JSON.parse(str); - } catch { - return str; - } -} - // OpenAI -> Claude format for Antigravity (without system prompt modifications) function openaiToClaudeRequestForAntigravity(model, body, stream) { const result = openaiToClaudeRequest(model, body, stream); @@ -377,7 +346,7 @@ function openaiToClaudeRequestForAntigravity(model, body, stream) { } const updatedContent = msg.content.map(block => { - if (block.type === "tool_use" && block.name && block.name.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) { + if (block.type === CLAUDE_BLOCK.TOOL_USE && block.name && block.name.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) { return { ...block, name: block.name.slice(CLAUDE_OAUTH_TOOL_PREFIX.length) diff --git a/open-sse/translator/request/openai-to-commandcode.js b/open-sse/translator/request/openai-to-commandcode.js index 219a4e25..9825048b 100644 --- a/open-sse/translator/request/openai-to-commandcode.js +++ b/open-sse/translator/request/openai-to-commandcode.js @@ -12,6 +12,8 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; import { randomUUID } from "crypto"; +import { ROLE, OPENAI_BLOCK } from "../schema/index.js"; +import { DEFAULT_MAX_TOKENS } from "../../config/runtimeConfig.js"; function flattenText(content) { if (content == null) return ""; @@ -28,26 +30,26 @@ function flattenText(content) { } function toContentBlocks(content) { - if (content == null) return [{ type: "text", text: "" }]; - if (typeof content === "string") return [{ type: "text", text: content }]; + if (content == null) return [{ type: OPENAI_BLOCK.TEXT, text: "" }]; + if (typeof content === "string") return [{ type: OPENAI_BLOCK.TEXT, text: content }]; if (Array.isArray(content)) { const blocks = []; for (const part of content) { if (typeof part === "string") { - blocks.push({ type: "text", text: part }); + blocks.push({ type: OPENAI_BLOCK.TEXT, text: part }); } else if (part && typeof part === "object") { - if (part.type === "text" && typeof part.text === "string") { - blocks.push({ type: "text", text: part.text }); - } else if (part.type === "image_url" || part.type === "image") { - blocks.push({ type: "text", text: "[image omitted]" }); + if (part.type === OPENAI_BLOCK.TEXT && typeof part.text === "string") { + blocks.push({ type: OPENAI_BLOCK.TEXT, text: part.text }); + } else if (part.type === OPENAI_BLOCK.IMAGE_URL || part.type === OPENAI_BLOCK.IMAGE) { + blocks.push({ type: OPENAI_BLOCK.TEXT, text: "[image omitted]" }); } else if (typeof part.text === "string") { - blocks.push({ type: "text", text: part.text }); + blocks.push({ type: OPENAI_BLOCK.TEXT, text: part.text }); } } } - return blocks.length ? blocks : [{ type: "text", text: "" }]; + return blocks.length ? blocks : [{ type: OPENAI_BLOCK.TEXT, text: "" }]; } - return [{ type: "text", text: String(content) }]; + return [{ type: OPENAI_BLOCK.TEXT, text: String(content) }]; } function safeParseJson(s) { @@ -64,16 +66,16 @@ function convertMessages(messages = []) { if (!m) continue; const role = m.role; - if (role === "system") { + if (role === ROLE.SYSTEM) { const t = flattenText(m.content); if (t) systemTexts.push(t); continue; } - if (role === "tool") { + if (role === ROLE.TOOL) { const value = typeof m.content === "string" ? m.content : flattenText(m.content); out.push({ - role: "tool", + role: ROLE.TOOL, content: [{ type: "tool-result", toolCallId: m.tool_call_id || "", @@ -84,10 +86,10 @@ function convertMessages(messages = []) { continue; } - if (role === "assistant") { + if (role === ROLE.ASSISTANT) { const blocks = []; const text = flattenText(m.content); - if (text) blocks.push({ type: "text", text }); + if (text) blocks.push({ type: OPENAI_BLOCK.TEXT, text }); if (Array.isArray(m.tool_calls)) { for (const tc of m.tool_calls) { const fn = tc.function || {}; @@ -99,11 +101,11 @@ function convertMessages(messages = []) { }); } } - out.push({ role: "assistant", content: blocks.length ? blocks : [{ type: "text", text: "" }] }); + out.push({ role: ROLE.ASSISTANT, content: blocks.length ? blocks : [{ type: OPENAI_BLOCK.TEXT, text: "" }] }); continue; } - out.push({ role: "user", content: toContentBlocks(m.content) }); + out.push({ role: ROLE.USER, content: toContentBlocks(m.content) }); } return { messages: out, system: systemTexts.join("\n\n") }; @@ -114,7 +116,7 @@ function convertTools(tools) { const result = []; for (const t of tools) { if (!t) continue; - if (t.type === "function" && t.function) { + if (t.type === OPENAI_BLOCK.FUNCTION && t.function) { result.push({ name: t.function.name, description: t.function.description, @@ -131,13 +133,13 @@ function convertTools(tools) { return result.length ? result : undefined; } -export function openaiToCommandCode(model, body, stream /* , credentials */) { +export function openaiToCommandCodeRequest(model, body, stream /* , credentials */) { const { messages, system } = convertMessages(body.messages); const params = { model, messages, stream: stream !== false, - max_tokens: body.max_tokens ?? body.max_output_tokens ?? 64000, + max_tokens: body.max_tokens ?? body.max_output_tokens ?? DEFAULT_MAX_TOKENS, temperature: body.temperature ?? 0.3, }; @@ -167,4 +169,4 @@ export function openaiToCommandCode(model, body, stream /* , credentials */) { }; } -register(FORMATS.OPENAI, FORMATS.COMMANDCODE, openaiToCommandCode, null); +register(FORMATS.OPENAI, FORMATS.COMMANDCODE, openaiToCommandCodeRequest, null); diff --git a/open-sse/translator/request/openai-to-cursor.js b/open-sse/translator/request/openai-to-cursor.js index 1e3aa9c6..02c55b3b 100644 --- a/open-sse/translator/request/openai-to-cursor.js +++ b/open-sse/translator/request/openai-to-cursor.js @@ -8,6 +8,8 @@ */ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; +import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js"; +import { DEFAULT_MIN_TOKENS } from "../../config/runtimeConfig.js"; function extractContent(content) { if (typeof content === "string") return content; @@ -15,7 +17,7 @@ function extractContent(content) { return content .filter(part => { if (!part || typeof part !== "object") return false; - return part.type === "text" && typeof part.text === "string"; + return part.type === OPENAI_BLOCK.TEXT && typeof part.text === "string"; }) .map(part => part.text || "") .join(""); @@ -63,14 +65,14 @@ function convertMessages(messages) { }; for (const msg of messages) { - if (msg.role === "assistant" && msg.tool_calls) { + if (msg.role === ROLE.ASSISTANT && msg.tool_calls) { for (const tc of msg.tool_calls) { rememberToolMeta(tc.id || "", tc.function?.name || "tool"); } } - if (msg.role === "assistant" && Array.isArray(msg.content)) { + if (msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) { for (const part of msg.content) { - if (part?.type !== "tool_use") continue; + if (part?.type !== CLAUDE_BLOCK.TOOL_USE) continue; rememberToolMeta(part.id || "", part.name || "tool"); } } @@ -79,38 +81,38 @@ function convertMessages(messages) { for (let i = 0; i < messages.length; i++) { const msg = messages[i]; - if (msg.role === "system") { + if (msg.role === ROLE.SYSTEM) { result.push({ - role: "user", + role: ROLE.USER, content: `[System Instructions]\n${extractContent(msg.content)}` }); continue; } - if (msg.role === "tool") { + if (msg.role === ROLE.TOOL) { const toolContent = extractContent(msg.content); const toolCallId = msg.tool_call_id || ""; const toolMeta = toolCallMetaMap.get(toolCallId) || {}; const toolName = msg.name || toolMeta.name || "tool"; result.push({ - role: "user", + role: ROLE.USER, content: buildToolResultBlock(toolName, toolCallId, toolContent) }); continue; } - if (msg.role === "user" || msg.role === "assistant") { - if (msg.role === "user" && Array.isArray(msg.content)) { + if (msg.role === ROLE.USER || msg.role === ROLE.ASSISTANT) { + if (msg.role === ROLE.USER && Array.isArray(msg.content)) { const parts = []; for (const block of msg.content) { if (!block || typeof block !== "object") continue; - if (block.type === "text") { + if (block.type === CLAUDE_BLOCK.TEXT) { if (typeof block.text === "string") { parts.push(block.text || ""); } continue; } - if (block.type === "tool_result") { + if (block.type === CLAUDE_BLOCK.TOOL_RESULT) { const toolCallId = block.tool_use_id || ""; const toolMeta = toolCallMetaMap.get(toolCallId) || @@ -121,25 +123,25 @@ function convertMessages(messages) { } } const joined = parts.filter(Boolean).join("\n"); - if (joined) result.push({ role: "user", content: joined }); + if (joined) result.push({ role: ROLE.USER, content: joined }); continue; } const content = extractContent(msg.content); - if (msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0) { - const assistantMsg = { role: "assistant", content: content || "" }; + if (msg.role === ROLE.ASSISTANT && msg.tool_calls && msg.tool_calls.length > 0) { + const assistantMsg = { role: ROLE.ASSISTANT, content: content || "" }; assistantMsg.tool_calls = msg.tool_calls.map(tc => { const { index, ...rest } = tc || {}; return rest; }); result.push(assistantMsg); - } else if (msg.role === "assistant" && Array.isArray(msg.content)) { + } else if (msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) { const extractedToolCalls = msg.content - .filter(b => b?.type === "tool_use") + .filter(b => b?.type === CLAUDE_BLOCK.TOOL_USE) .map(b => ({ id: b.id || "", - type: "function", + type: OPENAI_BLOCK.FUNCTION, function: { name: b.name || "tool", arguments: JSON.stringify(b.input || {}) @@ -149,12 +151,12 @@ function convertMessages(messages) { if (extractedToolCalls.length > 0) { result.push({ - role: "assistant", + role: ROLE.ASSISTANT, content: content || "", tool_calls: extractedToolCalls }); } else if (content) { - result.push({ role: "assistant", content }); + result.push({ role: ROLE.ASSISTANT, content }); } } else { if (content) { @@ -167,7 +169,7 @@ function convertMessages(messages) { return result; } -export function buildCursorRequest(model, body, stream, credentials) { +export function openaiToCursorRequest(model, body, stream, credentials) { const messages = convertMessages(body.messages || []); // Strip fields irrelevant to Cursor (OpenAI/Anthropic-specific) @@ -176,8 +178,8 @@ export function buildCursorRequest(model, body, stream, credentials) { return { ...rest, messages, - max_tokens: 32000 + max_tokens: DEFAULT_MIN_TOKENS }; } -register(FORMATS.OPENAI, FORMATS.CURSOR, buildCursorRequest, null); +register(FORMATS.OPENAI, FORMATS.CURSOR, openaiToCursorRequest, null); diff --git a/open-sse/translator/request/openai-to-gemini.js b/open-sse/translator/request/openai-to-gemini.js index 81d472b9..fe181631 100644 --- a/open-sse/translator/request/openai-to-gemini.js +++ b/open-sse/translator/request/openai-to-gemini.js @@ -3,7 +3,6 @@ import { FORMATS } from "../formats.js"; import { DEFAULT_THINKING_AG_SIGNATURE, DEFAULT_THINKING_GEMINI_CLI_SIGNATURE } from "../../config/defaultThinkingSignature.js"; import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/appConstants.js"; import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.js"; - function generateUUID() { return crypto.randomUUID(); } @@ -17,8 +16,9 @@ import { generateSessionId, generateProjectId, cleanJSONSchemaForAntigravity -} from "../helpers/geminiHelper.js"; -import { deriveSessionId } from "../../utils/sessionManager.js"; +} from "../formats/gemini.js"; +import { deriveSessionId, toNumericSessionId } from "../../utils/sessionManager.js"; +import { ROLE, GEMINI_ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js"; // Sanitize function names for Gemini API. // Gemini requires: starts with [a-zA-Z_], followed by [a-zA-Z0-9_.:\-], max 64 chars. @@ -62,9 +62,9 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG const tcID2Name = {}; if (body.messages && Array.isArray(body.messages)) { for (const msg of body.messages) { - if (msg.role === "assistant" && msg.tool_calls) { + if (msg.role === ROLE.ASSISTANT && msg.tool_calls) { for (const tc of msg.tool_calls) { - if (tc.type === "function" && tc.id && tc.function?.name) { + if (tc.type === OPENAI_BLOCK.FUNCTION && tc.id && tc.function?.name) { tcID2Name[tc.id] = tc.function.name; } } @@ -76,7 +76,7 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG const toolResponses = {}; if (body.messages && Array.isArray(body.messages)) { for (const msg of body.messages) { - if (msg.role === "tool" && msg.tool_call_id) { + if (msg.role === ROLE.TOOL && msg.tool_call_id) { toolResponses[msg.tool_call_id] = msg.content; } } @@ -89,17 +89,17 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG const role = msg.role; const content = msg.content; - if (role === "system" && body.messages.length > 1) { + if (role === ROLE.SYSTEM && body.messages.length > 1) { result.systemInstruction = { - role: "user", + role: GEMINI_ROLE.USER, parts: [{ text: typeof content === "string" ? content : extractTextContent(content) }] }; - } else if (role === "user" || (role === "system" && body.messages.length === 1)) { + } else if (role === ROLE.USER || (role === ROLE.SYSTEM && body.messages.length === 1)) { const parts = convertOpenAIContentToParts(content); if (parts.length > 0) { - result.contents.push({ role: "user", parts }); + result.contents.push({ role: GEMINI_ROLE.USER, parts }); } - } else if (role === "assistant") { + } else if (role === ROLE.ASSISTANT) { const parts = []; // Thinking/reasoning → thought part with signature @@ -124,7 +124,7 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG if (msg.tool_calls && Array.isArray(msg.tool_calls)) { const toolCallIds = []; for (const tc of msg.tool_calls) { - if (tc.type !== "function") continue; + if (tc.type !== OPENAI_BLOCK.FUNCTION) continue; const args = tryParseJSON(tc.function?.arguments || "{}"); parts.push({ @@ -139,7 +139,7 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG } if (parts.length > 0) { - result.contents.push({ role: "model", parts }); + result.contents.push({ role: GEMINI_ROLE.MODEL, parts }); } // Check if there are actual tool responses in the next messages @@ -177,11 +177,11 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG }); } if (toolParts.length > 0) { - result.contents.push({ role: "user", parts: toolParts }); + result.contents.push({ role: GEMINI_ROLE.USER, parts: toolParts }); } } } else if (parts.length > 0) { - result.contents.push({ role: "model", parts }); + result.contents.push({ role: GEMINI_ROLE.MODEL, parts }); } } } @@ -201,7 +201,7 @@ function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG }); } // OpenAI format - else if (t.type === "function" && t.function) { + else if (t.type === OPENAI_BLOCK.FUNCTION && t.function) { const fn = t.function; const cleanedSchema = cleanJSONSchemaForAntigravity(structuredClone(fn.parameters || { type: "object", properties: {} })); functionDeclarations.push({ @@ -228,24 +228,7 @@ export function openaiToGeminiRequest(model, body, stream) { // OpenAI -> Gemini CLI (Cloud Code Assist) export function openaiToGeminiCLIRequest(model, body, stream) { const gemini = openaiToGeminiBase(model, body, stream, DEFAULT_THINKING_GEMINI_CLI_SIGNATURE); - const isClaude = model.toLowerCase().includes("claude"); - - // Map reasoning effort → thinkingConfig.thinkingLevel (gemini-3 enum: minimal|low|medium|high) - // Gemini 3 cannot fully disable thinking; "none"/"off" map to "minimal" (closest to no-thinking) - // Accept both OpenAI chat (reasoning_effort) and Responses (reasoning.effort) shapes - const reasoningEffort = body.reasoning_effort ?? body.reasoning?.effort; - if (reasoningEffort) { - const effort = String(reasoningEffort).toLowerCase().trim(); - const level = (effort === "none" || effort === "off") ? "minimal" : effort; - gemini.generationConfig.thinkingConfig = { thinkingLevel: level, includeThoughts: level !== "minimal" }; - } - - // Claude-format thinking: disabled → minimal, enabled → high - if (body.thinking?.type === "disabled") { - gemini.generationConfig.thinkingConfig = { thinkingLevel: "minimal", includeThoughts: false }; - } else if (body.thinking?.type === "enabled") { - gemini.generationConfig.thinkingConfig = { thinkingLevel: "high", includeThoughts: true }; - } + // Thinking is normalized centrally by applyThinking (thinkingUnified.js) after translation. // Clean schema for tools if (gemini.tools?.[0]?.functionDeclarations) { @@ -276,7 +259,7 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra userAgent: isAntigravity ? "antigravity" : "gemini-cli", requestId: isAntigravity ? `agent-${generateUUID()}` : generateRequestId(), request: { - sessionId: isAntigravity ? deriveSessionId(credentials?.email || credentials?.connectionId) : generateSessionId(), + sessionId: toNumericSessionId(credentials?._clientSessionId) || (isAntigravity ? deriveSessionId(credentials?.email || credentials?.connectionId) : generateSessionId()), contents: geminiCLI.contents, systemInstruction: geminiCLI.systemInstruction, generationConfig: geminiCLI.generationConfig, @@ -298,7 +281,7 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra if (envelope.request.systemInstruction?.parts) { envelope.request.systemInstruction.parts.unshift(...systemParts); } else { - envelope.request.systemInstruction = { role: "user", parts: systemParts }; + envelope.request.systemInstruction = { role: GEMINI_ROLE.USER, parts: systemParts }; } // Add toolConfig for Antigravity @@ -326,7 +309,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu requestId: `agent-${generateUUID()}`, requestType: "agent", request: { - sessionId: deriveSessionId(credentials?.email || credentials?.connectionId), + sessionId: toNumericSessionId(credentials?._clientSessionId) || deriveSessionId(credentials?.email || credentials?.connectionId), contents: [], generationConfig: { temperature: claudeRequest.temperature || 1, @@ -341,7 +324,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu for (const msg of claudeRequest.messages) { if (Array.isArray(msg.content)) { for (const block of msg.content) { - if (block.type === "tool_use" && block.id && block.name) { + if (block.type === CLAUDE_BLOCK.TOOL_USE && block.id && block.name) { toolUseIdToName[block.id] = block.name; } } @@ -356,9 +339,9 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu if (Array.isArray(msg.content)) { for (const block of msg.content) { - if (block.type === "text") { + if (block.type === CLAUDE_BLOCK.TEXT) { parts.push({ text: block.text }); - } else if (block.type === "tool_use") { + } else if (block.type === CLAUDE_BLOCK.TOOL_USE) { parts.push({ functionCall: { id: block.id, @@ -366,10 +349,10 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu args: block.input || {} } }); - } else if (block.type === "tool_result") { + } else if (block.type === CLAUDE_BLOCK.TOOL_RESULT) { let content = block.content; if (Array.isArray(content)) { - content = content.map(c => c.type === "text" ? c.text : JSON.stringify(c)).join("\n"); + content = content.map(c => c.type === CLAUDE_BLOCK.TEXT ? c.text : JSON.stringify(c)).join("\n"); } // Resolve the original tool name from the id — Gemini requires it to match the functionCall name const resolvedName = toolUseIdToName[block.tool_use_id] @@ -390,7 +373,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu if (parts.length > 0) { envelope.request.contents.push({ - role: msg.role === "assistant" ? "model" : "user", + role: msg.role === ROLE.ASSISTANT ? GEMINI_ROLE.MODEL : GEMINI_ROLE.USER, parts }); } @@ -439,7 +422,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu if (envelope.request.systemInstruction?.parts) { envelope.request.systemInstruction.parts.unshift(...systemParts); } else { - envelope.request.systemInstruction = { role: "user", parts: systemParts }; + envelope.request.systemInstruction = { role: GEMINI_ROLE.USER, parts: systemParts }; } return envelope; diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index 49aa7f4f..e3e7f475 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -5,13 +5,17 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; import { v4 as uuidv4 } from "uuid"; +import { resolveSessionId } from "../../utils/sessionManager.js"; import { resolveKiroModel, - isThinkingEnabled, + resolveKiroThinkingBudget, buildThinkingSystemPrefix, KIRO_AGENTIC_SYSTEM_PROMPT, resolveDefaultProfileArn } from "../../config/kiroConstants.js"; +import { parseDataUri } from "../concerns/image.js"; +import { DEFAULT_IMAGE_MIME } from "../schema/index.js"; +import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js"; /** Render a single tool call as a readable text line. */ function toolCallToText(name, input) { @@ -55,18 +59,18 @@ function flattenToolInteractions(messages) { for (const msg of messages) { // OpenAI tool-result message → user text line - if (msg.role === "tool") { - out.push({ role: "user", content: toolResultToText(msg.content) }); + if (msg.role === ROLE.TOOL) { + out.push({ role: ROLE.USER, content: toolResultToText(msg.content) }); continue; } - if (msg.role === "assistant") { + if (msg.role === ROLE.ASSISTANT) { const parts = []; if (Array.isArray(msg.content)) { for (const c of msg.content) { - if (c.type === "tool_use") { + if (c.type === CLAUDE_BLOCK.TOOL_USE) { parts.push(toolCallToText(c.name, c.input)); - } else if (c.type === "text" || c.text) { + } else if (c.type === OPENAI_BLOCK.TEXT || c.text) { parts.push(c.text || ""); } } @@ -76,15 +80,15 @@ function flattenToolInteractions(messages) { for (const tc of msg.tool_calls || []) { parts.push(toolCallToText(tc.function?.name, tc.function?.arguments)); } - out.push({ role: "assistant", content: parts.filter(Boolean).join("\n") }); + out.push({ role: ROLE.ASSISTANT, content: parts.filter(Boolean).join("\n") }); continue; } // User messages: replace tool_result blocks with text, keep text + images. - if (msg.role === "user" && Array.isArray(msg.content)) { + if (msg.role === ROLE.USER && Array.isArray(msg.content)) { const newContent = msg.content.map(c => - c.type === "tool_result" - ? { type: "text", text: toolResultToText(c.content) } + c.type === CLAUDE_BLOCK.TOOL_RESULT + ? { type: OPENAI_BLOCK.TEXT, text: toolResultToText(c.content) } : c ); out.push({ ...msg, content: newContent }); @@ -266,8 +270,8 @@ function convertMessages(messages, tools, model) { let role = msg.role; // Normalize: system/tool -> user - if (role === "system" || role === "tool") { - role = "user"; + if (role === ROLE.SYSTEM || role === ROLE.TOOL) { + role = ROLE.USER; } // If role changes, flush pending @@ -276,7 +280,7 @@ function convertMessages(messages, tools, model) { } currentRole = role; - if (role === "user") { + if (role === ROLE.USER) { // Extract content let content = ""; if (typeof msg.content === "string") { @@ -284,24 +288,23 @@ function convertMessages(messages, tools, model) { } else if (Array.isArray(msg.content)) { const textParts = []; for (const c of msg.content) { - if (c.type === "text" || c.text) { + if (c.type === OPENAI_BLOCK.TEXT || c.text) { textParts.push(c.text || ""); - } else if (c.type === "image_url") { + } else if (c.type === OPENAI_BLOCK.IMAGE_URL) { // OpenAI format: image_url.url with data URI const url = c.image_url?.url || ""; - const base64Match = url.match(/^data:([^;]+);base64,(.+)$/); - if (base64Match) { - const mediaType = base64Match[1]; - const format = mediaType.split("/")[1] || mediaType; - pendingImages.push({ format, source: { bytes: base64Match[2] } }); + const parsed = parseDataUri(url); + if (parsed) { + const format = parsed.mimeType.split("/")[1] || parsed.mimeType; + pendingImages.push({ format, source: { bytes: parsed.base64 } }); } else if (url.startsWith("http://") || url.startsWith("https://")) { // Kiro only supports base64 — fallback to URL text textParts.push(`[Image: ${url}]`); } - } else if (c.type === "image") { + } else if (c.type === CLAUDE_BLOCK.IMAGE) { // Claude format: source.type = "base64", source.media_type, source.data if (c.source?.type === "base64" && c.source?.data) { - const mediaType = c.source.media_type || "image/png"; + const mediaType = c.source.media_type || DEFAULT_IMAGE_MIME; const format = mediaType.split("/")[1] || mediaType; pendingImages.push({ format, source: { bytes: c.source.data } }); } @@ -310,7 +313,7 @@ function convertMessages(messages, tools, model) { content = textParts.join("\n"); // Check for tool_result blocks - const toolResultBlocks = msg.content.filter(c => c.type === "tool_result"); + const toolResultBlocks = msg.content.filter(c => c.type === CLAUDE_BLOCK.TOOL_RESULT); if (toolResultBlocks.length > 0) { toolResultBlocks.forEach(block => { const text = Array.isArray(block.content) @@ -327,7 +330,7 @@ function convertMessages(messages, tools, model) { } // Handle tool role (from normalized) - if (msg.role === "tool") { + if (msg.role === ROLE.TOOL) { const toolContent = typeof msg.content === "string" ? msg.content : ""; pendingToolResults.push({ toolUseId: msg.tool_call_id, @@ -337,16 +340,16 @@ function convertMessages(messages, tools, model) { } else if (content) { pendingUserContent.push(content); } - } else if (role === "assistant") { + } else if (role === ROLE.ASSISTANT) { // Extract text content and tool uses let textContent = ""; let toolUses = []; if (Array.isArray(msg.content)) { - const textBlocks = msg.content.filter(c => c.type === "text"); + const textBlocks = msg.content.filter(c => c.type === OPENAI_BLOCK.TEXT); textContent = textBlocks.map(b => b.text).join("\n").trim(); - const toolUseBlocks = msg.content.filter(c => c.type === "tool_use"); + const toolUseBlocks = msg.content.filter(c => c.type === CLAUDE_BLOCK.TOOL_USE); toolUses = toolUseBlocks; } else if (typeof msg.content === "string") { textContent = msg.content.trim(); @@ -509,20 +512,28 @@ function convertMessages(messages, tools, model) { * `thinking`, OpenAI `reasoning_effort`, AMP/Cursor magic tags, and model * name hints. */ -export function buildKiroPayload(model, body, stream, credentials) { +export function openaiToKiroRequest(model, body, stream, credentials) { const messages = body.messages || []; const tools = body.tools || []; const maxTokens = 32000; const temperature = body.temperature; const topP = body.top_p; - const { upstream: upstreamModel, agentic, thinking: modelImpliesThinking } = resolveKiroModel(model); - const thinkingEnabled = modelImpliesThinking || isThinkingEnabled(body, null, model); + const { upstream: upstreamModel, agentic } = resolveKiroModel(model); + const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model); const { history, currentMessage } = convertMessages(messages, tools, upstreamModel); - const profileArn = credentials?.providerSpecificData?.profileArn - || resolveDefaultProfileArn(credentials?.providerSpecificData?.authMethod); + // API-key (headless) auth uses a raw CodeWhisperer credential whose profile is + // account-specific. Injecting the shared builder-id/social *default* placeholder + // ARN makes CodeWhisperer reject the request with 403 "bearer token invalid" + // (the ARN doesn't belong to the key's account). So for api_key, only send a + // profileArn that was actually resolved for this connection — never the default. + // OAuth/social keep the default fallback (their tokens accept it). + const authMethod = credentials?.providerSpecificData?.authMethod; + const profileArn = authMethod === "api_key" + ? (credentials?.providerSpecificData?.profileArn || "") + : (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod)); let finalContent = currentMessage?.userInputMessage?.content || ""; @@ -532,8 +543,8 @@ export function buildKiroPayload(model, body, stream, credentials) { // Order: thinking_mode tag first (so Kiro sees it before any user text), // then context/timestamp marker, then optional agentic chunked-write prompt. const prefixParts = []; - if (thinkingEnabled) { - prefixParts.push(buildThinkingSystemPrefix()); + if (thinkingBudget !== null) { + prefixParts.push(buildThinkingSystemPrefix(thinkingBudget)); } prefixParts.push(`[Context: Current time is ${timestamp}]`); if (agentic) { @@ -544,7 +555,7 @@ export function buildKiroPayload(model, body, stream, credentials) { const payload = { conversationState: { chatTriggerType: "MANUAL", - conversationId: uuidv4(), + conversationId: resolveSessionId({ headers: credentials?.rawHeaders, body, connectionId: credentials?.connectionId, scope: "kiro" }), currentMessage: { userInputMessage: { content: finalContent, @@ -582,4 +593,4 @@ export function buildKiroPayload(model, body, stream, credentials) { return payload; } -register(FORMATS.OPENAI, FORMATS.KIRO, buildKiroPayload, null); +register(FORMATS.OPENAI, FORMATS.KIRO, openaiToKiroRequest, null); diff --git a/open-sse/translator/request/openai-to-kiro.old.js b/open-sse/translator/request/openai-to-kiro.old.js deleted file mode 100644 index 2474051f..00000000 --- a/open-sse/translator/request/openai-to-kiro.old.js +++ /dev/null @@ -1,278 +0,0 @@ -/** - * OpenAI to Kiro Request Translator - * Converts OpenAI Chat Completions format to Kiro/AWS CodeWhisperer format - */ -import { register } from "../index.js"; -import { FORMATS } from "../formats.js"; -import { v4 as uuidv4 } from "uuid"; - -/** - * Convert OpenAI messages to Kiro format - */ -function convertMessages(messages, tools, model) { - let history = []; - let currentMessage = null; - let systemPrompt = ""; - - const toolResultsMap = new Map(); - - for (const msg of messages) { - if (msg.role === "tool" && msg.tool_call_id) { - const content = typeof msg.content === "string" ? msg.content : - (Array.isArray(msg.content) ? msg.content.map(c => c.text || "").join("\n") : ""); - toolResultsMap.set(msg.tool_call_id, content); - } - - if (msg.role === "user" && Array.isArray(msg.content)) { - for (const block of msg.content) { - if (block.type === "tool_result" && block.tool_use_id) { - const content = Array.isArray(block.content) - ? block.content.map(c => c.text || "").join("\n") - : (typeof block.content === "string" ? block.content : ""); - toolResultsMap.set(block.tool_use_id, content); - } - } - } - } - - for (const msg of messages) { - const role = msg.role; - - if (role === "tool") continue; - - const content = typeof msg.content === "string" ? msg.content : - (Array.isArray(msg.content) ? msg.content.map(c => c.text || "").join("\n") : ""); - - if (role === "system") { - systemPrompt += (systemPrompt ? "\n" : "") + content; - continue; - } - - if (role === "user") { - let finalContent = content; - let toolResults = []; - - // Check if this user message contains tool_result blocks - if (Array.isArray(msg.content)) { - const toolResultBlocks = msg.content.filter(c => c.type === "tool_result"); - if (toolResultBlocks.length > 0) { - toolResults = toolResultBlocks.map(block => { - const text = Array.isArray(block.content) - ? block.content.map(c => c.text || "").join("\n") - : (typeof block.content === "string" ? block.content : ""); - - return { - toolUseId: block.tool_use_id, - status: "success", - content: [{ text: text }] - }; - }); - - // Set simple content when tool results exist - finalContent = content || "Continue"; - } - } - - const userMsg = { - userInputMessage: { - content: finalContent, - modelId: "", - } - }; - - // Add tool results to userInputMessageContext - if (toolResults.length > 0) { - if (!userMsg.userInputMessage.userInputMessageContext) { - userMsg.userInputMessage.userInputMessageContext = {}; - } - userMsg.userInputMessage.userInputMessageContext.toolResults = toolResults; - } - - // Add tools to first user message - if (tools && tools.length > 0 && history.length === 0) { - if (!userMsg.userInputMessage.userInputMessageContext) { - userMsg.userInputMessage.userInputMessageContext = {}; - } - userMsg.userInputMessage.userInputMessageContext.tools = tools.map(t => { - const name = t.function?.name || t.name; - let description = t.function?.description || t.description || ""; - - if (!description.trim()) { - description = `Tool: ${name}`; - } - - return { - toolSpecification: { - name, - description, - inputSchema: { - json: t.function?.parameters || t.parameters || t.input_schema || {} - } - } - }; - }); - } - - currentMessage = userMsg; - history.push(userMsg); - } - - if (role === "assistant") { - // Extract text content and tool uses separately from content array - let textContent = ""; - let toolUses = []; - - if (Array.isArray(msg.content)) { - const textBlocks = msg.content.filter(c => c.type === "text"); - textContent = textBlocks.map(b => b.text).join("\n").trim(); - - const toolUseBlocks = msg.content.filter(c => c.type === "tool_use"); - toolUses = toolUseBlocks; - } else if (typeof msg.content === "string") { - textContent = msg.content.trim(); - } - - // Fallback for OpenAI tool_calls format - if (msg.tool_calls && msg.tool_calls.length > 0) { - toolUses = msg.tool_calls; - } - - const assistantMsg = { - assistantResponseMessage: { - content: textContent || "Call tools" - } - }; - - if (toolUses.length > 0) { - assistantMsg.assistantResponseMessage.toolUses = toolUses.map(tc => { - if (tc.function) { - // OpenAI format - return { - toolUseId: tc.id || uuidv4(), - name: tc.function.name, - input: typeof tc.function.arguments === "string" - ? JSON.parse(tc.function.arguments) - : (tc.function.arguments || {}) - }; - } else { - // Anthropic format - return { - toolUseId: tc.id || uuidv4(), - name: tc.name, - input: tc.input || {} - }; - } - }); - } - - history.push(assistantMsg); - } - } - - // If last message in history is userInputMessage, use it as currentMessage - if (history.length > 0 && history[history.length - 1].userInputMessage) { - currentMessage = history.pop(); - } - - const firstHistoryItem = history[0]; - if (firstHistoryItem?.userInputMessage?.userInputMessageContext?.tools && - !currentMessage?.userInputMessage?.userInputMessageContext?.tools) { - if (!currentMessage.userInputMessage.userInputMessageContext) { - currentMessage.userInputMessage.userInputMessageContext = {}; - } - currentMessage.userInputMessage.userInputMessageContext.tools = - firstHistoryItem.userInputMessage.userInputMessageContext.tools; - } - - // Clean up history for Kiro API compatibility - history.forEach(item => { - if (item.userInputMessage?.userInputMessageContext?.tools) { - delete item.userInputMessage.userInputMessageContext.tools; - } - - if (item.userInputMessage?.userInputMessageContext && - Object.keys(item.userInputMessage.userInputMessageContext).length === 0) { - delete item.userInputMessage.userInputMessageContext; - } - - if (item.userInputMessage && !item.userInputMessage.modelId) { - item.userInputMessage.modelId = model; - } - }); - - // Merge consecutive user messages (Kiro requires alternating user/assistant) - const mergedHistory = []; - for (let i = 0; i < history.length; i++) { - const current = history[i]; - - if (current.userInputMessage && - mergedHistory.length > 0 && - mergedHistory[mergedHistory.length - 1].userInputMessage) { - const prev = mergedHistory[mergedHistory.length - 1]; - prev.userInputMessage.content += "\n\n" + current.userInputMessage.content; - } else { - mergedHistory.push(current); - } - } - history = mergedHistory; - - return { history, currentMessage, systemPrompt }; -} - -/** - * Build Kiro payload from OpenAI format - */ -function buildKiroPayload(model, body, stream, credentials) { - const messages = body.messages || []; - const tools = body.tools || []; - const maxTokens = 32000; - const temperature = body.temperature; - const topP = body.top_p; - - const { history, currentMessage, systemPrompt } = convertMessages(messages, tools, model); - - const profileArn = credentials?.providerSpecificData?.profileArn || ""; - - let finalContent = currentMessage?.userInputMessage?.content || ""; - if (systemPrompt) { - finalContent = `[System: ${systemPrompt}]\n\n${finalContent}`; - } - - const timestamp = new Date().toISOString(); - finalContent = `[Context: Current time is ${timestamp}]\n\n${finalContent}`; - - const payload = { - conversationState: { - chatTriggerType: "MANUAL", - conversationId: uuidv4(), - currentMessage: { - userInputMessage: { - content: finalContent, - modelId: model, - origin: "AI_EDITOR", - ...(currentMessage?.userInputMessage?.userInputMessageContext && { - userInputMessageContext: currentMessage.userInputMessage.userInputMessageContext - }) - } - }, - history: history - } - }; - - if (profileArn) { - payload.profileArn = profileArn; - } - - if (maxTokens || temperature !== undefined || topP !== undefined) { - payload.inferenceConfig = {}; - if (maxTokens) payload.inferenceConfig.maxTokens = maxTokens; - if (temperature !== undefined) payload.inferenceConfig.temperature = temperature; - if (topP !== undefined) payload.inferenceConfig.topP = topP; - } - - return payload; -} - -register(FORMATS.OPENAI, FORMATS.KIRO, buildKiroPayload, null); - -export { buildKiroPayload }; diff --git a/open-sse/translator/request/openai-to-ollama.js b/open-sse/translator/request/openai-to-ollama.js index a1491685..9ecdb67f 100644 --- a/open-sse/translator/request/openai-to-ollama.js +++ b/open-sse/translator/request/openai-to-ollama.js @@ -1,5 +1,8 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; +import { parseDataUri } from "../concerns/image.js"; +import { safeParseJSON } from "../concerns/json.js"; +import { ROLE, OPENAI_BLOCK } from "../schema/index.js"; /** * Convert OpenAI request to Ollama format @@ -67,7 +70,7 @@ function normalizeMessages(messages) { // First pass: build tool_call_id -> tool_name map from assistant messages for (const msg of messages) { - if (msg.role === "assistant" && msg.tool_calls) { + if (msg.role === ROLE.ASSISTANT && msg.tool_calls) { for (const tc of msg.tool_calls) { if (tc.id && tc.function?.name) { toolCallMap.set(tc.id, tc.function.name); @@ -79,7 +82,7 @@ function normalizeMessages(messages) { // Second pass: convert messages for (const msg of messages) { // Handle tool result messages (OpenAI format -> Ollama format) - if (msg.role === "tool") { + if (msg.role === ROLE.TOOL) { const toolResult = normalizeContent(msg.content); if (!toolResult) continue; @@ -87,7 +90,7 @@ function normalizeMessages(messages) { const toolName = toolCallMap.get(msg.tool_call_id) || msg.name || "unknown_tool"; result.push({ - role: "tool", + role: ROLE.TOOL, tool_name: toolName, content: toolResult }); @@ -95,23 +98,23 @@ function normalizeMessages(messages) { } // Handle assistant messages with tool_calls - if (msg.role === "assistant" && msg.tool_calls) { + if (msg.role === ROLE.ASSISTANT && msg.tool_calls) { const content = normalizeContent(msg.content) || ""; // Convert OpenAI tool_calls format to Ollama format const ollamaToolCalls = msg.tool_calls.map(tc => ({ - type: "function", + type: OPENAI_BLOCK.FUNCTION, function: { index: tc.index || 0, name: tc.function?.name || "", arguments: typeof tc.function?.arguments === "string" - ? JSON.parse(tc.function.arguments || "{}") + ? safeParseJSON(tc.function.arguments || "{}", {}) : tc.function?.arguments || {} } })); result.push({ - role: "assistant", + role: ROLE.ASSISTANT, content: content, tool_calls: ollamaToolCalls }); @@ -124,7 +127,7 @@ function normalizeMessages(messages) { const images = extractImagesFromContent(msg.content); // Skip empty messages (except assistant) - if (!content && role !== "assistant") continue; + if (!content && role !== ROLE.ASSISTANT) continue; const out = { role: role, @@ -153,7 +156,7 @@ function normalizeContent(content) { if (Array.isArray(content)) { // Extract text from content array const textParts = content - .filter(block => block && block.type === "text" && block.text) + .filter(block => block && block.type === OPENAI_BLOCK.TEXT && block.text) .map(block => block.text); return textParts.join("\n") || ""; @@ -174,15 +177,15 @@ function extractImagesFromContent(content) { const images = []; for (const block of content) { - if (!block || block.type !== "image_url") continue; + if (!block || block.type !== OPENAI_BLOCK.IMAGE_URL) continue; const url = typeof block.image_url === "string" ? block.image_url : block.image_url?.url; if (typeof url !== "string" || !url) continue; - const m = url.match(/^data:[^;]+;base64,([\s\S]+)$/); - if (!m) continue; + const parsed = parseDataUri(url); + if (!parsed) continue; - images.push(m[1]); + images.push(parsed.base64); } return images; diff --git a/open-sse/translator/response/claude-to-openai.js b/open-sse/translator/response/claude-to-openai.js index 7e662d36..9dfd74d0 100644 --- a/open-sse/translator/response/claude-to-openai.js +++ b/open-sse/translator/response/claude-to-openai.js @@ -1,19 +1,18 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; +import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK, OPENAI_FINISH } from "../schema/index.js"; +import { buildChunk } from "../concerns/chunk.js"; +import { toOpenAIUsage } from "../concerns/usage.js"; +import { reasoningDelta } from "../concerns/reasoning.js"; +import { toOpenAIFinish } from "../concerns/finishReason.js"; // Create OpenAI chunk helper function createChunk(state, delta, finishReason = null) { - return { - id: `chatcmpl-${state.messageId}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: state.model, - choices: [{ - index: 0, - delta, - finish_reason: finishReason - }] - }; + return buildChunk( + { id: `chatcmpl-${state.messageId}`, created: Math.floor(Date.now() / 1000), model: state.model }, + delta, + finishReason + ); } // Convert Claude stream chunk to OpenAI format @@ -28,7 +27,7 @@ export function claudeToOpenAIResponse(chunk, state) { state.messageId = chunk.message?.id || `msg_${Date.now()}`; state.model = chunk.message?.model; state.toolCallIndex = 0; - results.push(createChunk(state, { role: "assistant" })); + results.push(createChunk(state, { role: ROLE.ASSISTANT })); break; } @@ -39,20 +38,20 @@ export function claudeToOpenAIResponse(chunk, state) { state.serverToolBlockIndex = chunk.index; break; } - if (block?.type === "text") { + if (block?.type === CLAUDE_BLOCK.TEXT) { state.textBlockStarted = true; - } else if (block?.type === "thinking") { + } else if (block?.type === CLAUDE_BLOCK.THINKING) { state.inThinkingBlock = true; state.currentBlockIndex = chunk.index; results.push(createChunk(state, { content: "" })); - } else if (block?.type === "tool_use") { + } else if (block?.type === CLAUDE_BLOCK.TOOL_USE) { const toolCallIndex = state.toolCallIndex++; // Restore original tool name from mapping (Claude OAuth) const toolName = state.toolNameMap?.get(block.name) || block.name; const toolCall = { index: toolCallIndex, id: block.id, - type: "function", + type: OPENAI_BLOCK.FUNCTION, function: { name: toolName, arguments: "" @@ -71,7 +70,7 @@ export function claudeToOpenAIResponse(chunk, state) { if (delta?.type === "text_delta" && delta.text) { results.push(createChunk(state, { content: delta.text })); } else if (delta?.type === "thinking_delta" && delta.thinking) { - results.push(createChunk(state, { reasoning_content: delta.thinking })); + results.push(createChunk(state, reasoningDelta(delta.thinking))); } else if (delta?.type === "input_json_delta" && delta.partial_json) { const toolCall = state.toolCalls.get(chunk.index); if (toolCall) { @@ -129,28 +128,10 @@ export function claudeToOpenAIResponse(chunk, state) { if (chunk.delta?.stop_reason) { state.finishReason = convertStopReason(chunk.delta.stop_reason); - const finalChunk = { - id: `chatcmpl-${state.messageId}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: state.model, - choices: [{ index: 0, delta: {}, finish_reason: state.finishReason }] - }; + const finalChunk = createChunk(state, {}, state.finishReason); if (state.usage) { - finalChunk.usage = { - prompt_tokens: state.usage.prompt_tokens, - completion_tokens: state.usage.completion_tokens, - total_tokens: state.usage.total_tokens - }; - - const cacheRead = state.usage.cache_read_input_tokens; - const cacheCreate = state.usage.cache_creation_input_tokens; - if (cacheRead > 0 || cacheCreate > 0) { - finalChunk.usage.prompt_tokens_details = {}; - if (cacheRead > 0) finalChunk.usage.prompt_tokens_details.cached_tokens = cacheRead; - if (cacheCreate > 0) finalChunk.usage.prompt_tokens_details.cache_creation_tokens = cacheCreate; - } + finalChunk.usage = toOpenAIUsage(chunk.usage, "claude"); } results.push(finalChunk); @@ -161,7 +142,7 @@ export function claudeToOpenAIResponse(chunk, state) { case "message_stop": { if (!state.finishReasonSent) { - const finishReason = state.finishReason || (state.toolCalls?.size > 0 ? "tool_calls" : "stop"); + const finishReason = state.finishReason || (state.toolCalls?.size > 0 ? OPENAI_FINISH.TOOL_CALLS : OPENAI_FINISH.STOP); const usageObj = (state.usage && typeof state.usage === 'object') ? { usage: { prompt_tokens: state.usage.input_tokens || 0, @@ -169,18 +150,7 @@ export function claudeToOpenAIResponse(chunk, state) { total_tokens: (state.usage.input_tokens || 0) + (state.usage.output_tokens || 0) } } : {}; - results.push({ - id: `chatcmpl-${state.messageId}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: state.model, - choices: [{ - index: 0, - delta: {}, - finish_reason: finishReason - }], - ...usageObj - }); + results.push({ ...createChunk(state, {}, finishReason), ...usageObj }); state.finishReasonSent = true; } break; @@ -190,16 +160,7 @@ export function claudeToOpenAIResponse(chunk, state) { return results.length > 0 ? results : null; } -// Convert Claude stop_reason to OpenAI finish_reason -function convertStopReason(reason) { - switch (reason) { - case "end_turn": return "stop"; - case "max_tokens": return "length"; - case "tool_use": return "tool_calls"; - case "stop_sequence": return "stop"; - default: return "stop"; - } -} +const convertStopReason = (reason) => toOpenAIFinish(reason, "claude"); // Register register(FORMATS.CLAUDE, FORMATS.OPENAI, null, claudeToOpenAIResponse); diff --git a/open-sse/translator/response/commandcode-to-openai.js b/open-sse/translator/response/commandcode-to-openai.js index f001b571..ab3d7d7b 100644 --- a/open-sse/translator/response/commandcode-to-openai.js +++ b/open-sse/translator/response/commandcode-to-openai.js @@ -17,6 +17,12 @@ */ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; +import { ROLE, OPENAI_BLOCK, OPENAI_FINISH } from "../schema/index.js"; +import { buildChunk } from "../concerns/chunk.js"; +import { toOpenAIUsage } from "../concerns/usage.js"; +import { reasoningDelta } from "../concerns/reasoning.js"; +import { fallbackToolCallId } from "../concerns/toolCall.js"; +import { toOpenAIFinish } from "../concerns/finishReason.js"; function ensureState(state, model) { if (!state.responseId) { @@ -34,28 +40,16 @@ function ensureState(state, model) { } function makeChunk(state, delta, finishReason = null) { - return { - id: state.responseId, - object: "chat.completion.chunk", - created: state.created, - model: state.model, - choices: [{ index: 0, delta, finish_reason: finishReason }], - }; + return buildChunk( + { id: state.responseId, created: state.created, model: state.model }, + delta, + finishReason + ); } -function mapFinishReason(reason) { - switch (reason) { - case "stop": return "stop"; - case "length": return "length"; - case "tool-calls": - case "tool_use": return "tool_calls"; - case "content-filter": return "content_filter"; - case "error": return "stop"; - default: return reason || "stop"; - } -} +const mapFinishReason = (reason) => toOpenAIFinish(reason, "commandcode"); -export function convertCommandCodeToOpenAI(chunk, state) { +export function commandCodeToOpenAIResponse(chunk, state) { if (!chunk) return null; // Already-OpenAI chunk: pass through @@ -87,7 +81,7 @@ export function convertCommandCodeToOpenAI(chunk, state) { case "text-delta": { const text = event.text || event.delta || ""; if (!text) break; - const delta = state.chunkIndex === 0 ? { role: "assistant", content: text } : { content: text }; + const delta = state.chunkIndex === 0 ? { role: ROLE.ASSISTANT, content: text } : { content: text }; state.chunkIndex++; state.openText = true; out.push(makeChunk(state, delta)); @@ -97,15 +91,13 @@ export function convertCommandCodeToOpenAI(chunk, state) { const text = event.text || ""; if (!text) break; // Map reasoning to OpenAI "reasoning_content" field (used by deepseek-reasoner-style clients). - const delta = state.chunkIndex === 0 - ? { role: "assistant", reasoning_content: text } - : { reasoning_content: text }; + const delta = reasoningDelta(text, state.chunkIndex === 0); state.chunkIndex++; out.push(makeChunk(state, delta)); break; } case "tool-input-start": { - const id = event.id || event.toolCallId || `call_${Date.now()}_${state.toolIndex}`; + const id = event.id || event.toolCallId || fallbackToolCallId(state.toolIndex); let idx = state.toolIndexById.get(id); if (idx == null) { idx = state.toolIndex++; @@ -113,11 +105,11 @@ export function convertCommandCodeToOpenAI(chunk, state) { } state.openTools.add(id); const delta = { - ...(state.chunkIndex === 0 ? { role: "assistant" } : {}), + ...(state.chunkIndex === 0 ? { role: ROLE.ASSISTANT } : {}), tool_calls: [{ index: idx, id, - type: "function", + type: OPENAI_BLOCK.FUNCTION, function: { name: event.toolName || "", arguments: "" }, }], }; @@ -146,11 +138,11 @@ export function convertCommandCodeToOpenAI(chunk, state) { state.toolIndexById.set(id, idx); const argsStr = typeof event.input === "string" ? event.input : JSON.stringify(event.input ?? {}); const delta = { - ...(state.chunkIndex === 0 ? { role: "assistant" } : {}), + ...(state.chunkIndex === 0 ? { role: ROLE.ASSISTANT } : {}), tool_calls: [{ index: idx, id, - type: "function", + type: OPENAI_BLOCK.FUNCTION, function: { name: event.toolName || "", arguments: argsStr }, }], }; @@ -167,22 +159,17 @@ export function convertCommandCodeToOpenAI(chunk, state) { const finishReason = state.finishReason || mapFinishReason(event.finishReason || "stop"); const finalChunk = makeChunk(state, {}, finishReason); const totalUsage = event.totalUsage || state.usage; - if (totalUsage) { - finalChunk.usage = { - prompt_tokens: totalUsage.inputTokens ?? 0, - completion_tokens: totalUsage.outputTokens ?? 0, - total_tokens: totalUsage.totalTokens ?? ((totalUsage.inputTokens ?? 0) + (totalUsage.outputTokens ?? 0)), - }; - } + const usage = toOpenAIUsage(totalUsage, "commandcode"); + if (usage) finalChunk.usage = usage; out.push(finalChunk); break; } case "error": { - state.finishReason = "stop"; + state.finishReason = OPENAI_FINISH.STOP; const errVal = event.error ?? event.message ?? "unknown"; const errStr = typeof errVal === "string" ? errVal : JSON.stringify(errVal); out.push(makeChunk(state, { content: `\n\n[CommandCode error: ${errStr}]` })); - out.push(makeChunk(state, {}, "stop")); + out.push(makeChunk(state, {}, OPENAI_FINISH.STOP)); break; } // Silently ignore: start, start-step, reasoning-start, reasoning-end, text-start, text-end, @@ -194,4 +181,4 @@ export function convertCommandCodeToOpenAI(chunk, state) { return out.length ? out : null; } -register(FORMATS.COMMANDCODE, FORMATS.OPENAI, null, convertCommandCodeToOpenAI); +register(FORMATS.COMMANDCODE, FORMATS.OPENAI, null, commandCodeToOpenAIResponse); diff --git a/open-sse/translator/response/cursor-to-openai.js b/open-sse/translator/response/cursor-to-openai.js index b2546918..012abcce 100644 --- a/open-sse/translator/response/cursor-to-openai.js +++ b/open-sse/translator/response/cursor-to-openai.js @@ -10,7 +10,7 @@ import { FORMATS } from "../formats.js"; * Since CursorExecutor.transformProtobufToSSE/JSON already emits OpenAI chunks, * this is a passthrough translator (similar to Kiro pattern) */ -export function convertCursorToOpenAI(chunk, state) { +export function cursorToOpenAIResponse(chunk, state) { if (!chunk) return null; // If chunk is already in OpenAI format (from executor transform), return as-is @@ -27,4 +27,4 @@ export function convertCursorToOpenAI(chunk, state) { return chunk; } -register(FORMATS.CURSOR, FORMATS.OPENAI, null, convertCursorToOpenAI); +register(FORMATS.CURSOR, FORMATS.OPENAI, null, cursorToOpenAIResponse); diff --git a/open-sse/translator/response/gemini-to-openai.js b/open-sse/translator/response/gemini-to-openai.js index 4f90b35b..28e7d39a 100644 --- a/open-sse/translator/response/gemini-to-openai.js +++ b/open-sse/translator/response/gemini-to-openai.js @@ -1,5 +1,33 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; +import { ROLE, OPENAI_BLOCK, OPENAI_FINISH, DEFAULT_IMAGE_MIME } from "../schema/index.js"; +import { buildChunk } from "../concerns/chunk.js"; +import { toOpenAIUsage } from "../concerns/usage.js"; +import { reasoningDelta } from "../concerns/reasoning.js"; +import { encodeDataUri } from "../concerns/image.js"; +import { toOpenAIFinish } from "../concerns/finishReason.js"; + +// Build chunk meta for current gemini state +function chunkMeta(state) { + return { id: `chatcmpl-${state.messageId}`, created: Math.floor(Date.now() / 1000), model: state.model }; +} + +// Build a tool_call chunk from a gemini functionCall part (shared by sig/non-sig branches) +function emitFunctionCall(functionCall, state) { + const rawName = functionCall.name; + // Restore original tool name from mapping (AG cloaking) + const fcName = state.toolNameMap?.get(rawName) || rawName; + const fcArgs = functionCall.args || {}; + const toolCallIndex = state.functionIndex++; + const toolCall = { + id: `${fcName}-${Date.now()}-${toolCallIndex}`, + index: toolCallIndex, + type: OPENAI_BLOCK.FUNCTION, + function: { name: fcName, arguments: JSON.stringify(fcArgs) }, + }; + state.toolCalls.set(toolCallIndex, toolCall); + return buildChunk(chunkMeta(state), { tool_calls: [toolCall] }, null); +} // Convert Gemini response chunk to OpenAI format export function geminiToOpenAIResponse(chunk, state) { @@ -18,17 +46,7 @@ export function geminiToOpenAIResponse(chunk, state) { state.messageId = response.responseId || `msg_${Date.now()}`; state.model = response.modelVersion || "gemini"; state.functionIndex = 0; - results.push({ - id: `chatcmpl-${state.messageId}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: state.model, - choices: [{ - index: 0, - delta: { role: "assistant" }, - finish_reason: null - }] - }); + results.push(buildChunk(chunkMeta(state), { role: ROLE.ASSISTANT }, null)); } // Process parts @@ -43,51 +61,15 @@ export function geminiToOpenAIResponse(chunk, state) { const hasFunctionCall = !!part.functionCall; if (hasTextContent) { - results.push({ - id: `chatcmpl-${state.messageId}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: state.model, - choices: [{ - index: 0, - delta: isThought - ? { reasoning_content: part.text } - : { content: part.text }, - finish_reason: null - }] - }); + results.push(buildChunk( + chunkMeta(state), + isThought ? reasoningDelta(part.text) : { content: part.text }, + null + )); } if (hasFunctionCall) { - const rawName = part.functionCall.name; - // Restore original tool name from mapping (AG cloaking) - const fcName = state.toolNameMap?.get(rawName) || rawName; - const fcArgs = part.functionCall.args || {}; - const toolCallIndex = state.functionIndex++; - - const toolCall = { - id: `${fcName}-${Date.now()}-${toolCallIndex}`, - index: toolCallIndex, - type: "function", - function: { - name: fcName, - arguments: JSON.stringify(fcArgs) - } - }; - - state.toolCalls.set(toolCallIndex, toolCall); - - results.push({ - id: `chatcmpl-${state.messageId}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: state.model, - choices: [{ - index: 0, - delta: { tool_calls: [toolCall] }, - finish_reason: null - }] - }); + results.push(emitFunctionCall(part.functionCall, state)); } continue; } @@ -97,138 +79,49 @@ export function geminiToOpenAIResponse(chunk, state) { // can also stream thought parts without a signature; those must not be // surfaced as normal assistant content in OpenAI-compatible clients. if (part.text !== undefined && part.text !== "") { - results.push({ - id: `chatcmpl-${state.messageId}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: state.model, - choices: [{ - index: 0, - delta: isThought - ? { reasoning_content: part.text } - : { content: part.text }, - finish_reason: null - }] - }); + results.push(buildChunk( + chunkMeta(state), + isThought ? reasoningDelta(part.text) : { content: part.text }, + null + )); } // Function call if (part.functionCall) { - const rawName = part.functionCall.name; - // Restore original tool name from mapping (AG cloaking) - const fcName = state.toolNameMap?.get(rawName) || rawName; - const fcArgs = part.functionCall.args || {}; - const toolCallIndex = state.functionIndex++; - - const toolCall = { - id: `${fcName}-${Date.now()}-${toolCallIndex}`, - index: toolCallIndex, - type: "function", - function: { - name: fcName, - arguments: JSON.stringify(fcArgs) - } - }; - - state.toolCalls.set(toolCallIndex, toolCall); - - results.push({ - id: `chatcmpl-${state.messageId}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: state.model, - choices: [{ - index: 0, - delta: { tool_calls: [toolCall] }, - finish_reason: null - }] - }); + results.push(emitFunctionCall(part.functionCall, state)); } // Inline data (images) const inlineData = part.inlineData || part.inline_data; if (inlineData?.data) { - const mimeType = inlineData.mimeType || inlineData.mime_type || "image/png"; - results.push({ - id: `chatcmpl-${state.messageId}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: state.model, - choices: [{ - index: 0, - delta: { - images: [{ - type: "image_url", - image_url: { url: `data:${mimeType};base64,${inlineData.data}` } - }] - }, - finish_reason: null - }] - }); + const mimeType = inlineData.mimeType || inlineData.mime_type || DEFAULT_IMAGE_MIME; + results.push(buildChunk( + chunkMeta(state), + { + images: [{ + type: OPENAI_BLOCK.IMAGE_URL, + image_url: { url: encodeDataUri(mimeType, inlineData.data) } + }] + }, + null + )); } } } // Usage metadata - extract before finish reason so we can include it const usageMeta = response.usageMetadata || chunk.usageMetadata; - if (usageMeta && typeof usageMeta === "object") { - const cachedTokens = typeof usageMeta.cachedContentTokenCount === "number" ? usageMeta.cachedContentTokenCount : 0; - const promptTokenCountRaw = typeof usageMeta.promptTokenCount === "number" ? usageMeta.promptTokenCount : 0; - const thoughtsTokens = typeof usageMeta.thoughtsTokenCount === "number" ? usageMeta.thoughtsTokenCount : 0; - let candidatesTokens = typeof usageMeta.candidatesTokenCount === "number" ? usageMeta.candidatesTokenCount : 0; - const totalTokens = typeof usageMeta.totalTokenCount === "number" ? usageMeta.totalTokenCount : 0; - - // prompt_tokens = promptTokenCount (includes cached tokens, matching claude-to-openai.js behavior) - const promptTokens = promptTokenCountRaw; - - // Fallback calculation if candidatesTokenCount is 0 but totalTokenCount exists - if (candidatesTokens === 0 && totalTokens > 0) { - candidatesTokens = totalTokens - promptTokenCountRaw - thoughtsTokens; - if (candidatesTokens < 0) candidatesTokens = 0; - } - - // completion_tokens = candidatesTokenCount + thoughtsTokenCount (match Go code) - const completionTokens = candidatesTokens + thoughtsTokens; - - state.usage = { - prompt_tokens: promptTokens, - completion_tokens: completionTokens, - total_tokens: totalTokens - }; - - // Add prompt_tokens_details if cached tokens exist - if (cachedTokens > 0) { - state.usage.prompt_tokens_details = { - cached_tokens: cachedTokens - }; - } - - // Add completion_tokens_details if reasoning tokens exist - if (thoughtsTokens > 0) { - state.usage.completion_tokens_details = { - reasoning_tokens: thoughtsTokens - }; - } - } + const geminiUsage = toOpenAIUsage(usageMeta, "gemini"); + if (geminiUsage) state.usage = geminiUsage; // Finish reason - include usage in final chunk if (candidate.finishReason) { - let finishReason = candidate.finishReason.toLowerCase(); - if (finishReason === "stop" && state.toolCalls.size > 0) { - finishReason = "tool_calls"; + let finishReason = toOpenAIFinish(candidate.finishReason, "gemini"); + if (finishReason === OPENAI_FINISH.STOP && state.toolCalls.size > 0) { + finishReason = OPENAI_FINISH.TOOL_CALLS; } - const finalChunk = { - id: `chatcmpl-${state.messageId}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: state.model, - choices: [{ - index: 0, - delta: {}, - finish_reason: finishReason - }] - }; + const finalChunk = buildChunk(chunkMeta(state), {}, finishReason); // Include usage in final chunk for downstream translators if (state.usage) { diff --git a/open-sse/translator/response/kiro-to-claude.js b/open-sse/translator/response/kiro-to-claude.js new file mode 100644 index 00000000..1c9ece5b --- /dev/null +++ b/open-sse/translator/response/kiro-to-claude.js @@ -0,0 +1,261 @@ +/** + * Kiro → Claude Response Translator (DIRECT route, no OpenAI pivot) + * + * IMPORTANT: This translator does NOT receive raw Kiro AWS-EventStream frames. + * KiroExecutor.transformEventStreamToSSE() (open-sse/executors/kiro.js) already + * parses the binary EventStream and emits OpenAI-shaped + * `chat.completion.chunk` objects. So the chunks arriving here are OpenAI + * streaming chunks, and our job is OpenAI-chunk → Claude SSE events — the same + * transformation openai-to-claude.js performs. We re-implement it here so the + * direct `kiro:claude` route is self-contained and lossless (reasoning_content + * → thinking blocks, tool_calls → tool_use blocks, usage → message_delta). + * + * Registered on the direct route by ../index.js; reached only when source + * format is Claude and target is Kiro. + */ +import { register } from "../index.js"; +import { FORMATS } from "../formats.js"; + +function stopThinkingBlock(state, results) { + if (!state.thinkingBlockStarted) return; + results.push({ type: "content_block_stop", index: state.thinkingBlockIndex }); + state.thinkingBlockStarted = false; +} + +function stopTextBlock(state, results) { + if (!state.textBlockStarted || state.textBlockClosed) return; + state.textBlockClosed = true; + results.push({ type: "content_block_stop", index: state.textBlockIndex }); + state.textBlockStarted = false; +} + +function convertFinishReason(reason) { + switch (reason) { + case "stop": + return "end_turn"; + case "length": + return "max_tokens"; + case "tool_calls": + return "tool_use"; + default: + return "end_turn"; + } +} + +/** + * Convert one OpenAI-format chunk (from KiroExecutor) into Claude SSE events. + * Returns an array of Claude events, or null when the chunk yields nothing. + */ +export function kiroToClaudeResponse(chunk, state) { + // KiroExecutor emits chat.completion.chunk objects; tolerate string chunks + // by attempting a parse (defensive — the direct path is always objects). + let data = chunk; + if (typeof chunk === "string") { + const trimmed = chunk.trim(); + if (!trimmed || trimmed === "[DONE]") return null; + try { + data = JSON.parse(trimmed.startsWith("data:") ? trimmed.slice(5).trim() : trimmed); + } catch { + return null; + } + } + + if (!data || !data.choices?.[0]) return null; + + const results = []; + const choice = data.choices[0]; + const delta = choice.delta || {}; + + // Track usage if present on the chunk. + if (data.usage && typeof data.usage === "object") { + const promptTokens = + typeof data.usage.prompt_tokens === "number" ? data.usage.prompt_tokens : 0; + const outputTokens = + typeof data.usage.completion_tokens === "number" + ? data.usage.completion_tokens + : 0; + state.usage = { input_tokens: promptTokens, output_tokens: outputTokens }; + } + + // First chunk → emit message_start. + if (!state.messageStartSent) { + state.messageStartSent = true; + state.messageId = + (typeof data.id === "string" && data.id.replace("chatcmpl-", "")) || + `msg_${Date.now()}`; + state.model = data.model || "kiro"; + state.nextBlockIndex = 0; + results.push({ + type: "message_start", + message: { + id: state.messageId, + type: "message", + role: "assistant", + model: state.model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }, + }); + } + + // Reasoning / thinking content (Kiro reasoningContentEvent → reasoning_content). + const reasoningContent = delta.reasoning_content || delta.reasoning; + if (reasoningContent) { + stopTextBlock(state, results); + if (!state.thinkingBlockStarted) { + state.thinkingBlockIndex = state.nextBlockIndex++; + state.thinkingBlockStarted = true; + results.push({ + type: "content_block_start", + index: state.thinkingBlockIndex, + content_block: { type: "thinking", thinking: "" }, + }); + } + results.push({ + type: "content_block_delta", + index: state.thinkingBlockIndex, + delta: { type: "thinking_delta", thinking: reasoningContent }, + }); + } + + // Regular text content. + if (delta.content) { + stopThinkingBlock(state, results); + if (!state.textBlockStarted) { + state.textBlockIndex = state.nextBlockIndex++; + state.textBlockStarted = true; + state.textBlockClosed = false; + results.push({ + type: "content_block_start", + index: state.textBlockIndex, + content_block: { type: "text", text: "" }, + }); + } + results.push({ + type: "content_block_delta", + index: state.textBlockIndex, + delta: { type: "text_delta", text: delta.content }, + }); + } + + // Tool calls. + if (delta.tool_calls) { + if (!state.toolCalls) state.toolCalls = new Map(); + if (!state.toolArgBuffers) state.toolArgBuffers = new Map(); + for (const tc of delta.tool_calls) { + const idx = tc.index ?? 0; + if (tc.id) { + stopThinkingBlock(state, results); + stopTextBlock(state, results); + const toolBlockIndex = state.nextBlockIndex++; + state.toolCalls.set(idx, { + id: tc.id, + name: tc.function?.name || "", + blockIndex: toolBlockIndex, + }); + results.push({ + type: "content_block_start", + index: toolBlockIndex, + content_block: { + type: "tool_use", + id: tc.id, + name: tc.function?.name || "", + input: {}, + }, + }); + } + if (tc.function?.arguments) { + const toolInfo = state.toolCalls.get(idx); + if (toolInfo) { + state.toolArgBuffers.set( + idx, + (state.toolArgBuffers.get(idx) || "") + tc.function.arguments + ); + } + } + } + } + + // Finish. + if (choice.finish_reason) { + stopThinkingBlock(state, results); + stopTextBlock(state, results); + + if (state.toolCalls) { + for (const [idx, toolInfo] of state.toolCalls) { + const buffered = state.toolArgBuffers?.get(idx); + if (buffered) { + results.push({ + type: "content_block_delta", + index: toolInfo.blockIndex, + delta: { type: "input_json_delta", partial_json: buffered }, + }); + } + results.push({ type: "content_block_stop", index: toolInfo.blockIndex }); + } + } + + state.finishReason = choice.finish_reason; + const finalUsage = state.usage || { input_tokens: 0, output_tokens: 0 }; + results.push({ + type: "message_delta", + delta: { stop_reason: convertFinishReason(choice.finish_reason) }, + usage: finalUsage, + }); + results.push({ type: "message_stop" }); + } + + return results.length > 0 ? results : null; +} + +/** + * Non-streaming Kiro → Claude. KiroExecutor only produces a stream, so this is + * a defensive helper for any non-streaming caller that hands us an aggregated + * OpenAI-shaped completion. + */ +export function kiroToClaudeNonStreaming(data) { + const content = []; + const choice = data?.choices?.[0]; + const message = choice?.message || {}; + + if (message.content) { + content.push({ type: "text", text: message.content }); + } + if (Array.isArray(message.tool_calls)) { + for (const tc of message.tool_calls) { + let input = {}; + try { + input = + typeof tc.function?.arguments === "string" + ? JSON.parse(tc.function.arguments) + : tc.function?.arguments || {}; + } catch { + input = {}; + } + content.push({ + type: "tool_use", + id: tc.id || `toolu_${Date.now()}`, + name: tc.function?.name || "", + input, + }); + } + } + + const usage = data?.usage || {}; + return { + id: `msg_${Date.now()}`, + type: "message", + role: "assistant", + content, + model: data?.model || "kiro", + stop_reason: convertFinishReason(choice?.finish_reason || "stop"), + usage: { + input_tokens: usage.prompt_tokens || 0, + output_tokens: usage.completion_tokens || 0, + }, + }; +} + +register(FORMATS.KIRO, FORMATS.CLAUDE, null, kiroToClaudeResponse); diff --git a/open-sse/translator/response/kiro-to-openai.js b/open-sse/translator/response/kiro-to-openai.js index a1a15b69..7059a851 100644 --- a/open-sse/translator/response/kiro-to-openai.js +++ b/open-sse/translator/response/kiro-to-openai.js @@ -4,12 +4,23 @@ */ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; +import { ROLE, OPENAI_BLOCK } from "../schema/index.js"; +import { buildChunk } from "../concerns/chunk.js"; +import { toOpenAIUsage } from "../concerns/usage.js"; +import { fallbackToolCallId } from "../concerns/toolCall.js"; +import { reasoningDelta } from "../concerns/reasoning.js"; +import { toOpenAIFinish } from "../concerns/finishReason.js"; + +// Build chunk meta for current kiro state +function chunkMeta(state) { + return { id: state.responseId, created: state.created, model: state.model || "kiro" }; +} /** * Parse Kiro SSE event and convert to OpenAI format * Kiro events: assistantResponseEvent, codeEvent, supplementaryWebLinksEvent, etc. */ -export function convertKiroToOpenAI(chunk, state) { +export function kiroToOpenAIResponse(chunk, state) { if (!chunk) return null; @@ -66,20 +77,10 @@ export function convertKiroToOpenAI(chunk, state) { const content = data.assistantResponseEvent?.content || data.content || ""; if (!content) return null; - const openaiChunk = { - id: state.responseId, - object: "chat.completion.chunk", - created: state.created, - model: state.model || "kiro", - choices: [{ - index: 0, - delta: { - ...(state.chunkIndex === 0 ? { role: "assistant" } : {}), - content: content - }, - finish_reason: null - }] - }; + const openaiChunk = buildChunk(chunkMeta(state), { + ...(state.chunkIndex === 0 ? { role: ROLE.ASSISTANT } : {}), + content: content + }, null); state.chunkIndex++; return openaiChunk; @@ -97,20 +98,7 @@ export function convertKiroToOpenAI(chunk, state) { : (reasoning.text || reasoning.content || data.content || ""); if (!content) return null; - const openaiChunk = { - id: state.responseId, - object: "chat.completion.chunk", - created: state.created, - model: state.model || "kiro", - choices: [{ - index: 0, - delta: { - ...(state.chunkIndex === 0 ? { role: "assistant" } : {}), - reasoning_content: content - }, - finish_reason: null - }] - }; + const openaiChunk = buildChunk(chunkMeta(state), reasoningDelta(content, state.chunkIndex === 0), null); state.chunkIndex++; return openaiChunk; @@ -118,33 +106,24 @@ export function convertKiroToOpenAI(chunk, state) { // Handle tool use events if (eventType === "toolUseEvent" || data.toolUseEvent) { + state.hadToolUse = true; const toolUse = data.toolUseEvent || data; - const toolCallId = toolUse.toolUseId || `call_${Date.now()}`; + const toolCallId = toolUse.toolUseId || fallbackToolCallId(); const toolName = toolUse.name || ""; const toolInput = toolUse.input || {}; - const openaiChunk = { - id: state.responseId, - object: "chat.completion.chunk", - created: state.created, - model: state.model || "kiro", - choices: [{ + const openaiChunk = buildChunk(chunkMeta(state), { + ...(state.chunkIndex === 0 ? { role: ROLE.ASSISTANT } : {}), + tool_calls: [{ index: 0, - delta: { - ...(state.chunkIndex === 0 ? { role: "assistant" } : {}), - tool_calls: [{ - index: 0, - id: toolCallId, - type: "function", - function: { - name: toolName, - arguments: JSON.stringify(toolInput) - } - }] - }, - finish_reason: null + id: toolCallId, + type: OPENAI_BLOCK.FUNCTION, + function: { + name: toolName, + arguments: JSON.stringify(toolInput) + } }] - }; + }, null); state.chunkIndex++; return openaiChunk; @@ -152,19 +131,11 @@ export function convertKiroToOpenAI(chunk, state) { // Handle completion/done events if (eventType === "messageStopEvent" || eventType === "done" || data.messageStopEvent) { - state.finishReason = "stop"; // Mark for usage injection in stream.js - - const openaiChunk = { - id: state.responseId, - object: "chat.completion.chunk", - created: state.created, - model: state.model || "kiro", - choices: [{ - index: 0, - delta: {}, - finish_reason: "stop" - }] - }; + // tool_calls when a tool was used this turn, else stop (kiro upstream has no explicit reason) + const finishReason = toOpenAIFinish(state.hadToolUse ? "tool_use" : "stop", "kiro"); + state.finishReason = finishReason; // Mark for usage injection in stream.js + + const openaiChunk = buildChunk(chunkMeta(state), {}, finishReason); // Include usage in final chunk if available if (state.usage && typeof state.usage === "object") { @@ -176,14 +147,8 @@ export function convertKiroToOpenAI(chunk, state) { // Handle usage events if (eventType === "usageEvent" || data.usageEvent) { - const usage = data.usageEvent || data; - if (usage && typeof usage === 'object') { - state.usage = { - prompt_tokens: usage.inputTokens || 0, - completion_tokens: usage.outputTokens || 0, - total_tokens: (usage.inputTokens || 0) + (usage.outputTokens || 0) - }; - } + const usage = toOpenAIUsage(data.usageEvent || data, "kiro"); + if (usage) state.usage = usage; return null; } @@ -192,4 +157,4 @@ export function convertKiroToOpenAI(chunk, state) { } // Register translator -register(FORMATS.KIRO, FORMATS.OPENAI, null, convertKiroToOpenAI); +register(FORMATS.KIRO, FORMATS.OPENAI, null, kiroToOpenAIResponse); diff --git a/open-sse/translator/response/ollama-to-openai.js b/open-sse/translator/response/ollama-to-openai.js index 9cd5b888..f0a20042 100644 --- a/open-sse/translator/response/ollama-to-openai.js +++ b/open-sse/translator/response/ollama-to-openai.js @@ -1,5 +1,10 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; +import { ROLE, OPENAI_BLOCK, OPENAI_FINISH } from "../schema/index.js"; +import { buildChunk } from "../concerns/chunk.js"; +import { toOpenAIUsage } from "../concerns/usage.js"; +import { fallbackToolCallId } from "../concerns/toolCall.js"; +import { toOpenAIFinish } from "../concerns/finishReason.js"; /** * Convert Ollama NDJSON response to OpenAI SSE format @@ -12,7 +17,7 @@ import { FORMATS } from "../formats.js"; * {"id": "...", "object": "chat.completion.chunk", "created": 123, "model": "...", * "choices": [{"index": 0, "delta": {"content": "..."}, "finish_reason": null}]} */ -export function ollamaToOpenAI(chunk, state) { +export function ollamaToOpenAIResponse(chunk, state) { if (!chunk || typeof chunk !== "object") return null; // Initialize state on first chunk @@ -30,24 +35,15 @@ export function ollamaToOpenAI(chunk, state) { if (chunk.done) { const usage = extractUsage(chunk); - // Determine finish_reason based on done_reason and previous tool_calls - let finishReason = "stop"; - if (chunk.done_reason === "tool_calls" || state.hadToolCalls) { - finishReason = "tool_calls"; + // Determine finish_reason: map upstream done_reason, override to tool_calls if tools used + let finishReason = toOpenAIFinish(chunk.done_reason, "ollama"); + if (chunk.done_reason === OPENAI_FINISH.TOOL_CALLS || state.hadToolCalls) { + finishReason = OPENAI_FINISH.TOOL_CALLS; } - return { - id: id, - object: "chat.completion.chunk", - created: created, - model: model, - choices: [{ - index: 0, - delta: {}, - finish_reason: finishReason - }], - usage: usage - }; + const doneChunk = buildChunk({ id, created, model }, {}, finishReason); + doneChunk.usage = usage; + return doneChunk; } // Content chunk @@ -79,28 +75,14 @@ export function ollamaToOpenAI(chunk, state) { delta.tool_calls = convertToolCalls(toolCalls); } - return { - id: id, - object: "chat.completion.chunk", - created: created, - model: model, - choices: [{ - index: 0, - delta: delta, - finish_reason: null - }] - }; + return buildChunk({ id, created, model }, delta, null); } /** * Extract usage stats from Ollama response */ function extractUsage(ollamaChunk) { - return { - prompt_tokens: ollamaChunk.prompt_eval_count || 0, - completion_tokens: ollamaChunk.eval_count || 0, - total_tokens: (ollamaChunk.prompt_eval_count || 0) + (ollamaChunk.eval_count || 0) - }; + return toOpenAIUsage(ollamaChunk, "ollama"); } /** @@ -109,8 +91,8 @@ function extractUsage(ollamaChunk) { function convertToolCalls(toolCalls) { return toolCalls.map((tc, i) => ({ index: tc.function?.index ?? i, - id: tc.id || `call_${i}_${Date.now()}`, - type: "function", + id: tc.id || fallbackToolCallId(i), + type: OPENAI_BLOCK.FUNCTION, function: { name: tc.function?.name || "", arguments: typeof tc.function?.arguments === "string" @@ -129,14 +111,14 @@ export function ollamaBodyToOpenAI(body) { const thinking = msg.thinking || ""; const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : []; - const message = { role: "assistant" }; + const message = { role: ROLE.ASSISTANT }; if (content) message.content = content; if (thinking) message.reasoning_content = thinking; if (toolCalls.length > 0) message.tool_calls = convertToolCalls(toolCalls); if (!message.content && !message.tool_calls) message.content = ""; - let finishReason = body.done_reason || "stop"; - if (toolCalls.length > 0) finishReason = "tool_calls"; + let finishReason = toOpenAIFinish(body.done_reason, "ollama"); + if (toolCalls.length > 0) finishReason = OPENAI_FINISH.TOOL_CALLS; return { id: `chatcmpl-${Date.now()}`, @@ -149,4 +131,4 @@ export function ollamaBodyToOpenAI(body) { } // Register translator -register(FORMATS.OLLAMA, FORMATS.OPENAI, null, ollamaToOpenAI); +register(FORMATS.OLLAMA, FORMATS.OPENAI, null, ollamaToOpenAIResponse); diff --git a/open-sse/translator/response/openai-responses.js b/open-sse/translator/response/openai-responses.js index e500e4c7..b9336785 100644 --- a/open-sse/translator/response/openai-responses.js +++ b/open-sse/translator/response/openai-responses.js @@ -4,6 +4,11 @@ */ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; +import { buildChunk } from "../concerns/chunk.js"; +import { buildUsage } from "../concerns/usage.js"; +import { fallbackToolCallId } from "../concerns/toolCall.js"; +import { reasoningDelta, extractReasoningText } from "../concerns/reasoning.js"; +import { ROLE, OPENAI_BLOCK, RESPONSES_ITEM, OPENAI_FINISH, MODEL_FALLBACK } from "../schema/index.js"; /** * Translate OpenAI chunk to Responses API events @@ -57,10 +62,11 @@ export function openaiToOpenAIResponsesResponse(chunk, state) { }); } - // Handle reasoning_content - if (delta.reasoning_content) { + // Handle reasoning across vendor shapes (reasoning_content / reasoning / reasoning_details) + const reasoningText = extractReasoningText(delta); + if (reasoningText) { startReasoning(state, emit, idx); - emitReasoningDelta(state, emit, delta.reasoning_content); + emitReasoningDelta(state, emit, reasoningText); } // Handle text content @@ -121,7 +127,7 @@ function startReasoning(state, emit, idx) { emit("response.output_item.added", { type: "response.output_item.added", output_index: idx, - item: { id: state.reasoningId, type: "reasoning", summary: [] } + item: { id: state.reasoningId, type: RESPONSES_ITEM.REASONING, summary: [] } }); emit("response.reasoning_summary_part.added", { @@ -129,7 +135,7 @@ function startReasoning(state, emit, idx) { item_id: state.reasoningId, output_index: idx, summary_index: 0, - part: { type: "summary_text", text: "" } + part: { type: RESPONSES_ITEM.SUMMARY_TEXT, text: "" } }); state.reasoningPartAdded = true; } @@ -164,7 +170,7 @@ function closeReasoning(state, emit) { item_id: state.reasoningId, output_index: state.reasoningIndex, summary_index: 0, - part: { type: "summary_text", text: state.reasoningBuf } + part: { type: RESPONSES_ITEM.SUMMARY_TEXT, text: state.reasoningBuf } }); emit("response.output_item.done", { @@ -172,8 +178,8 @@ function closeReasoning(state, emit) { output_index: state.reasoningIndex, item: { id: state.reasoningId, - type: "reasoning", - summary: [{ type: "summary_text", text: state.reasoningBuf }] + type: RESPONSES_ITEM.REASONING, + summary: [{ type: RESPONSES_ITEM.SUMMARY_TEXT, text: state.reasoningBuf }] } }); } @@ -187,7 +193,7 @@ function emitTextContent(state, emit, idx, content) { emit("response.output_item.added", { type: "response.output_item.added", output_index: idx, - item: { id: msgId, type: "message", content: [], role: "assistant" } + item: { id: msgId, type: RESPONSES_ITEM.MESSAGE, content: [], role: ROLE.ASSISTANT } }); } @@ -199,7 +205,7 @@ function emitTextContent(state, emit, idx, content) { item_id: `msg_${state.responseId}_${idx}`, output_index: idx, content_index: 0, - part: { type: "output_text", annotations: [], logprobs: [], text: "" } + part: { type: RESPONSES_ITEM.OUTPUT_TEXT, annotations: [], logprobs: [], text: "" } }); } @@ -236,7 +242,7 @@ function closeMessage(state, emit, idx) { item_id: msgId, output_index: parseInt(idx), content_index: 0, - part: { type: "output_text", annotations: [], logprobs: [], text: fullText } + part: { type: RESPONSES_ITEM.OUTPUT_TEXT, annotations: [], logprobs: [], text: fullText } }); emit("response.output_item.done", { @@ -244,9 +250,9 @@ function closeMessage(state, emit, idx) { output_index: parseInt(idx), item: { id: msgId, - type: "message", - content: [{ type: "output_text", annotations: [], logprobs: [], text: fullText }], - role: "assistant" + type: RESPONSES_ITEM.MESSAGE, + content: [{ type: RESPONSES_ITEM.OUTPUT_TEXT, annotations: [], logprobs: [], text: fullText }], + role: ROLE.ASSISTANT } }); } @@ -267,7 +273,7 @@ function emitToolCall(state, emit, tc) { output_index: tcIdx, item: { id: `fc_${newCallId}`, - type: "function_call", + type: RESPONSES_ITEM.FUNCTION_CALL, arguments: "", call_id: newCallId, name: state.funcNames[tcIdx] || "" @@ -308,7 +314,7 @@ function closeToolCall(state, emit, idx) { output_index: parseInt(idx), item: { id: `fc_${callId}`, - type: "function_call", + type: RESPONSES_ITEM.FUNCTION_CALL, arguments: args, call_id: callId, name: state.funcNames[idx] || "" @@ -359,8 +365,8 @@ function flushEvents(state) { // can still finalize as tool_calls even if the tool call was emitted before stream end. function computeFinishReason(state) { return state.toolCallIndex > 0 || state.currentToolCallId - ? "tool_calls" - : "stop"; + ? OPENAI_FINISH.TOOL_CALLS + : OPENAI_FINISH.STOP; } /** @@ -377,17 +383,11 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { state.finishReasonSent = true; state.finishReason = finishReason; - const finalChunk = { - id: state.chatId || `chatcmpl-${Date.now()}`, - object: "chat.completion.chunk", - created: state.created || Math.floor(Date.now() / 1000), - model: state.model || "unknown", - choices: [{ - index: 0, - delta: {}, - finish_reason: finishReason - }] - }; + const finalChunk = buildChunk( + { id: state.chatId || `chatcmpl-${Date.now()}`, created: state.created || Math.floor(Date.now() / 1000), model: state.model || MODEL_FALLBACK }, + {}, + finishReason + ); if (state.usage && typeof state.usage === "object") { finalChunk.usage = state.usage; @@ -414,17 +414,10 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { const delta = data.delta || ""; if (!delta) return null; - return { - id: state.chatId, - object: "chat.completion.chunk", - created: state.created, - model: state.model || "unknown", - choices: [{ - index: 0, - delta: { content: delta }, - finish_reason: null - }] - }; + return buildChunk( + { id: state.chatId, created: state.created, model: state.model || MODEL_FALLBACK }, + { content: delta } + ); } // Text content done (ignore, we handle via delta) @@ -433,31 +426,21 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { } // Function call started (standard function_call or custom_tool_call) - if (eventType === "response.output_item.added" && (data.item?.type === "function_call" || data.item?.type === "custom_tool_call")) { + if (eventType === "response.output_item.added" && (data.item?.type === RESPONSES_ITEM.FUNCTION_CALL || data.item?.type === "custom_tool_call")) { const item = data.item; - state.currentToolCallId = item.call_id || `call_${Date.now()}`; + state.currentToolCallId = item.call_id || fallbackToolCallId(); - return { - id: state.chatId, - object: "chat.completion.chunk", - created: state.created, - model: state.model || "unknown", - choices: [{ - index: 0, - delta: { - tool_calls: [{ - index: state.toolCallIndex, - id: state.currentToolCallId, - type: "function", - function: { - name: item.name || "", - arguments: "" - } - }] - }, - finish_reason: null - }] - }; + return buildChunk( + { id: state.chatId, created: state.created, model: state.model || MODEL_FALLBACK }, + { + tool_calls: [{ + index: state.toolCallIndex, + id: state.currentToolCallId, + type: OPENAI_BLOCK.FUNCTION, + function: { name: item.name || "", arguments: "" } + }] + } + ); } // Function call arguments delta (standard or custom_tool_call variant) @@ -465,26 +448,14 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { const argsDelta = data.delta || ""; if (!argsDelta) return null; - return { - id: state.chatId, - object: "chat.completion.chunk", - created: state.created, - model: state.model || "unknown", - choices: [{ - index: 0, - delta: { - tool_calls: [{ - index: state.toolCallIndex, - function: { arguments: argsDelta } - }] - }, - finish_reason: null - }] - }; + return buildChunk( + { id: state.chatId, created: state.created, model: state.model || MODEL_FALLBACK }, + { tool_calls: [{ index: state.toolCallIndex, function: { arguments: argsDelta } }] } + ); } // Function call done (standard or custom_tool_call variant) - if (eventType === "response.output_item.done" && (data.item?.type === "function_call" || data.item?.type === "custom_tool_call")) { + if (eventType === "response.output_item.done" && (data.item?.type === RESPONSES_ITEM.FUNCTION_CALL || data.item?.type === "custom_tool_call")) { state.toolCallIndex++; return null; } @@ -500,18 +471,7 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { // Cache info is in input_tokens_details.cached_tokens const cacheReadTokens = responseUsage.input_tokens_details?.cached_tokens || responseUsage.cache_read_input_tokens || 0; - state.usage = { - prompt_tokens: inputTokens, - completion_tokens: outputTokens, - total_tokens: inputTokens + outputTokens - }; - - // Add prompt_tokens_details if cache tokens exist - if (cacheReadTokens > 0) { - state.usage.prompt_tokens_details = { - cached_tokens: cacheReadTokens - }; - } + state.usage = buildUsage({ promptTokens: inputTokens, completionTokens: outputTokens, totalTokens: inputTokens + outputTokens, cachedTokens: cacheReadTokens }); } if (!state.finishReasonSent) { @@ -520,18 +480,12 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { state.finishReasonSent = true; state.finishReason = finishReason; // Mark for usage injection in stream.js - const finalChunk = { - id: state.chatId, - object: "chat.completion.chunk", - created: state.created, - model: state.model || "unknown", - choices: [{ - index: 0, - delta: {}, - finish_reason: finishReason - }] - }; - + const finalChunk = buildChunk( + { id: state.chatId, created: state.created, model: state.model || MODEL_FALLBACK }, + {}, + finishReason + ); + // Include usage in final chunk if available if (state.usage && typeof state.usage === "object") { finalChunk.usage = state.usage; @@ -553,17 +507,11 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { state.finishReasonSent = true; // Surface the error as an OpenAI-compatible error chunk - return { - id: state.chatId || `chatcmpl-${Date.now()}`, - object: "chat.completion.chunk", - created: state.created || Math.floor(Date.now() / 1000), - model: state.model || "unknown", - choices: [{ - index: 0, - delta: { content: `[Error] ${error.message || JSON.stringify(error)}` }, - finish_reason: "stop" - }] - }; + return buildChunk( + { id: state.chatId || `chatcmpl-${Date.now()}`, created: state.created || Math.floor(Date.now() / 1000), model: state.model || MODEL_FALLBACK }, + { content: `[Error] ${error.message || JSON.stringify(error)}` }, + OPENAI_FINISH.STOP + ); } return null; } @@ -572,13 +520,10 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { if (eventType === "response.reasoning_summary_text.delta") { const delta = data.delta || ""; if (!delta) return null; - return { - id: state.chatId, - object: "chat.completion.chunk", - created: state.created, - model: state.model || "unknown", - choices: [{ index: 0, delta: { reasoning_content: delta }, finish_reason: null }] - }; + return buildChunk( + { id: state.chatId, created: state.created, model: state.model || MODEL_FALLBACK }, + reasoningDelta(delta) + ); } // Ignore other events diff --git a/open-sse/translator/response/openai-to-antigravity.js b/open-sse/translator/response/openai-to-antigravity.js index 8e8d98c4..b0b360a0 100644 --- a/open-sse/translator/response/openai-to-antigravity.js +++ b/open-sse/translator/response/openai-to-antigravity.js @@ -1,5 +1,6 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; +import { GEMINI_ROLE, OPENAI_FINISH, GEMINI_FINISH } from "../schema/index.js"; // Convert OpenAI SSE chunk to Antigravity SSE format // Real Antigravity format: @@ -79,17 +80,17 @@ export function openaiToAntigravityResponse(chunk, state) { } // Build candidate - const candidate = { content: { role: "model", parts } }; + const candidate = { content: { role: GEMINI_ROLE.MODEL, parts } }; // Finish reason mapping if (finishReason) { const reasonMap = { - "stop": "STOP", - "length": "MAX_TOKENS", - "tool_calls": "STOP", - "content_filter": "SAFETY" + [OPENAI_FINISH.STOP]: GEMINI_FINISH.STOP, + [OPENAI_FINISH.LENGTH]: GEMINI_FINISH.MAX_TOKENS, + [OPENAI_FINISH.TOOL_CALLS]: GEMINI_FINISH.STOP, + [OPENAI_FINISH.CONTENT_FILTER]: GEMINI_FINISH.SAFETY }; - candidate.finishReason = reasonMap[finishReason] || "STOP"; + candidate.finishReason = reasonMap[finishReason] || GEMINI_FINISH.STOP; } // Build response diff --git a/open-sse/translator/response/openai-to-claude.js b/open-sse/translator/response/openai-to-claude.js index 6a46551d..e771c154 100644 --- a/open-sse/translator/response/openai-to-claude.js +++ b/open-sse/translator/response/openai-to-claude.js @@ -1,7 +1,13 @@ import { register } from "../index.js"; import { FORMATS } from "../formats.js"; +import { ROLE, CLAUDE_BLOCK, MODEL_FALLBACK } from "../schema/index.js"; +import { fromOpenAIFinish } from "../concerns/finishReason.js"; +import { extractReasoningText } from "../concerns/reasoning.js"; -// Prefix for Claude OAuth tool names (must match request translator) +// Legacy "proxy_" prefix used by older request translators. Response strips it +// defensively so tool names from such turns resolve back (e.g. proxy_Read → Read +// for arg sanitization). Current request translator emits no prefix ("") — strip +// is then a no-op. Kept intentionally; do NOT couple to request's empty prefix. const CLAUDE_OAUTH_TOOL_PREFIX = "proxy_"; // Sanitize tool call arguments to fix bad params from non-Anthropic models @@ -112,14 +118,14 @@ export function openaiToClaudeResponse(chunk, state) { chunk.extend_fields?.traceId || `msg_${Date.now()}`; } - state.model = chunk.model || "unknown"; + state.model = chunk.model || MODEL_FALLBACK; state.nextBlockIndex = 0; results.push({ type: "message_start", message: { id: state.messageId, type: "message", - role: "assistant", + role: ROLE.ASSISTANT, model: state.model, content: [], stop_reason: null, @@ -129,8 +135,8 @@ export function openaiToClaudeResponse(chunk, state) { }); } - // Handle reasoning_content (thinking) - GLM, DeepSeek, etc. - const reasoningContent = delta?.reasoning_content || delta?.reasoning; + // Handle reasoning (thinking) across vendor shapes - GLM/DeepSeek/Qwen/MiniMax/etc. + const reasoningContent = extractReasoningText(delta); if (reasoningContent) { stopTextBlock(state, results); @@ -140,7 +146,7 @@ export function openaiToClaudeResponse(chunk, state) { results.push({ type: "content_block_start", index: state.thinkingBlockIndex, - content_block: { type: "thinking", thinking: "" } + content_block: { type: CLAUDE_BLOCK.THINKING, thinking: "" } }); } @@ -162,7 +168,7 @@ export function openaiToClaudeResponse(chunk, state) { results.push({ type: "content_block_start", index: state.textBlockIndex, - content_block: { type: "text", text: "" } + content_block: { type: CLAUDE_BLOCK.TEXT, text: "" } }); } @@ -195,7 +201,7 @@ export function openaiToClaudeResponse(chunk, state) { type: "content_block_start", index: toolBlockIndex, content_block: { - type: "tool_use", + type: CLAUDE_BLOCK.TOOL_USE, id: tc.id, name: toolName, input: {} @@ -252,15 +258,7 @@ export function openaiToClaudeResponse(chunk, state) { return results.length > 0 ? results : null; } -// Convert OpenAI finish_reason to Claude stop_reason -function convertFinishReason(reason) { - switch (reason) { - case "stop": return "end_turn"; - case "length": return "max_tokens"; - case "tool_calls": return "tool_use"; - default: return "end_turn"; - } -} +const convertFinishReason = (reason) => fromOpenAIFinish(reason, "claude"); // Register register(FORMATS.OPENAI, FORMATS.CLAUDE, null, openaiToClaudeResponse); diff --git a/open-sse/translator/schema/blocks.js b/open-sse/translator/schema/blocks.js new file mode 100644 index 00000000..61c25645 --- /dev/null +++ b/open-sse/translator/schema/blocks.js @@ -0,0 +1,43 @@ +// Content-block "type" discriminators — fixed per format. Pure data (no logic). + +// OpenAI chat content blocks + tool_call wrapper. +export const OPENAI_BLOCK = { + TEXT: "text", + IMAGE_URL: "image_url", + IMAGE: "image", + INPUT_AUDIO: "input_audio", + AUDIO_URL: "audio_url", + FILE: "file", + FUNCTION: "function", +}; + +// Claude content blocks. +export const CLAUDE_BLOCK = { + TEXT: "text", + IMAGE: "image", + DOCUMENT: "document", + TOOL_USE: "tool_use", + TOOL_RESULT: "tool_result", + THINKING: "thinking", + REDACTED_THINKING: "redacted_thinking", +}; + +// OpenAI Responses API item types. +export const RESPONSES_ITEM = { + MESSAGE: "message", + FUNCTION_CALL: "function_call", + FUNCTION_CALL_OUTPUT: "function_call_output", + REASONING: "reasoning", + OUTPUT_TEXT: "output_text", + INPUT_TEXT: "input_text", + INPUT_IMAGE: "input_image", + SUMMARY_TEXT: "summary_text", +}; + +// Valid OpenAI block types (used by filterToOpenAIFormat). +export const VALID_OPENAI_CONTENT_TYPES = [ + OPENAI_BLOCK.TEXT, OPENAI_BLOCK.IMAGE_URL, OPENAI_BLOCK.IMAGE, OPENAI_BLOCK.INPUT_AUDIO, OPENAI_BLOCK.AUDIO_URL, OPENAI_BLOCK.FILE, +]; +export const VALID_OPENAI_MESSAGE_TYPES = [ + OPENAI_BLOCK.TEXT, OPENAI_BLOCK.IMAGE_URL, OPENAI_BLOCK.IMAGE, "tool_calls", CLAUDE_BLOCK.TOOL_RESULT, +]; diff --git a/open-sse/translator/schema/defaults.js b/open-sse/translator/schema/defaults.js new file mode 100644 index 00000000..9601a473 --- /dev/null +++ b/open-sse/translator/schema/defaults.js @@ -0,0 +1,7 @@ +// Shared translator default values (magic strings used across multiple translators). + +// Fallback model id when upstream chunk omits one. +export const MODEL_FALLBACK = "unknown"; + +// Default image mime when source omits it (base64 blobs without a declared type). +export const DEFAULT_IMAGE_MIME = "image/png"; diff --git a/open-sse/translator/schema/finishReasons.js b/open-sse/translator/schema/finishReasons.js new file mode 100644 index 00000000..73535dae --- /dev/null +++ b/open-sse/translator/schema/finishReasons.js @@ -0,0 +1,27 @@ +// Finish/stop reason enums. Pure data — mapping LOGIC lives in concerns/finishReason.js. + +// OpenAI finish_reason values (the hub format; shared across all response translators). +export const OPENAI_FINISH = { + STOP: "stop", + LENGTH: "length", + TOOL_CALLS: "tool_calls", + CONTENT_FILTER: "content_filter", +}; + +// Claude stop_reason values. +export const CLAUDE_STOP = { + END_TURN: "end_turn", + MAX_TOKENS: "max_tokens", + TOOL_USE: "tool_use", + STOP_SEQUENCE: "stop_sequence", +}; + +// Gemini finishReason values. +export const GEMINI_FINISH = { + STOP: "STOP", + MAX_TOKENS: "MAX_TOKENS", + SAFETY: "SAFETY", + RECITATION: "RECITATION", + BLOCKLIST: "BLOCKLIST", + PROHIBITED_CONTENT: "PROHIBITED_CONTENT", +}; diff --git a/open-sse/translator/schema/index.js b/open-sse/translator/schema/index.js new file mode 100644 index 00000000..3fada8c9 --- /dev/null +++ b/open-sse/translator/schema/index.js @@ -0,0 +1,8 @@ +// Translator schema barrel — pure data enums (roles, blocks). No logic here. +export { ROLE, GEMINI_ROLE } from "./roles.js"; +export { + OPENAI_BLOCK, CLAUDE_BLOCK, RESPONSES_ITEM, + VALID_OPENAI_CONTENT_TYPES, VALID_OPENAI_MESSAGE_TYPES, +} from "./blocks.js"; +export { OPENAI_FINISH, CLAUDE_STOP, GEMINI_FINISH } from "./finishReasons.js"; +export { MODEL_FALLBACK, DEFAULT_IMAGE_MIME } from "./defaults.js"; diff --git a/open-sse/translator/schema/roles.js b/open-sse/translator/schema/roles.js new file mode 100644 index 00000000..1fe91a6d --- /dev/null +++ b/open-sse/translator/schema/roles.js @@ -0,0 +1,16 @@ +// Role enums — fixed per format. Pure data (no logic). +// OpenAI chat / Claude share these; mapping between them stays in translators. + +export const ROLE = { + USER: "user", + ASSISTANT: "assistant", + TOOL: "tool", + SYSTEM: "system", + DEVELOPER: "developer", +}; + +// Gemini / Antigravity use "model" instead of "assistant". +export const GEMINI_ROLE = { + USER: "user", + MODEL: "model", +}; diff --git a/open-sse/utils/claudeCloaking.js b/open-sse/utils/claudeCloaking.js index 688573ab..46a44e4f 100644 --- a/open-sse/utils/claudeCloaking.js +++ b/open-sse/utils/claudeCloaking.js @@ -13,11 +13,18 @@ function generateBillingHeader(payload) { return `x-anthropic-billing-header: cc_version=${CLAUDE_VERSION}.${buildHash}; cc_entrypoint=${CC_ENTRYPOINT}; cch=${cch};`; } +// Derive a deterministic UUID-v4-shaped string from a seed (stable per account) +function deriveUuid(seed) { + const h = createHash("sha256").update(seed).digest("hex"); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${((parseInt(h[16], 16) & 0x3) | 0x8).toString(16)}${h.slice(17, 20)}-${h.slice(20, 32)}`; +} + // Generate fake user ID in Claude Code 2.1.92+ JSON format: // {"device_id":"<64hex>","account_uuid":"","session_id":""} -function generateFakeUserID(sessionId) { - const deviceId = randomBytes(32).toString("hex"); - const accountUuid = randomUUID(); +// device_id/account_uuid derive from apiKey (stable per account), session_id per-conversation +function generateFakeUserID(sessionId, apiKey) { + const deviceId = apiKey ? createHash("sha256").update(`device:${apiKey}`).digest("hex") : randomBytes(32).toString("hex"); + const accountUuid = apiKey ? deriveUuid(`account:${apiKey}`) : randomUUID(); const sessionUuid = sessionId || randomUUID(); return `{"device_id":"${deviceId}","account_uuid":"${accountUuid}","session_id":"${sessionUuid}"}`; } @@ -40,8 +47,11 @@ export function cloakClaudeTools(body) { const clientToolNames = new Set(); const clientDeclarations = []; - // All client tools get renamed with suffix + // All client tools get renamed with suffix. + // Built-in server tools (web_search_20250305, etc.) carry a `type` and require + // an exact reserved `name` — never suffix those or Claude rejects the request. for (const tool of tools) { + if (tool.type) { clientDeclarations.push(tool); continue; } const suffixed = suffix(tool.name); toolNameMap.set(suffixed, tool.name); clientToolNames.add(tool.name); @@ -148,7 +158,7 @@ export function applyCloaking(body, apiKey, sessionId) { // Inject fake user ID into metadata (session_id must match X-Claude-Code-Session-Id) const existingUserId = result.metadata?.user_id; if (!existingUserId) { - result.metadata = { ...result.metadata, user_id: generateFakeUserID(sessionId) }; + result.metadata = { ...result.metadata, user_id: generateFakeUserID(sessionId, apiKey) }; } return result; diff --git a/open-sse/utils/claudeSignature.js b/open-sse/utils/claudeSignature.js new file mode 100644 index 00000000..1a186967 --- /dev/null +++ b/open-sse/utils/claudeSignature.js @@ -0,0 +1,41 @@ +// Claude thinking signature validation (ported from CLIProxyAPI internal/signature). +// E-form: single-layer base64, decoded[0] == 0x12 (Claude marker). +// R-form: double-layer base64, outer decoded[0] == 'E', inner decoded[0] == 0x12. +// Cache prefix "...#sig" stripped before validation. + +const MAX_CLAUDE_SIGNATURE_LEN = 32 * 1024 * 1024; +const CLAUDE_SIGNATURE_MARKER = 0x12; + +function stripCachePrefix(rawSignature) { + const sig = (rawSignature || "").trim(); + if (!sig) return ""; + const idx = sig.indexOf("#"); + return idx >= 0 ? sig.slice(idx + 1).trim() : sig; +} + +export function hasClaudeSignaturePrefix(rawSignature) { + const sig = stripCachePrefix(rawSignature); + return sig.length > 0 && (sig[0] === "E" || sig[0] === "R"); +} + +// Strict-ish: validates base64 layers + Claude marker byte. +export function isValidClaudeSignature(rawSignature) { + const sig = stripCachePrefix(rawSignature); + if (!sig || sig.length > MAX_CLAUDE_SIGNATURE_LEN) return false; + + try { + if (sig[0] === "E") { + const decoded = Buffer.from(sig, "base64"); + return decoded.length > 0 && decoded[0] === CLAUDE_SIGNATURE_MARKER; + } + if (sig[0] === "R") { + const outer = Buffer.from(sig, "base64"); + if (!outer.length || outer[0] !== 0x45) return false; // 'E' + const inner = Buffer.from(outer.toString(), "base64"); + return inner.length > 0 && inner[0] === CLAUDE_SIGNATURE_MARKER; + } + return false; + } catch { + return false; + } +} diff --git a/open-sse/utils/reasoningContentInjector.js b/open-sse/utils/reasoningContentInjector.js index a6829b72..14fd27b7 100644 --- a/open-sse/utils/reasoningContentInjector.js +++ b/open-sse/utils/reasoningContentInjector.js @@ -1,15 +1,12 @@ // Some thinking-mode providers (DeepSeek, Kimi, MiniMax, ...) require reasoning_content // to be echoed back on assistant messages. Clients in OpenAI format don't send it, // so we inject a non-empty placeholder to satisfy upstream validation. +import { PROVIDERS } from "../config/providers.js"; const PLACEHOLDER = " "; -// Provider-level rules: keyed by executor.provider -const PROVIDER_RULES = { - deepseek: { scope: "all" }, - minimax: { scope: "all" }, - "minimax-cn": { scope: "all" } -}; +// Provider-level rules derive from registry transport.reasoningInject (single source) +const providerRuleFor = (provider) => PROVIDERS[provider]?.reasoningInject; // Model-level rules: matched by predicate against model id const MODEL_RULES = [ @@ -71,7 +68,7 @@ function applyDeepSeekV4ProAlias({ provider, model, body }) { } export function injectReasoningContent({ provider, model, body }) { - const providerRule = PROVIDER_RULES[provider]; + const providerRule = providerRuleFor(provider); const modelRule = MODEL_RULES.find(r => r.match(model)); const rule = providerRule || modelRule; const nextBody = applyDeepSeekV4ProAlias({ provider, model, body }); diff --git a/open-sse/utils/sessionManager.js b/open-sse/utils/sessionManager.js index 7c08013e..05f90896 100644 --- a/open-sse/utils/sessionManager.js +++ b/open-sse/utils/sessionManager.js @@ -79,4 +79,153 @@ export function generateBinaryStyleId() { */ export function clearSessionStore() { runtimeSessionStore.clear(); + assistantSessionStore.clear(); } + +// Conversation-stable session store: Key = hash(scope+assistant text), Value = { sessionId, lastUsed } +const assistantSessionStore = new Map(); +const ASSISTANT_MIN_LEN = 50; +const ASSISTANT_CAP_LEN = 50; +const MAX_ASSISTANT_SESSIONS = 5000; + +// Client headers/body fields that carry an upstream session id (priority order) +const SESSION_HEADER_KEYS = ["x-session-id", "session-id", "session_id", "x-amp-thread-id", "x-client-request-id"]; +const CLAUDE_CODE_SESSION_RE = /_session_([a-f0-9-]+)$/; + +function sha16(text) { + return crypto.createHash("sha256").update(text).digest("hex").slice(0, 16); +} + +// 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; +} + +// Extract Claude Code session id from metadata.user_id (_session_{uuid} | JSON {session_id}) +function extractClaudeCodeSession(userId) { + if (typeof userId !== "string" || !userId) return null; + const m = userId.match(CLAUDE_CODE_SESSION_RE); + if (m) return m[1]; + if (userId[0] === "{") { + try { return normalizeSessionId(JSON.parse(userId)?.session_id); } catch { /* noop */ } + } + return null; +} + +// Lowercase-key lookup for raw client headers +function headerValue(headers, key) { + if (!headers || typeof headers !== "object") return null; + return normalizeSessionId(headers[key] ?? headers[key.toLowerCase()]); +} + +// Read client-provided session id from headers/body (no generation) +// Antigravity envelope carries session in request.sessionId; requestId embeds conversation uuid +const ANTIGRAVITY_CONV_RE = /^[a-z]+\/([0-9a-f-]{36})\//i; +function extractAntigravitySession(body) { + const sid = body?.request?.sessionId; + if (sid != null && sid !== "") return normalizeSessionId(String(sid)); + const m = typeof body?.requestId === "string" ? body.requestId.match(ANTIGRAVITY_CONV_RE) : null; + return m ? normalizeSessionId(m[1]) : null; +} + +function extractClientSessionId(headers, body) { + const claude = extractClaudeCodeSession(body?.metadata?.user_id); + if (claude) return `claude:${claude}`; + const antigravity = extractAntigravitySession(body); + if (antigravity) return `antigravity:${antigravity}`; + for (const key of SESSION_HEADER_KEYS) { + const v = headerValue(headers, key); + if (v) return v; + } + const fromBody = + normalizeSessionId(body?.prompt_cache_key) || + normalizeSessionId(body?.session_id) || + normalizeSessionId(body?.conversation_id) || + normalizeSessionId(body?.metadata?.user_id); + return fromBody || null; +} + +// Accumulate assistant text from OpenAI/Responses-style input/messages (cap-limited) +function accumulateAssistantText(body) { + const items = Array.isArray(body?.input) ? body.input + : Array.isArray(body?.messages) ? body.messages : null; + if (!items) return ""; + let text = ""; + for (const item of items) { + if (item?.role !== "assistant") continue; + if (typeof item.content === "string") text += item.content; + else if (Array.isArray(item.content)) { + for (const c of item.content) text += c?.text || c?.output || ""; + } + if (text.length >= ASSISTANT_CAP_LEN) break; + } + return text; +} + +// Stable session id keyed on accumulated assistant text (avoids collision on identical first user prompt) +function assistantTextSessionId(scope, body) { + const text = accumulateAssistantText(body); + if (text.length < ASSISTANT_MIN_LEN) return null; + const hash = sha16(`${scope}:${text.slice(0, ASSISTANT_CAP_LEN)}`); + const existing = assistantSessionStore.get(hash); + if (existing) { + existing.lastUsed = Date.now(); + return existing.sessionId; + } + if (assistantSessionStore.size >= MAX_ASSISTANT_SESSIONS) { + assistantSessionStore.delete(assistantSessionStore.keys().next().value); + } + const sessionId = generateBinaryStyleId(); + assistantSessionStore.set(hash, { sessionId, lastUsed: Date.now() }); + return sessionId; +} + +/** + * Resolve a conversation-stable session id (generalizes Codex resolveCacheSessionId). + * Priority: client session → accumulated-assistant-text hash → workspaceId → per-connection. + * + * @param {object} opts + * @param {object} [opts.headers] - Raw client request headers (lowercase keys) + * @param {object} [opts.body] - Parsed request body + * @param {string} [opts.connectionId] - Connection identifier (fallback scope) + * @param {string} [opts.workspaceId] - Provider workspace id (account-wide fallback) + * @param {string} [opts.scope] - Provider scope to isolate cache keys across providers + * @returns {string} A stable session id + */ +export function resolveSessionId({ headers, body, connectionId, workspaceId, scope = "" } = {}) { + const client = extractClientSessionId(headers, body); + if (client) return client; + const fromAssistant = assistantTextSessionId(`${scope}:${connectionId || ""}`, body); + if (fromAssistant) return fromAssistant; + const ws = normalizeSessionId(workspaceId); + if (ws) return ws; + return deriveSessionId(connectionId); +} + +// Capture session id from request body + credentials (envelope still intact here) +export function captureSessionId(body, credentials, connectionId, scope = "") { + return resolveSessionId({ headers: credentials?.rawHeaders, body, connectionId, scope }); +} + +// Convert any session id to Antigravity numeric format "-" (matches real AG / CLIProxyAPI). +// Already-numeric ids (native AG sessionId) pass through unchanged. +export function toNumericSessionId(sessionId) { + const v = normalizeSessionId(sessionId); + if (!v) return null; + if (/^-?\d+$/.test(v)) return v; + const h = crypto.createHash("sha256").update(v).digest(); + const n = h.readBigUInt64BE(0) & 0x7fffffffffffffffn; + return `-${n.toString()}`; +} + +// Cleanup expired assistant-session entries +const assistantCleanup = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of assistantSessionStore) { + if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) assistantSessionStore.delete(key); + } +}, MEMORY_CONFIG.sessionCleanupIntervalMs); +if (assistantCleanup.unref) assistantCleanup.unref(); diff --git a/open-sse/utils/sse.js b/open-sse/utils/sse.js new file mode 100644 index 00000000..2fa47e9d --- /dev/null +++ b/open-sse/utils/sse.js @@ -0,0 +1,14 @@ +export function sseChunk(data) { + return `data: ${JSON.stringify(data)}\n\n`; +} + +// Build OpenAI chat.completion.chunk SSE frame. Key order: id, object, created, model, choices. +export function chatChunkSse({ id, created, model, delta, finishReason = null }) { + return sseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta, finish_reason: finishReason }], + }); +} diff --git a/open-sse/utils/sseConstants.js b/open-sse/utils/sseConstants.js new file mode 100644 index 00000000..680a05ca --- /dev/null +++ b/open-sse/utils/sseConstants.js @@ -0,0 +1,23 @@ +// Shared SSE primitives (no imports → safe for executors + stream.js) +export const SSE_DONE = "data: [DONE]\n\n"; + +export const SSE_HEADERS = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive" +}; + +// Variant for web-cookie executors behind nginx (disable proxy buffering) +export const SSE_HEADERS_NO_BUFFER = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no" +}; + +// Variant for client-facing SSE responses (adds permissive CORS) +export const SSE_HEADERS_CORS = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Access-Control-Allow-Origin": "*" +}; diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index b8d5d819..3b2cfb7e 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -7,7 +7,10 @@ import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./str import { getOpenAIResponsesEventName, isOpenAIResponsesTerminalEvent, formatIncompleteOpenAIResponsesStreamFailure } from "./responsesStreamHelpers.js"; import { dbg, isDebugEnabled } from "./debugLog.js"; +import { SSE_DONE, SSE_HEADERS, SSE_HEADERS_NO_BUFFER } from "./sseConstants.js"; + export { COLORS, formatSSE }; +export { SSE_DONE, SSE_HEADERS, SSE_HEADERS_NO_BUFFER }; // sharedEncoder is stateless — safe to share across streams const sharedEncoder = new TextEncoder(); @@ -69,6 +72,7 @@ export function createSSEStream(options = {}) { let currentOpenAIResponsesEvent = null; let openAIResponsesTerminalSeen = false; let openAIResponsesDoneSent = false; + let streamDoneSent = false; // track duplicate [DONE] across transform + flush return new TransformStream({ transform(chunk, controller) { @@ -164,7 +168,12 @@ export function createSSEStream(options = {}) { output = `data: ${JSON.stringify(parsed)}\n`; injectedUsage = true; } - } catch { } + } catch { + // Skip non-JSON data lines silently — don't forward garbage to clients. + // Upstream providers sometimes return plain-text errors (HTML, rate-limit + // messages) in the SSE stream that would break downstream JSON decoders. + continue; + } } if (!injectedUsage) { @@ -209,9 +218,10 @@ export function createSSEStream(options = {}) { sseEmittedCount++; } - const output = "data: [DONE]\n\n"; - reqLogger?.appendConvertedChunk?.(output); - controller.enqueue(sharedEncoder.encode(output)); + // [DONE] not emitted in translate mode — some clients' SSE decoders + // fail to parse the OpenAI sentinel on Claude-format translated streams. + // message_stop already signals end-of-response; stream close handles it. + streamDoneSent = true; if (keepsOpenAIResponsesFormat) openAIResponsesDoneSent = true; continue; } @@ -341,9 +351,11 @@ export function createSSEStream(options = {}) { // Some clients (e.g. OpenClaw) expect the OpenAI-style sentinel: // data: [DONE]\n\n // Without it they can hang until timeout and trigger failover. - const doneOutput = "data: [DONE]\n\n"; - reqLogger?.appendConvertedChunk?.(doneOutput); - controller.enqueue(sharedEncoder.encode(doneOutput)); + if (!streamDoneSent) { + const doneOutput = "data: [DONE]\n\n"; + reqLogger?.appendConvertedChunk?.(doneOutput); + controller.enqueue(sharedEncoder.encode(doneOutput)); + } if (onStreamComplete) { onStreamComplete({ @@ -404,11 +416,8 @@ export function createSSEStream(options = {}) { openAIResponsesTerminalSeen = true; } - if (!keepsOpenAIResponsesFormat || !openAIResponsesDoneSent) { - const doneOutput = "data: [DONE]\n\n"; - reqLogger?.appendConvertedChunk?.(doneOutput); - controller.enqueue(sharedEncoder.encode(doneOutput)); - } + // [DONE] not emitted in translate mode — see comment above. + // Passthrough mode still emits it for standard OpenAI clients. if (!hasValidUsage(state?.usage) && totalContentLength > 0) { state.usage = estimateUsage(body, totalContentLength, sourceFormat); diff --git a/open-sse/utils/streamHandler.js b/open-sse/utils/streamHandler.js index 6c8b031a..b8a06e2f 100644 --- a/open-sse/utils/streamHandler.js +++ b/open-sse/utils/streamHandler.js @@ -128,7 +128,10 @@ export function createDisconnectAwareStream(transformStream, streamController, o controller.enqueue(value); } catch (error) { const wasConnected = streamController.isConnected(); - streamController.handleError(error); + // Controller already closed = downstream ended; not an upstream error, skip noisy log. + const msg0 = error?.message || ""; + const isControllerClosed = msg0.includes("already closed") || msg0.includes("Invalid state"); + if (!isControllerClosed) streamController.handleError(error); reader.cancel().catch(() => {}); writer.abort().catch(() => {}); diff --git a/open-sse/utils/usageTracking.js b/open-sse/utils/usageTracking.js index 8055bc14..c30350c5 100644 --- a/open-sse/utils/usageTracking.js +++ b/open-sse/utils/usageTracking.js @@ -298,3 +298,37 @@ export function estimateUsage(body, contentLength, targetFormat = FORMATS.OPENAI targetFormat ); } +/** + * Log usage with cache info (green color) + */ +export function logUsage(provider, usage, model = null, connectionId = null, apiKey = null) { + if (!usage || typeof usage !== "object") return; + + const p = provider?.toUpperCase() || "UNKNOWN"; + + // Support both formats: + // - OpenAI: prompt_tokens, completion_tokens + // - Claude: input_tokens, output_tokens + const inTokens = usage?.prompt_tokens || usage?.input_tokens || 0; + const outTokens = usage?.completion_tokens || usage?.output_tokens || 0; + const accountPrefix = connectionId ? connectionId.slice(0, 8) + "..." : "unknown"; + + let msg = `[${getTimeString()}] 📊 ${COLORS.green}[USAGE] ${p} | in=${inTokens} | out=${outTokens} | account=${accountPrefix}${COLORS.reset}`; + + // Add estimated flag if present + if (usage.estimated) { + msg += ` ${COLORS.yellow}(estimated)${COLORS.reset}`; + } + + // Add cache info if present (unified from different formats) + const cacheRead = usage.cache_read_input_tokens || usage.cached_tokens || usage.prompt_tokens_details?.cached_tokens; + if (cacheRead) msg += ` | cache_read=${cacheRead}`; + + const cacheCreation = usage.cache_creation_input_tokens; + if (cacheCreation) msg += ` | cache_create=${cacheCreation}`; + + const reasoning = usage.reasoning_tokens; + if (reasoning) msg += ` | reasoning=${reasoning}`; + + console.log(msg); +} diff --git a/package.json b/package.json index 91dd2f1d..eeb93d1a 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "name": "9router-app", - "version": "0.4.80", + "version": "0.5.12", "description": "9Router web dashboard", "private": true, "scripts": { - "dev": "next dev --webpack --port 20128", + "dev": "next dev --webpack --port 20127", "build": "next build --webpack", "start": "next start", - "dev:bun": "bun --bun next dev --webpack --port 20128", + "dev:bun": "bun --bun next dev --webpack --port 20127", "build:bun": "bun --bun next build --webpack", "start:bun": "bun ./.next/standalone/server.js", "cli:pack": "npm --prefix cli run pack:cli", @@ -19,6 +19,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.7.0", + "@next/third-parties": "^16.2.9", "@xyflow/react": "^12.10.1", "bcryptjs": "^3.0.3", "confbox": "^0.2.4", diff --git a/public/i18n/literals/zh-CN.json b/public/i18n/literals/zh-CN.json index 7b3deb81..41d29c5e 100644 --- a/public/i18n/literals/zh-CN.json +++ b/public/i18n/literals/zh-CN.json @@ -187,6 +187,7 @@ "End Date": "结束日期", "End-to-end TLS via Cloudflare": "通过 Cloudflare 的端到端 TLS", "Endpoint": "端点", + "Endpoint & Key": "端点与密钥", "Enter current password": "输入当前密码", "Enter new API key": "输入新的 API 密钥", "Enter new password": "输入新密码", diff --git a/public/providers/codebuddy-cn.png b/public/providers/codebuddy-cn.png new file mode 100644 index 00000000..eae3f4c1 Binary files /dev/null and b/public/providers/codebuddy-cn.png differ diff --git a/scripts/injectDisplayToRegistry.mjs b/scripts/injectDisplayToRegistry.mjs new file mode 100644 index 00000000..1da6be55 --- /dev/null +++ b/scripts/injectDisplayToRegistry.mjs @@ -0,0 +1,223 @@ +/** + * Script: đọc providersDisplay.js + providers.js, inject display+category+uiAlias+extra vào từng registry file. + * Chạy: node scripts/injectDisplayToRegistry.mjs + */ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, ".."); +const REGISTRY_DIR = path.join(ROOT, "open-sse/providers/registry"); + +// ── 1. Build DISPLAY map từ providersDisplay.js (parse thủ công để không cần import) ── +// Đọc file, eval trong sandbox đơn giản +const displaySrc = fs.readFileSync(path.join(ROOT, "src/shared/constants/providersDisplay.js"), "utf8"); +const RISK_NOTICE = "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk."; +// strip export keywords + inject RISK_NOTICE as param so no redeclaration +const displayBody = displaySrc + .replace(/^export const /gm, "const ") + .replace(/^export function /gm, "function ") + .replace(/^const RISK_NOTICE\s*=.*$/m, ""); // remove redeclaration +// eslint-disable-next-line no-new-func +const getDisplay = new Function("RISK_NOTICE", `${displayBody}; return PROVIDER_DISPLAY;`); +const DISPLAY = getDisplay(RISK_NOTICE); + +// ── 2. Build CATEGORY + EXTRA map từ providers.js ── +// Map: providerId → { category, uiAlias, extra fields } +const CATEGORY_MAP = {}; + +// Đọc providers.js source để extract thủ công từng dòng +const provSrc = fs.readFileSync(path.join(ROOT, "src/shared/constants/providers.js"), "utf8"); + +// Detect category blocks +const CATEGORIES = { + free: /export const FREE_PROVIDERS\s*=\s*\{([\s\S]*?)\n\};/, + freeTier: /export const FREE_TIER_PROVIDERS\s*=\s*\{([\s\S]*?)\n\};/, + oauth: /export const OAUTH_PROVIDERS\s*=\s*\{([\s\S]*?)\n\};/, + apikey: /export const APIKEY_PROVIDERS\s*=\s*\{([\s\S]*?)\n\};/, + webCookie: /export const WEB_COOKIE_PROVIDERS\s*=\s*\{([\s\S]*?)\n\};/, +}; + +// Extract provider ids + uiAlias + extra fields per category +// Parse dòng dạng: " openai: { ...D("openai"), id: "openai", alias: "openai", ... }" +const ENTRY_RE = /^\s{2}["']?([\w-]+)["']?\s*:\s*\{[^}]*?id:\s*["']([\w-]+)["'][^}]*?alias:\s*["']([\w-]+)["']([\s\S]*?)(?=\n\s{2}["']?[\w-]|\n\};)/gm; + +// Extra fields cần lấy từ providers.js (không lấy display, id, alias vì đã có nguồn khác) +const EXTRA_FIELDS = [ + "thinkingConfig", + "regions", + "defaultRegion", + "hasProviderSpecificData", + "authType", + "authHint", + "passthroughModels", + "noAuth", + "hiddenKinds", + "hasOAuth", + "authModes", +]; + +// THINKING_CONFIG values để inline +const THINKING_CONFIG = { + extended: { options: ["auto", "on", "off"], defaultMode: "auto", defaultBudgetTokens: 10000 }, + effort: { options: ["auto", "none", "low", "medium", "high"], defaultMode: "auto" }, +}; + +// Parse thủ công từng category block +for (const [cat, re] of Object.entries(CATEGORIES)) { + const match = provSrc.match(re); + if (!match) continue; + const block = match[1]; + + // Tìm tất cả entry lines (không comment) + const lines = block.split("\n").filter(l => l.trim() && !l.trim().startsWith("//")); + for (const line of lines) { + // Extract id từ id: "xxx" + const idM = line.match(/\bid:\s*["']([\w-]+)["']/); + // Extract uiAlias từ alias: "xxx" + const aliasM = line.match(/\balias:\s*["']([\w-]+)["']/); + if (!idM) continue; + const id = idM[1]; + const uiAlias = aliasM ? aliasM[1] : id; + + const extra = {}; + + // thinkingConfig + if (line.includes("THINKING_CONFIG.effort")) extra.thinkingConfig = THINKING_CONFIG.effort; + else if (line.includes("THINKING_CONFIG.extended")) extra.thinkingConfig = THINKING_CONFIG.extended; + + // hasProviderSpecificData + if (line.includes("hasProviderSpecificData: true")) extra.hasProviderSpecificData = true; + + // hasOAuth + if (line.includes("hasOAuth: true")) extra.hasOAuth = true; + + // authModes + const authModesM = line.match(/authModes:\s*(\[[^\]]+\])/); + if (authModesM) { + try { extra.authModes = JSON.parse(authModesM[1].replace(/'/g, '"')); } catch {} + } + + // authType (webCookie) + const authTypeM = line.match(/authType:\s*["']([\w-]+)["']/); + if (authTypeM) extra.authType = authTypeM[1]; + + // authHint + const authHintM = line.match(/authHint:\s*["']([^"']+)["']/); + if (authHintM) extra.authHint = authHintM[1]; + + // noAuth + if (line.includes("noAuth: true")) extra.noAuth = true; + + // passthroughModels + if (line.includes("passthroughModels: true")) extra.passthroughModels = true; + + // hiddenKinds + const hiddenKindsM = line.match(/hiddenKinds:\s*(\[[^\]]+\])/); + if (hiddenKindsM) { + try { extra.hiddenKinds = JSON.parse(hiddenKindsM[1].replace(/'/g, '"')); } catch {} + } + + // regions (xiaomi-tokenplan) + const regionsM = line.match(/regions:\s*(\[[\s\S]*?\])/); + if (regionsM) { + try { extra.regions = JSON.parse(regionsM[1].replace(/'/g, '"')); } catch {} + } + const defRegionM = line.match(/defaultRegion:\s*["']([\w-]+)["']/); + if (defRegionM) extra.defaultRegion = defRegionM[1]; + + CATEGORY_MAP[id] = { category: cat, uiAlias, extra }; + } +} + +// ── 3. Inject vào từng registry file ── +const registryFiles = fs.readdirSync(REGISTRY_DIR) + .filter(f => f.endsWith(".js") && f !== "index.js") + .map(f => f.replace(".js", "")); + +let injected = 0; +let skipped = 0; +const results = []; + +for (const id of registryFiles) { + const filePath = path.join(REGISTRY_DIR, `${id}.js`); + let src = fs.readFileSync(filePath, "utf8"); + + // Bỏ qua nếu đã có display field + if (src.includes("display:")) { + skipped++; + results.push(`⏭️ ${id} (already has display)`); + continue; + } + + const display = DISPLAY[id]; + const catInfo = CATEGORY_MAP[id]; + + if (!display && !catInfo) { + skipped++; + results.push(`⚠️ ${id} (no display + no category data)`); + continue; + } + + // Build display block + let displayBlock = ""; + if (display) { + const d = { ...display }; + // Thay RISK_NOTICE string về const reference khi serialize + const RISK = RISK_NOTICE; + const displayJson = JSON.stringify(d, null, 4) + .replace(new RegExp(JSON.stringify(RISK).slice(1, -1), "g"), "RISK_NOTICE"); + + displayBlock = ` display: ${displayJson.replace(/^/gm, " ").trimStart()},\n`; + } + + // Build category line + const categoryLine = catInfo ? ` category: "${catInfo.category}",\n` : ""; + + // Build uiAlias line (chỉ khi khác với alias routing) + let uiAliasLine = ""; + if (catInfo && catInfo.uiAlias && catInfo.uiAlias !== id) { + uiAliasLine = ` uiAlias: "${catInfo.uiAlias}",\n`; + } + + // Build extra fields + let extraBlock = ""; + if (catInfo && Object.keys(catInfo.extra).length > 0) { + for (const [k, v] of Object.entries(catInfo.extra)) { + extraBlock += ` ${k}: ${JSON.stringify(v)},\n`; + } + } + + // Inject SAU dòng "alias:" hoặc cuối object (trước closing "};") + const insertBlock = displayBlock + categoryLine + uiAliasLine + extraBlock; + + if (!insertBlock.trim()) { + skipped++; + results.push(`⏭️ ${id} (nothing to inject)`); + continue; + } + + // Tìm vị trí sau field "alias:" để inject + const aliasLineRe = /^(\s+"?alias"?\s*:\s*["'][^"']+["'],?\n)/m; + if (aliasLineRe.test(src)) { + src = src.replace(aliasLineRe, `$1${insertBlock}`); + } else { + // Fallback: inject trước closing "};" + src = src.replace(/^(\}\s*;\s*)$/m, `${insertBlock}$1`); + } + + // Thêm RISK_NOTICE import nếu cần + if (insertBlock.includes("RISK_NOTICE") && !src.includes("RISK_NOTICE")) { + const riskLine = `const RISK_NOTICE = ${JSON.stringify(RISK_NOTICE)};\n\n`; + src = riskLine + src; + } + + fs.writeFileSync(filePath, src); + injected++; + results.push(`✅ ${id}`); +} + +console.log(`\n📦 Inject display+category vào registry files:`); +for (const r of results) console.log(` ${r}`); +console.log(`\n✅ Injected: ${injected} | ⏭️ Skipped: ${skipped}`); diff --git a/scripts/migrate-registry.mjs b/scripts/migrate-registry.mjs new file mode 100644 index 00000000..ba1d0e48 --- /dev/null +++ b/scripts/migrate-registry.mjs @@ -0,0 +1,271 @@ +/** + * migrate-registry.mjs + * Migrates all registry files to Model-A schema: + * - models[] = ALL models (chat + media), field `kind` (default "llm") + * - media wrapper removed → fields promoted top-level + * - *Config.models removed (data merged into models[]) + * - format: terse, consistent indent + * + * Run: node --experimental-vm-modules migrate-registry.mjs [--dry] + */ +import { readFileSync, writeFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { createRequire } from "node:module"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REGISTRY_DIR = __dirname; // script lives in registry/ +const DRY = process.argv.includes("--dry"); + +// *Config.models field → kind value +const CFG_KIND = { + ttsConfig: "tts", + sttConfig: "stt", + embeddingConfig: "embedding", + imageConfig: "image", + imageToTextConfig: "imageToText", + videoConfig: "video", + musicConfig: "music", +}; + +// Fields in *Config that are NOT models (keep on config) +const MODEL_ONLY_KEY = "models"; + +// Top-level registry fields that are NOT media-config (don't flatten these from media) +// serviceKinds + *Config + searchViaChat + mediaConfig + passthroughModels are media fields +// Everything else is already top-level +const MEDIA_WHITELIST = new Set([ + "serviceKinds", + "ttsConfig", "sttConfig", "embeddingConfig", + "imageConfig", "imageToTextConfig", "videoConfig", "musicConfig", + "searchViaChat", "searchConfig", "fetchConfig", + "modelsFetcher", "hasProviderSpecificData", "passthroughModels", + "mediaPriority", "hiddenKinds", +]); + +function migrateEntry(entry, filename) { + const out = {}; + + // 1. Top-level identity/transport fields (preserve order) + const TRANSPORT_KEYS = ["id", "alias", "aliases", "uiAlias", "display", "category", + "authType", "authHint", "authModes", "hasOAuth", "noAuth", + "hasProviderSpecificData", "thinkingConfig", "hiddenKinds", + "regions", "defaultRegion", "passthroughModels", "transport"]; + for (const k of TRANSPORT_KEYS) { + if (entry[k] !== undefined) out[k] = entry[k]; + } + + // 2. Collect existing models[] (convert type→kind, skip if kind already set) + const existingModels = (entry.models || []).map(m => { + const { type, ...rest } = m; + const kind = m.kind ?? (type && type !== "llm" ? type : undefined); + return kind ? { ...rest, kind } : rest; + }); + const existingIds = new Set(existingModels.map(m => m.id)); + + // 3. Extract models from *Config.models (merge into models[]) + const mediaModels = []; + const media = entry.media || {}; + for (const [cfgKey, kind] of Object.entries(CFG_KIND)) { + const cfg = media[cfgKey]; + if (!cfg?.models) continue; + for (const m of cfg.models) { + // Check if same id+kind combo already exists to avoid true duplicates + const dup = existingModels.find(x => x.id === m.id && (x.kind ?? "llm") === kind); + if (dup) continue; + const { ...mClean } = m; + mediaModels.push({ ...mClean, kind }); + } + } + + // 4. Merge models (existing first, then media additions) + const allModels = [...existingModels, ...mediaModels]; + // Only include models key if non-empty or explicitly defined + if (allModels.length > 0 || entry.models !== undefined) { + out.models = allModels; + } + + // 5. Flatten media fields (without .models sub-arrays) + for (const [k, v] of Object.entries(media)) { + if (!MEDIA_WHITELIST.has(k)) continue; + if (CFG_KIND[k]) { + // Strip .models from config, keep rest + const { models: _m, ...cfgRest } = (v || {}); + if (Object.keys(cfgRest).length > 0) out[k] = cfgRest; + } else { + out[k] = v; + } + } + + // 6. Other top-level fields not in TRANSPORT_KEYS and not media (e.g. features, oauth, usage in transport) + const SKIP = new Set([...TRANSPORT_KEYS, "models", "media", ...Object.keys(CFG_KIND), + "serviceKinds", "searchViaChat", "searchConfig", "fetchConfig", + "modelsFetcher", "passthroughModels", "mediaPriority"]); + for (const [k, v] of Object.entries(entry)) { + if (!SKIP.has(k)) out[k] = v; + } + + return out; +} + +// Format a registry entry as clean JS (no JSON.stringify — write proper ES module) +function formatValue(v, indent = 0) { + const pad = " ".repeat(indent); + const pad1 = " ".repeat(indent + 1); + + if (v === null || v === undefined) return String(v); + if (typeof v === "boolean" || typeof v === "number") return String(v); + if (typeof v === "string") return JSON.stringify(v); + + if (Array.isArray(v)) { + if (v.length === 0) return "[]"; + // Model arrays: 1 model per line (compact inline object) + const items = v.map(item => { + if (typeof item === "object" && item !== null && !Array.isArray(item)) { + return `${pad1}${formatInlineObject(item)}`; + } + return `${pad1}${formatValue(item, indent + 1)}`; + }); + return `[\n${items.join(",\n")},\n${pad}]`; + } + + if (typeof v === "object") { + const keys = Object.keys(v); + if (keys.length === 0) return "{}"; + const lines = keys.map(k => { + const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(k) ? k : JSON.stringify(k); + return `${pad1}${key}: ${formatValue(v[k], indent + 1)}`; + }); + return `{\n${lines.join(",\n")},\n${pad}}`; + } + + return JSON.stringify(v); +} + +// Inline compact object: { id: "x", name: "y", kind: "tts", dimensions: 1536 } +function formatInlineObject(obj) { + const parts = Object.entries(obj).map(([k, v]) => { + const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(k) ? k : JSON.stringify(k); + return `${key}: ${JSON.stringify(v)}`; + }); + return `{ ${parts.join(", ")} }`; +} + +// Config objects (ttsConfig etc) — inline single line if short, else multi-line +function formatConfig(cfg) { + const line = `{ ${Object.entries(cfg).map(([k,v])=>`${k}: ${JSON.stringify(v)}`).join(", ")} }`; + if (line.length <= 120) return line; + const pad1 = " ".repeat(2); + const lines = Object.entries(cfg).map(([k,v]) => `${pad1}${k}: ${JSON.stringify(v)}`); + return `{\n${lines.join(",\n")},\n }`; +} + +// Top-level registry entry formatter +function formatEntry(entry, imports = "") { + const lines = []; + if (imports) lines.push(imports, ""); + lines.push("export default {"); + + const TOP_ORDER = [ + "id", "alias", "aliases", "uiAlias", "display", "category", + "authType", "authHint", "authModes", "hasOAuth", "noAuth", + "hasProviderSpecificData", "thinkingConfig", "hiddenKinds", + "regions", "defaultRegion", "transport", + "models", + // media fields + "serviceKinds", + "ttsConfig", "sttConfig", "embeddingConfig", + "imageConfig", "imageToTextConfig", "videoConfig", "musicConfig", + "searchViaChat", "searchConfig", "fetchConfig", "modelsFetcher", + "passthroughModels", "mediaPriority", + // other + "oauth", "features", + ]; + + const emitted = new Set(); + + function emitKey(k) { + if (!(k in entry) || emitted.has(k)) return; + emitted.add(k); + const v = entry[k]; + const key = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(k) ? k : JSON.stringify(k); + + // Config objects (xConfig) — special inline format + if (CFG_KIND[k] || k === "searchViaChat" || k === "searchConfig" || k === "fetchConfig" || k === "modelsFetcher") { + lines.push(` ${key}: ${formatConfig(v)},`); + return; + } + + // models[] — terse per-line + if (k === "models" && Array.isArray(v)) { + if (v.length === 0) { lines.push(` models: [],`); return; } + lines.push(` models: [`); + for (const m of v) lines.push(` ${formatInlineObject(m)},`); + lines.push(` ],`); + return; + } + + // serviceKinds — inline array + if (k === "serviceKinds") { + lines.push(` serviceKinds: ${JSON.stringify(v)},`); + return; + } + + // display — multi-line + if (k === "display") { + lines.push(` display: ${formatValue(v, 1)},`); + return; + } + + // transport — multi-line + if (k === "transport") { + lines.push(` transport: ${formatValue(v, 1)},`); + return; + } + + // Everything else + lines.push(` ${key}: ${formatValue(v, 1)},`); + } + + for (const k of TOP_ORDER) emitKey(k); + // Emit any remaining keys not in TOP_ORDER + for (const k of Object.keys(entry)) emitKey(k); + + lines.push("};"); + return lines.join("\n") + "\n"; +} + +// --- Main --- +const files = readdirSync(REGISTRY_DIR).filter(f => f.endsWith(".js") && f !== "index.js"); +let count = 0; + +for (const file of files) { + const path = join(REGISTRY_DIR, file); + const src = readFileSync(path, "utf8"); + + // Extract import lines (for files that import shared constants) + const importLines = src.split("\n").filter(l => l.startsWith("import ")); + const importSrc = importLines.join("\n"); + + // Dynamic import to get entry + let entry; + try { + const mod = await import(`${join(REGISTRY_DIR, file)}?t=${Date.now()}`); + entry = mod.default; + } catch (e) { + console.error(`SKIP ${file}: ${e.message}`); + continue; + } + + const migrated = migrateEntry(entry, file); + const output = formatEntry(migrated, importSrc); + + if (DRY) { + console.log(`\n=== ${file} ===\n${output}`); + } else { + writeFileSync(path, output, "utf8"); + count++; + } +} + +console.log(DRY ? `[DRY] Would migrate ${files.length} files` : `✅ Migrated ${count} files`); diff --git a/scripts/test-combo-autoswitch.mjs b/scripts/test-combo-autoswitch.mjs new file mode 100644 index 00000000..358ebcf9 --- /dev/null +++ b/scripts/test-combo-autoswitch.mjs @@ -0,0 +1,84 @@ +// Live test: combo capacity display + auto-switch routing. +// Sends text / image / search requests to a combo and reports which member ran. +// node scripts/test-combo-autoswitch.mjs +const BASE = process.env.BASE_URL || "http://localhost:20127"; +const KEY = process.env.API_KEY || "sk-6581be4f05a82b6b-uxy6jn-c8190ea8"; +const COMBO = process.env.COMBO || "haha"; + +// 16x16 PNG (valid image so vision providers accept it). +const PNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAFklEQVR4nGO4I2JDEmIY1TCqYfhqAAAeBCwQ8YdREQAAAABJRU5ErkJggg=="; + +function memberFromModel(model) { + // Response model usually = upstream id; map back to a combo member by substring. + return model || "(none)"; +} + +async function send(label, content, extra = {}) { + const body = { + model: COMBO, + stream: false, + max_tokens: 64, + messages: [{ role: "user", content }], + ...extra, + }; + const t0 = Date.now(); + let res, json, text; + try { + res = await fetch(`${BASE}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${KEY}` }, + body: JSON.stringify(body), + }); + text = await res.text(); + try { json = JSON.parse(text); } catch { /* keep text */ } + } catch (e) { + console.log(`\n[${label}] NETWORK ERROR: ${e.message}`); + return; + } + const ms = Date.now() - t0; + const model = json?.model || "(no model field)"; + const ok = res.ok; + const snippet = (json?.choices?.[0]?.message?.content || text || "").slice(0, 80).replace(/\n/g, " "); + console.log(`\n[${label}] ${ok ? "OK" : "FAIL"} ${res.status} (${ms}ms)`); + console.log(` model executed: ${memberFromModel(model)}`); + if (!ok) console.log(` error: ${(json?.error?.message || text || "").slice(0, 160)}`); + else console.log(` reply: ${snippet}`); +} + +async function showCaps() { + try { + const r = await fetch(`${BASE}/api/models`, { headers: { Authorization: `Bearer ${KEY}` } }); + if (!r.ok) { console.log("(/api/models needs dashboard auth, skipping caps table)"); return; } + const { models } = await r.json(); + const map = {}; + for (const m of models || []) if (m.caps) map[m.fullModel] = m.caps; + console.log("Capacity of combo members (vision/search):"); + for (const m of (process.env.MEMBERS || "").split(",").filter(Boolean)) { + const c = map[m] || {}; + console.log(` ${m}: vision=${!!c.vision} search=${!!c.search}`); + } + } catch { /* ignore */ } +} + +(async () => { + console.log(`Testing combo "${COMBO}" @ ${BASE}\n${"=".repeat(50)}`); + await showCaps(); + + // 1. Text-only: round-robin order (no capability requirement). + await send("text-only #1", "Say hello in one word."); + await send("text-only #2", "Say hi in one word."); + + // 2. Image: should auto-switch to a vision-capable member. + await send("image (needs vision)", [ + { type: "text", text: "What color is this image? One word." }, + { type: "image_url", image_url: { url: PNG } }, + ]); + + // 3. Search: should auto-switch to a search-capable member. + // Claude built-in web search requires a versioned tool type. + await send("search (needs search)", "What is the latest news today?", { + tools: [{ type: "web_search_20250305", name: "web_search" }], + }); + + console.log(`\n${"=".repeat(50)}\nDone. Compare 'model executed' across cases to verify auto-switch.`); +})(); diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js index c276fcb9..589d847a 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -347,10 +347,10 @@ export default function ClaudeToolCard({ - - info - diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index 8f795e30..abfc215c 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -5,7 +5,7 @@ import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, us import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers"; -import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, Toggle, ConfirmModal } from "@/shared/components"; +import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ConfirmModal, CapacityBadges, Select } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; @@ -19,6 +19,7 @@ export default function CombosPage() { const [editingCombo, setEditingCombo] = useState(null); const [activeProviders, setActiveProviders] = useState([]); const [comboStrategies, setComboStrategies] = useState({}); + const [modelCaps, setModelCaps] = useState({}); const [confirmState, setConfirmState] = useState(null); const { copied, copy } = useCopyToClipboard(); @@ -28,10 +29,11 @@ export default function CombosPage() { const fetchData = async () => { try { - const [combosRes, providersRes, settingsRes] = await Promise.all([ + const [combosRes, providersRes, settingsRes, modelsRes] = await Promise.all([ fetch("/api/combos"), fetch("/api/providers"), fetch("/api/settings"), + fetch("/api/models"), ]); const combosData = await combosRes.json(); const providersData = await providersRes.json(); @@ -42,6 +44,13 @@ export default function CombosPage() { if (providersRes.ok) { setActiveProviders(providersData.connections || []); } + if (modelsRes.ok) { + const md = await modelsRes.json(); + // Build fullModel -> caps map for badge lookup + const map = {}; + for (const m of md.models || []) if (m.caps) map[m.fullModel] = m.caps; + setModelCaps(map); + } setComboStrategies(settingsData.comboStrategies || {}); } catch (error) { console.log("Error fetching data:", error); @@ -106,21 +115,25 @@ export default function CombosPage() { }); }; - const handleToggleRoundRobin = async (comboName, enabled) => { + // Merge a per-combo strategy patch into settings.comboStrategies. Passing an empty + // patch (strategy back to default "fallback") drops the entry entirely. + const handleSetComboStrategy = async (comboName, patch) => { try { const updated = { ...comboStrategies }; - if (enabled) { - updated[comboName] = { fallbackStrategy: "round-robin" }; - } else { + const next = { ...(updated[comboName] || {}), ...patch }; + // Prune to keep settings clean: default fallback with no extras = no entry. + if (!next.fallbackStrategy || next.fallbackStrategy === "fallback") { delete updated[comboName]; + } else { + updated[comboName] = next; } - + await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ comboStrategies: updated }), }); - + setComboStrategies(updated); } catch (error) { console.log("Error updating combo strategy:", error); @@ -141,12 +154,17 @@ export default function CombosPage() { {/* Header */}
-

Combos

- Create model combos with fallback support + Group models under one name, then pick a strategy per combo:

+
    +
  • Fallback — tries models in order (next on failure)
  • +
  • Round Robin — rotates models across requests to spread load
  • +
  • Fusion — queries all models in parallel, then a judge synthesizes one answer. Best quality, but costs the most: every request bills all panel models + the judge (N+1 calls)
  • +
  • Capacity auto-switch — sends image/PDF/audio requests to a model that supports them first
  • +
-
@@ -171,12 +189,14 @@ export default function CombosPage() { setEditingCombo(combo)} onDelete={() => handleDelete(combo.id)} - roundRobinEnabled={comboStrategies[combo.name]?.fallbackStrategy === "round-robin"} - onToggleRoundRobin={(enabled) => handleToggleRoundRobin(combo.name, enabled)} + strategy={comboStrategies[combo.name] || {}} + onSetStrategy={(patch) => handleSetComboStrategy(combo.name, patch)} /> ))} @@ -214,7 +234,18 @@ export default function CombosPage() { ); } -function ComboCard({ combo, copied, onCopy, onEdit, onDelete, roundRobinEnabled, onToggleRoundRobin }) { +const STRATEGY_OPTIONS = [ + { value: "fallback", label: "Fallback — try in order" }, + { value: "round-robin", label: "Round Robin — rotate" }, + { value: "fusion", label: "Fusion — panel + judge" }, +]; + +function ComboCard({ combo, modelCaps = {}, activeProviders = [], copied, onCopy, onEdit, onDelete, strategy = {}, onSetStrategy }) { + const [showJudgeSelect, setShowJudgeSelect] = useState(false); + const current = strategy.fallbackStrategy || "fallback"; + const judge = strategy.judgeModel || ""; + const isFusion = current === "fusion"; + return (
@@ -229,8 +260,9 @@ function ComboCard({ combo, copied, onCopy, onEdit, onDelete, roundRobinEnabled, No models ) : ( combo.models.slice(0, 3).map((model, index) => ( - - {model} + + {model} + )) )} @@ -238,18 +270,41 @@ function ComboCard({ combo, copied, onCopy, onEdit, onDelete, roundRobinEnabled, +{combo.models.length - 3} more )}
+ {/* Fusion: judge picker (Auto = first model) */} + {isFusion && ( +
+ Judge + + {judge && ( + + )} +
+ )} {/* Actions */}
- {/* Round Robin Toggle — always visible */} -
- Round Robin - + - - {actions} -
- ); -} - -/** Reusable status alert */ -function StatusAlert({ status, className = "" }) { - // Render URLs in message as clickable links - const renderMessage = (msg) => { - const parts = msg.split(/(https?:\/\/[^\s]+)/g); - return parts.map((part, i) => - /^https?:\/\//.test(part) - ? {part} - : part - ); - }; - - return ( -
- {renderMessage(status.message)} -
- ); -} - -/** Inline tooltip, Claude Code CLI style */ -function Tooltip({ text }) { - return ( - - help - - {text} - - - ); -} - -/** Security warning banner with optional action link */ -function SecurityWarning({ message, action }) { - return ( - - ); -} APIPageClient.propTypes = { machineId: PropTypes.string.isRequired, diff --git a/src/app/(dashboard)/dashboard/endpoint/components/EndpointRow.js b/src/app/(dashboard)/dashboard/endpoint/components/EndpointRow.js new file mode 100644 index 00000000..29d3d653 --- /dev/null +++ b/src/app/(dashboard)/dashboard/endpoint/components/EndpointRow.js @@ -0,0 +1,22 @@ +"use client"; + +import { Input } from "@/shared/components"; + +/** Reusable endpoint row component */ +export default function EndpointRow({ label, url, copyId, copied, onCopy, badge, actions }) { + return ( +
+ {label} + + + {actions} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/endpoint/components/SecurityWarning.js b/src/app/(dashboard)/dashboard/endpoint/components/SecurityWarning.js new file mode 100644 index 00000000..87565038 --- /dev/null +++ b/src/app/(dashboard)/dashboard/endpoint/components/SecurityWarning.js @@ -0,0 +1,23 @@ +"use client"; + +/** Security warning banner with optional action link */ +export default function SecurityWarning({ message, action }) { + return ( + + ); +} diff --git a/src/app/(dashboard)/dashboard/endpoint/components/StatusAlert.js b/src/app/(dashboard)/dashboard/endpoint/components/StatusAlert.js new file mode 100644 index 00000000..c0609abc --- /dev/null +++ b/src/app/(dashboard)/dashboard/endpoint/components/StatusAlert.js @@ -0,0 +1,23 @@ +"use client"; + +/** Reusable status alert */ +export default function StatusAlert({ status, className = "" }) { + const renderMessage = (msg) => { + const parts = msg.split(/(https?:\/\/[^\s]+)/g); + return parts.map((part, i) => + /^https?:\/\//.test(part) + ? {part} + : part + ); + }; + + return ( +
+ {renderMessage(status.message)} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/endpoint/components/Tooltip.js b/src/app/(dashboard)/dashboard/endpoint/components/Tooltip.js new file mode 100644 index 00000000..c9726ea0 --- /dev/null +++ b/src/app/(dashboard)/dashboard/endpoint/components/Tooltip.js @@ -0,0 +1,13 @@ +"use client"; + +/** Inline tooltip, Claude Code CLI style */ +export default function Tooltip({ text }) { + return ( + + help + + {text} + + + ); +} diff --git a/src/app/(dashboard)/dashboard/endpoint/endpointConstants.js b/src/app/(dashboard)/dashboard/endpoint/endpointConstants.js new file mode 100644 index 00000000..ac10b76b --- /dev/null +++ b/src/app/(dashboard)/dashboard/endpoint/endpointConstants.js @@ -0,0 +1,32 @@ +export const WENYAN_LOCALES = ["zh-CN", "zh-TW"]; + +export const TUNNEL_BENEFITS = [ + { icon: "public", title: "Access Anywhere", desc: "Use your API from any network" }, + { icon: "group", title: "Share Endpoint", desc: "Share URL with team members" }, + { icon: "code", title: "Use in Cursor/Cline", desc: "Connect AI tools remotely" }, + { icon: "lock", title: "Encrypted", desc: "End-to-end TLS via Cloudflare" }, +]; + +export const TUNNEL_PING_INTERVAL_MS = 2000; +export const TUNNEL_PING_MAX_MS = 300000; +export const STATUS_POLL_FAST_MS = 5000; +export const STATUS_POLL_SLOW_MS = 30000; +export const REACHABLE_MISS_THRESHOLD = 5; +export const CLIENT_PING_FAST_MS = 10000; +export const CLIENT_PING_SLOW_MS = 60000; +export const CLIENT_PING_TIMEOUT_MS = 5000; + +export const CAVEMAN_LEVELS = [ + { id: "lite", label: "Lite", desc: "Drop filler, keep grammar" }, + { id: "full", label: "Full", desc: "Drop articles, fragments OK" }, + { id: "ultra", label: "Ultra", desc: "Telegraphic, max compression" }, + { id: "wenyan-lite", label: "文 Lite", desc: "Classical Chinese, light compression", wenyan: true }, + { id: "wenyan", label: "文 Full", desc: "Maximum 文言文, 80-90% reduction", wenyan: true }, + { id: "wenyan-ultra", label: "文 Ultra", desc: "Extreme classical compression", wenyan: true }, +]; + +export const PONYTAIL_LEVELS = [ + { id: "lite", label: "Lite", desc: "Build asked, name lazier option" }, + { id: "full", label: "Full", desc: "Ladder enforced: stdlib/native first" }, + { id: "ultra", label: "Ultra", desc: "YAGNI extremist, deletion first" }, +]; diff --git a/src/app/(dashboard)/dashboard/endpoint/endpointPing.js b/src/app/(dashboard)/dashboard/endpoint/endpointPing.js new file mode 100644 index 00000000..5c522cdc --- /dev/null +++ b/src/app/(dashboard)/dashboard/endpoint/endpointPing.js @@ -0,0 +1,29 @@ +import { CLIENT_PING_TIMEOUT_MS } from "./endpointConstants"; + +// Browser-side health probe: must reach origin (not just CF/TS edge). +// cors mode → res.ok=false for 5xx (e.g. Cloudflare 530 when origin dead). +// /api/health route sets Access-Control-Allow-Origin: * → CORS works through tunnel. +export async function clientPingUrl(url) { + if (!url) return false; + try { + const res = await fetch(`${url}/api/health`, { + mode: "cors", + cache: "no-store", + signal: AbortSignal.timeout(CLIENT_PING_TIMEOUT_MS), + }); + return res.ok; + } catch { return false; } +} + +// Race multiple URLs: resolve true as soon as any one passes ping. +export async function clientPingAny(...urls) { + const checks = urls.filter(Boolean).map(clientPingUrl); + if (!checks.length) return false; + return new Promise((resolve) => { + let pending = checks.length; + checks.forEach((p) => p.then((ok) => { + if (ok) resolve(true); + else if (--pending === 0) resolve(false); + })); + }); +} diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/EmbeddingExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/EmbeddingExampleCard.js new file mode 100644 index 00000000..5580d759 --- /dev/null +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/EmbeddingExampleCard.js @@ -0,0 +1,254 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card } from "@/shared/components"; +import { getProviderAlias, isCustomEmbeddingProvider } from "@/shared/constants/providers"; +import { getModelsByProviderId, getModelKind } from "@/shared/constants/models"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { Row } from "./exampleShared"; + +const DEFAULT_RESPONSE_EXAMPLE = `{ + "object": "list", + "data": [{ + "object": "embedding", + "index": 0, + "embedding": [0.002301, -0.019212, 0.004815, -0.031249, ...] + }], + "model": "...", + "usage": { "prompt_tokens": 9, "total_tokens": 9 } +}`; + +export function EmbeddingExampleCard({ providerId, customAlias }) { + const isCustom = isCustomEmbeddingProvider(providerId); + const providerAlias = isCustom ? (customAlias || providerId) : getProviderAlias(providerId); + const embeddingModels = isCustom ? [] : getModelsByProviderId(providerId).filter((m) => getModelKind(m) === "embedding"); + + const [selectedModel, setSelectedModel] = useState(embeddingModels[0]?.id ?? ""); + const [input, setInput] = useState("The quick brown fox jumps over the lazy dog"); + const [dimensions, setDimensions] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [useTunnel, setUseTunnel] = useState(false); + const [localEndpoint, setLocalEndpoint] = useState(""); + const [tunnelEndpoint, setTunnelEndpoint] = useState(""); + const [result, setResult] = useState(null); + const [running, setRunning] = useState(false); + const [error, setError] = useState(""); + const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard(); + const { copied: copiedRes, copy: copyRes } = useCopyToClipboard(); + + useEffect(() => { + setLocalEndpoint(window.location.origin); + fetch("/api/keys") + .then((r) => r.json()) + .then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); }) + .catch(() => {}); + fetch("/api/tunnel/status") + .then((r) => r.json()) + .then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); }) + .catch(() => {}); + }, []); + + const endpoint = useTunnel ? tunnelEndpoint : localEndpoint; + const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : ""; + + // Build request body — include dimensions only if user provided a positive number + const buildBody = () => { + const body = { model: modelFull, input: input.trim() }; + const dim = Number(dimensions); + if (dimensions && Number.isFinite(dim) && dim > 0) body.dimensions = dim; + return body; + }; + + const curlSnippet = `curl -X POST ${endpoint}/v1/embeddings \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\ + -d '${JSON.stringify(buildBody())}'`; + + const handleRun = async () => { + if (!input.trim() || !modelFull) return; + setRunning(true); + setError(""); + setResult(null); + const start = Date.now(); + try { + const headers = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const res = await fetch("/api/v1/embeddings", { + method: "POST", + headers, + body: JSON.stringify(buildBody()), + }); + const latencyMs = Date.now() - start; + const data = await res.json(); + if (!res.ok) { setError(data?.error?.message || data?.error || `HTTP ${res.status}`); return; } + setResult({ data, latencyMs }); + } catch (e) { + setError(e.message || "Network error"); + } finally { + setRunning(false); + } + }; + + // Compact embedding array: first 4 values + count + const formatResultJson = (data) => { + if (!data) return DEFAULT_RESPONSE_EXAMPLE; + const clone = JSON.parse(JSON.stringify(data)); + (clone.data || []).forEach((item) => { + if (Array.isArray(item.embedding) && item.embedding.length > 4) { + item.embedding = [...item.embedding.slice(0, 4).map((v) => parseFloat(v.toFixed(6))), `... (${item.embedding.length} dims)`]; + } + }); + return JSON.stringify(clone, null, 2); + }; + + const resultJson = result ? JSON.stringify(result.data, null, 2) : ""; + + return ( + +

Example

+ +
+ {/* Model — text input for custom node, dropdown otherwise */} + + {isCustom ? ( + setSelectedModel(e.target.value)} + placeholder="e.g. voyage-3, embed-english-v3.0, text-embedding-3-small" + className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono" + /> + ) : ( + + )} + + + {/* Endpoint */} + +
+ useTunnel ? setTunnelEndpoint(e.target.value) : setLocalEndpoint(e.target.value)} + className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono" + placeholder="http://localhost:3000" + /> + {/* Tunnel toggle — only show if tunnel URL is available */} + {tunnelEndpoint && ( + + )} +
+
+ + {/* API Key */} + + setApiKey(e.target.value)} + placeholder="sk-..." + className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono" + /> + + + {/* Input */} + +
+ setInput(e.target.value)} + className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> + {input && ( + + )} +
+
+ + {/* Dimensions (optional) — truncate embedding vector length */} + + setDimensions(e.target.value)} + placeholder="optional, e.g. 512, 1024 (leave empty for default)" + className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> + + + {/* Curl + Run */} +
+
+ Request +
+ + +
+
+
{curlSnippet}
+
+ + {/* Error */} + {error &&

{error}

} + + {/* Response — default example or real result */} +
+
+ + Response {result && ⚡ {result.latencyMs}ms} + + {result && ( + + )} +
+
+            {formatResultJson(result?.data)}
+          
+
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js new file mode 100644 index 00000000..815528d7 --- /dev/null +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/GenericExampleCard.js @@ -0,0 +1,539 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card } from "@/shared/components"; +import { MEDIA_PROVIDER_KINDS, getProviderAlias, resolveProviderId } from "@/shared/constants/providers"; +import { getModelsByProviderId, getModelKind } from "@/shared/constants/models"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { Row, KIND_EXAMPLE_CONFIG } from "./exampleShared"; + +const CLOUDFLARE_TEST_IMAGE_URL = "https://pub-1fb693cb11cc46b2b2f656f51e015a2c.r2.dev/dog.png"; +const CLOUDFLARE_TEST_MASK_URL = "https://pub-1fb693cb11cc46b2b2f656f51e015a2c.r2.dev/dog-mask.png"; + +function getImageEditDefaults(providerId, modelId) { + if (providerId !== "cloudflare-ai") return {}; + if (modelId === "@cf/runwayml/stable-diffusion-v1-5-img2img") { + return { image: CLOUDFLARE_TEST_IMAGE_URL }; + } + if (modelId === "@cf/runwayml/stable-diffusion-v1-5-inpainting") { + return { image: CLOUDFLARE_TEST_IMAGE_URL, mask_image: CLOUDFLARE_TEST_MASK_URL }; + } + return {}; +} + +function toImagePreviewSrc(value) { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (!trimmed) return ""; + if (/^(data:image\/|https?:\/\/)/i.test(trimmed)) return trimmed; + return `data:image/png;base64,${trimmed}`; +} + +export function GenericExampleCard({ providerId, kind }) { + const providerAlias = getProviderAlias(providerId); + const resolvedId = resolveProviderId(providerAlias); + const safeProviderAlias = resolvedId === providerId ? providerAlias : providerId; + const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind); + const exConfig = KIND_EXAMPLE_CONFIG[kind]; + const safeExConfig = exConfig || {}; + + // Get models for this kind (e.g., type="image") + const kindModels = getModelsByProviderId(providerId).filter((m) => getModelKind(m) === kind); + // Kinds that need a model identifier in the request (image/video/music) + const KIND_NEEDS_MODEL = new Set(["image", "video", "music", "imageToText"]); + const needsModel = KIND_NEEDS_MODEL.has(kind); + const allowManualModel = needsModel && kindModels.length === 0; + const [selectedModel, setSelectedModel] = useState(kindModels[0]?.id ?? ""); + const selectedModelObj = kindModels.find((m) => m.id === selectedModel); + const supportsEdit = !!selectedModelObj?.capabilities?.includes("edit"); + const supportsMask = !!selectedModelObj?.capabilities?.includes("mask"); + + const [input, setInput] = useState(safeExConfig.defaultInput || ""); + const [refImage, setRefImage] = useState(""); + const [maskImage, setMaskImage] = useState(""); + const [extraValues, setExtraValues] = useState(() => + (safeExConfig.extraFields || []).reduce((acc, f) => { acc[f.key] = f.default ?? ""; return acc; }, {}) + ); + const [apiKey, setApiKey] = useState(""); + const [useTunnel, setUseTunnel] = useState(false); + const [localEndpoint, setLocalEndpoint] = useState(""); + const [tunnelEndpoint, setTunnelEndpoint] = useState(""); + const [result, setResult] = useState(null); + const [progress, setProgress] = useState(null); // { stage, bytesReceived } + const [partialImage, setPartialImage] = useState(null); + const [imageOutputFormat, setImageOutputFormat] = useState("json"); // json | binary + const [binaryImageUrl, setBinaryImageUrl] = useState(""); + const [running, setRunning] = useState(false); + const [error, setError] = useState(""); + const [connections, setConnections] = useState([]); + const [pinnedConnectionId, setPinnedConnectionId] = useState(""); + const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard(); + const { copied: copiedRes, copy: copyRes } = useCopyToClipboard(); + + useEffect(() => { + setLocalEndpoint(window.location.origin); + fetch("/api/keys") + .then((r) => r.json()) + .then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); }) + .catch(() => {}); + fetch("/api/tunnel/status") + .then((r) => r.json()) + .then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); }) + .catch(() => {}); + // Load active connections of this provider for pinning + fetch("/api/providers/client") + .then((r) => r.json()) + .then((d) => { + const conns = (d.connections || []).filter((c) => c.provider === providerId && c.isActive !== false); + setConnections(conns); + }) + .catch(() => {}); + }, [providerId]); + + // Safe to early-return now that all hooks are declared + if (!kindConfig || !exConfig) return null; + + const endpoint = useTunnel ? tunnelEndpoint : localEndpoint; + const apiPath = kindConfig.endpoint.path; + // webSearch/webFetch: use safeProviderAlias only. Other kinds: append model when present. + const modelFull = !needsModel + ? safeProviderAlias + : (selectedModel ? `${safeProviderAlias}/${selectedModel}` : (allowManualModel ? "" : safeProviderAlias)); + const imageEditDefaults = getImageEditDefaults(providerId, selectedModel); + const effectiveRefImage = refImage.trim() || imageEditDefaults.image || ""; + const effectiveMaskImage = maskImage.trim() || imageEditDefaults.mask_image || ""; + const refImagePreviewSrc = toImagePreviewSrc(effectiveRefImage); + const maskImagePreviewSrc = toImagePreviewSrc(effectiveMaskImage); + + // Build request body with optional extra fields (only non-empty values) + const extraBodyFromFields = Object.entries(extraValues).reduce((acc, [k, v]) => { + if (v === "" || v === null || v === undefined) return acc; + if (typeof v === "number" && Number.isNaN(v)) return acc; + acc[k] = v; + return acc; + }, {}); + const requestBody = { + model: modelFull, + [exConfig.bodyKey]: input, + ...exConfig.extraBody, + ...extraBodyFromFields, + ...(supportsEdit && effectiveRefImage ? { image: effectiveRefImage } : {}), + ...(supportsMask && effectiveMaskImage ? { mask_image: effectiveMaskImage } : {}), + }; + + // Streaming supported for codex image (Plus/Pro accounts) — disabled when binary output requested + const wantBinary = kind === "image" && imageOutputFormat === "binary"; + const useStreaming = kind === "image" && providerId === "codex" && !wantBinary; + const apiPathWithQuery = `${apiPath}${wantBinary ? "?response_format=binary" : ""}`; + const headersPreview = `-H "Content-Type: application/json" \\\n -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}"${pinnedConnectionId ? ` \\\n -H "x-connection-id: ${pinnedConnectionId}"` : ""}${useStreaming ? ` \\\n -H "Accept: text/event-stream"` : ""}`; + const curlSnippet = `curl -X ${kindConfig.endpoint.method} ${endpoint}${apiPathWithQuery} \\ + ${headersPreview.replace(/\\\n /g, "\\\n ")} \\ + -d '${JSON.stringify(requestBody)}'${wantBinary ? " \\\n --output image.png" : ""}`; + + const handleRun = async () => { + if (!input.trim() || !modelFull) return; + setRunning(true); + setError(""); + setResult(null); + setProgress(null); + setPartialImage(null); + if (binaryImageUrl) { try { URL.revokeObjectURL(binaryImageUrl); } catch {} setBinaryImageUrl(""); } + const start = Date.now(); + try { + const headers = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + if (pinnedConnectionId) headers["x-connection-id"] = pinnedConnectionId; + if (useStreaming) headers["Accept"] = "text/event-stream"; + const body = { ...requestBody, model: modelFull }; + const res = await fetch(`/api${apiPathWithQuery}`, { + method: kindConfig.endpoint.method, + headers, + body: JSON.stringify(body), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(data?.error?.message || data?.error || `HTTP ${res.status}`); + return; + } + const ctype = res.headers.get("content-type") || ""; + // Binary image response — convert to blob URL + if (ctype.startsWith("image/")) { + const blob = await res.blob(); + const objUrl = URL.createObjectURL(blob); + setBinaryImageUrl(objUrl); + setResult({ data: { binary: true, mime: ctype, size: blob.size }, latencyMs: Date.now() - start }); + return; + } + const isSse = ctype.includes("text/event-stream"); + if (isSse && res.body) { + // Parse SSE: progress / partial_image / done / error + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + let finalData = null; + let streamErr = null; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let sep; + while ((sep = buf.indexOf("\n\n")) !== -1) { + const block = buf.slice(0, sep); + buf = buf.slice(sep + 2); + let evt = null, dataStr = ""; + for (const line of block.split("\n")) { + if (line.startsWith("event:")) evt = line.slice(6).trim(); + else if (line.startsWith("data:")) dataStr += line.slice(5).trim(); + } + if (!evt) continue; + try { + const payload = dataStr ? JSON.parse(dataStr) : {}; + if (evt === "progress") setProgress(payload); + else if (evt === "partial_image") setPartialImage(payload); + else if (evt === "done") finalData = payload; + else if (evt === "error") streamErr = payload?.message || "Stream error"; + } catch {} + } + } + const latencyMs = Date.now() - start; + if (streamErr) { setError(streamErr); return; } + if (finalData) setResult({ data: finalData, latencyMs }); + } else { + const data = await res.json(); + const latencyMs = Date.now() - start; + setResult({ data, latencyMs }); + } + } catch (e) { + setError(e.message || "Network error"); + } finally { + setRunning(false); + } + }; + + // Mask large b64_json strings in JSON view to keep it readable + const maskB64 = (obj) => { + if (!obj || typeof obj !== "object") return obj; + if (Array.isArray(obj)) return obj.map(maskB64); + const out = {}; + for (const [k, v] of Object.entries(obj)) { + out[k] = (k === "b64_json" && typeof v === "string" && v.length > 100) + ? `<${v.length} chars base64>` + : maskB64(v); + } + return out; + }; + const resultJson = result ? JSON.stringify(maskB64(result.data), null, 2) : ""; + + return ( + +

Example

+
+ {/* Model selector — dropdown if presets exist, else manual input for media kinds */} + {kindModels.length > 0 ? ( + + + + ) : allowManualModel ? ( + + setSelectedModel(e.target.value)} + placeholder="Enter model id (provider-specific)" + className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono" + /> + + ) : null} + + {/* Endpoint */} + +
+ + {endpoint}{apiPath} + + {tunnelEndpoint && ( + + )} +
+
+ + {/* API Key */} + + + {apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : No key configured} + + + + {/* Connection picker - only show when 2+ connections (or any with email) */} + {connections.length > 0 && ( + + + + )} + + {/* Input */} + +
+ setInput(e.target.value)} + placeholder={exConfig.inputPlaceholder} + className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> + {input && ( + + )} +
+
+ + {/* Reference image (only for edit-capable image models) */} + {supportsEdit && ( + +
+
+ setRefImage(e.target.value)} + placeholder={imageEditDefaults.image || "https://example.com/source.png"} + className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> + {refImage && ( + + )} +
+ {refImagePreviewSrc && ( + Reference { e.currentTarget.style.display = "none"; }} + onLoad={(e) => { e.currentTarget.style.display = "block"; }} + /> + )} +
+
+ )} + + {supportsMask && ( + +
+
+ setMaskImage(e.target.value)} + placeholder={imageEditDefaults.mask_image || "https://example.com/mask.png"} + className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> + {maskImage && ( + + )} +
+ {maskImagePreviewSrc && ( + Mask { e.currentTarget.style.display = "none"; }} + onLoad={(e) => { e.currentTarget.style.display = "block"; }} + /> + )} +
+
+ )} + + {/* Extra fields — for kinds without model concept (webSearch/webFetch), show all; otherwise filter by model.params */} + {(exConfig.extraFields || []) + .filter((f) => kindModels.length === 0 || (Array.isArray(selectedModelObj?.params) && selectedModelObj.params.includes(f.key))) + .map((f) => ( + + {f.type === "select" ? ( + + ) : f.type === "text" ? ( + setExtraValues((s) => ({ ...s, [f.key]: e.target.value }))} + className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> + ) : ( + setExtraValues((s) => ({ ...s, [f.key]: e.target.value === "" ? "" : Number(e.target.value) }))} + className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> + )} + + ))} + + {/* Output Format toggle (image only) — last */} + {kind === "image" && ( + + + + )} + + {/* Curl + Run */} +
+
+ Request +
+ + +
+
+
{curlSnippet}
+
+ + {/* Streaming progress */} + {(running || progress) && useStreaming && ( +
+ + {running ? "progress_activity" : "check_circle"} + + + {progress?.stage || "starting"} + {!running && progress?.bytesReceived ? ` · ${(progress.bytesReceived / 1024).toFixed(1)} KB` : ""} + +
+ )} + + {/* Partial image preview (codex stream) */} + {partialImage?.b64_json && !result && ( +
+ Partial preview + Partial +
+ )} + + {/* Error */} + {error &&

{error}

} + + {/* Response */} +
+
+ + Response {result && ⚡ {result.latencyMs}ms} + + {result && ( + + )} +
+
+            {result ? resultJson : exConfig.defaultResponse}
+          
+ {kind === "image" && (binaryImageUrl || result?.data?.data?.[0]) && ( + + )} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/SttExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/SttExampleCard.js new file mode 100644 index 00000000..18f2a4f6 --- /dev/null +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/SttExampleCard.js @@ -0,0 +1,290 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card } from "@/shared/components"; +import { getProviderAlias } from "@/shared/constants/providers"; +import { getModelKind } from "@/shared/constants/models"; +import { getModelsByProviderId } from "@/shared/constants/models"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { Row } from "./exampleShared"; + +export function SttExampleCard({ providerId }) { + const providerAlias = getProviderAlias(providerId); + const builtinSttModels = getModelsByProviderId(providerId).filter((m) => getModelKind(m) === "stt"); + const [customSttModels, setCustomSttModels] = useState([]); + const sttModels = [...builtinSttModels, ...customSttModels]; + + const [selectedModel, setSelectedModel] = useState(builtinSttModels[0]?.id ?? ""); + const selectedModelObj = sttModels.find((m) => m.id === selectedModel); + const allowedParams = Array.isArray(selectedModelObj?.params) ? selectedModelObj.params : []; + + const [audioFile, setAudioFile] = useState(null); + const [language, setLanguage] = useState(""); + const [prompt, setPrompt] = useState(""); + const [responseFormat, setResponseFormat] = useState("json"); + const [temperature, setTemperature] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [useTunnel, setUseTunnel] = useState(false); + const [localEndpoint, setLocalEndpoint] = useState(""); + const [tunnelEndpoint, setTunnelEndpoint] = useState(""); + const [result, setResult] = useState(null); + const [latency, setLatency] = useState(null); + const [running, setRunning] = useState(false); + const [error, setError] = useState(""); + const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard(); + const { copied: copiedRes, copy: copyRes } = useCopyToClipboard(); + + useEffect(() => { + setLocalEndpoint(window.location.origin); + fetch("/api/keys") + .then((r) => r.json()) + .then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); }) + .catch(() => {}); + fetch("/api/tunnel/status") + .then((r) => r.json()) + .then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); }) + .catch(() => {}); + const loadCustom = () => { + fetch("/api/models/custom", { cache: "no-store" }) + .then((r) => r.json()) + .then((d) => { + const list = (d.models || []).filter((m) => getModelKind(m) === "stt" && m.providerAlias === providerAlias); + setCustomSttModels(list); + }) + .catch(() => {}); + }; + loadCustom(); + window.addEventListener("focus", loadCustom); + window.addEventListener("customModelChanged", loadCustom); + return () => { + window.removeEventListener("focus", loadCustom); + window.removeEventListener("customModelChanged", loadCustom); + }; + }, [providerAlias]); + + const endpoint = useTunnel ? tunnelEndpoint : localEndpoint; + const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : ""; + + const curlSnippet = `curl -X POST ${endpoint}/v1/audio/transcriptions \\ + -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\ + -F "file=@${audioFile?.name || "audio.mp3"}" \\ + -F "model=${modelFull}"${allowedParams.includes("language") && language ? ` \\\n -F "language=${language}"` : ""}${allowedParams.includes("response_format") ? ` \\\n -F "response_format=${responseFormat}"` : ""}${allowedParams.includes("temperature") && temperature ? ` \\\n -F "temperature=${temperature}"` : ""}${allowedParams.includes("prompt") && prompt ? ` \\\n -F "prompt=${prompt}"` : ""}`; + + const handleRun = async () => { + if (!audioFile || !modelFull) return; + setRunning(true); + setError(""); + setResult(null); + const start = Date.now(); + try { + const fd = new FormData(); + fd.append("file", audioFile); + fd.append("model", modelFull); + if (allowedParams.includes("language") && language) fd.append("language", language); + if (allowedParams.includes("response_format")) fd.append("response_format", responseFormat); + if (allowedParams.includes("temperature") && temperature) fd.append("temperature", temperature); + if (allowedParams.includes("prompt") && prompt) fd.append("prompt", prompt); + + const headers = {}; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const res = await fetch("/api/v1/audio/transcriptions", { method: "POST", headers, body: fd }); + setLatency(Date.now() - start); + const ct = res.headers.get("content-type") || ""; + const data = ct.includes("application/json") ? await res.json() : await res.text(); + if (!res.ok) { + setError(data?.error?.message || data?.error || data || `HTTP ${res.status}`); + return; + } + setResult(data); + } catch (e) { + setError(e.message || "Network error"); + } finally { + setRunning(false); + } + }; + + const resultStr = typeof result === "string" ? result : (result ? JSON.stringify(result, null, 2) : `{\n "text": "Hello world..."\n}`); + + return ( + +

Example

+
+ {/* Model */} + {sttModels.length > 0 ? ( + + + + ) : ( + + setSelectedModel(e.target.value)} + placeholder="Enter model id" + className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono" + /> + + )} + + {/* Endpoint */} + +
+ + {endpoint}/v1/audio/transcriptions + + {tunnelEndpoint && ( + + )} +
+
+ + {/* API Key */} + + + {apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : No key configured} + + + + {/* Audio file */} + +
+ setAudioFile(e.target.files?.[0] || null)} + className="w-full text-xs text-text-muted file:mr-2 file:py-1 file:px-2.5 file:rounded-lg file:border file:border-border file:bg-background file:text-text-main hover:file:bg-sidebar file:cursor-pointer" + /> + {audioFile && ( + + {audioFile.name} · {(audioFile.size / 1024).toFixed(1)} KB + + )} +
+
+ + {/* Language (if model supports) */} + {allowedParams.includes("language") && ( + + setLanguage(e.target.value)} + placeholder="e.g. en, vi, ja (auto-detect if empty)" + className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono" + /> + + )} + + {/* Prompt (if model supports) */} + {allowedParams.includes("prompt") && ( + + setPrompt(e.target.value)} + placeholder="optional context to improve accuracy" + className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> + + )} + + {/* Temperature (if model supports) */} + {allowedParams.includes("temperature") && ( + + setTemperature(e.target.value)} + placeholder="0 - 1 (default 0)" + className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> + + )} + + {/* Response format (if model supports) */} + {allowedParams.includes("response_format") && ( + + + + )} + + {/* Curl + Run */} +
+
+ Request +
+ + +
+
+
{curlSnippet}
+
+ + {error &&

{error}

} + + {/* Response */} +
+
+ + Response {result && latency && ⚡ {latency}ms} + + {result && ( + + )} +
+
+            {resultStr}
+          
+
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js new file mode 100644 index 00000000..e0191903 --- /dev/null +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/TtsExampleCard.js @@ -0,0 +1,566 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card } from "@/shared/components"; +import { AI_PROVIDERS, getProviderAlias } from "@/shared/constants/providers"; +import { getModelsByProviderId, getModelKind } from "@/shared/constants/models"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { TTS_PROVIDER_CONFIG } from "@/shared/constants/ttsProviders"; +import { getTtsVoicesForModel } from "open-sse/config/ttsModels.js"; +import { GOOGLE_TTS_LANGUAGES } from "open-sse/config/googleTtsLanguages.js"; +import { Row } from "./exampleShared"; + +const DEFAULT_TTS_RESPONSE_EXAMPLE = `// Audio will appear here after running. +// Example JSON response (response_format=json): +{ + "format": "mp3", + "audio": "//NExAANaAIIAUAAANNNNNNNN..." // base64 encoded MP3 +}`; + +export function TtsExampleCard({ providerId }) { + const providerAlias = getProviderAlias(providerId); + const config = TTS_PROVIDER_CONFIG[providerId] || TTS_PROVIDER_CONFIG["edge-tts"]; + + // Voice state + const [selectedVoice, setSelectedVoice] = useState(config.defaultVoiceId || ""); + const [selectedVoiceName, setSelectedVoiceName] = useState(""); + const [voiceId, setVoiceId] = useState(config.defaultVoiceId || ""); // editable voice id (elevenlabs/config providers) + // Voices shown below Voice row after language selected + const [countryVoices, setCountryVoices] = useState([]); + const [selectedLang, setSelectedLang] = useState(""); + const [selectedModel, setSelectedModel] = useState(() => { + const cfgModels = AI_PROVIDERS[providerId]?.ttsConfig?.models; + if (cfgModels?.length) return cfgModels[0].id; + if (config.hasModelSelector && config.modelKey) { + const models = getModelsByProviderId(config.modelKey); + return models?.[0]?.id || ""; + } + return ""; + }); + + // Form state + const [input, setInput] = useState("Hello, this is a text to speech test."); + const [apiKey, setApiKey] = useState(""); + const [useTunnel, setUseTunnel] = useState(false); + const [localEndpoint, setLocalEndpoint] = useState(""); + const [tunnelEndpoint, setTunnelEndpoint] = useState(""); + const [responseFormat, setResponseFormat] = useState("mp3"); // mp3 | json + const [audioUrl, setAudioUrl] = useState(""); + const [jsonResponse, setJsonResponse] = useState(null); // Store JSON response + const [running, setRunning] = useState(false); + const [error, setError] = useState(""); + const [latency, setLatency] = useState(null); + const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard(); + + // Country picker modal state + const [modalOpen, setModalOpen] = useState(false); + const [languages, setLanguages] = useState([]); + const [modalLoading, setModalLoading] = useState(false); + const [modalSearch, setModalSearch] = useState(""); + const [modalError, setModalError] = useState(""); + const [byLang, setByLang] = useState({}); + // Language hint (e.g. Gemini): controls the spoken language without affecting voice selection + const [languageHint, setLanguageHint] = useState(""); + + useEffect(() => { + setLocalEndpoint(window.location.origin); + fetch("/api/keys") + .then((r) => r.json()) + .then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); }) + .catch(() => {}); + fetch("/api/tunnel/status") + .then((r) => r.json()) + .then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); }) + .catch(() => {}); + + // Pre-select default voice based on provider config + if (config.voiceSource === "hardcoded") { + const defaultModel = config.hasModelSelector && config.modelKey + ? (getModelsByProviderId(config.modelKey)?.[0]?.id || "") + : ""; + // Use per-model voices if available, else flat list + const voices = (config.voicesPerModel && defaultModel) + ? (getTtsVoicesForModel(providerId, defaultModel) || []) + : getModelsByProviderId(config.voiceKey || providerId).filter((m) => getModelKind(m) === "tts"); + if (voices.length) { + if (config.hasBrowseButton) { + // Google TTS: pre-select "en" (English) as default, show as single voice chip + const defaultVoice = voices.find((v) => v.id === "en") || voices[0]; + setSelectedLang(defaultVoice.id); + setSelectedVoice(defaultVoice.id); + setSelectedVoiceName(defaultVoice.name); + setCountryVoices([{ id: defaultVoice.id, name: defaultVoice.name }]); + } else { + // OpenAI/OpenRouter: set voice chips directly (no language picker) + setCountryVoices(voices); + setSelectedVoice(voices[0].id); + setSelectedVoiceName(voices[0].name || voices[0].id); + } + } + } + // api-language (edge-tts, local-device, elevenlabs): NO default load, wait for user to pick language + // config (nvidia, hyperbolic, deepgram, huggingface, cartesia, playht, coqui, tortoise, inworld, qwen): + // use ttsConfig.models for model selector; voice is empty by default (backend uses provider default) + }, [providerId]); + + // Update voices when model changes (voicesPerModel providers) + useEffect(() => { + if (!config.voicesPerModel || !selectedModel) return; + const voices = getTtsVoicesForModel(providerId, selectedModel) || []; + setCountryVoices(voices); + if (voices.length) { + setSelectedVoice(voices[0].id); + setSelectedVoiceName(voices[0].name || voices[0].id); + } + }, [selectedModel]); + + // Open modal — load language list + const openModal = async () => { + setModalOpen(true); + setModalSearch(""); + setModalError(""); + if (languages.length) return; // already loaded + setModalLoading(true); + try { + if (config.voiceSource === "hardcoded") { + // Build languages/byLang from static providerModels data + const voiceKey = config.voiceKey || providerId; + const voices = getModelsByProviderId(voiceKey).filter((m) => getModelKind(m) === "tts"); + const byLangMap = {}; + for (const v of voices) { + if (!byLangMap[v.id]) byLangMap[v.id] = { code: v.id, name: v.name, voices: [{ id: v.id, name: v.name }] }; + } + setByLang(byLangMap); + setLanguages(Object.values(byLangMap).sort((a, b) => a.name.localeCompare(b.name))); + } else { + // Use provider-specific apiEndpoint if available, else default to edge-tts voices API + const url = config.apiEndpoint + ? config.apiEndpoint + : `/api/media-providers/tts/voices?provider=${providerId === "local-device" ? "local-device" : "edge-tts"}`; + const r = await fetch(url); + const d = await r.json(); + if (d.error) { setModalError(d.error); return; } + setLanguages(d.languages || []); + setByLang(d.byLang || {}); + } + } catch (e) { + setModalError(e.message); + } finally { + setModalLoading(false); + } + }; + + // Click language → close modal → show voices below + const handlePickLanguage = (lang) => { + setModalOpen(false); + setSelectedLang(lang.code); + const voices = byLang[lang.code]?.voices || []; + setCountryVoices(voices); + // Auto-select first voice + if (voices.length) { + setSelectedVoice(voices[0].id); + setSelectedVoiceName(voices[0].name); + if (config.hasVoiceIdInput) setVoiceId(voices[0].id); + } + }; + + const filteredLanguages = modalSearch + ? languages.filter((c) => + c.name.toLowerCase().includes(modalSearch.toLowerCase()) || + c.code.toLowerCase().includes(modalSearch.toLowerCase()) + ) + : languages; + + const endpoint = useTunnel ? tunnelEndpoint : localEndpoint; + // For ElevenLabs/config-driven: prefer manual voiceId (if any), else fall back to selectedVoice + const activeVoiceId = config.hasVoiceIdInput ? (voiceId || selectedVoice) : selectedVoice; + const modelFull = (() => { + if (config.hasModelSelector && selectedModel && activeVoiceId) return `${providerAlias}/${selectedModel}/${activeVoiceId}`; + if (config.hasModelSelector && selectedModel) return `${providerAlias}/${selectedModel}`; + if (activeVoiceId) return `${providerAlias}/${activeVoiceId}`; + return ""; + })(); + + const ttsBody = (() => { + const b = { model: modelFull, input }; + if (config.hasLanguageHint && languageHint) b.language = languageHint; + return b; + })(); + const curlSnippet = `curl -X POST ${endpoint}/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""} \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\ + -d '${JSON.stringify(ttsBody)}' \\ + ${responseFormat === "json" ? "" : "--output speech.mp3"}`; + + const handleRun = async () => { + if (!input.trim() || !modelFull) return; + setRunning(true); + setError(""); + setAudioUrl(""); + setJsonResponse(null); + const start = Date.now(); + try { + const headers = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const url = `/api/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""}`; + const res = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ ...ttsBody, input: input.trim() }), + }); + setLatency(Date.now() - start); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + setError(d?.error?.message || d?.error || `HTTP ${res.status}`); + return; + } + + if (responseFormat === "json") { + const data = await res.json(); + setJsonResponse(data); // Store full JSON response + const audioBlob = await fetch(`data:audio/mp3;base64,${data.audio}`).then(r => r.blob()); + setAudioUrl(URL.createObjectURL(audioBlob)); + } else { + const blob = await res.blob(); + setAudioUrl(URL.createObjectURL(blob)); + } + } catch (e) { + setError(e.message || "Network error"); + } finally { + setRunning(false); + } + }; + + return ( + <> + +

Example

+ +
+ {/* Endpoint + API Key as read-only text */} + +
+ + {endpoint}/v1/audio/speech + + {tunnelEndpoint && ( + + )} +
+
+ + + {apiKey ? `${apiKey.slice(0, 8)}${"•".repeat(Math.min(20, apiKey.length - 8))}` : No key configured} + + + + {/* Model selector — prefer PROVIDER_MODELS[kind=tts], else providerModels via modelKey */} + {config.hasModelSelector && (config.modelKey || getModelsByProviderId(providerId).some(m => getModelKind(m) === "tts")) && ( + + + + )} + + {/* Language hint dropdown (Gemini) — sends body.language to guide pronunciation */} + {config.hasLanguageHint && ( + + + + )} + + {/* Language row + Browse button (edge-tts, local-device, elevenlabs) */} + {config.hasBrowseButton && ( + +
+ + +
+
+ )} + + {/* Voice chips — shown after language picked (edge-tts, local-device) or always (OpenAI/ElevenLabs) */} + {countryVoices.length > 0 && ( + +
+ {countryVoices.map((v) => ( + + ))} +
+
+ )} + + {/* Voice ID input (ElevenLabs) — manual entry or auto-fill from chip */} + {config.hasVoiceIdInput && ( + +
+
+ { + setVoiceId(e.target.value); + setSelectedVoice(e.target.value); + }} + placeholder="e.g. CwhRBWXzGAHq8TQ4Fs17" + className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono" + /> + {voiceId && ( + + )} +
+
+
+ )} + + {/* Google TTS: Language dropdown */} + {config.hasLanguageDropdown && ( + + + + )} + + {/* Input */} + +
+ setInput(e.target.value)} + className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> + {input && ( + + )} +
+
+ + {/* Output Format */} + + + + + {/* Curl + Run */} +
+
+ Request +
+ + +
+
+
{curlSnippet}
+
+ + {error &&

{error}

} + + {/* Audio player */} + {audioUrl ? ( +
+
+ + Response {latency && ⚡ {latency}ms} + + + download + Download + +
+
+ ) : ( +
+ Response +
{DEFAULT_TTS_RESPONSE_EXAMPLE}
+
+ )} +
+
+ + {/* Country Picker Modal */} + {modalOpen && ( +
setModalOpen(false)} + > +
e.stopPropagation()} + > + {/* Header */} +
+

Select Language

+ +
+ + {/* Search */} +
+ setModalSearch(e.target.value)} + placeholder="Search language..." + className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" + /> +
+ + {/* Language list */} +
+ {modalError &&

{modalError}

} + {modalLoading ? ( +

Loading...

+ ) : ( +
+ {filteredLanguages.map((c) => ( + + ))} + {filteredLanguages.length === 0 && ( +

No languages found.

+ )} +
+ )} +
+
+
+ )} + + ); +} diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/exampleShared.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/exampleShared.js new file mode 100644 index 00000000..2fb4cb5e --- /dev/null +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/components/exampleShared.js @@ -0,0 +1,76 @@ +"use client"; + +export function Row({ label, children }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +export const KIND_EXAMPLE_CONFIG = { + webSearch: { + inputLabel: "Query", + inputPlaceholder: "What is the latest news about AI?", + defaultInput: "What is the latest news about AI?", + bodyKey: "query", + defaultResponse: `{\n "results": [\n { "title": "...", "url": "...", "snippet": "..." }\n ]\n}`, + extraFields: [ + { key: "search_type", label: "Type", type: "select", default: "web", options: ["web", "news"] }, + { key: "max_results", label: "Max results", type: "number", default: 5, min: 1, max: 100 }, + { key: "country", label: "Country", type: "text", default: "" }, + { key: "language", label: "Language", type: "text", default: "" }, + ], + }, + webFetch: { + inputLabel: "URL", + inputPlaceholder: "https://example.com", + defaultInput: "https://example.com", + bodyKey: "url", + defaultResponse: `{\n "content": "...",\n "title": "...",\n "url": "..."\n}`, + extraFields: [ + { key: "format", label: "Format", type: "select", default: "markdown", options: ["markdown", "text", "html"] }, + { key: "max_characters", label: "Max chars", type: "number", default: 0, min: 0 }, + ], + }, + image: { + inputLabel: "Prompt", + inputPlaceholder: "A cute cat wearing a hat", + defaultInput: "A cute cat wearing a hat", + bodyKey: "prompt", + defaultResponse: `{\n "data": [\n { "url": "...", "b64_json": "..." }\n ]\n}`, + extraFields: [ + { key: "n", label: "n", type: "number", default: 1, min: 1, max: 4 }, + { key: "size", label: "Size", type: "select", default: "auto", options: ["auto", "1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024"] }, + { key: "quality", label: "Quality", type: "select", default: "auto", options: ["auto", "low", "medium", "high", "standard", "hd"] }, + { key: "background", label: "Background", type: "select", default: "auto", options: ["auto", "transparent", "opaque"] }, + { key: "style", label: "Style", type: "select", default: "", options: ["", "vivid", "natural"] }, + { key: "response_format", label: "Format", type: "select", default: "", options: ["", "url", "b64_json"] }, + { key: "image_detail", label: "Image Detail", type: "select", default: "high", options: ["auto", "low", "high", "original"] }, + { key: "output_format", label: "Codec", type: "select", default: "png", options: ["png", "jpeg", "webp"] }, + ], + }, + imageToText: { + inputLabel: "Image URL", + inputPlaceholder: "https://example.com/image.png", + defaultInput: "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg", + bodyKey: "url", + extraBody: { prompt: "Describe this image in detail" }, + defaultResponse: `{\n "text": "A cat sitting on a windowsill...",\n "model": "..."\n}`, + }, + video: { + inputLabel: "Prompt", + inputPlaceholder: "A serene lake at sunset", + defaultInput: "A serene lake at sunset", + bodyKey: "prompt", + defaultResponse: `{\n "data": [\n { "url": "..." }\n ]\n}`, + }, + music: { + inputLabel: "Prompt", + inputPlaceholder: "A calm piano melody", + defaultInput: "A calm piano melody", + bodyKey: "prompt", + defaultResponse: `{\n "data": [\n { "url": "...", "format": "mp3" }\n ]\n}`, + }, +}; diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js index 5382f6f4..c331267b 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.js @@ -5,1707 +5,14 @@ import Link from "next/link"; import { useState, useEffect } from "react"; import { Card, Badge, Button, AddCustomEmbeddingModal, NoAuthProxyCard, ProviderInfoCard } from "@/shared/components"; import ProviderIcon from "@/shared/components/ProviderIcon"; -import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProviderAlias, isCustomEmbeddingProvider, resolveProviderId } from "@/shared/constants/providers"; -import { getModelsByProviderId } from "@/shared/constants/models"; -import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, isCustomEmbeddingProvider } from "@/shared/constants/providers"; import ConnectionsCard from "@/app/(dashboard)/dashboard/providers/components/ConnectionsCard"; import ModelsCard from "@/app/(dashboard)/dashboard/providers/components/ModelsCard"; -import { TTS_PROVIDER_CONFIG } from "@/shared/constants/ttsProviders"; -import { getTtsVoicesForModel } from "open-sse/config/ttsModels.js"; -import { GOOGLE_TTS_LANGUAGES } from "open-sse/config/googleTtsLanguages.js"; - -// Shared row layout — defined outside components to avoid re-mount on re-render -function Row({ label, children }) { - return ( -
- {label} -
{children}
-
- ); -} - -const DEFAULT_TTS_RESPONSE_EXAMPLE = `// Audio will appear here after running. -// Example JSON response (response_format=json): -{ - "format": "mp3", - "audio": "//NExAANaAIIAUAAANNNNNNNN..." // base64 encoded MP3 -}`; - -const DEFAULT_RESPONSE_EXAMPLE = `{ - "object": "list", - "data": [{ - "object": "embedding", - "index": 0, - "embedding": [0.002301, -0.019212, 0.004815, -0.031249, ...] - }], - "model": "...", - "usage": { "prompt_tokens": 9, "total_tokens": 9 } -}`; - -const CLOUDFLARE_TEST_IMAGE_URL = "https://pub-1fb693cb11cc46b2b2f656f51e015a2c.r2.dev/dog.png"; -const CLOUDFLARE_TEST_MASK_URL = "https://pub-1fb693cb11cc46b2b2f656f51e015a2c.r2.dev/dog-mask.png"; - -function getImageEditDefaults(providerId, modelId) { - if (providerId !== "cloudflare-ai") return {}; - if (modelId === "@cf/runwayml/stable-diffusion-v1-5-img2img") { - return { image: CLOUDFLARE_TEST_IMAGE_URL }; - } - if (modelId === "@cf/runwayml/stable-diffusion-v1-5-inpainting") { - return { image: CLOUDFLARE_TEST_IMAGE_URL, mask_image: CLOUDFLARE_TEST_MASK_URL }; - } - return {}; -} - -function toImagePreviewSrc(value) { - const trimmed = typeof value === "string" ? value.trim() : ""; - if (!trimmed) return ""; - if (/^(data:image\/|https?:\/\/)/i.test(trimmed)) return trimmed; - return `data:image/png;base64,${trimmed}`; -} - -// Config-driven example defaults per kind -const KIND_EXAMPLE_CONFIG = { - webSearch: { - inputLabel: "Query", - inputPlaceholder: "What is the latest news about AI?", - defaultInput: "What is the latest news about AI?", - bodyKey: "query", - defaultResponse: `{\n "results": [\n { "title": "...", "url": "...", "snippet": "..." }\n ]\n}`, - extraFields: [ - { key: "search_type", label: "Type", type: "select", default: "web", options: ["web", "news"] }, - { key: "max_results", label: "Max results", type: "number", default: 5, min: 1, max: 100 }, - { key: "country", label: "Country", type: "text", default: "" }, - { key: "language", label: "Language", type: "text", default: "" }, - ], - }, - webFetch: { - inputLabel: "URL", - inputPlaceholder: "https://example.com", - defaultInput: "https://example.com", - bodyKey: "url", - defaultResponse: `{\n "content": "...",\n "title": "...",\n "url": "..."\n}`, - extraFields: [ - { key: "format", label: "Format", type: "select", default: "markdown", options: ["markdown", "text", "html"] }, - { key: "max_characters", label: "Max chars", type: "number", default: 0, min: 0 }, - ], - }, - image: { - inputLabel: "Prompt", - inputPlaceholder: "A cute cat wearing a hat", - defaultInput: "A cute cat wearing a hat", - bodyKey: "prompt", - defaultResponse: `{\n "data": [\n { "url": "...", "b64_json": "..." }\n ]\n}`, - extraFields: [ - { key: "n", label: "n", type: "number", default: 1, min: 1, max: 4 }, - { key: "size", label: "Size", type: "select", default: "auto", options: ["auto", "1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024"] }, - { key: "quality", label: "Quality", type: "select", default: "auto", options: ["auto", "low", "medium", "high", "standard", "hd"] }, - { key: "background", label: "Background", type: "select", default: "auto", options: ["auto", "transparent", "opaque"] }, - { key: "style", label: "Style", type: "select", default: "", options: ["", "vivid", "natural"] }, - { key: "response_format", label: "Format", type: "select", default: "", options: ["", "url", "b64_json"] }, - { key: "image_detail", label: "Image Detail", type: "select", default: "high", options: ["auto", "low", "high", "original"] }, - { key: "output_format", label: "Codec", type: "select", default: "png", options: ["png", "jpeg", "webp"] }, - ], - }, - imageToText: { - inputLabel: "Image URL", - inputPlaceholder: "https://example.com/image.png", - defaultInput: "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg", - bodyKey: "url", - extraBody: { prompt: "Describe this image in detail" }, - defaultResponse: `{\n "text": "A cat sitting on a windowsill...",\n "model": "..."\n}`, - }, - video: { - inputLabel: "Prompt", - inputPlaceholder: "A serene lake at sunset", - defaultInput: "A serene lake at sunset", - bodyKey: "prompt", - defaultResponse: `{\n "data": [\n { "url": "..." }\n ]\n}`, - }, - music: { - inputLabel: "Prompt", - inputPlaceholder: "A calm piano melody", - defaultInput: "A calm piano melody", - bodyKey: "prompt", - defaultResponse: `{\n "data": [\n { "url": "...", "format": "mp3" }\n ]\n}`, - }, -}; - -// EmbeddingExampleCard -function EmbeddingExampleCard({ providerId, customAlias }) { - const isCustom = isCustomEmbeddingProvider(providerId); - const providerAlias = isCustom ? (customAlias || providerId) : getProviderAlias(providerId); - const embeddingModels = isCustom ? [] : getModelsByProviderId(providerId).filter((m) => m.type === "embedding"); - - const [selectedModel, setSelectedModel] = useState(embeddingModels[0]?.id ?? ""); - const [input, setInput] = useState("The quick brown fox jumps over the lazy dog"); - const [dimensions, setDimensions] = useState(""); - const [apiKey, setApiKey] = useState(""); - const [useTunnel, setUseTunnel] = useState(false); - const [localEndpoint, setLocalEndpoint] = useState(""); - const [tunnelEndpoint, setTunnelEndpoint] = useState(""); - const [result, setResult] = useState(null); - const [running, setRunning] = useState(false); - const [error, setError] = useState(""); - const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard(); - const { copied: copiedRes, copy: copyRes } = useCopyToClipboard(); - - useEffect(() => { - setLocalEndpoint(window.location.origin); - fetch("/api/keys") - .then((r) => r.json()) - .then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); }) - .catch(() => {}); - fetch("/api/tunnel/status") - .then((r) => r.json()) - .then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); }) - .catch(() => {}); - }, []); - - const endpoint = useTunnel ? tunnelEndpoint : localEndpoint; - const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : ""; - - // Build request body — include dimensions only if user provided a positive number - const buildBody = () => { - const body = { model: modelFull, input: input.trim() }; - const dim = Number(dimensions); - if (dimensions && Number.isFinite(dim) && dim > 0) body.dimensions = dim; - return body; - }; - - const curlSnippet = `curl -X POST ${endpoint}/v1/embeddings \\ - -H "Content-Type: application/json" \\ - -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\ - -d '${JSON.stringify(buildBody())}'`; - - const handleRun = async () => { - if (!input.trim() || !modelFull) return; - setRunning(true); - setError(""); - setResult(null); - const start = Date.now(); - try { - const headers = { "Content-Type": "application/json" }; - if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; - const res = await fetch("/api/v1/embeddings", { - method: "POST", - headers, - body: JSON.stringify(buildBody()), - }); - const latencyMs = Date.now() - start; - const data = await res.json(); - if (!res.ok) { setError(data?.error?.message || data?.error || `HTTP ${res.status}`); return; } - setResult({ data, latencyMs }); - } catch (e) { - setError(e.message || "Network error"); - } finally { - setRunning(false); - } - }; - - // Compact embedding array: first 4 values + count - const formatResultJson = (data) => { - if (!data) return DEFAULT_RESPONSE_EXAMPLE; - const clone = JSON.parse(JSON.stringify(data)); - (clone.data || []).forEach((item) => { - if (Array.isArray(item.embedding) && item.embedding.length > 4) { - item.embedding = [...item.embedding.slice(0, 4).map((v) => parseFloat(v.toFixed(6))), `... (${item.embedding.length} dims)`]; - } - }); - return JSON.stringify(clone, null, 2); - }; - - const resultJson = result ? JSON.stringify(result.data, null, 2) : ""; - - return ( - -

Example

- -
- {/* Model — text input for custom node, dropdown otherwise */} - - {isCustom ? ( - setSelectedModel(e.target.value)} - placeholder="e.g. voyage-3, embed-english-v3.0, text-embedding-3-small" - className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono" - /> - ) : ( - - )} - - - {/* Endpoint */} - -
- useTunnel ? setTunnelEndpoint(e.target.value) : setLocalEndpoint(e.target.value)} - className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono" - placeholder="http://localhost:3000" - /> - {/* Tunnel toggle — only show if tunnel URL is available */} - {tunnelEndpoint && ( - - )} -
-
- - {/* API Key */} - - setApiKey(e.target.value)} - placeholder="sk-..." - className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono" - /> - - - {/* Input */} - -
- setInput(e.target.value)} - className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" - /> - {input && ( - - )} -
-
- - {/* Dimensions (optional) — truncate embedding vector length */} - - setDimensions(e.target.value)} - placeholder="optional, e.g. 512, 1024 (leave empty for default)" - className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" - /> - - - {/* Curl + Run */} -
-
- Request -
- - -
-
-
{curlSnippet}
-
- - {/* Error */} - {error &&

{error}

} - - {/* Response — default example or real result */} -
-
- - Response {result && ⚡ {result.latencyMs}ms} - - {result && ( - - )} -
-
-            {formatResultJson(result?.data)}
-          
-
-
-
- ); -} - -// ─── TTS Example Card ──────────────────────────────────────────────────────── -function TtsExampleCard({ providerId }) { - const providerAlias = getProviderAlias(providerId); - const config = TTS_PROVIDER_CONFIG[providerId] || TTS_PROVIDER_CONFIG["edge-tts"]; - - // Voice state - const [selectedVoice, setSelectedVoice] = useState(config.defaultVoiceId || ""); - const [selectedVoiceName, setSelectedVoiceName] = useState(""); - const [voiceId, setVoiceId] = useState(config.defaultVoiceId || ""); // editable voice id (elevenlabs/config providers) - // Voices shown below Voice row after language selected - const [countryVoices, setCountryVoices] = useState([]); - const [selectedLang, setSelectedLang] = useState(""); - const [selectedModel, setSelectedModel] = useState(() => { - const cfgModels = AI_PROVIDERS[providerId]?.ttsConfig?.models; - if (cfgModels?.length) return cfgModels[0].id; - if (config.hasModelSelector && config.modelKey) { - const models = getModelsByProviderId(config.modelKey); - return models?.[0]?.id || ""; - } - return ""; - }); - - // Form state - const [input, setInput] = useState("Hello, this is a text to speech test."); - const [apiKey, setApiKey] = useState(""); - const [useTunnel, setUseTunnel] = useState(false); - const [localEndpoint, setLocalEndpoint] = useState(""); - const [tunnelEndpoint, setTunnelEndpoint] = useState(""); - const [responseFormat, setResponseFormat] = useState("mp3"); // mp3 | json - const [audioUrl, setAudioUrl] = useState(""); - const [jsonResponse, setJsonResponse] = useState(null); // Store JSON response - const [running, setRunning] = useState(false); - const [error, setError] = useState(""); - const [latency, setLatency] = useState(null); - const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard(); - - // Country picker modal state - const [modalOpen, setModalOpen] = useState(false); - const [languages, setLanguages] = useState([]); - const [modalLoading, setModalLoading] = useState(false); - const [modalSearch, setModalSearch] = useState(""); - const [modalError, setModalError] = useState(""); - const [byLang, setByLang] = useState({}); - // Language hint (e.g. Gemini): controls the spoken language without affecting voice selection - const [languageHint, setLanguageHint] = useState(""); - - useEffect(() => { - setLocalEndpoint(window.location.origin); - fetch("/api/keys") - .then((r) => r.json()) - .then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); }) - .catch(() => {}); - fetch("/api/tunnel/status") - .then((r) => r.json()) - .then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); }) - .catch(() => {}); - - // Pre-select default voice based on provider config - if (config.voiceSource === "hardcoded") { - const defaultModel = config.hasModelSelector && config.modelKey - ? (getModelsByProviderId(config.modelKey)?.[0]?.id || "") - : ""; - // Use per-model voices if available, else flat list - const voices = (config.voicesPerModel && defaultModel) - ? (getTtsVoicesForModel(providerId, defaultModel) || []) - : getModelsByProviderId(config.voiceKey || providerId).filter((m) => m.type === "tts"); - if (voices.length) { - if (config.hasBrowseButton) { - // Google TTS: pre-select "en" (English) as default, show as single voice chip - const defaultVoice = voices.find((v) => v.id === "en") || voices[0]; - setSelectedLang(defaultVoice.id); - setSelectedVoice(defaultVoice.id); - setSelectedVoiceName(defaultVoice.name); - setCountryVoices([{ id: defaultVoice.id, name: defaultVoice.name }]); - } else { - // OpenAI/OpenRouter: set voice chips directly (no language picker) - setCountryVoices(voices); - setSelectedVoice(voices[0].id); - setSelectedVoiceName(voices[0].name || voices[0].id); - } - } - } - // api-language (edge-tts, local-device, elevenlabs): NO default load, wait for user to pick language - // config (nvidia, hyperbolic, deepgram, huggingface, cartesia, playht, coqui, tortoise, inworld, qwen): - // use ttsConfig.models for model selector; voice is empty by default (backend uses provider default) - }, [providerId]); - - // Update voices when model changes (voicesPerModel providers) - useEffect(() => { - if (!config.voicesPerModel || !selectedModel) return; - const voices = getTtsVoicesForModel(providerId, selectedModel) || []; - setCountryVoices(voices); - if (voices.length) { - setSelectedVoice(voices[0].id); - setSelectedVoiceName(voices[0].name || voices[0].id); - } - }, [selectedModel]); - - // Open modal — load language list - const openModal = async () => { - setModalOpen(true); - setModalSearch(""); - setModalError(""); - if (languages.length) return; // already loaded - setModalLoading(true); - try { - if (config.voiceSource === "hardcoded") { - // Build languages/byLang from static providerModels data - const voiceKey = config.voiceKey || providerId; - const voices = getModelsByProviderId(voiceKey).filter((m) => m.type === "tts"); - const byLangMap = {}; - for (const v of voices) { - if (!byLangMap[v.id]) byLangMap[v.id] = { code: v.id, name: v.name, voices: [{ id: v.id, name: v.name }] }; - } - setByLang(byLangMap); - setLanguages(Object.values(byLangMap).sort((a, b) => a.name.localeCompare(b.name))); - } else { - // Use provider-specific apiEndpoint if available, else default to edge-tts voices API - const url = config.apiEndpoint - ? config.apiEndpoint - : `/api/media-providers/tts/voices?provider=${providerId === "local-device" ? "local-device" : "edge-tts"}`; - const r = await fetch(url); - const d = await r.json(); - if (d.error) { setModalError(d.error); return; } - setLanguages(d.languages || []); - setByLang(d.byLang || {}); - } - } catch (e) { - setModalError(e.message); - } finally { - setModalLoading(false); - } - }; - - // Click language → close modal → show voices below - const handlePickLanguage = (lang) => { - setModalOpen(false); - setSelectedLang(lang.code); - const voices = byLang[lang.code]?.voices || []; - setCountryVoices(voices); - // Auto-select first voice - if (voices.length) { - setSelectedVoice(voices[0].id); - setSelectedVoiceName(voices[0].name); - if (config.hasVoiceIdInput) setVoiceId(voices[0].id); - } - }; - - const filteredLanguages = modalSearch - ? languages.filter((c) => - c.name.toLowerCase().includes(modalSearch.toLowerCase()) || - c.code.toLowerCase().includes(modalSearch.toLowerCase()) - ) - : languages; - - const endpoint = useTunnel ? tunnelEndpoint : localEndpoint; - // For ElevenLabs/config-driven: prefer manual voiceId (if any), else fall back to selectedVoice - const activeVoiceId = config.hasVoiceIdInput ? (voiceId || selectedVoice) : selectedVoice; - const modelFull = (() => { - if (config.hasModelSelector && selectedModel && activeVoiceId) return `${providerAlias}/${selectedModel}/${activeVoiceId}`; - if (config.hasModelSelector && selectedModel) return `${providerAlias}/${selectedModel}`; - if (activeVoiceId) return `${providerAlias}/${activeVoiceId}`; - return ""; - })(); - - const ttsBody = (() => { - const b = { model: modelFull, input }; - if (config.hasLanguageHint && languageHint) b.language = languageHint; - return b; - })(); - const curlSnippet = `curl -X POST ${endpoint}/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""} \\ - -H "Content-Type: application/json" \\ - -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\ - -d '${JSON.stringify(ttsBody)}' \\ - ${responseFormat === "json" ? "" : "--output speech.mp3"}`; - - const handleRun = async () => { - if (!input.trim() || !modelFull) return; - setRunning(true); - setError(""); - setAudioUrl(""); - setJsonResponse(null); - const start = Date.now(); - try { - const headers = { "Content-Type": "application/json" }; - if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; - const url = `/api/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""}`; - const res = await fetch(url, { - method: "POST", - headers, - body: JSON.stringify({ ...ttsBody, input: input.trim() }), - }); - setLatency(Date.now() - start); - if (!res.ok) { - const d = await res.json().catch(() => ({})); - setError(d?.error?.message || d?.error || `HTTP ${res.status}`); - return; - } - - if (responseFormat === "json") { - const data = await res.json(); - setJsonResponse(data); // Store full JSON response - const audioBlob = await fetch(`data:audio/mp3;base64,${data.audio}`).then(r => r.blob()); - setAudioUrl(URL.createObjectURL(audioBlob)); - } else { - const blob = await res.blob(); - setAudioUrl(URL.createObjectURL(blob)); - } - } catch (e) { - setError(e.message || "Network error"); - } finally { - setRunning(false); - } - }; - - return ( - <> - -

Example

- -
- {/* Endpoint + API Key as read-only text */} - -
- - {endpoint}/v1/audio/speech - - {tunnelEndpoint && ( - - )} -
-
- - - {apiKey ? `${apiKey.slice(0, 8)}${"•".repeat(Math.min(20, apiKey.length - 8))}` : No key configured} - - - - {/* Model selector — prefer ttsConfig.models, else providerModels via modelKey */} - {config.hasModelSelector && (config.modelKey || AI_PROVIDERS[providerId]?.ttsConfig?.models?.length) && ( - - - - )} - - {/* Language hint dropdown (Gemini) — sends body.language to guide pronunciation */} - {config.hasLanguageHint && ( - - - - )} - - {/* Language row + Browse button (edge-tts, local-device, elevenlabs) */} - {config.hasBrowseButton && ( - -
- - -
-
- )} - - {/* Voice chips — shown after language picked (edge-tts, local-device) or always (OpenAI/ElevenLabs) */} - {countryVoices.length > 0 && ( - -
- {countryVoices.map((v) => ( - - ))} -
-
- )} - - {/* Voice ID input (ElevenLabs) — manual entry or auto-fill from chip */} - {config.hasVoiceIdInput && ( - -
-
- { - setVoiceId(e.target.value); - setSelectedVoice(e.target.value); - }} - placeholder="e.g. CwhRBWXzGAHq8TQ4Fs17" - className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono" - /> - {voiceId && ( - - )} -
-
-
- )} - - {/* Google TTS: Language dropdown */} - {config.hasLanguageDropdown && ( - - - - )} - - {/* Input */} - -
- setInput(e.target.value)} - className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" - /> - {input && ( - - )} -
-
- - {/* Output Format */} - - - - - {/* Curl + Run */} -
-
- Request -
- - -
-
-
{curlSnippet}
-
- - {error &&

{error}

} - - {/* Audio player */} - {audioUrl ? ( -
-
- - Response {latency && ⚡ {latency}ms} - - - download - Download - -
-
- ) : ( -
- Response -
{DEFAULT_TTS_RESPONSE_EXAMPLE}
-
- )} -
-
- - {/* Country Picker Modal */} - {modalOpen && ( -
setModalOpen(false)} - > -
e.stopPropagation()} - > - {/* Header */} -
-

Select Language

- -
- - {/* Search */} -
- setModalSearch(e.target.value)} - placeholder="Search language..." - className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" - /> -
- - {/* Language list */} -
- {modalError &&

{modalError}

} - {modalLoading ? ( -

Loading...

- ) : ( -
- {filteredLanguages.map((c) => ( - - ))} - {filteredLanguages.length === 0 && ( -

No languages found.

- )} -
- )} -
-
-
- )} - - ); -} - -// Generic Example Card — config-driven for webSearch, webFetch, image, imageToText, stt, video, music -function GenericExampleCard({ providerId, kind }) { - const providerAlias = getProviderAlias(providerId); - const resolvedId = resolveProviderId(providerAlias); - const safeProviderAlias = resolvedId === providerId ? providerAlias : providerId; - const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind); - const exConfig = KIND_EXAMPLE_CONFIG[kind]; - const safeExConfig = exConfig || {}; - - // Get models for this kind (e.g., type="image") - const kindModels = getModelsByProviderId(providerId).filter((m) => m.type === kind); - // Kinds that need a model identifier in the request (image/video/music) - const KIND_NEEDS_MODEL = new Set(["image", "video", "music", "imageToText"]); - const needsModel = KIND_NEEDS_MODEL.has(kind); - const allowManualModel = needsModel && kindModels.length === 0; - const [selectedModel, setSelectedModel] = useState(kindModels[0]?.id ?? ""); - const selectedModelObj = kindModels.find((m) => m.id === selectedModel); - const supportsEdit = !!selectedModelObj?.capabilities?.includes("edit"); - const supportsMask = !!selectedModelObj?.capabilities?.includes("mask"); - - const [input, setInput] = useState(safeExConfig.defaultInput || ""); - const [refImage, setRefImage] = useState(""); - const [maskImage, setMaskImage] = useState(""); - const [extraValues, setExtraValues] = useState(() => - (safeExConfig.extraFields || []).reduce((acc, f) => { acc[f.key] = f.default ?? ""; return acc; }, {}) - ); - const [apiKey, setApiKey] = useState(""); - const [useTunnel, setUseTunnel] = useState(false); - const [localEndpoint, setLocalEndpoint] = useState(""); - const [tunnelEndpoint, setTunnelEndpoint] = useState(""); - const [result, setResult] = useState(null); - const [progress, setProgress] = useState(null); // { stage, bytesReceived } - const [partialImage, setPartialImage] = useState(null); - const [imageOutputFormat, setImageOutputFormat] = useState("json"); // json | binary - const [binaryImageUrl, setBinaryImageUrl] = useState(""); - const [running, setRunning] = useState(false); - const [error, setError] = useState(""); - const [connections, setConnections] = useState([]); - const [pinnedConnectionId, setPinnedConnectionId] = useState(""); - const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard(); - const { copied: copiedRes, copy: copyRes } = useCopyToClipboard(); - - useEffect(() => { - setLocalEndpoint(window.location.origin); - fetch("/api/keys") - .then((r) => r.json()) - .then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); }) - .catch(() => {}); - fetch("/api/tunnel/status") - .then((r) => r.json()) - .then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); }) - .catch(() => {}); - // Load active connections of this provider for pinning - fetch("/api/providers/client") - .then((r) => r.json()) - .then((d) => { - const conns = (d.connections || []).filter((c) => c.provider === providerId && c.isActive !== false); - setConnections(conns); - }) - .catch(() => {}); - }, [providerId]); - - // Safe to early-return now that all hooks are declared - if (!kindConfig || !exConfig) return null; - - const endpoint = useTunnel ? tunnelEndpoint : localEndpoint; - const apiPath = kindConfig.endpoint.path; - // webSearch/webFetch: use safeProviderAlias only. Other kinds: append model when present. - const modelFull = !needsModel - ? safeProviderAlias - : (selectedModel ? `${safeProviderAlias}/${selectedModel}` : (allowManualModel ? "" : safeProviderAlias)); - const imageEditDefaults = getImageEditDefaults(providerId, selectedModel); - const effectiveRefImage = refImage.trim() || imageEditDefaults.image || ""; - const effectiveMaskImage = maskImage.trim() || imageEditDefaults.mask_image || ""; - const refImagePreviewSrc = toImagePreviewSrc(effectiveRefImage); - const maskImagePreviewSrc = toImagePreviewSrc(effectiveMaskImage); - - // Build request body with optional extra fields (only non-empty values) - const extraBodyFromFields = Object.entries(extraValues).reduce((acc, [k, v]) => { - if (v === "" || v === null || v === undefined) return acc; - if (typeof v === "number" && Number.isNaN(v)) return acc; - acc[k] = v; - return acc; - }, {}); - const requestBody = { - model: modelFull, - [exConfig.bodyKey]: input, - ...exConfig.extraBody, - ...extraBodyFromFields, - ...(supportsEdit && effectiveRefImage ? { image: effectiveRefImage } : {}), - ...(supportsMask && effectiveMaskImage ? { mask_image: effectiveMaskImage } : {}), - }; - - // Streaming supported for codex image (Plus/Pro accounts) — disabled when binary output requested - const wantBinary = kind === "image" && imageOutputFormat === "binary"; - const useStreaming = kind === "image" && providerId === "codex" && !wantBinary; - const apiPathWithQuery = `${apiPath}${wantBinary ? "?response_format=binary" : ""}`; - const headersPreview = `-H "Content-Type: application/json" \\\n -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}"${pinnedConnectionId ? ` \\\n -H "x-connection-id: ${pinnedConnectionId}"` : ""}${useStreaming ? ` \\\n -H "Accept: text/event-stream"` : ""}`; - const curlSnippet = `curl -X ${kindConfig.endpoint.method} ${endpoint}${apiPathWithQuery} \\ - ${headersPreview.replace(/\\\n /g, "\\\n ")} \\ - -d '${JSON.stringify(requestBody)}'${wantBinary ? " \\\n --output image.png" : ""}`; - - const handleRun = async () => { - if (!input.trim() || !modelFull) return; - setRunning(true); - setError(""); - setResult(null); - setProgress(null); - setPartialImage(null); - if (binaryImageUrl) { try { URL.revokeObjectURL(binaryImageUrl); } catch {} setBinaryImageUrl(""); } - const start = Date.now(); - try { - const headers = { "Content-Type": "application/json" }; - if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; - if (pinnedConnectionId) headers["x-connection-id"] = pinnedConnectionId; - if (useStreaming) headers["Accept"] = "text/event-stream"; - const body = { ...requestBody, model: modelFull }; - const res = await fetch(`/api${apiPathWithQuery}`, { - method: kindConfig.endpoint.method, - headers, - body: JSON.stringify(body), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - setError(data?.error?.message || data?.error || `HTTP ${res.status}`); - return; - } - const ctype = res.headers.get("content-type") || ""; - // Binary image response — convert to blob URL - if (ctype.startsWith("image/")) { - const blob = await res.blob(); - const objUrl = URL.createObjectURL(blob); - setBinaryImageUrl(objUrl); - setResult({ data: { binary: true, mime: ctype, size: blob.size }, latencyMs: Date.now() - start }); - return; - } - const isSse = ctype.includes("text/event-stream"); - if (isSse && res.body) { - // Parse SSE: progress / partial_image / done / error - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buf = ""; - let finalData = null; - let streamErr = null; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buf += decoder.decode(value, { stream: true }); - let sep; - while ((sep = buf.indexOf("\n\n")) !== -1) { - const block = buf.slice(0, sep); - buf = buf.slice(sep + 2); - let evt = null, dataStr = ""; - for (const line of block.split("\n")) { - if (line.startsWith("event:")) evt = line.slice(6).trim(); - else if (line.startsWith("data:")) dataStr += line.slice(5).trim(); - } - if (!evt) continue; - try { - const payload = dataStr ? JSON.parse(dataStr) : {}; - if (evt === "progress") setProgress(payload); - else if (evt === "partial_image") setPartialImage(payload); - else if (evt === "done") finalData = payload; - else if (evt === "error") streamErr = payload?.message || "Stream error"; - } catch {} - } - } - const latencyMs = Date.now() - start; - if (streamErr) { setError(streamErr); return; } - if (finalData) setResult({ data: finalData, latencyMs }); - } else { - const data = await res.json(); - const latencyMs = Date.now() - start; - setResult({ data, latencyMs }); - } - } catch (e) { - setError(e.message || "Network error"); - } finally { - setRunning(false); - } - }; - - // Mask large b64_json strings in JSON view to keep it readable - const maskB64 = (obj) => { - if (!obj || typeof obj !== "object") return obj; - if (Array.isArray(obj)) return obj.map(maskB64); - const out = {}; - for (const [k, v] of Object.entries(obj)) { - out[k] = (k === "b64_json" && typeof v === "string" && v.length > 100) - ? `<${v.length} chars base64>` - : maskB64(v); - } - return out; - }; - const resultJson = result ? JSON.stringify(maskB64(result.data), null, 2) : ""; - - return ( - -

Example

-
- {/* Model selector — dropdown if presets exist, else manual input for media kinds */} - {kindModels.length > 0 ? ( - - - - ) : allowManualModel ? ( - - setSelectedModel(e.target.value)} - placeholder="Enter model id (provider-specific)" - className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono" - /> - - ) : null} - - {/* Endpoint */} - -
- - {endpoint}{apiPath} - - {tunnelEndpoint && ( - - )} -
-
- - {/* API Key */} - - - {apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : No key configured} - - - - {/* Connection picker - only show when 2+ connections (or any with email) */} - {connections.length > 0 && ( - - - - )} - - {/* Input */} - -
- setInput(e.target.value)} - placeholder={exConfig.inputPlaceholder} - className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" - /> - {input && ( - - )} -
-
- - {/* Reference image (only for edit-capable image models) */} - {supportsEdit && ( - -
-
- setRefImage(e.target.value)} - placeholder={imageEditDefaults.image || "https://example.com/source.png"} - className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" - /> - {refImage && ( - - )} -
- {refImagePreviewSrc && ( - Reference { e.currentTarget.style.display = "none"; }} - onLoad={(e) => { e.currentTarget.style.display = "block"; }} - /> - )} -
-
- )} - - {supportsMask && ( - -
-
- setMaskImage(e.target.value)} - placeholder={imageEditDefaults.mask_image || "https://example.com/mask.png"} - className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" - /> - {maskImage && ( - - )} -
- {maskImagePreviewSrc && ( - Mask { e.currentTarget.style.display = "none"; }} - onLoad={(e) => { e.currentTarget.style.display = "block"; }} - /> - )} -
-
- )} - - {/* Extra fields — for kinds without model concept (webSearch/webFetch), show all; otherwise filter by model.params */} - {(exConfig.extraFields || []) - .filter((f) => kindModels.length === 0 || (Array.isArray(selectedModelObj?.params) && selectedModelObj.params.includes(f.key))) - .map((f) => ( - - {f.type === "select" ? ( - - ) : f.type === "text" ? ( - setExtraValues((s) => ({ ...s, [f.key]: e.target.value }))} - className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" - /> - ) : ( - setExtraValues((s) => ({ ...s, [f.key]: e.target.value === "" ? "" : Number(e.target.value) }))} - className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" - /> - )} - - ))} - - {/* Output Format toggle (image only) — last */} - {kind === "image" && ( - - - - )} - - {/* Curl + Run */} -
-
- Request -
- - -
-
-
{curlSnippet}
-
- - {/* Streaming progress */} - {(running || progress) && useStreaming && ( -
- - {running ? "progress_activity" : "check_circle"} - - - {progress?.stage || "starting"} - {!running && progress?.bytesReceived ? ` · ${(progress.bytesReceived / 1024).toFixed(1)} KB` : ""} - -
- )} - - {/* Partial image preview (codex stream) */} - {partialImage?.b64_json && !result && ( -
- Partial preview - Partial -
- )} - - {/* Error */} - {error &&

{error}

} - - {/* Response */} -
-
- - Response {result && ⚡ {result.latencyMs}ms} - - {result && ( - - )} -
-
-            {result ? resultJson : exConfig.defaultResponse}
-          
- {kind === "image" && (binaryImageUrl || result?.data?.data?.[0]) && ( - - )} -
-
-
- ); -} - -// ─── STT Example Card ──────────────────────────────────────────────────────── -function SttExampleCard({ providerId }) { - const providerAlias = getProviderAlias(providerId); - const builtinSttModels = getModelsByProviderId(providerId).filter((m) => m.type === "stt"); - const [customSttModels, setCustomSttModels] = useState([]); - const sttModels = [...builtinSttModels, ...customSttModels]; - - const [selectedModel, setSelectedModel] = useState(builtinSttModels[0]?.id ?? ""); - const selectedModelObj = sttModels.find((m) => m.id === selectedModel); - const allowedParams = Array.isArray(selectedModelObj?.params) ? selectedModelObj.params : []; - - const [audioFile, setAudioFile] = useState(null); - const [language, setLanguage] = useState(""); - const [prompt, setPrompt] = useState(""); - const [responseFormat, setResponseFormat] = useState("json"); - const [temperature, setTemperature] = useState(""); - const [apiKey, setApiKey] = useState(""); - const [useTunnel, setUseTunnel] = useState(false); - const [localEndpoint, setLocalEndpoint] = useState(""); - const [tunnelEndpoint, setTunnelEndpoint] = useState(""); - const [result, setResult] = useState(null); - const [latency, setLatency] = useState(null); - const [running, setRunning] = useState(false); - const [error, setError] = useState(""); - const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard(); - const { copied: copiedRes, copy: copyRes } = useCopyToClipboard(); - - useEffect(() => { - setLocalEndpoint(window.location.origin); - fetch("/api/keys") - .then((r) => r.json()) - .then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); }) - .catch(() => {}); - fetch("/api/tunnel/status") - .then((r) => r.json()) - .then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); }) - .catch(() => {}); - const loadCustom = () => { - fetch("/api/models/custom", { cache: "no-store" }) - .then((r) => r.json()) - .then((d) => { - const list = (d.models || []).filter((m) => m.type === "stt" && m.providerAlias === providerAlias); - setCustomSttModels(list); - }) - .catch(() => {}); - }; - loadCustom(); - window.addEventListener("focus", loadCustom); - window.addEventListener("customModelChanged", loadCustom); - return () => { - window.removeEventListener("focus", loadCustom); - window.removeEventListener("customModelChanged", loadCustom); - }; - }, [providerAlias]); - - const endpoint = useTunnel ? tunnelEndpoint : localEndpoint; - const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : ""; - - const curlSnippet = `curl -X POST ${endpoint}/v1/audio/transcriptions \\ - -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\ - -F "file=@${audioFile?.name || "audio.mp3"}" \\ - -F "model=${modelFull}"${allowedParams.includes("language") && language ? ` \\\n -F "language=${language}"` : ""}${allowedParams.includes("response_format") ? ` \\\n -F "response_format=${responseFormat}"` : ""}${allowedParams.includes("temperature") && temperature ? ` \\\n -F "temperature=${temperature}"` : ""}${allowedParams.includes("prompt") && prompt ? ` \\\n -F "prompt=${prompt}"` : ""}`; - - const handleRun = async () => { - if (!audioFile || !modelFull) return; - setRunning(true); - setError(""); - setResult(null); - const start = Date.now(); - try { - const fd = new FormData(); - fd.append("file", audioFile); - fd.append("model", modelFull); - if (allowedParams.includes("language") && language) fd.append("language", language); - if (allowedParams.includes("response_format")) fd.append("response_format", responseFormat); - if (allowedParams.includes("temperature") && temperature) fd.append("temperature", temperature); - if (allowedParams.includes("prompt") && prompt) fd.append("prompt", prompt); - - const headers = {}; - if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; - const res = await fetch("/api/v1/audio/transcriptions", { method: "POST", headers, body: fd }); - setLatency(Date.now() - start); - const ct = res.headers.get("content-type") || ""; - const data = ct.includes("application/json") ? await res.json() : await res.text(); - if (!res.ok) { - setError(data?.error?.message || data?.error || data || `HTTP ${res.status}`); - return; - } - setResult(data); - } catch (e) { - setError(e.message || "Network error"); - } finally { - setRunning(false); - } - }; - - const resultStr = typeof result === "string" ? result : (result ? JSON.stringify(result, null, 2) : `{\n "text": "Hello world..."\n}`); - - return ( - -

Example

-
- {/* Model */} - {sttModels.length > 0 ? ( - - - - ) : ( - - setSelectedModel(e.target.value)} - placeholder="Enter model id" - className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono" - /> - - )} - - {/* Endpoint */} - -
- - {endpoint}/v1/audio/transcriptions - - {tunnelEndpoint && ( - - )} -
-
- - {/* API Key */} - - - {apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : No key configured} - - - - {/* Audio file */} - -
- setAudioFile(e.target.files?.[0] || null)} - className="w-full text-xs text-text-muted file:mr-2 file:py-1 file:px-2.5 file:rounded-lg file:border file:border-border file:bg-background file:text-text-main hover:file:bg-sidebar file:cursor-pointer" - /> - {audioFile && ( - - {audioFile.name} · {(audioFile.size / 1024).toFixed(1)} KB - - )} -
-
- - {/* Language (if model supports) */} - {allowedParams.includes("language") && ( - - setLanguage(e.target.value)} - placeholder="e.g. en, vi, ja (auto-detect if empty)" - className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono" - /> - - )} - - {/* Prompt (if model supports) */} - {allowedParams.includes("prompt") && ( - - setPrompt(e.target.value)} - placeholder="optional context to improve accuracy" - className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" - /> - - )} - - {/* Temperature (if model supports) */} - {allowedParams.includes("temperature") && ( - - setTemperature(e.target.value)} - placeholder="0 - 1 (default 0)" - className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" - /> - - )} - - {/* Response format (if model supports) */} - {allowedParams.includes("response_format") && ( - - - - )} - - {/* Curl + Run */} -
-
- Request -
- - -
-
-
{curlSnippet}
-
- - {error &&

{error}

} - - {/* Response */} -
-
- - Response {result && latency && ⚡ {latency}ms} - - {result && ( - - )} -
-
-            {resultStr}
-          
-
-
-
- ); -} +import { KIND_EXAMPLE_CONFIG } from "./components/exampleShared"; +import { EmbeddingExampleCard } from "./components/EmbeddingExampleCard"; +import { TtsExampleCard } from "./components/TtsExampleCard"; +import { GenericExampleCard } from "./components/GenericExampleCard"; +import { SttExampleCard } from "./components/SttExampleCard"; // MediaProviderDetailPage export default function MediaProviderDetailPage() { diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index 2851bc0c..4df8cbef 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -1,7 +1,6 @@ "use client"; import { useState, useEffect, useRef } from "react"; -import { useRouter } from "next/navigation"; import { Card, Button, Toggle, Input } from "@/shared/components"; import Modal, { ConfirmModal } from "@/shared/components/Modal"; import LanguageSwitcher from "@/shared/components/LanguageSwitcher"; @@ -21,7 +20,6 @@ function getLocaleFromCookie() { } export default function ProfilePage() { - const router = useRouter(); const { theme, setTheme, isDark } = useTheme(); const [locale, setLocale] = useState("en"); const [langOpen, setLangOpen] = useState(false); @@ -569,8 +567,7 @@ export default function ProfilePage() { try { const res = await fetch("/api/auth/logout", { method: "POST" }); if (res.ok) { - router.push("/login"); - router.refresh(); + window.location.assign("/login"); } } catch (err) { console.error("Failed to logout:", err); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js b/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js index 33f1f05a..bfc12a13 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js @@ -3,6 +3,7 @@ import { useState } from "react"; import PropTypes from "prop-types"; import { Button } from "@/shared/components"; +import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels"; function CompatibleModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias, onTest, testStatus, isTesting }) { const borderColor = testStatus === "ok" ? "border-green-500/40" @@ -70,7 +71,7 @@ function CompatibleModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias, ); } -export default function CompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias, connections, isAnthropic }) { +export default function CompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, customModels, copied, onCopy, onDeleteAlias, onAddCustomModel, onDeleteCustomModel, connections, isAnthropic }) { const [newModel, setNewModel] = useState(""); const [adding, setAdding] = useState(false); const [importing, setImporting] = useState(false); @@ -95,44 +96,24 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider } }; - const providerAliases = Object.entries(modelAliases).filter( - ([, model]) => model.startsWith(`${providerStorageAlias}/`) - ); - - const allModels = providerAliases.map(([alias, fullModel]) => ({ - modelId: fullModel.replace(`${providerStorageAlias}/`, ""), - fullModel, - alias, - })); - - const generateDefaultAlias = (modelId) => { - const parts = modelId.split("/"); - return parts[parts.length - 1]; - }; - - const resolveAlias = (modelId) => { - const fullModel = `${providerStorageAlias}/${modelId}`; - // Skip if this exact model already has an alias - if (Object.values(modelAliases).includes(fullModel)) return null; - const baseAlias = generateDefaultAlias(modelId); - if (!modelAliases[baseAlias]) return baseAlias; - const prefixedAlias = `${providerDisplayAlias}-${baseAlias}`; - if (!modelAliases[prefixedAlias]) return prefixedAlias; - return null; - }; + const allModels = getProviderCustomModelRows({ + customModels, + modelAliases, + providerAlias: providerStorageAlias, + type: "llm", + }); const handleAdd = async () => { if (!newModel.trim() || adding) return; const modelId = newModel.trim(); - const resolvedAlias = resolveAlias(modelId); - if (!resolvedAlias) { - alert("All suggested aliases already exist. Please choose a different model or remove conflicting aliases."); + if (allModels.some((model) => model.id === modelId)) { + alert("Model already exists for this provider."); return; } setAdding(true); try { - await onSetAlias(modelId, resolvedAlias, providerStorageAlias); + await onAddCustomModel(modelId); setNewModel(""); } catch (error) { console.log("Error adding model:", error); @@ -163,9 +144,8 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider for (const model of models) { const modelId = model.id || model.name || model.model; if (!modelId) continue; - const resolvedAlias = resolveAlias(modelId); - if (!resolvedAlias) continue; - await onSetAlias(modelId, resolvedAlias, providerStorageAlias); + if (allModels.some((entry) => entry.id === modelId)) continue; + await onAddCustomModel(modelId); importedCount += 1; } if (importedCount === 0) { @@ -215,17 +195,17 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider {allModels.length > 0 && (
- {allModels.map(({ modelId, fullModel, alias }) => ( + {allModels.map(({ id, alias, source }) => ( onDeleteAlias(alias)} - onTest={connections.length > 0 ? () => handleTestModel(modelId) : undefined} - testStatus={modelTestResults[modelId]} - isTesting={testingModelId === modelId} + onDeleteAlias={() => source === "custom" ? onDeleteCustomModel(id) : onDeleteAlias(alias)} + onTest={connections.length > 0 ? () => handleTestModel(id) : undefined} + testStatus={modelTestResults[id]} + isTesting={testingModelId === id} /> ))}
@@ -238,10 +218,12 @@ CompatibleModelsSection.propTypes = { providerStorageAlias: PropTypes.string.isRequired, providerDisplayAlias: PropTypes.string.isRequired, modelAliases: PropTypes.object.isRequired, + customModels: PropTypes.arrayOf(PropTypes.object), copied: PropTypes.string, onCopy: PropTypes.func.isRequired, - onSetAlias: PropTypes.func.isRequired, onDeleteAlias: PropTypes.func.isRequired, + onAddCustomModel: PropTypes.func.isRequired, + onDeleteCustomModel: PropTypes.func.isRequired, connections: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string, isActive: PropTypes.bool, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js index e6e7b611..15096050 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js @@ -1,11 +1,12 @@ "use client"; import { useState, useEffect, useRef } from "react"; +import { getStatusVariant as getConnectionStatusVariant } from "@/shared/utils/connectionStatus"; import PropTypes from "prop-types"; -import { Badge, Toggle } from "@/shared/components"; +import { Badge, Toggle, Tooltip } from "@/shared/components"; import CooldownTimer from "./CooldownTimer"; -export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null }) { +export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null, autoPing = null }) { const [showProxyDropdown, setShowProxyDropdown] = useState(false); const [updatingProxy, setUpdatingProxy] = useState(false); const proxyDropdownRef = useRef(null); @@ -70,10 +71,15 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst const isCookieConnection = rowAuthType === "cookie"; const authIcon = isCookieConnection ? "cookie" : isOAuthConnection ? "lock" : "key"; const authLabel = isOAuthConnection ? "OAuth" : isCookieConnection ? "Cookie" : "API Key"; - const isEmail = (v) => typeof v === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); - const displayName = isOAuthConnection - ? (isEmail(connection.email) ? connection.email : (isEmail(connection.name) ? connection.name : (connection.name || connection.email || connection.displayName || "OAuth Account"))) - : (connection.name || connection.email || connection.displayName || "API Key"); + const displayName = connection.name?.trim() + || connection.email?.trim() + || connection.displayName?.trim() + || (isOAuthConnection ? "OAuth Account" : isCookieConnection ? "Cookie Account" : "API Key"); + const secondaryDisplayName = connection.name?.trim() && connection.email?.trim() && connection.name.trim() !== connection.email.trim() + ? connection.email.trim() + : connection.name?.trim() && connection.displayName?.trim() && connection.name.trim() !== connection.displayName.trim() + ? connection.displayName.trim() + : null; // Use useState + useEffect for impure Date.now() to avoid calling during render const [isCooldown, setIsCooldown] = useState(false); @@ -107,12 +113,7 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst ? "active" // Cooldown expired u2192 treat as active : connection.testStatus; - const getStatusVariant = () => { - if (connection.isActive === false) return "default"; - if (effectiveStatus === "active" || effectiveStatus === "success") return "success"; - if (effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable") return "error"; - return "default"; - }; + const getStatusVariant = () => getConnectionStatusVariant(connection.isActive, effectiveStatus); const getOneByOneVariant = () => { if (!oneByOneStatus) return "default"; @@ -156,6 +157,9 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst

{displayName}

+ {secondaryDisplayName && ( +

{secondaryDisplayName}

+ )}
{connection.isActive === false ? "disabled" : (effectiveStatus || "Unknown")} @@ -239,6 +243,17 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst )}
)} + {autoPing && ( + + + + )} + )}
)} + {connections.length > 0 && ( +
+ +
+ )} {connectionsList} {!isCompatible && (
@@ -1449,7 +1577,7 @@ export default function ProviderDetailPage() { const allIds = [ ...models, ...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)), - ].filter((m) => !m.type || m.type === "llm").map((m) => m.id); + ].filter((m) => { const k = getModelKind(m); return !k || k === "llm"; }).map((m) => m.id); const activeIds = allIds.filter((id) => !disabledModelIds.includes(id)); return (
@@ -1552,11 +1680,7 @@ export default function ProviderDetailPage() { providerAlias={providerStorageAlias} providerDisplayAlias={providerDisplayAlias} onSave={async (modelId) => { - // For passthrough providers (OpenRouter), use last segment as alias to avoid slash conflicts - const alias = providerInfo?.passthroughModels - ? modelId.split("/").pop() - : modelId; - await handleSetAlias(modelId, alias, providerStorageAlias); + await handleAddCustomModel(modelId, "llm", providerStorageAlias); setShowAddCustomModel(false); }} onClose={() => setShowAddCustomModel(false)} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.new.js b/src/app/(dashboard)/dashboard/providers/[id]/page.new.js deleted file mode 100644 index 16f4a368..00000000 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.new.js +++ /dev/null @@ -1,1724 +0,0 @@ -"use client"; - -import { useState, useEffect, useCallback, useMemo } from "react"; -import PropTypes from "prop-types"; -import { useParams, useRouter } from "next/navigation"; -import Link from "next/link"; -import Image from "next/image"; -import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, Toggle, Select } from "@/shared/components"; -import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; -import { getModelsByProviderId } from "@/shared/constants/models"; -import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; - -export default function ProviderDetailPage() { - const params = useParams(); - const router = useRouter(); - const providerId = params.id; - const [connections, setConnections] = useState([]); - const [loading, setLoading] = useState(true); - const [providerNode, setProviderNode] = useState(null); - const [showOAuthModal, setShowOAuthModal] = useState(false); - const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false); - const [showEditModal, setShowEditModal] = useState(false); - const [showEditNodeModal, setShowEditNodeModal] = useState(false); - const [selectedConnection, setSelectedConnection] = useState(null); - const [modelAliases, setModelAliases] = useState({}); - const [remoteModels, setRemoteModels] = useState([]); - const [loadingRemoteModels, setLoadingRemoteModels] = useState(false); - const [selectedModelIds, setSelectedModelIds] = useState([]); - const [savingSelectedModels, setSavingSelectedModels] = useState(false); - const [modelSearchQuery, setModelSearchQuery] = useState(""); - const [showSelectedOnly, setShowSelectedOnly] = useState(false); - const [headerImgError, setHeaderImgError] = useState(false); - const { copied, copy } = useCopyToClipboard(); - - const providerInfo = providerNode - ? { - id: providerNode.id, - name: providerNode.name || (providerNode.type === "anthropic-compatible" ? "Anthropic Compatible" : "OpenAI Compatible"), - color: providerNode.type === "anthropic-compatible" ? "#D97757" : "#10A37F", - textIcon: providerNode.type === "anthropic-compatible" ? "AC" : "OC", - apiType: providerNode.apiType, - baseUrl: providerNode.baseUrl, - type: providerNode.type, - } - : (OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId] || FREE_PROVIDERS[providerId]); - const isOAuth = !!OAUTH_PROVIDERS[providerId] || !!FREE_PROVIDERS[providerId]; - const models = useMemo(() => getModelsByProviderId(providerId), [providerId]); - const providerAlias = getProviderAlias(providerId); - - const isOpenAICompatible = isOpenAICompatibleProvider(providerId); - const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId); - const isCompatible = isOpenAICompatible || isAnthropicCompatible; - - const providerStorageAlias = isCompatible ? providerId : providerAlias; - const providerDisplayAlias = isCompatible - ? (providerNode?.prefix || providerId) - : providerAlias; - const activeConnection = connections.find((conn) => conn.isActive !== false) || null; - const allProviderModels = models.length > 0 ? models : remoteModels; - const allProviderModelIds = useMemo( - () => allProviderModels.map((model) => model.id), - [allProviderModels] - ); - const savedEnabledModels = useMemo(() => { - const enabled = activeConnection?.providerSpecificData?.enabledModels; - return Array.isArray(enabled) - ? enabled.filter((modelId) => allProviderModelIds.includes(modelId)) - : []; - }, [activeConnection?.providerSpecificData?.enabledModels, allProviderModelIds]); - const savedEnabledModelsKey = useMemo( - () => savedEnabledModels.join("|"), - [savedEnabledModels] - ); - - // Define callbacks BEFORE the useEffect that uses them - const fetchAliases = useCallback(async () => { - try { - const res = await fetch("/api/models/alias"); - const data = await res.json(); - if (res.ok) { - setModelAliases(data.aliases || {}); - } - } catch (error) { - console.log("Error fetching aliases:", error); - } - }, []); - - const fetchConnections = useCallback(async () => { - try { - const [connectionsRes, nodesRes] = await Promise.all([ - fetch("/api/providers", { cache: "no-store" }), - fetch("/api/provider-nodes", { cache: "no-store" }), - ]); - const connectionsData = await connectionsRes.json(); - const nodesData = await nodesRes.json(); - if (connectionsRes.ok) { - const filtered = (connectionsData.connections || []).filter(c => c.provider === providerId); - setConnections(filtered); - } - if (nodesRes.ok) { - let node = (nodesData.nodes || []).find((entry) => entry.id === providerId) || null; - - // Newly created compatible nodes can be briefly unavailable on one worker. - // Retry a few times before showing "Provider not found". - if (!node && isCompatible) { - for (let attempt = 0; attempt < 3; attempt += 1) { - await new Promise((resolve) => setTimeout(resolve, 150)); - const retryRes = await fetch("/api/provider-nodes", { cache: "no-store" }); - if (!retryRes.ok) continue; - const retryData = await retryRes.json(); - node = (retryData.nodes || []).find((entry) => entry.id === providerId) || null; - if (node) break; - } - } - - setProviderNode(node); - } - } catch (error) { - console.log("Error fetching connections:", error); - } finally { - setLoading(false); - } - }, [providerId, isCompatible]); - - const handleUpdateNode = async (formData) => { - try { - const res = await fetch(`/api/provider-nodes/${providerId}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(formData), - }); - const data = await res.json(); - if (res.ok) { - setProviderNode(data.node); - await fetchConnections(); - setShowEditNodeModal(false); - } - } catch (error) { - console.log("Error updating provider node:", error); - } - }; - - const handleToggleModelSelected = (modelId) => { - setSelectedModelIds((prev) => ( - prev.includes(modelId) - ? prev.filter((id) => id !== modelId) - : [...prev, modelId] - )); - }; - - const handleSaveSelectedModels = async () => { - if (!activeConnection || savingSelectedModels) return; - setSavingSelectedModels(true); - try { - const res = await fetch(`/api/providers/${activeConnection.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - providerSpecificData: { - enabledModels: selectedModelIds, - }, - }), - }); - - if (!res.ok) { - const data = await res.json(); - alert(data.error || "Failed to save selected models"); - return; - } - - await fetchConnections(); - } catch (error) { - console.log("Error saving selected models:", error); - alert("Failed to save selected models"); - } finally { - setSavingSelectedModels(false); - } - }; - - useEffect(() => { - fetchConnections(); - fetchAliases(); - }, [fetchConnections, fetchAliases]); - - useEffect(() => { - const nextSelectedModelIds = (isCompatible || providerInfo?.passthroughModels) - ? [] - : savedEnabledModels; - - setSelectedModelIds((prev) => { - if ( - prev.length === nextSelectedModelIds.length - && prev.every((modelId, index) => modelId === nextSelectedModelIds[index]) - ) { - return prev; - } - return nextSelectedModelIds; - }); - }, [ - isCompatible, - providerInfo?.passthroughModels, - activeConnection?.id, - savedEnabledModels - ]); - - const fetchRemoteModels = useCallback(async () => { - if (isCompatible || providerInfo?.passthroughModels || models.length > 0) { - setRemoteModels([]); - return; - } - - if (!activeConnection) { - setRemoteModels([]); - return; - } - - setLoadingRemoteModels(true); - try { - const res = await fetch(`/api/providers/${activeConnection.id}/models`); - const data = await res.json(); - if (!res.ok) { - setRemoteModels([]); - return; - } - - const parsed = (data.models || []) - .map((item) => { - if (typeof item === "string") return { id: item, name: item }; - const modelId = item?.id || item?.name || item?.model; - if (!modelId) return null; - return { id: modelId, name: item?.name || modelId }; - }) - .filter(Boolean); - - const deduped = Array.from( - new Map(parsed.map((item) => [item.id, item])).values() - ); - - setRemoteModels(deduped); - } catch (error) { - console.log("Error fetching remote models:", error); - setRemoteModels([]); - } finally { - setLoadingRemoteModels(false); - } - }, [activeConnection, isCompatible, models.length, providerInfo?.passthroughModels]); - - useEffect(() => { - fetchRemoteModels(); - }, [fetchRemoteModels]); - - const handleSetAlias = async (modelId, alias, providerAliasOverride = providerAlias) => { - const fullModel = `${providerAliasOverride}/${modelId}`; - try { - const res = await fetch("/api/models/alias", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: fullModel, alias }), - }); - if (res.ok) { - await fetchAliases(); - } else { - const data = await res.json(); - alert(data.error || "Failed to set alias"); - } - } catch (error) { - console.log("Error setting alias:", error); - } - }; - - const handleDeleteAlias = async (alias) => { - try { - const res = await fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, { - method: "DELETE", - }); - if (res.ok) { - await fetchAliases(); - } - } catch (error) { - console.log("Error deleting alias:", error); - } - }; - - const handleDelete = async (id) => { - if (!confirm("Delete this connection?")) return; - try { - const res = await fetch(`/api/providers/${id}`, { method: "DELETE" }); - if (res.ok) { - setConnections(connections.filter(c => c.id !== id)); - } - } catch (error) { - console.log("Error deleting connection:", error); - } - }; - - const handleOAuthSuccess = () => { - fetchConnections(); - setShowOAuthModal(false); - }; - - const handleSaveApiKey = async (formData) => { - try { - const res = await fetch("/api/providers", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider: providerId, ...formData }), - }); - if (res.ok) { - await fetchConnections(); - setShowAddApiKeyModal(false); - } - } catch (error) { - console.log("Error saving connection:", error); - } - }; - - const handleUpdateConnection = async (formData) => { - try { - const res = await fetch(`/api/providers/${selectedConnection.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(formData), - }); - if (res.ok) { - await fetchConnections(); - setShowEditModal(false); - } - } catch (error) { - console.log("Error updating connection:", error); - } - }; - - const handleUpdateConnectionStatus = async (id, isActive) => { - try { - const res = await fetch(`/api/providers/${id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ isActive }), - }); - if (res.ok) { - setConnections(prev => prev.map(c => c.id === id ? { ...c, isActive } : c)); - } - } catch (error) { - console.log("Error updating connection status:", error); - } - }; - - const handleSwapPriority = async (conn1, conn2) => { - if (!conn1 || !conn2) return; - try { - // If they have the same priority, we need to ensure the one moving up - // gets a lower value than the one moving down. - // We use a small offset which the backend re-indexing will fix. - let p1 = conn2.priority; - let p2 = conn1.priority; - - if (p1 === p2) { - // If moving conn1 "up" (index decreases) - const isConn1MovingUp = connections.indexOf(conn1) > connections.indexOf(conn2); - if (isConn1MovingUp) { - p1 = conn2.priority - 0.5; - } else { - p1 = conn2.priority + 0.5; - } - } - - await Promise.all([ - fetch(`/api/providers/${conn1.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ priority: p1 }), - }), - fetch(`/api/providers/${conn2.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ priority: p2 }), - }), - ]); - await fetchConnections(); - } catch (error) { - console.log("Error swapping priority:", error); - } - }; - - const renderModelsSection = () => { - if (isCompatible) { - return ( - - ); - } - if (providerInfo.passthroughModels) { - return ( - - ); - } - - const availableModels = allProviderModels; - if (availableModels.length === 0) { - if (loadingRemoteModels) { - return

Loading models from provider...

; - } - return

No models configured

; - } - - const selectedSet = new Set(selectedModelIds); - const filteredBySelection = showSelectedOnly - ? availableModels.filter((model) => selectedSet.has(model.id)) - : availableModels; - const query = modelSearchQuery.trim().toLowerCase(); - const visibleModels = query - ? filteredBySelection.filter((model) => - model.id.toLowerCase().includes(query) || - (model.name || "").toLowerCase().includes(query) - ) - : filteredBySelection; - const hasSelectionChanges = - savedEnabledModels.length !== selectedModelIds.length || - savedEnabledModels.some((modelId) => !selectedSet.has(modelId)); - - return ( -
-
-
- setModelSearchQuery(e.target.value)} - placeholder="Search model id" - className="pr-8" - /> - {modelSearchQuery && ( - - )} -
- - - -
- Selected only - -
-
- -

- {selectedModelIds.length > 0 - ? `${selectedModelIds.length} selected` - : "All models enabled"} -

- - {visibleModels.length === 0 ? ( -

No models match your filter.

- ) : ( -
- {visibleModels.map((model) => { - const fullModel = `${providerStorageAlias}/${model.id}`; - const oldFormatModel = `${providerId}/${model.id}`; - const existingAlias = Object.entries(modelAliases).find( - ([, m]) => m === fullModel || m === oldFormatModel - )?.[0]; - return ( - handleToggleModelSelected(model.id)} - onSetAlias={(alias) => handleSetAlias(model.id, alias, providerStorageAlias)} - onDeleteAlias={() => handleDeleteAlias(existingAlias)} - /> - ); - })} -
- )} -
- ); - }; - - if (loading) { - return ( -
- - -
- ); - } - - if (!providerInfo) { - return ( -
-

Provider not found

- - Back to Providers - -
- ); - } - - // Determine icon path: OpenAI Compatible providers use specialized icons - const getHeaderIconPath = () => { - if (isOpenAICompatible && providerInfo.apiType) { - return providerInfo.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png"; - } - if (isAnthropicCompatible) { - return "/providers/anthropic-m.png"; - } - return `/providers/${providerInfo.id}.png`; - }; - - return ( -
- {/* Header */} -
- - arrow_back - Back to Providers - -
-
- {headerImgError ? ( - - {providerInfo.textIcon || providerInfo.id.slice(0, 2).toUpperCase()} - - ) : ( - {providerInfo.name} setHeaderImgError(true)} - /> - )} -
-
-

{providerInfo.name}

-

- {connections.length} connection{connections.length === 1 ? "" : "s"} -

-
-
-
- - {isCompatible && providerNode && ( - -
-
-

{isAnthropicCompatible ? "Anthropic Compatible Details" : "OpenAI Compatible Details"}

-

- {isAnthropicCompatible ? "Messages API" : (providerNode.apiType === "responses" ? "Responses API" : "Chat Completions")} · {(providerNode.baseUrl || "").replace(/\/$/, "")}/ - {isAnthropicCompatible ? "messages" : (providerNode.apiType === "responses" ? "responses" : "chat/completions")} -

-
-
- - - -
-
- {connections.length > 0 && ( -

- Only one connection is allowed per compatible node. Add another node if you need more connections. -

- )} -
- )} - - {/* Connections */} - -
-

Connections

- {!isCompatible && ( - - )} -
- - {connections.length === 0 ? ( -
-
- {isOAuth ? "lock" : "key"} -
-

No connections yet

-

Add your first connection to get started

- {!isCompatible && ( - - )} -
- ) : ( -
- {connections - .sort((a, b) => (a.priority || 0) - (b.priority || 0)) - .map((conn, index) => ( - handleSwapPriority(conn, connections[index - 1])} - onMoveDown={() => handleSwapPriority(conn, connections[index + 1])} - onToggleActive={(isActive) => handleUpdateConnectionStatus(conn.id, isActive)} - onEdit={() => { - setSelectedConnection(conn); - setShowEditModal(true); - }} - onDelete={() => handleDelete(conn.id)} - /> - ))} -
- )} -
- - {/* Models */} - -

- {providerInfo.passthroughModels ? "Model Aliases" : "Available Models"} -

- {renderModelsSection()} - -
- - {/* Modals */} - {providerId === "kiro" ? ( - setShowOAuthModal(false)} - /> - ) : providerId === "cursor" ? ( - setShowOAuthModal(false)} - /> - ) : ( - setShowOAuthModal(false)} - /> - )} - setShowAddApiKeyModal(false)} - /> - setShowEditModal(false)} - /> - {isCompatible && ( - setShowEditNodeModal(false)} - isAnthropic={isAnthropicCompatible} - /> - )} -
- ); -} - -function ModelRow({ model, fullModel, alias, selected, onToggleSelect, copied, onCopy }) { - return ( -
- - smart_toy - {fullModel} - -
- ); -} - -ModelRow.propTypes = { - model: PropTypes.shape({ - id: PropTypes.string.isRequired, - }).isRequired, - fullModel: PropTypes.string.isRequired, - alias: PropTypes.string, - selected: PropTypes.bool, - onToggleSelect: PropTypes.func, - copied: PropTypes.string, - onCopy: PropTypes.func.isRequired, -}; - -function PassthroughModelsSection({ providerAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias }) { - const [newModel, setNewModel] = useState(""); - const [adding, setAdding] = useState(false); - - // Filter aliases for this provider - models are persisted via alias - const providerAliases = Object.entries(modelAliases).filter( - ([, model]) => model.startsWith(`${providerAlias}/`) - ); - - const allModels = providerAliases.map(([alias, fullModel]) => ({ - modelId: fullModel.replace(`${providerAlias}/`, ""), - fullModel, - alias, - })); - - // Generate default alias from modelId (last part after /) - const generateDefaultAlias = (modelId) => { - const parts = modelId.split("/"); - return parts[parts.length - 1]; - }; - - const handleAdd = async () => { - if (!newModel.trim() || adding) return; - const modelId = newModel.trim(); - const defaultAlias = generateDefaultAlias(modelId); - - // Check if alias already exists - if (modelAliases[defaultAlias]) { - alert(`Alias "${defaultAlias}" already exists. Please use a different model or edit existing alias.`); - return; - } - - setAdding(true); - try { - await onSetAlias(modelId, defaultAlias); - setNewModel(""); - } catch (error) { - console.log("Error adding model:", error); - } finally { - setAdding(false); - } - }; - - return ( -
-

- OpenRouter supports any model. Add models and create aliases for quick access. -

- - {/* Add new model */} -
-
- - setNewModel(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleAdd()} - placeholder="anthropic/claude-3-opus" - className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" - /> -
- -
- - {/* Models list */} - {allModels.length > 0 && ( -
- {allModels.map(({ modelId, fullModel, alias }) => ( - onDeleteAlias(alias)} - /> - ))} -
- )} -
- ); -} - -PassthroughModelsSection.propTypes = { - providerAlias: PropTypes.string.isRequired, - modelAliases: PropTypes.object.isRequired, - copied: PropTypes.string, - onCopy: PropTypes.func.isRequired, - onSetAlias: PropTypes.func.isRequired, - onDeleteAlias: PropTypes.func.isRequired, -}; - -function PassthroughModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias }) { - return ( -
- smart_toy - -
-

{modelId}

- -
- {fullModel} - -
-
- - {/* Delete button */} - -
- ); -} - -PassthroughModelRow.propTypes = { - modelId: PropTypes.string.isRequired, - fullModel: PropTypes.string.isRequired, - copied: PropTypes.string, - onCopy: PropTypes.func.isRequired, - onDeleteAlias: PropTypes.func.isRequired, -}; - -function CompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias, connections, isAnthropic }) { - const [newModel, setNewModel] = useState(""); - const [adding, setAdding] = useState(false); - const [importing, setImporting] = useState(false); - - const providerAliases = Object.entries(modelAliases).filter( - ([, model]) => model.startsWith(`${providerStorageAlias}/`) - ); - - const allModels = providerAliases.map(([alias, fullModel]) => ({ - modelId: fullModel.replace(`${providerStorageAlias}/`, ""), - fullModel, - alias, - })); - - const generateDefaultAlias = (modelId) => { - const parts = modelId.split("/"); - return parts[parts.length - 1]; - }; - - const resolveAlias = (modelId) => { - const baseAlias = generateDefaultAlias(modelId); - if (!modelAliases[baseAlias]) return baseAlias; - const prefixedAlias = `${providerDisplayAlias}-${baseAlias}`; - if (!modelAliases[prefixedAlias]) return prefixedAlias; - return null; - }; - - const handleAdd = async () => { - if (!newModel.trim() || adding) return; - const modelId = newModel.trim(); - const resolvedAlias = resolveAlias(modelId); - if (!resolvedAlias) { - alert("All suggested aliases already exist. Please choose a different model or remove conflicting aliases."); - return; - } - - setAdding(true); - try { - await onSetAlias(modelId, resolvedAlias, providerStorageAlias); - setNewModel(""); - } catch (error) { - console.log("Error adding model:", error); - } finally { - setAdding(false); - } - }; - - const handleImport = async () => { - if (importing) return; - const activeConnection = connections.find((conn) => conn.isActive !== false); - if (!activeConnection) return; - - setImporting(true); - try { - const res = await fetch(`/api/providers/${activeConnection.id}/models`); - const data = await res.json(); - if (!res.ok) { - alert(data.error || "Failed to import models"); - return; - } - const models = data.models || []; - if (models.length === 0) { - alert("No models returned from /models."); - return; - } - let importedCount = 0; - for (const model of models) { - const modelId = model.id || model.name || model.model; - if (!modelId) continue; - const resolvedAlias = resolveAlias(modelId); - if (!resolvedAlias) continue; - await onSetAlias(modelId, resolvedAlias, providerStorageAlias); - importedCount += 1; - } - if (importedCount === 0) { - alert("No new models were added."); - } - } catch (error) { - console.log("Error importing models:", error); - } finally { - setImporting(false); - } - }; - - const canImport = connections.some((conn) => conn.isActive !== false); - - return ( -
-

- Add {isAnthropic ? "Anthropic" : "OpenAI"}-compatible models manually or import them from the /models endpoint. -

- -
-
- - setNewModel(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleAdd()} - placeholder={isAnthropic ? "claude-3-opus-20240229" : "gpt-4o"} - className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" - /> -
- - -
- - {!canImport && ( -

- Add a connection to enable importing models. -

- )} - - {allModels.length > 0 && ( -
- {allModels.map(({ modelId, fullModel, alias }) => ( - onDeleteAlias(alias)} - /> - ))} -
- )} -
- ); -} - -CompatibleModelsSection.propTypes = { - providerStorageAlias: PropTypes.string.isRequired, - providerDisplayAlias: PropTypes.string.isRequired, - modelAliases: PropTypes.object.isRequired, - copied: PropTypes.string, - onCopy: PropTypes.func.isRequired, - onSetAlias: PropTypes.func.isRequired, - onDeleteAlias: PropTypes.func.isRequired, - connections: PropTypes.arrayOf(PropTypes.shape({ - id: PropTypes.string, - isActive: PropTypes.bool, - })).isRequired, - isAnthropic: PropTypes.bool, -}; - -function CooldownTimer({ until }) { - const [remaining, setRemaining] = useState(""); - - useEffect(() => { - const updateRemaining = () => { - const diff = new Date(until).getTime() - Date.now(); - if (diff <= 0) { - setRemaining(""); - return; - } - const secs = Math.floor(diff / 1000); - if (secs < 60) { - setRemaining(`${secs}s`); - } else if (secs < 3600) { - setRemaining(`${Math.floor(secs / 60)}m ${secs % 60}s`); - } else { - const hrs = Math.floor(secs / 3600); - const mins = Math.floor((secs % 3600) / 60); - setRemaining(`${hrs}h ${mins}m`); - } - }; - - updateRemaining(); - const interval = setInterval(updateRemaining, 1000); - return () => clearInterval(interval); - }, [until]); - - if (!remaining) return null; - - return ( - - ⏱ {remaining} - - ); -} - -CooldownTimer.propTypes = { - until: PropTypes.string.isRequired, -}; - -function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onEdit, onDelete }) { - const displayName = isOAuth - ? connection.name || connection.email || connection.displayName || "OAuth Account" - : connection.name; - - // Use useState + useEffect for impure Date.now() to avoid calling during render - const [isCooldown, setIsCooldown] = useState(false); - - const modelLockUntil = Object.entries(connection) - .filter(([k]) => k.startsWith("modelLock_")) - .map(([, v]) => v) - .filter(v => v && new Date(v).getTime() > Date.now()) - .sort()[0] || null; - - useEffect(() => { - const checkCooldown = () => { - const until = Object.entries(connection) - .filter(([k]) => k.startsWith("modelLock_")) - .map(([, v]) => v) - .filter(v => v && new Date(v).getTime() > Date.now()) - .sort()[0] || null; - setIsCooldown(!!until); - }; - - checkCooldown(); - const interval = modelLockUntil ? setInterval(checkCooldown, 1000) : null; - return () => { - if (interval) clearInterval(interval); - }; - }, [modelLockUntil]); - - // Determine effective status (override unavailable if cooldown expired) - const effectiveStatus = (connection.testStatus === "unavailable" && !isCooldown) - ? "active" // Cooldown expired → treat as active - : connection.testStatus; - - const getStatusVariant = () => { - if (connection.isActive === false) return "default"; - if (effectiveStatus === "active" || effectiveStatus === "success") return "success"; - if (effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable") return "error"; - return "default"; - }; - - return ( -
-
- {/* Priority arrows */} -
- - -
- - {isOAuth ? "lock" : "key"} - -
-

{displayName}

-
- - {connection.isActive === false ? "disabled" : (effectiveStatus || "Unknown")} - - {isCooldown && connection.isActive !== false && } - {connection.lastError && connection.isActive !== false && ( - - {connection.lastError} - - )} - #{connection.priority} - {connection.globalPriority && ( - Auto: {connection.globalPriority} - )} -
-
-
-
- -
- - -
-
-
- ); -} - -ConnectionRow.propTypes = { - connection: PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - email: PropTypes.string, - displayName: PropTypes.string, - modelLockUntil: PropTypes.string, - testStatus: PropTypes.string, - isActive: PropTypes.bool, - lastError: PropTypes.string, - priority: PropTypes.number, - globalPriority: PropTypes.number, - }).isRequired, - isOAuth: PropTypes.bool.isRequired, - isFirst: PropTypes.bool.isRequired, - isLast: PropTypes.bool.isRequired, - onMoveUp: PropTypes.func.isRequired, - onMoveDown: PropTypes.func.isRequired, - onToggleActive: PropTypes.func.isRequired, - onEdit: PropTypes.func.isRequired, - onDelete: PropTypes.func.isRequired, -}; - -function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, onSave, onClose }) { - const [formData, setFormData] = useState({ - name: "", - apiKey: "", - priority: 1, - }); - const [validating, setValidating] = useState(false); - const [validationResult, setValidationResult] = useState(null); - const [saving, setSaving] = useState(false); - - const handleValidate = async () => { - setValidating(true); - try { - const res = await fetch("/api/providers/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider, apiKey: formData.apiKey }), - }); - const data = await res.json(); - setValidationResult(data.valid ? "success" : "failed"); - } catch { - setValidationResult("failed"); - } finally { - setValidating(false); - } - }; - - const handleSubmit = async () => { - if (!provider || !formData.apiKey) return; - - setSaving(true); - try { - let isValid = false; - try { - setValidating(true); - setValidationResult(null); - const res = await fetch("/api/providers/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider, apiKey: formData.apiKey }), - }); - const data = await res.json(); - isValid = !!data.valid; - setValidationResult(isValid ? "success" : "failed"); - } catch { - setValidationResult("failed"); - } finally { - setValidating(false); - } - - await onSave({ - name: formData.name, - apiKey: formData.apiKey, - priority: formData.priority, - testStatus: isValid ? "active" : "unknown", - }); - } finally { - setSaving(false); - } - }; - - if (!provider) return null; - - return ( - -
- setFormData({ ...formData, name: e.target.value })} - placeholder="Production Key" - /> -
- setFormData({ ...formData, apiKey: e.target.value })} - className="flex-1" - /> -
- -
-
- {validationResult && ( - - {validationResult === "success" ? "Valid" : "Invalid"} - - )} - {isCompatible && ( -

- {isAnthropic - ? `Validation checks ${providerName || "Anthropic Compatible"} by verifying the API key.` - : `Validation checks ${providerName || "OpenAI Compatible"} via /models on your base URL.` - } -

- )} - setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })} - /> -
- - -
-
-
- ); -} - -AddApiKeyModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - provider: PropTypes.string, - providerName: PropTypes.string, - isCompatible: PropTypes.bool, - isAnthropic: PropTypes.bool, - onSave: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, -}; - -function EditConnectionModal({ isOpen, connection, onSave, onClose }) { - const [formData, setFormData] = useState({ - name: "", - priority: 1, - apiKey: "", - }); - const [testing, setTesting] = useState(false); - const [testResult, setTestResult] = useState(null); - const [validating, setValidating] = useState(false); - const [validationResult, setValidationResult] = useState(null); - const [saving, setSaving] = useState(false); - - useEffect(() => { - if (connection) { - setFormData({ - name: connection.name || "", - priority: connection.priority || 1, - apiKey: "", - }); - setTestResult(null); - setValidationResult(null); - } - }, [connection]); - - const handleTest = async () => { - if (!connection?.provider) return; - setTesting(true); - setTestResult(null); - try { - const res = await fetch(`/api/providers/${connection.id}/test`, { method: "POST" }); - const data = await res.json(); - setTestResult(data.valid ? "success" : "failed"); - } catch { - setTestResult("failed"); - } finally { - setTesting(false); - } - }; - - const handleValidate = async () => { - if (!connection?.provider || !formData.apiKey) return; - setValidating(true); - setValidationResult(null); - try { - const res = await fetch("/api/providers/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }), - }); - const data = await res.json(); - setValidationResult(data.valid ? "success" : "failed"); - } catch { - setValidationResult("failed"); - } finally { - setValidating(false); - } - }; - - const handleSubmit = async () => { - setSaving(true); - try { - const updates = { name: formData.name, priority: formData.priority }; - if (!isOAuth && formData.apiKey) { - updates.apiKey = formData.apiKey; - let isValid = validationResult === "success"; - if (!isValid) { - try { - setValidating(true); - setValidationResult(null); - const res = await fetch("/api/providers/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }), - }); - const data = await res.json(); - isValid = !!data.valid; - setValidationResult(isValid ? "success" : "failed"); - } catch { - setValidationResult("failed"); - } finally { - setValidating(false); - } - } - if (isValid) { - updates.testStatus = "active"; - updates.lastError = null; - updates.lastErrorAt = null; - } - } - await onSave(updates); - } finally { - setSaving(false); - } - }; - - if (!connection) return null; - - const isOAuth = connection.authType === "oauth"; - const isCompatible = isOpenAICompatibleProvider(connection.provider) || isAnthropicCompatibleProvider(connection.provider); - - return ( - -
- setFormData({ ...formData, name: e.target.value })} - placeholder={isOAuth ? "Account name" : "Production Key"} - /> - {isOAuth && connection.email && ( -
-

Email

-

{connection.email}

-
- )} - setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })} - /> - {!isOAuth && ( - <> -
- setFormData({ ...formData, apiKey: e.target.value })} - placeholder="Enter new API key" - hint="Leave blank to keep the current API key." - className="flex-1" - /> -
- -
-
- {validationResult && ( - - {validationResult === "success" ? "Valid" : "Invalid"} - - )} - - )} - - {/* Test Connection */} - {!isCompatible && ( -
- - {testResult && ( - - {testResult === "success" ? "Valid" : "Failed"} - - )} -
- )} - -
- - -
-
-
- ); -} - -EditConnectionModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - connection: PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - email: PropTypes.string, - priority: PropTypes.number, - authType: PropTypes.string, - provider: PropTypes.string, - }), - onSave: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, -}; - -function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic }) { - const [formData, setFormData] = useState({ - name: "", - prefix: "", - apiType: "chat", - baseUrl: "https://api.openai.com/v1", - }); - const [saving, setSaving] = useState(false); - const [checkKey, setCheckKey] = useState(""); - const [validating, setValidating] = useState(false); - const [validationResult, setValidationResult] = useState(null); - - useEffect(() => { - if (node) { - setFormData({ - name: node.name || "", - prefix: node.prefix || "", - apiType: node.apiType || "chat", - baseUrl: node.baseUrl || (isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"), - }); - } - }, [node, isAnthropic]); - - const apiTypeOptions = [ - { value: "chat", label: "Chat Completions" }, - { value: "responses", label: "Responses API" }, - ]; - - const handleSubmit = async () => { - if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; - setSaving(true); - try { - const payload = { - name: formData.name, - prefix: formData.prefix, - baseUrl: formData.baseUrl, - }; - if (!isAnthropic) { - payload.apiType = formData.apiType; - } - await onSave(payload); - } finally { - setSaving(false); - } - }; - - const handleValidate = async () => { - setValidating(true); - try { - const res = await fetch("/api/provider-nodes/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: formData.baseUrl, - apiKey: checkKey, - type: isAnthropic ? "anthropic-compatible" : "openai-compatible" - }), - }); - const data = await res.json(); - setValidationResult(data.valid ? "success" : "failed"); - } catch { - setValidationResult("failed"); - } finally { - setValidating(false); - } - }; - - if (!node) return null; - - return ( - -
- setFormData({ ...formData, name: e.target.value })} - placeholder={`${isAnthropic ? "Anthropic" : "OpenAI"} Compatible (Prod)`} - hint="Required. A friendly label for this node." - /> - setFormData({ ...formData, prefix: e.target.value })} - placeholder={isAnthropic ? "ac-prod" : "oc-prod"} - hint="Required. Used as the provider prefix for model IDs." - /> - {!isAnthropic && ( - setFormData({ ...formData, baseUrl: e.target.value })} - placeholder={isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"} - hint={`Use the base URL (ending in /v1) for your ${isAnthropic ? "Anthropic" : "OpenAI"}-compatible API.`} - /> -
- setCheckKey(e.target.value)} - className="flex-1" - /> -
- -
-
- {validationResult && ( - - {validationResult === "success" ? "Valid" : "Invalid"} - - )} -
- - -
-
-
- ); -} - -EditCompatibleNodeModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - node: PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - prefix: PropTypes.string, - apiType: PropTypes.string, - baseUrl: PropTypes.string, - }), - onSave: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, - isAnthropic: PropTypes.bool, -}; diff --git a/src/app/(dashboard)/dashboard/providers/components/AddCompatibleModal.js b/src/app/(dashboard)/dashboard/providers/components/AddCompatibleModal.js new file mode 100644 index 00000000..d0951d71 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/AddCompatibleModal.js @@ -0,0 +1,221 @@ +"use client"; + +import { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import { Badge, Button, Input, Modal, Select } from "@/shared/components"; + +const VARIANT_CONFIG = { + openai: { + title: "Add OpenAI Compatible", + type: "openai-compatible", + defaultBaseUrl: "https://api.openai.com/v1", + namePlaceholder: "OpenAI Compatible (Prod)", + prefixPlaceholder: "oc-prod", + baseUrlHint: "Use the base URL (ending in /v1) for your OpenAI-compatible API.", + modelIdPlaceholder: "e.g. gpt-4, claude-3-opus", + errorLabel: "OpenAI Compatible", + hasApiType: true, + }, + anthropic: { + title: "Add Anthropic Compatible", + type: "anthropic-compatible", + defaultBaseUrl: "https://api.anthropic.com/v1", + namePlaceholder: "Anthropic Compatible (Prod)", + prefixPlaceholder: "ac-prod", + baseUrlHint: "Use the base URL (ending in /v1) for your Anthropic-compatible API. The system will append /messages.", + modelIdPlaceholder: "e.g. claude-3-opus", + errorLabel: "Anthropic Compatible", + hasApiType: false, + }, +}; + +const API_TYPE_OPTIONS = [ + { value: "chat", label: "Chat Completions" }, + { value: "responses", label: "Responses API" }, +]; + +function AddCompatibleModal({ variant, isOpen, onClose, onCreated }) { + const config = VARIANT_CONFIG[variant]; + const initialFormData = () => ({ + name: "", + prefix: "", + ...(config.hasApiType ? { apiType: "chat" } : {}), + baseUrl: config.defaultBaseUrl, + }); + + const [formData, setFormData] = useState(initialFormData); + const [submitting, setSubmitting] = useState(false); + const [checkKey, setCheckKey] = useState(""); + const [checkModelId, setCheckModelId] = useState(""); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + + // openai: reset baseUrl when apiType changes; anthropic: reset checks when opened + useEffect(() => { + if (config.hasApiType) { + setFormData((prev) => ({ ...prev, baseUrl: config.defaultBaseUrl })); + } else if (isOpen) { + setValidationResult(null); + setCheckKey(""); + setCheckModelId(""); + } + }, [config.hasApiType ? formData.apiType : isOpen]); + + const handleSubmit = async () => { + if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; + setSubmitting(true); + try { + const res = await fetch("/api/provider-nodes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: formData.name, + prefix: formData.prefix, + ...(config.hasApiType ? { apiType: formData.apiType } : {}), + baseUrl: formData.baseUrl, + type: config.type, + }), + }); + const data = await res.json(); + if (res.ok) { + onCreated(data.node); + setFormData(initialFormData()); + setCheckKey(""); + setValidationResult(null); + } + } catch (error) { + console.log(`Error creating ${config.errorLabel} node:`, error); + } finally { + setSubmitting(false); + } + }; + + const handleValidate = async () => { + setValidating(true); + try { + const res = await fetch("/api/provider-nodes/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: formData.baseUrl, + apiKey: checkKey, + type: config.type, + modelId: checkModelId.trim() || undefined, + }), + }); + const data = await res.json(); + setValidationResult(data); + } catch { + setValidationResult({ valid: false, error: "Network error" }); + } finally { + setValidating(false); + } + }; + + const renderValidationResult = () => { + if (!validationResult) return null; + const { valid, error, method } = validationResult; + if (valid) { + return ( + <> + Valid + {method === "chat" && ( + (via inference test) + )} + + ); + } + return ( +
+ Invalid + {error && {error}} +
+ ); + }; + + return ( + +
+ setFormData({ ...formData, name: e.target.value })} + placeholder={config.namePlaceholder} + hint="Required. A friendly label for this node." + /> + setFormData({ ...formData, prefix: e.target.value })} + placeholder={config.prefixPlaceholder} + hint="Required. Used as the provider prefix for model IDs." + /> + {config.hasApiType && ( + setFormData({ ...formData, baseUrl: e.target.value })} + placeholder={config.defaultBaseUrl} + hint={config.baseUrlHint} + /> + setCheckKey(e.target.value)} + /> + setCheckModelId(e.target.value)} + placeholder={config.modelIdPlaceholder} + hint="If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead." + /> +
+ + {renderValidationResult()} +
+
+ + +
+
+
+ ); +} + +AddCompatibleModal.propTypes = { + variant: PropTypes.oneOf(["openai", "anthropic"]).isRequired, + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onCreated: PropTypes.func.isRequired, +}; + +export default AddCompatibleModal; diff --git a/src/app/(dashboard)/dashboard/providers/components/ConnectionsCard.js b/src/app/(dashboard)/dashboard/providers/components/ConnectionsCard.js index b8d196ce..6b178bdd 100644 --- a/src/app/(dashboard)/dashboard/providers/components/ConnectionsCard.js +++ b/src/app/(dashboard)/dashboard/providers/components/ConnectionsCard.js @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback, useRef } from "react"; +import { getStatusVariant as getConnectionStatusVariant } from "@/shared/utils/connectionStatus"; import PropTypes from "prop-types"; import { Card, Badge, Button, Modal, Select, Toggle, EditConnectionModal, ConfirmModal } from "@/shared/components"; @@ -86,12 +87,7 @@ function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMov const effectiveStatus = connection.testStatus === "unavailable" && !isCooldown ? "active" : connection.testStatus; - const getStatusVariant = () => { - if (connection.isActive === false) return "default"; - if (effectiveStatus === "active" || effectiveStatus === "success") return "success"; - if (effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable") return "error"; - return "default"; - }; + const getStatusVariant = () => getConnectionStatusVariant(connection.isActive, effectiveStatus); const displayName = isOAuth ? connection.name || connection.email || connection.displayName || "OAuth Account" diff --git a/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js b/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js index f55d001f..a6d9533e 100644 --- a/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js +++ b/src/app/(dashboard)/dashboard/providers/components/ModelsCard.js @@ -3,7 +3,7 @@ import { useState, useCallback, useEffect } from "react"; import PropTypes from "prop-types"; import { Card, Button, Modal } from "@/shared/components"; -import { getModelsByProviderId } from "@/shared/constants/models"; +import { getModelsByProviderId, getModelKind } from "@/shared/constants/models"; import { getProviderAlias } from "@/shared/constants/providers"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; @@ -206,14 +206,14 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri const builtInModels = kindFilter ? allBuiltIn.filter((m) => { if (m.kinds) return m.kinds.includes(kindFilter); - return (m.type || "llm") === kindFilter; + return getModelKind(m, "llm") === kindFilter; }) : allBuiltIn; // Custom models for this provider + kind, dedupe vs built-in const myCustomModels = customModels.filter( (m) => m.providerAlias === providerAlias - && (m.type || "llm") === effectiveType + && getModelKind(m, "llm") === effectiveType && !builtInModels.some((b) => b.id === m.id) ); diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index 8ca94ab8..dd134f33 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -7,9 +7,6 @@ import { CardSkeleton, Badge, Button, - Input, - Modal, - Select, Toggle, } from "@/shared/components"; import ProviderIcon from "@/shared/components/ProviderIcon"; @@ -26,6 +23,7 @@ import { getErrorCode, getRelativeTime } from "@/shared/utils"; import { useNotificationStore } from "@/store/notificationStore"; import { useHeaderSearchStore } from "@/store/headerSearchStore"; import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge"; +import AddCompatibleModal from "./components/AddCompatibleModal"; function getStatusDisplay(connected, error, errorCode) { const parts = []; @@ -122,6 +120,9 @@ export default function ProvidersPage() { const sortByPriority = (entries, authType) => [...entries].sort(([ka, a], [kb, b]) => { + const pa = a.priority ?? 999; + const pb = b.priority ?? 999; + if (pa !== pb) return pa - pb; const sa = getProviderStats(ka, authType); const sb = getProviderStats(kb, authType); const ca = sa.connected > 0 ? 1 : 0; @@ -132,6 +133,9 @@ export default function ProvidersPage() { const sortItemsByPriority = (items, authType) => [...items].sort((a, b) => { + const pa = a.priority ?? 999; + const pb = b.priority ?? 999; + if (pa !== pb) return pa - pb; const sa = getProviderStats(a.id, authType); const sb = getProviderStats(b.id, authType); const ca = sa.connected > 0 ? 1 : 0; @@ -162,8 +166,9 @@ export default function ProvidersPage() { }, []); const getProviderStats = (providerId, authType) => { + const authTypes = Array.isArray(authType) ? authType : [authType]; const providerConnections = connections.filter( - (c) => c.provider === providerId && c.authType === authType, + (c) => c.provider === providerId && authTypes.includes(c.authType), ); const getEffectiveStatus = (conn) => { @@ -204,17 +209,15 @@ export default function ProvidersPage() { return { connected, error, total, errorCode, errorTime, allDisabled }; }; - // Toggle all connections for a provider on/off + // Toggle all connections for a provider on/off. authType may be a single + // string or an array (kiro counts oauth + api_key/apikey together). const handleToggleProvider = async (providerId, authType, newActive) => { - const providerConns = connections.filter( - (c) => c.provider === providerId && c.authType === authType, - ); + const authTypes = Array.isArray(authType) ? authType : [authType]; + const matches = (c) => + c.provider === providerId && authTypes.includes(c.authType); + const providerConns = connections.filter(matches); setConnections((prev) => - prev.map((c) => - c.provider === providerId && c.authType === authType - ? { ...c, isActive: newActive } - : c, - ), + prev.map((c) => (matches(c) ? { ...c, isActive: newActive } : c)), ); await Promise.allSettled( providerConns.map((c) => @@ -273,24 +276,36 @@ export default function ProvidersPage() { })) .filter((p) => matchSearch(p.name)); - const oauthEntries = Object.entries(OAUTH_PROVIDERS).filter( - ([, info]) => !info.hidden && matchSearch(info.name), + const oauthEntries = sortByPriority( + Object.entries(OAUTH_PROVIDERS).filter(([, info]) => !info.hidden && matchSearch(info.name)), + "oauth", ); - const freeEntries = Object.entries(FREE_PROVIDERS).filter( - ([, info]) => !info.hidden && matchSearch(info.name), - ); - const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS).filter( - ([, info]) => !info.hidden && matchSearch(info.name), - ); - const apikeyEntries = sortByPriority( - Object.entries(APIKEY_PROVIDERS).filter( + const freeEntries = Object.entries(FREE_PROVIDERS) + .filter(([, info]) => !info.hidden && matchSearch(info.name)) + .sort(([, a], [, b]) => (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0)); + const freeTierEntries = sortByPriority( + Object.entries(FREE_TIER_PROVIDERS).filter( + ([, info]) => + !info.hidden && + matchSearch(info.name) && + (info.serviceKinds ?? ["llm"]).includes("llm"), + ), + "freeTier", + ).sort(([, a], [, b]) => (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0)); + // API Key: connected providers first, then alphabetical by name + const apikeyEntries = Object.entries(APIKEY_PROVIDERS) + .filter( ([, info]) => !info.hidden && (info.serviceKinds ?? ["llm"]).includes("llm") && matchSearch(info.name), - ), - "apikey", - ); + ) + .sort(([ka, a], [kb, b]) => { + const ca = getProviderStats(ka, "apikey").total > 0 ? 0 : 1; + const cb = getProviderStats(kb, "apikey").total > 0 ? 0 : 1; + if (ca !== cb) return ca - cb; + return (a.name || "").localeCompare(b.name || ""); + }); const isApikeySearching = !!searchQuery.trim(); const visibleApikeyEntries = isApikeySearching || showAllApikey @@ -449,16 +464,26 @@ export default function ProvidersPage() {
- {freeEntries.map(([key, info]) => ( - handleToggleProvider(key, "oauth", active)} - /> - ))} + {freeEntries.map(([key, info]) => { + // Kiro accepts both OAuth and api-key connections; count/toggle both + // so the card total matches the provider detail page (#kiro-apikey). + // Kiro's headless api-key flow persists authType "api_key" (underscore), + // while generic apikey providers use "apikey" — include both spellings. + const freeAuthTypes = + key === "kiro" ? ["oauth", "apikey", "api_key"] : "oauth"; + return ( + + handleToggleProvider(key, freeAuthTypes, active) + } + /> + ); + })} {freeTierEntries.map(([key, info]) => (
*/} - setShowAddCompatibleModal(false)} onCreated={(node) => { @@ -552,7 +578,8 @@ export default function ProvidersPage() { setShowAddCompatibleModal(false); }} /> - setShowAddAnthropicCompatibleModal(false)} onCreated={(node) => { @@ -841,383 +868,6 @@ ApiKeyProviderCard.propTypes = { onToggle: PropTypes.func, }; -function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) { - const [formData, setFormData] = useState({ - name: "", - prefix: "", - apiType: "chat", - baseUrl: "https://api.openai.com/v1", - }); - const [submitting, setSubmitting] = useState(false); - const [checkKey, setCheckKey] = useState(""); - const [checkModelId, setCheckModelId] = useState(""); - const [validating, setValidating] = useState(false); - const [validationResult, setValidationResult] = useState(null); - - const apiTypeOptions = [ - { value: "chat", label: "Chat Completions" }, - { value: "responses", label: "Responses API" }, - ]; - - useEffect(() => { - const defaultBaseUrl = "https://api.openai.com/v1"; - setFormData((prev) => ({ ...prev, baseUrl: defaultBaseUrl })); - }, [formData.apiType]); - - const handleSubmit = async () => { - if ( - !formData.name.trim() || - !formData.prefix.trim() || - !formData.baseUrl.trim() - ) - return; - setSubmitting(true); - try { - const res = await fetch("/api/provider-nodes", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: formData.name, - prefix: formData.prefix, - apiType: formData.apiType, - baseUrl: formData.baseUrl, - type: "openai-compatible", - }), - }); - const data = await res.json(); - if (res.ok) { - onCreated(data.node); - setFormData({ - name: "", - prefix: "", - apiType: "chat", - baseUrl: "https://api.openai.com/v1", - }); - setCheckKey(""); - setValidationResult(null); - } - } catch (error) { - console.log("Error creating OpenAI Compatible node:", error); - } finally { - setSubmitting(false); - } - }; - - const handleValidate = async () => { - setValidating(true); - try { - const res = await fetch("/api/provider-nodes/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: formData.baseUrl, - apiKey: checkKey, - type: "openai-compatible", - modelId: checkModelId.trim() || undefined, - }), - }); - const data = await res.json(); - setValidationResult(data); - } catch { - setValidationResult({ valid: false, error: "Network error" }); - } finally { - setValidating(false); - } - }; - - // Helper to render validation result - const renderValidationResult = () => { - if (!validationResult) return null; - const { valid, error, method } = validationResult; - - if (valid) { - return ( - <> - Valid - {method === "chat" && ( - - (via inference test) - - )} - - ); - } - return ( -
- Invalid - {error && {error}} -
- ); - }; - - return ( - -
- setFormData({ ...formData, name: e.target.value })} - placeholder="OpenAI Compatible (Prod)" - hint="Required. A friendly label for this node." - /> - setFormData({ ...formData, prefix: e.target.value })} - placeholder="oc-prod" - hint="Required. Used as the provider prefix for model IDs." - /> - - setFormData({ ...formData, baseUrl: e.target.value }) - } - placeholder="https://api.openai.com/v1" - hint="Use the base URL (ending in /v1) for your OpenAI-compatible API." - /> - setCheckKey(e.target.value)} - /> - setCheckModelId(e.target.value)} - placeholder="e.g. gpt-4, claude-3-opus" - hint="If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead." - /> -
- - {renderValidationResult()} -
-
- - -
-
-
- ); -} - -AddOpenAICompatibleModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - onCreated: PropTypes.func.isRequired, -}; - -function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { - const [formData, setFormData] = useState({ - name: "", - prefix: "", - baseUrl: "https://api.anthropic.com/v1", - }); - const [submitting, setSubmitting] = useState(false); - const [checkKey, setCheckKey] = useState(""); - const [checkModelId, setCheckModelId] = useState(""); - const [validating, setValidating] = useState(false); - const [validationResult, setValidationResult] = useState(null); // { valid, error, method } - - useEffect(() => { - if (isOpen) { - setValidationResult(null); - setCheckKey(""); - setCheckModelId(""); - } - }, [isOpen]); - - const handleSubmit = async () => { - if ( - !formData.name.trim() || - !formData.prefix.trim() || - !formData.baseUrl.trim() - ) - return; - setSubmitting(true); - try { - const res = await fetch("/api/provider-nodes", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: formData.name, - prefix: formData.prefix, - baseUrl: formData.baseUrl, - type: "anthropic-compatible", - }), - }); - const data = await res.json(); - if (res.ok) { - onCreated(data.node); - setFormData({ - name: "", - prefix: "", - baseUrl: "https://api.anthropic.com/v1", - }); - setCheckKey(""); - setValidationResult(null); - } - } catch (error) { - console.log("Error creating Anthropic Compatible node:", error); - } finally { - setSubmitting(false); - } - }; - - const handleValidate = async () => { - setValidating(true); - try { - const res = await fetch("/api/provider-nodes/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: formData.baseUrl, - apiKey: checkKey, - type: "anthropic-compatible", - modelId: checkModelId.trim() || undefined, - }), - }); - const data = await res.json(); - setValidationResult(data); - } catch { - setValidationResult({ valid: false, error: "Network error" }); - } finally { - setValidating(false); - } - }; - - // Helper to render validation result - const renderValidationResult = () => { - if (!validationResult) return null; - const { valid, error, method } = validationResult; - - if (valid) { - return ( - <> - Valid - {method === "chat" && ( - - (via inference test) - - )} - - ); - } - return ( -
- Invalid - {error && {error}} -
- ); - }; - - return ( - -
- setFormData({ ...formData, name: e.target.value })} - placeholder="Anthropic Compatible (Prod)" - hint="Required. A friendly label for this node." - /> - setFormData({ ...formData, prefix: e.target.value })} - placeholder="ac-prod" - hint="Required. Used as the provider prefix for model IDs." - /> - - setFormData({ ...formData, baseUrl: e.target.value }) - } - placeholder="https://api.anthropic.com/v1" - hint="Use the base URL (ending in /v1) for your Anthropic-compatible API. The system will append /messages." - /> - setCheckKey(e.target.value)} - /> - setCheckModelId(e.target.value)} - placeholder="e.g. claude-3-opus" - hint="If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead." - /> -
- - {renderValidationResult()} -
-
- - -
-
-
- ); -} - -AddAnthropicCompatibleModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - onCreated: PropTypes.func.isRequired, -}; - function ProviderTestResultsView({ results }) { if (results.error && !results.results) { return ( diff --git a/src/app/(dashboard)/dashboard/token-saver/TokenSaverClient.js b/src/app/(dashboard)/dashboard/token-saver/TokenSaverClient.js new file mode 100644 index 00000000..637ddced --- /dev/null +++ b/src/app/(dashboard)/dashboard/token-saver/TokenSaverClient.js @@ -0,0 +1,462 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Card, Button, Input, Modal, Toggle } from "@/shared/components"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { getCurrentLocale, onLocaleChange } from "@/i18n/runtime"; +import { + WENYAN_LOCALES, + CAVEMAN_LEVELS, + PONYTAIL_LEVELS, +} from "../endpoint/endpointConstants"; + +export default function TokenSaverClient() { + const [rtkEnabled, setRtkEnabledState] = useState(true); + const [headroomEnabled, setHeadroomEnabled] = useState(false); + const [headroomUrl, setHeadroomUrl] = useState("http://localhost:8787"); + const [headroomStatus, setHeadroomStatus] = useState({ + installed: false, + running: false, + python: null, + loading: true, + }); + const [showHeadroomInstallModal, setShowHeadroomInstallModal] = + useState(false); + const [headroomActionLoading, setHeadroomActionLoading] = useState(false); + const [headroomActionError, setHeadroomActionError] = useState(""); + const [cavemanEnabled, setCavemanEnabled] = useState(false); + const [cavemanLevel, setCavemanLevel] = useState("full"); + const [ponytailEnabled, setPonytailEnabled] = useState(false); + const [ponytailLevel, setPonytailLevel] = useState("full"); + const [locale, setLocale] = useState("en"); + + const { copied, copy } = useCopyToClipboard(); + + useEffect(() => { + setLocale(getCurrentLocale()); + return onLocaleChange(() => setLocale(getCurrentLocale())); + }, []); + + const isWenyanLocale = WENYAN_LOCALES.includes(locale); + const visibleCavemanLevels = isWenyanLocale + ? CAVEMAN_LEVELS + : CAVEMAN_LEVELS.filter((lvl) => !lvl.wenyan); + + useEffect(() => { + const current = CAVEMAN_LEVELS.find((lvl) => lvl.id === cavemanLevel); + if (current?.wenyan && !isWenyanLocale) { + setCavemanLevel("ultra"); + patchSetting({ cavemanLevel: "ultra" }); + } + }, [isWenyanLocale, cavemanLevel]); + + const patchSetting = async (patch) => { + try { + await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + } catch (error) { + console.log("Error updating setting:", error); + } + }; + + const handleRtkEnabled = async (value) => { + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rtkEnabled: value }), + }); + if (res.ok) setRtkEnabledState(value); + } catch (error) { + console.log("Error updating rtkEnabled:", error); + } + }; + + const handleCavemanEnabled = (value) => { + setCavemanEnabled(value); + patchSetting({ cavemanEnabled: value }); + }; + + const handleHeadroomEnabled = (value) => { + const nextUrl = headroomUrl.trim() || "http://localhost:8787"; + setHeadroomUrl(nextUrl); + setHeadroomEnabled(value); + patchSetting({ headroomEnabled: value, headroomUrl: nextUrl }); + }; + + const handleHeadroomUrlBlur = async () => { + const next = headroomUrl.trim() || "http://localhost:8787"; + setHeadroomUrl(next); + await patchSetting({ headroomUrl: next }); + refreshHeadroomStatus(); + }; + + const refreshHeadroomStatus = useCallback(async () => { + setHeadroomStatus((s) => ({ ...s, loading: true })); + try { + const res = await fetch("/api/headroom/status", { + headers: { "Cache-Control": "no-store" }, + }); + const data = await res.json(); + setHeadroomStatus({ ...data, loading: false }); + } catch { + setHeadroomStatus({ + installed: false, + running: false, + python: null, + loading: false, + }); + } + }, []); + + const handleHeadroomStart = useCallback(async () => { + setHeadroomActionError(""); + setHeadroomActionLoading(true); + try { + const res = await fetch("/api/headroom/start", { method: "POST" }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || "Failed to start proxy"); + await refreshHeadroomStatus(); + } catch (e) { + setHeadroomActionError(e.message); + } finally { + setHeadroomActionLoading(false); + } + }, [refreshHeadroomStatus]); + + const handleHeadroomStop = useCallback(async () => { + setHeadroomActionLoading(true); + try { + await fetch("/api/headroom/stop", { method: "POST" }); + await refreshHeadroomStatus(); + } finally { + setHeadroomActionLoading(false); + } + }, [refreshHeadroomStatus]); + + const handleCavemanLevel = (level) => { + setCavemanLevel(level); + patchSetting({ cavemanLevel: level }); + }; + + const handlePonytailEnabled = (value) => { + setPonytailEnabled(value); + patchSetting({ ponytailEnabled: value }); + }; + + const handlePonytailLevel = (level) => { + setPonytailLevel(level); + patchSetting({ ponytailLevel: level }); + }; + + useEffect(() => { + const loadSettings = async () => { + try { + const res = await fetch("/api/settings"); + if (res.ok) { + const data = await res.json(); + setRtkEnabledState(data.rtkEnabled !== false); + setHeadroomEnabled(!!data.headroomEnabled); + setHeadroomUrl(data.headroomUrl || "http://localhost:8787"); + setCavemanEnabled(!!data.cavemanEnabled); + setCavemanLevel(data.cavemanLevel || "full"); + setPonytailEnabled(!!data.ponytailEnabled); + setPonytailLevel(data.ponytailLevel || "full"); + refreshHeadroomStatus(); + } + } catch {} + }; + loadSettings(); + }, [refreshHeadroomStatus]); + + const headroomRunning = !!headroomStatus.running; + const headroomStatusLabel = headroomStatus.loading + ? "Checking…" + : headroomRunning + ? "Running" + : headroomStatus.localUrl !== false && !headroomStatus.installed + ? "Not installed" + : headroomStatus.localUrl !== false + ? "Stopped" + : "External"; + const headroomLocalUrl = headroomStatus.localUrl !== false; + const headroomCanStart = !!headroomStatus.canStart; + const headroomManaged = + headroomLocalUrl && !!headroomStatus.managedPid; + + return ( +
+ +
+

+ + bolt + + Token Saver +

+
+
+
+

+ Compress tool output{" "} + + (RTK) + +

+

+ git/grep/ls/tree/logs → 60-90% fewer input tokens +

+
+ handleRtkEnabled(!rtkEnabled)} + /> +
+
+
+
+

+ Compress context{" "} + + (Headroom) + +

+ + {headroomStatusLabel} + + +
+

+ Compress prompts via /v1/compress before routing to the model +

+
+ handleHeadroomEnabled(!headroomEnabled)} + /> +
+
+
+

+ Compress LLM output{" "} + + (Caveman) + +

+

+ Terse-style system prompt → ~65% fewer output tokens (up to 87%) +

+
+
+ {cavemanEnabled && ( +
+
+ {visibleCavemanLevels.map((lvl) => ( + + ))} +
+

+ { + CAVEMAN_LEVELS.find((lvl) => lvl.id === cavemanLevel) + ?.desc + } +

+
+ )} + handleCavemanEnabled(!cavemanEnabled)} + /> +
+
+
+
+

+ Lazy senior dev{" "} + + (Ponytail) + +

+

+ Bias the model toward minimal code: YAGNI, reuse stdlib, + deletion over addition +

+
+
+ {ponytailEnabled && ( +
+
+ {PONYTAIL_LEVELS.map((lvl) => ( + + ))} +
+

+ { + PONYTAIL_LEVELS.find((lvl) => lvl.id === ponytailLevel) + ?.desc + } +

+
+ )} + handlePonytailEnabled(!ponytailEnabled)} + /> +
+
+
+ + setShowHeadroomInstallModal(false)} + > +
+
+ Status + + {headroomStatusLabel} + +
+
+

Proxy URL

+ setHeadroomUrl(e.target.value)} + onBlur={handleHeadroomUrlBlur} + placeholder="http://localhost:8787" + className="font-mono text-sm" + /> +

+ Use a local proxy for Start/Stop, or an external Docker sidecar + like http://headroom:8787. +

+
+ {headroomManaged ? ( + + ) : headroomRunning ? ( +

+ Headroom proxy is reachable. You can enable the token saver. +

+ ) : headroomCanStart ? ( + + ) : !headroomLocalUrl ? ( +

+ Start Headroom separately at the configured URL, then recheck. +

+ ) : !headroomStatus.python ? ( +

+ Python ≥ 3.10 required for local managed mode. Install Python + first, or use an external proxy URL. +

+ ) : ( +
+

Install then click Start:

+
+
+                  {`pip install "headroom-ai[proxy]"`}
+                
+ +
+
+ )} + {headroomActionError && ( +

{headroomActionError}

+ )} +
+ + +
+
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/token-saver/page.js b/src/app/(dashboard)/dashboard/token-saver/page.js new file mode 100644 index 00000000..765b51f9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/token-saver/page.js @@ -0,0 +1,5 @@ +import TokenSaverClient from "./TokenSaverClient"; + +export default function TokenSaverPage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index 9498ed30..dfec68e3 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -4,228 +4,97 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import ProviderIcon from "@/shared/components/ProviderIcon"; import QuotaTable from "./QuotaTable"; import Toggle from "@/shared/components/Toggle"; -import { parseQuotaData, calculatePercentage } from "./utils"; +import Tooltip from "@/shared/components/Tooltip"; +import { + parseQuotaData, + calculatePercentage, + getConnectionLabel, + getConnectionQuotaRemaining, + sortVisibleConnections, + buildLoadingState, + filterQuotaStateByConnections, + getConnectionsEmptyMessage, + getPageSizeLabel, + getConnectionsPaginationSummary, + getSafePagination, + getSafeTotals, + shouldResetPage, + getPaginationPageValue, + getProviderOptions, + reconcileConnectionsPage, + getQuotaCache, + setQuotaCache, + QUOTA_CACHE_KEY, + REFRESH_INTERVAL_MS, + CLAUDE_REFRESH_INTERVAL_MS, + DEPLETED_QUOTA_THRESHOLD, + AUTO_REFRESH_STORAGE_KEY, + CONNECTIONS_PAGE_SIZE, + ACCOUNT_PAGE_SIZE_OPTIONS, + ACCOUNT_PAGE_SIZE_MAX, + ACCOUNT_FILTER_OPTIONS, + QUOTA_SORT_OPTIONS, +} from "./utils"; import Card from "@/shared/components/Card"; -import { EditConnectionModal } from "@/shared/components"; +import { ConfirmModal, EditConnectionModal } from "@/shared/components"; import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; -function getConnectionLabel(connection) { - const isEmail = (value) => - typeof value === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); - if (isEmail(connection.email)) return connection.email; - if (isEmail(connection.name)) return connection.name; - return connection.name; +// Maps the stored providerSpecificData.authMethod to a human label for Kiro. +// Values come from the Kiro connect flows: builder-id/idc (device code), +// google/github (social), imported (refresh-token paste), api_key (headless). +const KIRO_METHOD_LABELS = { + "builder-id": "AWS Builder ID", + idc: "IAM Identity Center", + google: "Google", + github: "GitHub", + imported: "Imported Token", + api_key: "API Key", +}; + +function kiroMethodLabel(conn) { + const m = conn.providerSpecificData?.authMethod; + if (m && KIRO_METHOD_LABELS[m]) return KIRO_METHOD_LABELS[m]; + return conn.authType === "api_key" ? "API Key" : "OAuth"; } -function getConnectionQuotaRemaining(connection, quotaData) { - const quota = quotaData[connection.id]?.quotas?.[0]; - if (!quota) return Number.POSITIVE_INFINITY; - if (typeof quota.remaining === "number") return quota.remaining; - return Number.POSITIVE_INFINITY; -} - -function sortVisibleConnections( - connections, - quotaData, - expiringFirst, - providerFilter, - quotaSortMode, -) { - if (providerFilter === "codex" && quotaSortMode !== "default") { - return [...connections].sort((a, b) => { - const remainingA = getConnectionQuotaRemaining(a, quotaData); - const remainingB = getConnectionQuotaRemaining(b, quotaData); - const remainingDiff = - quotaSortMode === "remaining-asc" - ? remainingA - remainingB - : remainingB - remainingA; - - if (remainingDiff !== 0) return remainingDiff; - return (getConnectionLabel(a) || "").localeCompare( - getConnectionLabel(b) || "", - ); - }); +function getConnectionSecondaryLabel(connection) { + if (connection.name?.trim() && connection.email?.trim() && connection.name.trim() !== connection.email.trim()) { + return connection.email.trim(); } - if (!expiringFirst) return connections; - - const getEarliestResetTime = (connection) => { - const resetTimes = (quotaData[connection.id]?.quotas || []) - .map((quota) => - quota.resetAt - ? new Date(quota.resetAt).getTime() - : Number.POSITIVE_INFINITY, - ) - .filter((time) => Number.isFinite(time)); - return resetTimes.length > 0 - ? Math.min(...resetTimes) - : Number.POSITIVE_INFINITY; - }; - - return [...connections].sort((a, b) => { - const expiryDiff = getEarliestResetTime(a) - getEarliestResetTime(b); - if (expiryDiff !== 0) return expiryDiff; - return ( - (a.provider || "").localeCompare(b.provider || "") || - (getConnectionLabel(a) || "").localeCompare(getConnectionLabel(b) || "") - ); - }); -} - -function buildLoadingState(connections) { - const nextLoadingState = {}; - connections.forEach((connection) => { - nextLoadingState[connection.id] = true; - }); - return nextLoadingState; -} - -function filterQuotaStateByConnections(state, connections) { - const visibleIds = new Set(connections.map((connection) => connection.id)); - return Object.fromEntries( - Object.entries(state).filter(([id]) => visibleIds.has(id)), - ); -} - -function getConnectionsPageRange(pagination) { - if (!pagination.total) { - return { start: 0, end: 0 }; + if (connection.name?.trim() && connection.displayName?.trim() && connection.name.trim() !== connection.displayName.trim()) { + return connection.displayName.trim(); } - const start = (pagination.page - 1) * pagination.pageSize + 1; - const end = Math.min(pagination.page * pagination.pageSize, pagination.total); - return { start, end }; + return null; } -function getConnectionsEmptyMessage(totals, providerFilter, accountFilter) { - if (!totals.eligibleConnections) { - return { - icon: "cloud_off", - title: "No Providers Connected", - description: - "Connect to providers with OAuth to track your API quota limits and usage.", - }; - } - - if (!totals.providerFilteredConnections) { - return { - icon: "filter_alt_off", - title: "No Accounts Match Current Filters", - description: - providerFilter === "all" - ? "Try changing the account status filter to see more quota trackers." - : `No ${accountFilter === "inactive" ? "turned off" : accountFilter === "active" ? "active" : "matching"} accounts found for ${providerFilter}.`, - }; - } - - return { - icon: "filter_alt_off", - title: "No Accounts On This Page", - description: - "Try moving to another page or refreshing the current filters.", - }; +// Region is stored for builder-id/idc/api_key flows; social and imported flows +// omit it, so fall back to the region segment of the profileArn +// (arn:aws:codewhisperer::...). +function kiroRegion(conn) { + const r = conn.providerSpecificData?.region; + if (r) return r; + const arn = conn.providerSpecificData?.profileArn; + const seg = typeof arn === "string" ? arn.split(":")[3] : ""; + return seg || ""; } -function sortRequestFromExpiringFirst(expiringFirst) { - return expiringFirst ? "expiring" : "priority"; +function getCodexResetCreditCount(quota) { + const value = quota?.raw?.resetCredits?.availableCount; + const count = typeof value === "number" ? value : Number(value); + return Number.isFinite(count) ? Math.max(0, count) : 0; } -function getPageSizeLabel(pageSize, isCustomPageSize) { - return isCustomPageSize ? `Custom: ${pageSize} / page` : `${pageSize} / page`; -} - -function getConnectionsPaginationSummary(pagination) { - const { start, end } = getConnectionsPageRange(pagination); - return `Showing ${start}-${end} of ${pagination.total}`; -} - -function getSafePagination(pagination, fallbackPageSize) { - return ( - pagination || { - page: 1, - pageSize: fallbackPageSize, - total: 0, - totalPages: 1, - } - ); -} - -function getSafeTotals(totals, fallbackTotal = 0) { - return ( - totals || { - eligibleConnections: fallbackTotal, - providerFilteredConnections: fallbackTotal, - } - ); -} - -function shouldResetPage(previousValue, nextValue) { - return previousValue !== nextValue; -} - -function getPaginationPageValue(dataPagination, fallbackPage) { - return dataPagination?.page || fallbackPage; -} - -function getProviderOptions(dataProviderOptions) { - return dataProviderOptions || []; -} - -async function reconcileConnectionsPage(fetchConnections, targetPage) { - const nextConnections = await fetchConnections(targetPage); - return nextConnections; -} - -const QUOTA_CACHE_KEY = "quotaCacheData"; - -function getQuotaCache() { - if (typeof window === "undefined") return {}; - try { - const cached = window.localStorage.getItem(QUOTA_CACHE_KEY); - return cached ? JSON.parse(cached) : {}; - } catch (error) { - console.error("Error reading quota cache:", error); - return {}; - } -} - -function setQuotaCache(connectionId, quotaEntry) { - if (typeof window === "undefined") return; - try { - const cache = getQuotaCache(); - cache[connectionId] = { - ...quotaEntry, - cachedAt: new Date().toISOString(), - }; - window.localStorage.setItem(QUOTA_CACHE_KEY, JSON.stringify(cache)); - } catch (error) { - console.error("Error writing quota cache:", error); - } -} - -const REFRESH_INTERVAL_MS = 60000; // 60 seconds -const DEPLETED_QUOTA_THRESHOLD = 5; // percent -const AUTO_REFRESH_STORAGE_KEY = "quotaAutoRefresh"; -const ACCOUNT_FILTER_OPTIONS = [ - { value: "all", label: "All accounts" }, - { value: "active", label: "Active" }, - { value: "inactive", label: "Turned off" }, -]; -const QUOTA_SORT_OPTIONS = [ - { value: "default", label: "Default quota order" }, - { value: "remaining-asc", label: "% quota: low to high" }, - { value: "remaining-desc", label: "% quota: high to low" }, -]; -const CONNECTIONS_PAGE_SIZE = 20; -const ACCOUNT_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; -const ACCOUNT_PAGE_SIZE_MAX = 500; - export default function ProviderLimits() { + const { copied, copy } = useCopyToClipboard(); const [connections, setConnections] = useState([]); const [quotaData, setQuotaData] = useState({}); const [loading, setLoading] = useState({}); const [errors, setErrors] = useState({}); const [autoRefresh, setAutoRefresh] = useState(true); + const [autoPingMap, setAutoPingMap] = useState({}); const [lastUpdated, setLastUpdated] = useState(null); const [hasHydratedAutoRefresh, setHasHydratedAutoRefresh] = useState(false); const [refreshingAll, setRefreshingAll] = useState(false); @@ -233,6 +102,8 @@ export default function ProviderLimits() { const [connectionsLoading, setConnectionsLoading] = useState(true); const [deletingId, setDeletingId] = useState(null); const [togglingId, setTogglingId] = useState(null); + const [resettingLimitId, setResettingLimitId] = useState(null); + const [resetConfirmState, setResetConfirmState] = useState(null); const [showEditModal, setShowEditModal] = useState(false); const [selectedConnection, setSelectedConnection] = useState(null); const [proxyPools, setProxyPools] = useState([]); @@ -261,6 +132,7 @@ export default function ProviderLimits() { const intervalRef = useRef(null); const countdownRef = useRef(null); + const tickCountRef = useRef(0); const fetchConnections = useCallback( async (targetPage = page) => { @@ -390,6 +262,32 @@ export default function ProviderLimits() { [fetchQuota], ); + const handleResetCodexLimit = useCallback( + async (connectionId, provider) => { + if (provider !== "codex" || resettingLimitId) return; + + setResettingLimitId(connectionId); + setErrors((prev) => ({ ...prev, [connectionId]: null })); + + try { + const response = await fetch(`/api/usage/${connectionId}/codex-reset-credits`, { method: "POST" }); + const result = await response.json().catch(() => ({})); + + if (!response.ok) { + throw new Error(result.message || result.error || result.code || "Failed to reset Codex limit"); + } + + await fetchQuota(connectionId, provider); + setLastUpdated(new Date()); + } catch (error) { + setErrors((prev) => ({ ...prev, [connectionId]: error.message || "Failed to reset Codex limit" })); + } finally { + setResettingLimitId(null); + } + }, + [fetchQuota, resettingLimitId], + ); + const handleDeleteConnection = useCallback( async (id) => { if (!confirm("Delete this connection?")) return; @@ -505,12 +403,18 @@ export default function ProviderLimits() { }; }, []); - const refreshAll = useCallback(async () => { + const refreshAll = useCallback(async (force = false) => { if (refreshingAll) return; setRefreshingAll(true); setCountdown(60); + // Throttle Claude: poll its quota every Nth auto-tick (manual force bypasses) + const tick = (tickCountRef.current += 1); + const claudeEvery = Math.round(CLAUDE_REFRESH_INTERVAL_MS / REFRESH_INTERVAL_MS); + const shouldFetch = (conn) => + force || conn.provider !== "claude" || tick % claudeEvery === 0; + try { const visibleConnections = await fetchConnections(page); @@ -523,7 +427,9 @@ export default function ProviderLimits() { ); await Promise.all( - visibleConnections.map((conn) => fetchQuota(conn.id, conn.provider)), + visibleConnections + .filter(shouldFetch) + .map((conn) => fetchQuota(conn.id, conn.provider)), ); setLastUpdated(new Date()); @@ -571,6 +477,31 @@ export default function ProviderLimits() { window.localStorage.setItem(AUTO_REFRESH_STORAGE_KEY, String(autoRefresh)); }, [autoRefresh, hasHydratedAutoRefresh]); + // Load Claude auto-ping per-connection map + useEffect(() => { + fetch("/api/settings", { cache: "no-store" }) + .then((r) => (r.ok ? r.json() : {})) + .then((s) => setAutoPingMap(s?.claudeAutoPing?.connections || {})) + .catch(() => {}); + }, []); + + const toggleAutoPing = useCallback(async (connectionId, on) => { + const next = { ...autoPingMap, [connectionId]: on }; + setAutoPingMap(next); + try { + const r = await fetch("/api/settings", { cache: "no-store" }); + const s = r.ok ? await r.json() : {}; + const cfg = { ...(s.claudeAutoPing || {}), connections: next }; + await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ claudeAutoPing: cfg }), + }); + } catch { + setAutoPingMap(autoPingMap); + } + }, [autoPingMap]); + // Auto-refresh interval useEffect(() => { if (!hasHydratedAutoRefresh || !autoRefresh) { @@ -618,7 +549,7 @@ export default function ProviderLimits() { } } else if (autoRefresh && hasHydratedAutoRefresh) { // Resume auto-refresh when tab becomes visible - intervalRef.current = setInterval(refreshAll, REFRESH_INTERVAL_MS); + intervalRef.current = setInterval(() => refreshAll(), REFRESH_INTERVAL_MS); countdownRef.current = setInterval(() => { setCountdown((prev) => (prev <= 1 ? 60 : prev - 1)); }, 1000); @@ -941,10 +872,11 @@ export default function ProviderLimits() { )} + {/* Refresh all button */} + )} +
+ )}
- + + )} + {conn.provider === "claude" && conn.authType === "oauth" && ( + + + + )} + + - - + + + + + edit + + + + + +
+ { + if (!resettingLimitId) setResetConfirmState(null); + }} + onConfirm={async () => { + const connection = resetConfirmState?.connection; + if (!connection) return; + await handleResetCodexLimit(connection.id, connection.provider); + setResetConfirmState(null); + }} + title="Reset Codex limit?" + message={`Use 1 Codex reset credit for ${getConnectionLabel(resetConfirmState?.connection || {}) || "this account"}. This cannot be undone. Remaining credits: ${resetConfirmState?.resetCreditCount ?? 0}.`} + confirmText="Reset limit" + cancelText="Cancel" + variant="danger" + loading={Boolean(resettingLimitId)} + /> + { + const remainingA = getConnectionQuotaRemaining(a, quotaData); + const remainingB = getConnectionQuotaRemaining(b, quotaData); + const remainingDiff = + quotaSortMode === "remaining-asc" + ? remainingA - remainingB + : remainingB - remainingA; + if (remainingDiff !== 0) return remainingDiff; + return (getConnectionLabel(a) || "").localeCompare( + getConnectionLabel(b) || "", + ); + }); + } + + if (!expiringFirst) return connections; + + const getEarliestResetTime = (connection) => { + const resetTimes = (quotaData[connection.id]?.quotas || []) + .map((quota) => + quota.resetAt + ? new Date(quota.resetAt).getTime() + : Number.POSITIVE_INFINITY, + ) + .filter((time) => Number.isFinite(time)); + return resetTimes.length > 0 + ? Math.min(...resetTimes) + : Number.POSITIVE_INFINITY; + }; + + return [...connections].sort((a, b) => { + const expiryDiff = getEarliestResetTime(a) - getEarliestResetTime(b); + if (expiryDiff !== 0) return expiryDiff; + return ( + (a.provider || "").localeCompare(b.provider || "") || + (getConnectionLabel(a) || "").localeCompare(getConnectionLabel(b) || "") + ); + }); +} + +export function buildLoadingState(connections) { + const nextLoadingState = {}; + connections.forEach((connection) => { + nextLoadingState[connection.id] = true; + }); + return nextLoadingState; +} + +export function filterQuotaStateByConnections(state, connections) { + const visibleIds = new Set(connections.map((connection) => connection.id)); + return Object.fromEntries( + Object.entries(state).filter(([id]) => visibleIds.has(id)), + ); +} + +export function getConnectionsPageRange(pagination) { + if (!pagination.total) { + return { start: 0, end: 0 }; + } + const start = (pagination.page - 1) * pagination.pageSize + 1; + const end = Math.min(pagination.page * pagination.pageSize, pagination.total); + return { start, end }; +} + +export function getConnectionsEmptyMessage(totals, providerFilter, accountFilter) { + if (!totals.eligibleConnections) { + return { + icon: "cloud_off", + title: "No Providers Connected", + description: + "Connect to providers with OAuth to track your API quota limits and usage.", + }; + } + if (!totals.providerFilteredConnections) { + return { + icon: "filter_alt_off", + title: "No Accounts Match Current Filters", + description: + providerFilter === "all" + ? "Try changing the account status filter to see more quota trackers." + : `No ${accountFilter === "inactive" ? "turned off" : accountFilter === "active" ? "active" : "matching"} accounts found for ${providerFilter}.`, + }; + } + return { + icon: "filter_alt_off", + title: "No Accounts On This Page", + description: + "Try moving to another page or refreshing the current filters.", + }; +} + +export function sortRequestFromExpiringFirst(expiringFirst) { + return expiringFirst ? "expiring" : "priority"; +} + +export function getPageSizeLabel(pageSize, isCustomPageSize) { + return isCustomPageSize ? `Custom: ${pageSize} / page` : `${pageSize} / page`; +} + +export function getConnectionsPaginationSummary(pagination) { + const { start, end } = getConnectionsPageRange(pagination); + return `Showing ${start}-${end} of ${pagination.total}`; +} + +export function getSafePagination(pagination, fallbackPageSize) { + return ( + pagination || { + page: 1, + pageSize: fallbackPageSize, + total: 0, + totalPages: 1, + } + ); +} + +export function getSafeTotals(totals, fallbackTotal = 0) { + return ( + totals || { + eligibleConnections: fallbackTotal, + providerFilteredConnections: fallbackTotal, + } + ); +} + +export function shouldResetPage(previousValue, nextValue) { + return previousValue !== nextValue; +} + +export function getPaginationPageValue(dataPagination, fallbackPage) { + return dataPagination?.page || fallbackPage; +} + +export function getProviderOptions(dataProviderOptions) { + return dataProviderOptions || []; +} + +export async function reconcileConnectionsPage(fetchConnections, targetPage) { + return await fetchConnections(targetPage); +} + +export function getQuotaCache() { + if (typeof window === "undefined") return {}; + try { + const cached = window.localStorage.getItem(QUOTA_CACHE_KEY); + return cached ? JSON.parse(cached) : {}; + } catch (error) { + console.error("Error reading quota cache:", error); + return {}; + } +} + +export function setQuotaCache(connectionId, quotaEntry) { + if (typeof window === "undefined") return; + try { + const cache = getQuotaCache(); + cache[connectionId] = { + ...quotaEntry, + cachedAt: new Date().toISOString(), + }; + window.localStorage.setItem(QUOTA_CACHE_KEY, JSON.stringify(cache)); + } catch (error) { + console.error("Error writing quota cache:", error); + } +} + /** * Format ISO date string to countdown format (inspired by vscode-antigravity-cockpit) * @param {string|Date} date - ISO date string or Date object diff --git a/src/app/api/auth/login/route.js b/src/app/api/auth/login/route.js index e6b264dc..cbd3c6ab 100644 --- a/src/app/api/auth/login/route.js +++ b/src/app/api/auth/login/route.js @@ -8,6 +8,7 @@ import { checkLock, recordFail, recordSuccess, getClientIp } from "@/lib/auth/lo import { isLocalRequest } from "@/dashboardGuard"; const RESET_HINT = "Forgot password? Reset to default via 9Router CLI → Settings → Reset Password to Default."; +const NO_STORE_HEADERS = { "Cache-Control": "no-store" }; function isTunnelRequest(request, settings) { const host = (request.headers.get("host") || "").split(":")[0].toLowerCase(); @@ -61,7 +62,7 @@ export async function POST(request) { const mustChangePassword = !storedHash && !process.env.INITIAL_PASSWORD && !isLocalRequest(request); - return NextResponse.json({ success: true, mustChangePassword }); + return NextResponse.json({ success: true, mustChangePassword }, { headers: NO_STORE_HEADERS }); } const { remainingBeforeLock } = recordFail(ip); diff --git a/src/app/api/auth/logout/route.js b/src/app/api/auth/logout/route.js index d6a58142..2b0fa5ca 100644 --- a/src/app/api/auth/logout/route.js +++ b/src/app/api/auth/logout/route.js @@ -8,5 +8,5 @@ export async function POST() { cookieStore.delete("oidc_state"); cookieStore.delete("oidc_nonce"); cookieStore.delete("oidc_code_verifier"); - return NextResponse.json({ success: true }); + return NextResponse.json({ success: true }, { headers: { "Cache-Control": "no-store" } }); } diff --git a/src/app/api/cli-tools/claude-settings/route.js b/src/app/api/cli-tools/claude-settings/route.js index 121879da..bb5fa06f 100644 --- a/src/app/api/cli-tools/claude-settings/route.js +++ b/src/app/api/cli-tools/claude-settings/route.js @@ -41,12 +41,12 @@ const readSettings = async () => { try { const settingsPath = getClaudeSettingsPath(); const content = await fs.readFile(settingsPath, "utf-8"); - return JSON.parse(content); + // Tolerate JSONC (trailing commas) and treat unparseable files as "no config" + // rather than throwing a 500 that the UI misreads as "tool not installed". + const stripped = content.replace(/,(\s*[}\]])/g, "$1"); + return JSON.parse(stripped); } catch (error) { - if (error.code === "ENOENT") { - return null; - } - throw error; + return null; } }; diff --git a/src/app/api/cli-tools/cline-settings/route.js b/src/app/api/cli-tools/cline-settings/route.js index cc723979..ecbccbd2 100644 --- a/src/app/api/cli-tools/cline-settings/route.js +++ b/src/app/api/cli-tools/cline-settings/route.js @@ -35,10 +35,12 @@ const checkInstalled = async () => { const readJson = async (filePath) => { try { const content = await fs.readFile(filePath, "utf-8"); - return JSON.parse(content); + // Tolerate JSONC (trailing commas) and treat unparseable files as "no config" + // rather than throwing a 500 that the UI misreads as "tool not installed". + const stripped = content.replace(/,(\s*[}\]])/g, "$1"); + return JSON.parse(stripped); } catch (error) { - if (error.code === "ENOENT") return null; - throw error; + return null; } }; diff --git a/src/app/api/cli-tools/copilot-settings/route.js b/src/app/api/cli-tools/copilot-settings/route.js index 449c6e2c..3c0bd669 100644 --- a/src/app/api/cli-tools/copilot-settings/route.js +++ b/src/app/api/cli-tools/copilot-settings/route.js @@ -21,10 +21,12 @@ const getConfigPath = () => { const readConfig = async () => { try { const content = await fs.readFile(getConfigPath(), "utf-8"); - return JSON.parse(content); + // Tolerate JSONC (trailing commas) and treat unparseable files as "no config" + // rather than throwing a 500 that the UI misreads as "tool not installed". + const stripped = content.replace(/,(\s*[}\]])/g, "$1"); + return JSON.parse(stripped); } catch (error) { - if (error.code === "ENOENT") return null; - throw error; + return null; } }; diff --git a/src/app/api/cli-tools/cowork-settings/route.js b/src/app/api/cli-tools/cowork-settings/route.js index e09ec283..d30b667e 100644 --- a/src/app/api/cli-tools/cowork-settings/route.js +++ b/src/app/api/cli-tools/cowork-settings/route.js @@ -116,10 +116,14 @@ const get1pRoot = () => { const get1pConfigPath = () => path.join(get1pRoot(), "claude_desktop_config.json"); const read1pConfig = async () => { - try { return JSON.parse(await fs.readFile(get1pConfigPath(), "utf-8")) || {}; } - catch (error) { - if (error.code === "ENOENT") return {}; - throw error; + try { + const content = await fs.readFile(get1pConfigPath(), "utf-8"); + // Tolerate JSONC (trailing commas) and treat unparseable files as empty config + // rather than throwing a 500 that the UI misreads as "tool not installed". + const stripped = content.replace(/,(\s*[}\]])/g, "$1"); + return JSON.parse(stripped) || {}; + } catch (error) { + return {}; } }; @@ -193,10 +197,14 @@ const checkInstalled = async () => { }; const readJson = async (filePath) => { - try { return JSON.parse(await fs.readFile(filePath, "utf-8")); } - catch (error) { - if (error.code === "ENOENT") return null; - throw error; + try { + const content = await fs.readFile(filePath, "utf-8"); + // Tolerate JSONC (trailing commas) and treat unparseable files as "no config" + // rather than throwing a 500 that the UI misreads as "tool not installed". + const stripped = content.replace(/,(\s*[}\]])/g, "$1"); + return JSON.parse(stripped); + } catch (error) { + return null; } }; diff --git a/src/app/api/cli-tools/droid-settings/route.js b/src/app/api/cli-tools/droid-settings/route.js index 34f53262..a4162578 100644 --- a/src/app/api/cli-tools/droid-settings/route.js +++ b/src/app/api/cli-tools/droid-settings/route.js @@ -37,10 +37,12 @@ const readSettings = async () => { try { const settingsPath = getDroidSettingsPath(); const content = await fs.readFile(settingsPath, "utf-8"); - return JSON.parse(content); + // Tolerate JSONC (trailing commas) and treat unparseable files as "no config" + // rather than throwing a 500 that the UI misreads as "tool not installed". + const stripped = content.replace(/,(\s*[}\]])/g, "$1"); + return JSON.parse(stripped); } catch (error) { - if (error.code === "ENOENT") return null; - throw error; + return null; } }; diff --git a/src/app/api/cli-tools/kilo-settings/route.js b/src/app/api/cli-tools/kilo-settings/route.js index 6802adc8..9c5993f2 100644 --- a/src/app/api/cli-tools/kilo-settings/route.js +++ b/src/app/api/cli-tools/kilo-settings/route.js @@ -35,10 +35,12 @@ const checkInstalled = async () => { const readJson = async (filePath) => { try { const content = await fs.readFile(filePath, "utf-8"); - return JSON.parse(content); + // Tolerate JSONC (trailing commas) and treat unparseable files as "no config" + // rather than throwing a 500 that the UI misreads as "tool not installed". + const stripped = content.replace(/,(\s*[}\]])/g, "$1"); + return JSON.parse(stripped); } catch (error) { - if (error.code === "ENOENT") return null; - throw error; + return null; } }; diff --git a/src/app/api/cli-tools/openclaw-settings/route.js b/src/app/api/cli-tools/openclaw-settings/route.js index 85af6a92..2047a256 100644 --- a/src/app/api/cli-tools/openclaw-settings/route.js +++ b/src/app/api/cli-tools/openclaw-settings/route.js @@ -47,10 +47,12 @@ const readSettings = async () => { try { const settingsPath = getOpenClawSettingsPath(); const content = await fs.readFile(settingsPath, "utf-8"); - return JSON.parse(content); + // Tolerate JSONC (trailing commas) and treat unparseable files as "no config" + // rather than throwing a 500 that the UI misreads as "tool not installed". + const stripped = content.replace(/,(\s*[}\]])/g, "$1"); + return JSON.parse(stripped); } catch (error) { - if (error.code === "ENOENT") return null; - throw error; + return null; } }; diff --git a/src/app/api/cli-tools/opencode-settings/route.js b/src/app/api/cli-tools/opencode-settings/route.js index f4429cf5..03819c66 100644 --- a/src/app/api/cli-tools/opencode-settings/route.js +++ b/src/app/api/cli-tools/opencode-settings/route.js @@ -35,10 +35,16 @@ const checkOpenCodeInstalled = async () => { const readConfig = async () => { try { const content = await fs.readFile(getConfigPath(), "utf-8"); - return JSON.parse(content); + // opencode config files may use JSONC format (trailing commas, comments). + // Strip trailing commas before parsing to avoid SyntaxError on valid JSONC. + const stripped = content.replace(/,(\s*[}\]])/g, "$1"); + return JSON.parse(stripped); } catch (error) { if (error.code === "ENOENT") return null; - throw error; + // If the config file exists but is unparseable (corrupted, exotic JSONC), + // treat it as "no config" rather than throwing a 500 that the UI + // misinterprets as "opencode not installed". + return null; } }; diff --git a/src/app/api/headroom/start/route.js b/src/app/api/headroom/start/route.js new file mode 100644 index 00000000..56af456c --- /dev/null +++ b/src/app/api/headroom/start/route.js @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { getSettings } from "@/lib/localDb"; +import { startHeadroomProxy } from "@/lib/headroom/process"; +import { DEFAULT_HEADROOM_URL, isLoopbackHeadroomUrl } from "@/lib/headroom/detect"; + +export const dynamic = "force-dynamic"; + +function parsePortFromUrl(url) { + try { + const u = new URL(url); + const p = parseInt(u.port, 10); + if (p > 0 && p < 65536) return p; + } catch { /* ignore, fall through to default */ } + return null; +} + +export async function POST() { + try { + const settings = await getSettings(); + const url = settings.headroomUrl || DEFAULT_HEADROOM_URL; + if (!isLoopbackHeadroomUrl(url)) { + return NextResponse.json({ error: "External Headroom proxies must be started outside 9Router", code: "EXTERNAL_PROXY" }, { status: 400 }); + } + const port = parsePortFromUrl(url) || 8787; + const result = await startHeadroomProxy({ port }); + return NextResponse.json({ success: true, ...result }); + } catch (error) { + const status = error.code === "NOT_INSTALLED" ? 400 : 500; + return NextResponse.json({ error: error.message, code: error.code || null }, { status }); + } +} diff --git a/src/app/api/headroom/status/route.js b/src/app/api/headroom/status/route.js new file mode 100644 index 00000000..582147cb --- /dev/null +++ b/src/app/api/headroom/status/route.js @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { getSettings } from "@/lib/localDb"; +import { DEFAULT_HEADROOM_URL, getHeadroomStatus } from "@/lib/headroom/detect"; +import { getManagedPid } from "@/lib/headroom/process"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const settings = await getSettings(); + const url = settings.headroomUrl || DEFAULT_HEADROOM_URL; + const status = await getHeadroomStatus(url); + const managedPid = getManagedPid(); + return NextResponse.json({ ...status, url, managedPid }); + } catch (error) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/headroom/stop/route.js b/src/app/api/headroom/stop/route.js new file mode 100644 index 00000000..122251e1 --- /dev/null +++ b/src/app/api/headroom/stop/route.js @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { stopHeadroomProxy } from "@/lib/headroom/process"; + +export const dynamic = "force-dynamic"; + +export async function POST() { + try { + const result = stopHeadroomProxy(); + const status = result.stopped ? 200 : 409; + return NextResponse.json({ ...result }, { status }); + } catch (error) { + return NextResponse.json({ error: error.message, code: error.code || null }, { status: 500 }); + } +} diff --git a/src/app/api/models/route.js b/src/app/api/models/route.js index 7215f7e3..a4e833a9 100644 --- a/src/app/api/models/route.js +++ b/src/app/api/models/route.js @@ -3,6 +3,7 @@ import { getModelAliases, setModelAlias } from "@/models"; import { getDisabledModels } from "@/lib/disabledModelsDb"; import { AI_MODELS } from "@/shared/constants/config"; import { getProviderAlias } from "@/shared/constants/providers"; +import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js"; // GET /api/models - Get models with aliases export async function GET() { @@ -18,10 +19,12 @@ export async function GET() { }) .map((m) => { const fullModel = `${m.provider}/${m.model}`; + const c = getCapabilitiesForModel(m.provider, m.model); return { ...m, fullModel, alias: modelAliases[fullModel] || m.model, + caps: { vision: c.vision, search: c.search, reasoning: c.reasoning }, }; }); diff --git a/src/app/api/models/test/ping.js b/src/app/api/models/test/ping.js index 5b7ee8a0..f7ea92ac 100644 --- a/src/app/api/models/test/ping.js +++ b/src/app/api/models/test/ping.js @@ -135,7 +135,9 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$ headers, body: JSON.stringify({ model, - max_tokens: 1, + // Claude-on-Copilot returns empty choices at max_tokens:1 (budget is spent + // before a content token emits), so a 1-token probe yields a false negative. + max_tokens: 16, stream: false, messages: [{ role: "user", content: "hi" }], }), diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js index a65b0b4c..78922c96 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -151,7 +151,7 @@ export async function GET(request, { params }) { : undefined; // Providers that don't use PKCE for device code - const noPkceDeviceProviders = ["github", "kiro", "kimi-coding", "kilocode", "codebuddy", "qoder"]; + const noPkceDeviceProviders = ["github", "kiro", "kimi-coding", "kilocode", "codebuddy-cn", "qoder"]; let deviceData; if (noPkceDeviceProviders.includes(provider)) { deviceData = await requestDeviceCode(provider, undefined, deviceOptions); @@ -271,7 +271,7 @@ export async function POST(request, { params }) { } // Providers that don't use PKCE for device code - const noPkceProviders = ["github", "kimi-coding", "kilocode", "codebuddy"]; + const noPkceProviders = ["github", "kimi-coding", "kilocode", "codebuddy-cn"]; let result; if (noPkceProviders.includes(provider)) { result = await pollForToken(provider, deviceCode); diff --git a/src/app/api/oauth/kiro/api-key/route.js b/src/app/api/oauth/kiro/api-key/route.js new file mode 100644 index 00000000..139df9b5 --- /dev/null +++ b/src/app/api/oauth/kiro/api-key/route.js @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { KiroService } from "@/lib/oauth/services/kiro"; +import { createProviderConnection } from "@/models"; + +/** + * POST /api/oauth/kiro/api-key + * Import a Kiro API key (headless auth). The key is a long-lived bearer + * credential — there is no refresh token. It is validated by listing + * CodeWhisperer profiles, then stored with authMethod="api_key". + */ +export async function POST(request) { + try { + const { apiKey, region } = await request.json(); + + if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) { + return NextResponse.json( + { error: "API key is required" }, + { status: 400 } + ); + } + + const kiroService = new KiroService(); + + // Validate the key and resolve its profileArn via ListAvailableProfiles + const credential = await kiroService.validateApiKey( + apiKey, + region || "us-east-1" + ); + + // Extract email from JWT if the key happens to be a JWT (optional display) + const email = kiroService.extractEmailFromJWT(credential.accessToken); + + // API keys never expire on a fixed schedule; persist a long horizon so the + // proactive refresh path (which requires a refreshToken anyway) is skipped. + const connection = await createProviderConnection({ + provider: "kiro", + authType: "api_key", + accessToken: credential.accessToken, + refreshToken: null, + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + email: email || null, + providerSpecificData: { + profileArn: credential.profileArn, + region: credential.region, + authMethod: "api_key", + provider: "API Key", + }, + testStatus: "active", + }); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + }, + }); + } catch (error) { + console.log("Kiro API key import error:", error); + // Do not reflect upstream response body to the client (SSRF hardening) + return NextResponse.json( + { error: "API key validation failed" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/oauth/kiro/auto-import/route.js b/src/app/api/oauth/kiro/auto-import/route.js index e2a6182f..0d28ea6e 100644 --- a/src/app/api/oauth/kiro/auto-import/route.js +++ b/src/app/api/oauth/kiro/auto-import/route.js @@ -5,13 +5,14 @@ import { join } from "path"; /** * GET /api/oauth/kiro/auto-import - * Auto-detect and extract Kiro refresh token from AWS SSO cache + * Auto-detect and extract Kiro refresh token from AWS SSO cache. + * For IDC (organization) tokens, also resolves clientId/clientSecret from the + * linked client registration file so token refresh works. */ export async function GET() { try { const cachePath = join(homedir(), ".aws/sso/cache"); - // Try to read cache directory let files; try { files = await readdir(cachePath); @@ -22,9 +23,9 @@ export async function GET() { }); } - // Look for kiro-auth-token.json or any .json file with refreshToken let refreshToken = null; let foundFile = null; + let tokenData = null; // First try kiro-auth-token.json const kiroTokenFile = "kiro-auth-token.json"; @@ -35,6 +36,7 @@ export async function GET() { if (data.refreshToken && data.refreshToken.startsWith("aorAAAAAG")) { refreshToken = data.refreshToken; foundFile = kiroTokenFile; + tokenData = data; } } catch (error) { // Continue to search other files @@ -45,19 +47,16 @@ export async function GET() { if (!refreshToken) { for (const file of files) { if (!file.endsWith(".json")) continue; - try { const content = await readFile(join(cachePath, file), "utf-8"); const data = JSON.parse(content); - - // Look for Kiro refresh token (starts with aorAAAAAG) if (data.refreshToken && data.refreshToken.startsWith("aorAAAAAG")) { refreshToken = data.refreshToken; foundFile = file; + tokenData = data; break; } } catch (error) { - // Skip invalid JSON files continue; } } @@ -70,10 +69,58 @@ export async function GET() { }); } + // For IDC/organization tokens, resolve clientId and clientSecret from + // the linked client registration file (referenced by clientIdHash). + let clientId = null; + let clientSecret = null; + const region = tokenData?.region || null; + const authMethod = tokenData?.authMethod || null; + + if (tokenData?.clientIdHash) { + const clientFile = `${tokenData.clientIdHash}.json`; + try { + const clientContent = await readFile(join(cachePath, clientFile), "utf-8"); + const clientData = JSON.parse(clientContent); + if (clientData.clientId && clientData.clientSecret) { + clientId = clientData.clientId; + clientSecret = clientData.clientSecret; + } + } catch (error) { + // Client registration file not found - continue without it + } + } + + // Read profileArn from Kiro IDE's profile.json. + // Important: the runtime gateway requires us-east-1 in the ARN regardless + // of the IDC region, so we normalize the region in the ARN to us-east-1. + let profileArn = null; + const kiroProfilePaths = [ + join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), "Kiro", "User", "globalStorage", "kiro.kiroagent", "profile.json"), + join(homedir(), ".config", "Kiro", "User", "globalStorage", "kiro.kiroagent", "profile.json"), + ]; + for (const profilePath of kiroProfilePaths) { + try { + const profileContent = await readFile(profilePath, "utf-8"); + const profileData = JSON.parse(profileContent); + if (profileData.arn) { + // Normalize region to us-east-1 for the runtime gateway + profileArn = profileData.arn.replace(/arn:aws:codewhisperer:[^:]+:/, "arn:aws:codewhisperer:us-east-1:"); + break; + } + } catch (error) { + continue; + } + } + return NextResponse.json({ found: true, refreshToken, source: foundFile, + clientId, + clientSecret, + region, + authMethod, + profileArn, }); } catch (error) { console.log("Kiro auto-import error:", error); diff --git a/src/app/api/oauth/kiro/import-cli-proxy/route.js b/src/app/api/oauth/kiro/import-cli-proxy/route.js new file mode 100644 index 00000000..d71d6a7c --- /dev/null +++ b/src/app/api/oauth/kiro/import-cli-proxy/route.js @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { createProviderConnection } from "@/models"; +import { normalizeKiroExternalIdpAuth } from "@/lib/oauth/kiroExternalIdp"; + +/** + * POST /api/oauth/kiro/import-cli-proxy + * Import Kiro CLIProxyAPI auth JSON for Microsoft external_idp accounts. + */ +export async function POST(request) { + try { + const body = await request.json(); + const rawAuth = body?.cliProxyAuth ?? body?.auth ?? body?.json ?? body; + const tokenData = normalizeKiroExternalIdpAuth(rawAuth); + + const connection = await createProviderConnection({ + provider: "kiro", + authType: "oauth", + accessToken: tokenData.accessToken, + refreshToken: tokenData.refreshToken, + expiresAt: tokenData.expiresAt, + email: tokenData.email || null, + providerSpecificData: tokenData.providerSpecificData, + testStatus: "active", + }); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + }, + }); + } catch (error) { + return NextResponse.json( + { error: error?.message || "CLIProxyAPI import failed" }, + { status: 400 } + ); + } +} diff --git a/src/app/api/oauth/kiro/import/route.js b/src/app/api/oauth/kiro/import/route.js index a278a1e8..46383410 100644 --- a/src/app/api/oauth/kiro/import/route.js +++ b/src/app/api/oauth/kiro/import/route.js @@ -4,11 +4,13 @@ import { createProviderConnection } from "@/models"; /** * POST /api/oauth/kiro/import - * Import and validate refresh token from Kiro IDE + * Import and validate refresh token from Kiro IDE. + * For IDC (organization) tokens, accepts clientId/clientSecret/region so the + * token can be refreshed via the regional AWS OIDC endpoint. */ export async function POST(request) { try { - const { refreshToken } = await request.json(); + const { refreshToken, clientId, clientSecret, region, authMethod, profileArn } = await request.json(); if (!refreshToken || typeof refreshToken !== "string") { return NextResponse.json( @@ -18,25 +20,33 @@ export async function POST(request) { } const kiroService = new KiroService(); + const isIdc = !!(clientId && clientSecret); - // Validate and refresh token - const tokenData = await kiroService.validateImportToken(refreshToken.trim()); + // For IDC tokens, refresh via the regional OIDC endpoint with client credentials. + // For social/builder-id tokens, use the standard social refresh endpoint. + const providerSpecificData = isIdc + ? { clientId, clientSecret, region: region || "us-east-1", authMethod: "idc" } + : {}; + + const tokenData = await kiroService.refreshToken(refreshToken.trim(), providerSpecificData); - // Extract email from JWT if available const email = kiroService.extractEmailFromJWT(tokenData.accessToken); + const resolvedAuthMethod = isIdc ? "idc" : "imported"; + const providerLabel = isIdc ? "Enterprise" : "Imported"; + const resolvedProfileArn = profileArn || tokenData.profileArn || null; - // Save to database const connection = await createProviderConnection({ provider: "kiro", authType: "oauth", accessToken: tokenData.accessToken, - refreshToken: tokenData.refreshToken, - expiresAt: new Date(Date.now() + tokenData.expiresIn * 1000).toISOString(), + refreshToken: tokenData.refreshToken || refreshToken.trim(), + expiresAt: new Date(Date.now() + (tokenData.expiresIn || 3600) * 1000).toISOString(), email: email || null, providerSpecificData: { - profileArn: tokenData.profileArn, - authMethod: "imported", - provider: "Imported", + profileArn: resolvedProfileArn, + authMethod: resolvedAuthMethod, + provider: providerLabel, + ...(isIdc ? { clientId, clientSecret, region: region || "us-east-1" } : {}), }, testStatus: "active", }); diff --git a/src/app/api/pricing/route.js b/src/app/api/pricing/route.js index 7d553a4c..18a8584c 100644 --- a/src/app/api/pricing/route.js +++ b/src/app/api/pricing/route.js @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { getPricing, updatePricing, resetPricing, resetAllPricing } from "@/lib/localDb.js"; -import { getDefaultPricing } from "@/shared/constants/pricing.js"; +import { getDefaultPricing } from "open-sse/providers/pricing.js"; /** * GET /api/pricing diff --git a/src/app/api/provider-nodes/validate/route.js b/src/app/api/provider-nodes/validate/route.js index 0d7882ae..4148ab41 100644 --- a/src/app/api/provider-nodes/validate/route.js +++ b/src/app/api/provider-nodes/validate/route.js @@ -1,4 +1,6 @@ import { NextResponse } from "next/server"; +import { assertPublicUrl } from "@/shared/utils/ssrfGuard.js"; +import { isLocalRequest } from "@/dashboardGuard"; // Fetch with timeout wrapper const fetchWithTimeout = (url, options, timeout = 10000) => { @@ -64,6 +66,15 @@ export async function POST(request) { return NextResponse.json({ error: "Invalid URL format" }, { status: 400 }); } + // SSRF guard for remote callers; local host keeps self-hosted nodes (e.g. ollama-local) + if (!isLocalRequest(request)) { + try { + assertPublicUrl(baseUrl); + } catch { + return NextResponse.json({ error: "URL not allowed" }, { status: 400 }); + } + } + // Custom Embedding Validation - test POST /embeddings directly if (type === "custom-embedding") { const normalizedBase = baseUrl.trim().replace(/\/$/, ""); diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index 5147899e..17af05e2 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -226,7 +226,7 @@ const PROVIDER_MODELS_CONFIG = { groq: createOpenAIModelsConfig("https://api.groq.com/openai/v1/models"), xai: createOpenAIModelsConfig("https://api.x.ai/v1/models"), mistral: createOpenAIModelsConfig("https://api.mistral.ai/v1/models"), - perplexity: createOpenAIModelsConfig("https://api.perplexity.ai/models"), + perplexity: createOpenAIModelsConfig("https://api.perplexity.ai/v1/models"), together: createOpenAIModelsConfig("https://api.together.xyz/v1/models"), fireworks: createOpenAIModelsConfig("https://api.fireworks.ai/inference/v1/models"), cerebras: createOpenAIModelsConfig("https://api.cerebras.ai/v1/models"), diff --git a/src/app/api/providers/[id]/test-models/route.js b/src/app/api/providers/[id]/test-models/route.js index 23aacabb..84946396 100644 --- a/src/app/api/providers/[id]/test-models/route.js +++ b/src/app/api/providers/[id]/test-models/route.js @@ -44,14 +44,14 @@ export async function POST(request, { params }) { // Warm up with first model to trigger token refresh (if needed) before parallel calls. // This prevents race condition where multiple requests concurrently refresh the same token. const [first, ...rest] = models; - const firstKind = first.type || "llm"; + const firstKind = first.kind || first.type || "llm"; const firstResult = await pingModelByKind(`${alias}/${first.id}`, firstKind, baseUrl); const results = [{ modelId: first.id, name: first.name || first.id, ...firstResult }]; if (rest.length > 0) { const restResults = await Promise.all( rest.map(async (model) => { - const result = await pingModelByKind(`${alias}/${model.id}`, model.type || "llm", baseUrl); + const result = await pingModelByKind(`${alias}/${model.id}`, model.kind || model.type || "llm", baseUrl); return { modelId: model.id, name: model.name || model.id, ...result }; }) ); diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index e85dbadd..cebd50a4 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -2,9 +2,8 @@ import { getProviderConnectionById, updateProviderConnection } from "@/lib/local import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { testProxyUrl } from "@/lib/network/proxyTest"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; -import { PROVIDER_ENDPOINTS } from "@/shared/constants/config"; import { getDefaultModel } from "open-sse/config/providerModels.js"; -import { resolveOllamaLocalHost } from "open-sse/config/providers.js"; +import { resolveOllamaLocalHost, PROVIDERS } from "open-sse/config/providers.js"; import { refreshProviderCredentials, shouldRefreshCredentials, @@ -91,7 +90,7 @@ const OAUTH_TEST_CONFIG = { authHeader: "Authorization", authPrefix: "Bearer ", }, - codebuddy: { tokenExists: true }, + "codebuddy-cn": { tokenExists: true }, }; async function probeClineAccessToken(accessToken) { @@ -105,6 +104,53 @@ async function probeClineAccessToken(accessToken) { return res; } +const CLOUD_CODE_ASSIST_TEST_URL = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"; +const CLOUD_CODE_ASSIST_TEST_BODY = JSON.stringify({ + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, +}); + +function parseProviderErrorMessage(bodyText, fallback) { + if (!bodyText) return fallback; + try { + const parsed = JSON.parse(bodyText); + const message = parsed?.error?.message || parsed?.message || parsed?.error; + if (typeof message === "string" && message.trim()) return message.trim(); + if (message) return JSON.stringify(message); + } catch { + // fall through + } + return bodyText.trim() || fallback; +} + +async function probeCloudCodeAssistAccess(connection, accessToken, effectiveProxy = null) { + const userAgent = connection.provider === "antigravity" + ? "google-api-nodejs-client/9.15.1 vscode-antigravity/1.107.0" + : "google-api-nodejs-client/9.15.1 gemini-cli/0.34.0"; + + const res = await fetchWithConnectionProxy(CLOUD_CODE_ASSIST_TEST_URL, { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": userAgent, + }, + body: CLOUD_CODE_ASSIST_TEST_BODY, + }, effectiveProxy); + + if (res.ok) return { valid: true, error: null }; + + const bodyText = await res.text().catch(() => ""); + return { + valid: false, + error: parseProviderErrorMessage(bodyText, `API returned ${res.status}`), + status: res.status, + }; +} + async function refreshOAuthToken(connection) { const provider = connection.provider; const refreshToken = connection.refreshToken; @@ -254,6 +300,23 @@ async function testOAuthConnection(connection, effectiveProxy = null) { return { valid: true, error: null, refreshed: false, newTokens: null }; } + if (connection.provider === "gemini-cli" || connection.provider === "antigravity") { + const initial = await probeCloudCodeAssistAccess(connection, accessToken, effectiveProxy); + if (initial.valid) return { valid: true, error: null, refreshed, newTokens }; + + if (initial.status === 401 && config.refreshable && !refreshed && connection.refreshToken) { + const tokens = await refreshOAuthToken(connection); + if (tokens?.accessToken) { + const retry = await probeCloudCodeAssistAccess(connection, tokens.accessToken, effectiveProxy); + if (retry.valid) return { valid: true, error: null, refreshed: true, newTokens: tokens }; + return { valid: false, error: retry.error, refreshed: true, newTokens: tokens }; + } + return { valid: false, error: "Token invalid or revoked", refreshed: false }; + } + + return { valid: false, error: initial.error, refreshed }; + } + if (connection.provider === "cline") { const tryProbe = async (token) => { const res = await probeClineAccessToken(token); @@ -356,10 +419,25 @@ async function testApiKeyConnection(connection, effectiveProxy = null) { try { modelsBase = modelsBase.replace(/\/$/, ""); if (modelsBase.endsWith("/messages")) modelsBase = modelsBase.slice(0, -9); - const res = await fetchWithConnectionProxy(`${modelsBase}/models`, { - headers: { "x-api-key": connection.apiKey, "anthropic-version": "2023-06-01", "Authorization": `Bearer ${connection.apiKey}` }, + const messagesUrl = `${modelsBase}/v1/messages`; + const model = connection.defaultModel || "claude-3-haiku-20240307"; + const res = await fetchWithConnectionProxy(messagesUrl, { + method: "POST", + headers: { + "x-api-key": connection.apiKey, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + "Authorization": `Bearer ${connection.apiKey}`, + }, + body: JSON.stringify({ + model, + max_tokens: 1, + messages: [{ role: "user", content: "test" }], + }), }, effectiveProxy); - return { valid: res.ok, error: res.ok ? null : "Invalid API key or base URL" }; + // 400/529 still confirms key accepted; only 401/403 = bad key + const valid = res.status !== 401 && res.status !== 403; + return { valid, error: valid ? null : "Invalid API key or base URL" }; } catch (err) { return { valid: false, error: err.message }; } @@ -474,7 +552,7 @@ async function testApiKeyConnection(connection, effectiveProxy = null) { } case "volcengine-ark": case "byteplus": { - const res = await fetchWithConnectionProxy(PROVIDER_ENDPOINTS[connection.provider], { + const res = await fetchWithConnectionProxy(PROVIDERS[connection.provider]?.baseUrl, { method: "POST", headers: { "Authorization": `Bearer ${connection.apiKey}`, "content-type": "application/json" }, body: JSON.stringify({ model: getDefaultModel(connection.provider), max_tokens: 1, messages: [{ role: "user", content: "test" }] }), @@ -503,7 +581,7 @@ async function testApiKeyConnection(connection, effectiveProxy = null) { return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; } case "perplexity": { - const res = await fetchWithConnectionProxy("https://api.perplexity.ai/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy); + const res = await fetchWithConnectionProxy("https://api.perplexity.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy); return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; } case "together": { @@ -614,6 +692,13 @@ async function testApiKeyConnection(connection, effectiveProxy = null) { }, effectiveProxy); return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; } + case "blackbox": { + const baseUrl = PROVIDERS["blackbox"]?.baseUrl?.replace(/\/chat\/completions$/, "") || "https://api.blackbox.ai/v1"; + const res = await fetchWithConnectionProxy(`${baseUrl}/models`, { + headers: { Authorization: `Bearer ${connection.apiKey}` }, + }, effectiveProxy); + return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; + } default: return { valid: false, error: "Provider test not supported" }; } diff --git a/src/app/api/providers/client/route.js b/src/app/api/providers/client/route.js index 22bbb282..be5342c1 100644 --- a/src/app/api/providers/client/route.js +++ b/src/app/api/providers/client/route.js @@ -17,6 +17,7 @@ const SAFE_PSD_FIELDS = [ "connectionProxyEnabled", "connectionProxyUrl", "connectionNoProxy", "githubLogin", "githubName", "githubEmail", "githubUserId", "username", "firstName", "lastName", "authMethod", "authKind", + "profileArn", ]; const DEFAULT_PAGE_SIZE = 20; diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js index f289cd25..1a664f4a 100644 --- a/src/app/api/providers/route.js +++ b/src/app/api/providers/route.js @@ -102,8 +102,12 @@ export async function POST(request) { // Validation const isWebCookieProvider = !!WEB_COOKIE_PROVIDERS[provider]; + // Dual-auth providers (e.g. codebuddy-cn, xai) live under category "oauth" but also + // accept an API key via authModes — they aren't in APIKEY_PROVIDERS, so allow them here. + const supportsApiKeyMode = !!AI_PROVIDERS[provider]?.authModes?.includes("apikey"); const isValidProvider = APIKEY_PROVIDERS[provider] || FREE_TIER_PROVIDERS[provider] || + supportsApiKeyMode || isWebCookieProvider || isOpenAICompatibleProvider(provider) || isAnthropicCompatibleProvider(provider) || diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js index 28a446ae..85001371 100644 --- a/src/app/api/providers/validate/route.js +++ b/src/app/api/providers/validate/route.js @@ -3,8 +3,7 @@ import { getProviderNodeById } from "@/models"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbeddingProvider, AI_PROVIDERS } from "@/shared/constants/providers"; import { getDefaultModel } from "open-sse/config/providerModels.js"; import { resolveOllamaLocalHost, resolveXiaomiTokenplanBaseUrl, PROVIDERS } from "open-sse/config/providers.js"; -import { openaiToCommandCode } from "open-sse/translator/request/openai-to-commandcode.js"; -import { PROVIDER_ENDPOINTS } from "@/shared/constants/config"; +import { openaiToCommandCodeRequest } from "open-sse/translator/request/openai-to-commandcode.js"; import { normalizeProviderId } from "@/lib/providerNormalization"; // Probe a webSearch/webFetch provider using its searchConfig/fetchConfig. @@ -75,7 +74,7 @@ async function probeMediaProvider(provider, apiKey) { const res = await fetch(cfg.baseUrl, { method, headers, - body: method === "GET" ? undefined : JSON.stringify({ input: "ping", text: "ping", prompt: "ping", model: cfg.models?.[0]?.id || "test" }), + body: method === "GET" ? undefined : JSON.stringify({ input: "ping", text: "ping", prompt: "ping", model: getDefaultModel(provider) || "test" }), signal: AbortSignal.timeout(8000), }); return res.status !== 401 && res.status !== 403; @@ -156,17 +155,26 @@ export async function POST(request) { normalizedBase = normalizedBase.slice(0, -9); // remove /messages } - const modelsUrl = `${normalizedBase}/models`; + const messagesUrl = `${normalizedBase}/v1/messages`; + const model = node.defaultModel || "claude-3-haiku-20240307"; - const res = await fetch(modelsUrl, { + const res = await fetch(messagesUrl, { + method: "POST", headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01", - "Authorization": `Bearer ${apiKey}` + "content-type": "application/json", + "Authorization": `Bearer ${apiKey}`, }, + body: JSON.stringify({ + model, + max_tokens: 1, + messages: [{ role: "user", content: "test" }], + }), }); - isValid = res.ok; + // 400/529 still confirms key accepted; only 401/403 = bad key + isValid = res.status !== 401 && res.status !== 403; return NextResponse.json({ valid: isValid, error: isValid ? null : "Invalid API key", @@ -326,7 +334,7 @@ export async function POST(request) { } case "volcengine-ark": case "byteplus": { - const res = await fetch(PROVIDER_ENDPOINTS[provider], { + const res = await fetch(PROVIDERS[provider]?.baseUrl, { method: "POST", headers: { "Authorization": `Bearer ${apiKey}`, @@ -363,26 +371,12 @@ export async function POST(request) { case "xiaomi-tokenplan": case "nvidia": { const endpoints = { - deepseek: "https://api.deepseek.com/models", - groq: "https://api.groq.com/openai/v1/models", - xai: "https://api.x.ai/v1/models", - mistral: "https://api.mistral.ai/v1/models", - perplexity: "https://api.perplexity.ai/models", - together: "https://api.together.xyz/v1/models", - fireworks: "https://api.fireworks.ai/inference/v1/models", - cerebras: "https://api.cerebras.ai/v1/models", - cohere: "https://api.cohere.ai/v1/models", - nebius: "https://api.studio.nebius.ai/v1/models", - siliconflow: "https://api.siliconflow.com/v1/models", - hyperbolic: "https://api.hyperbolic.xyz/v1/models", - ollama: "https://ollama.com/api/tags", + ...Object.fromEntries( + Object.entries(PROVIDERS).filter(([, t]) => t.validateUrl).map(([id, t]) => [id, t.validateUrl]) + ), + // dynamic URLs (depend on providerSpecificData) — kept inline "ollama-local": `${resolveOllamaLocalHost({ providerSpecificData })}/api/tags`, - assemblyai: "https://api.assemblyai.com/v1/account", - nanobanana: "https://api.nanobananaapi.ai/v1/models", - chutes: "https://llm.chutes.ai/v1/models", - nvidia: "https://integrate.api.nvidia.com/v1/models", - "xiaomi-mimo": "https://api.xiaomimimo.com/v1/models", - "xiaomi-tokenplan": `${resolveXiaomiTokenplanBaseUrl({ providerSpecificData })}/models` + "xiaomi-tokenplan": `${resolveXiaomiTokenplanBaseUrl({ providerSpecificData })}/models`, }; const headers = {}; if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; @@ -414,7 +408,7 @@ export async function POST(request) { case "commandcode": { const cfg = PROVIDERS.commandcode; const model = getDefaultModel("commandcode"); - const payload = openaiToCommandCode(model, { + const payload = openaiToCommandCodeRequest(model, { messages: [{ role: "user", content: "ping" }], max_tokens: 1, stream: false, diff --git a/src/app/api/settings/route.js b/src/app/api/settings/route.js index bc69e0d8..ee2682ca 100644 --- a/src/app/api/settings/route.js +++ b/src/app/api/settings/route.js @@ -11,6 +11,9 @@ const SETTINGS_RESPONSE_HEADERS = { "Cache-Control": "no-store" }; +// Secrets must never be mass-assigned from request body (CWE-915) +const PROTECTED_SETTING_KEYS = ["password", "mitmSudoEncrypted"]; + export async function GET() { try { const settings = await getSettings(); @@ -36,6 +39,9 @@ export async function PATCH(request) { try { const body = await request.json(); + // Strip protected secrets before any internal handling sets them + for (const key of PROTECTED_SETTING_KEYS) delete body[key]; + // If updating password, hash it if (body.newPassword) { const settings = await getSettings(); diff --git a/src/app/api/translator/console-logs/stream/route.js b/src/app/api/translator/console-logs/stream/route.js index e90f11f7..5eb3ae37 100644 --- a/src/app/api/translator/console-logs/stream/route.js +++ b/src/app/api/translator/console-logs/stream/route.js @@ -7,13 +7,14 @@ initConsoleLogCapture(); export async function GET(request) { const encoder = new TextEncoder(); const emitter = getConsoleEmitter(); - const state = { closed: false, send: null, sendClear: null, keepalive: null }; + const state = { closed: false, send: null, sendLines: null, sendClear: null, keepalive: null }; // Idempotent: safe to call from request.signal abort, cancel(), or enqueue failure. const cleanup = () => { if (state.closed) return; state.closed = true; if (state.send) emitter.off("line", state.send); + if (state.sendLines) emitter.off("lines", state.sendLines); if (state.sendClear) emitter.off("clear", state.sendClear); if (state.keepalive) clearInterval(state.keepalive); }; @@ -40,6 +41,15 @@ export async function GET(request) { } }; + state.sendLines = (lines) => { + if (state.closed || !Array.isArray(lines) || lines.length === 0) return; + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "lines", lines })}\n\n`)); + } catch { + cleanup(); + } + }; + // Notify client when cleared state.sendClear = () => { if (state.closed) return; @@ -51,6 +61,7 @@ export async function GET(request) { }; emitter.on("line", state.send); + emitter.on("lines", state.sendLines); emitter.on("clear", state.sendClear); // Keepalive ping every 25s diff --git a/src/app/api/translator/translate/route.js b/src/app/api/translator/translate/route.js index d5fb2687..c0f3c12b 100644 --- a/src/app/api/translator/translate/route.js +++ b/src/app/api/translator/translate/route.js @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { detectFormat, getTargetFormat } from "open-sse/services/provider.js"; import { translateRequest } from "open-sse/translator/index.js"; import { FORMATS } from "open-sse/translator/formats.js"; -import { parseModel } from "open-sse/services/model.js"; +import { getModelInfo } from "@/sse/services/model.js"; import { getProviderConnections } from "@/lib/localDb.js"; import { getExecutor } from "open-sse/executors/index.js"; @@ -18,7 +18,7 @@ export async function POST(request) { case 1: { // Detect provider + formats from 1_req_client.json const clientBody = body.body || body; - const { provider, model } = parseModel(clientBody.model); + const { provider, model } = await getModelInfo(clientBody.model); const sourceFormat = detectFormat(clientBody); const targetFormat = getTargetFormat(provider); return NextResponse.json({ success: true, result: { provider, model, sourceFormat, targetFormat } }); @@ -28,7 +28,7 @@ export async function POST(request) { // source → OpenAI intermediate (mirrors 3_req_openai.json) // Translate source→openai only (half of the pipeline) const clientBody = body.body || body; - const { provider, model } = parseModel(clientBody.model); + const { provider, model } = await getModelInfo(clientBody.model); const sourceFormat = detectFormat(clientBody); const stream = clientBody.stream !== false; diff --git a/src/app/api/usage/[connectionId]/codex-reset-credits/route.js b/src/app/api/usage/[connectionId]/codex-reset-credits/route.js new file mode 100644 index 00000000..5fa2f6da --- /dev/null +++ b/src/app/api/usage/[connectionId]/codex-reset-credits/route.js @@ -0,0 +1,104 @@ +// Ensure proxyFetch is loaded to patch globalThis.fetch +import "open-sse/index.js"; + +import { getProviderConnectionById } from "@/lib/localDb"; +import { consumeCodexRateLimitResetCredit } from "open-sse/services/usage.js"; +import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; +import { refreshAndUpdateCredentials } from "../route.js"; + +const AUTH_EXPIRED_PATTERNS = ["expired", "authentication", "unauthorized", "401", "re-authorize"]; + +function isAuthExpiredResult(result) { + const values = [result?.message, result?.code, result?.raw?.detail, result?.raw?.error] + .filter(Boolean) + .map((value) => String(value).toLowerCase()); + return values.some((value) => AUTH_EXPIRED_PATTERNS.some((pattern) => value.includes(pattern))); +} + +function getResponseForConsumeResult(result, redeemRequestId) { + if (result.ok) { + return Response.json({ + code: result.code, + reset: true, + windows_reset: result.windowsReset, + redeemRequestId, + credit: result.raw?.credit || null, + }); + } + + if (result.noCredit) { + return Response.json({ + code: "no_credit", + reset: false, + windows_reset: result.windowsReset, + message: "No Codex reset credits available.", + }, { status: 409 }); + } + + return Response.json({ + code: result.code || "unknown_response", + reset: false, + windows_reset: result.windowsReset, + message: result.message || "Codex reset credit consume returned an unexpected response.", + }, { status: result.status >= 400 && result.status < 500 ? result.status : 502 }); +} + +export async function POST(request, { params }) { + let connection; + try { + const { connectionId } = await params; + connection = await getProviderConnectionById(connectionId); + if (!connection) { + return Response.json({ error: "Connection not found" }, { status: 404 }); + } + + if (connection.provider !== "codex") { + return Response.json({ error: "Codex reset credits are only available for Codex connections." }, { status: 400 }); + } + + const isOAuth = connection.authType === "oauth"; + const isAccessToken = connection.authType === "access_token"; + if (!isOAuth && !isAccessToken) { + return Response.json({ error: "Codex reset credits require an OAuth or access-token connection." }, { status: 400 }); + } + + const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData); + const proxyOptions = { + connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true, + connectionProxyUrl: proxyConfig.connectionProxyUrl || "", + connectionNoProxy: proxyConfig.connectionNoProxy || "", + vercelRelayUrl: proxyConfig.vercelRelayUrl || "", + strictProxy: false, + }; + + if (isOAuth) { + try { + const result = await refreshAndUpdateCredentials(connection, false, proxyOptions); + connection = result.connection; + } catch (refreshError) { + console.error("[Codex Reset Credits API] Credential refresh failed:", refreshError); + return Response.json({ error: `Credential refresh failed: ${refreshError.message}` }, { status: 401 }); + } + } + + // Server-generated redeem id prevents client-controlled replay + const redeemRequestId = crypto.randomUUID(); + let consumeResult = await consumeCodexRateLimitResetCredit(connection.accessToken, redeemRequestId, proxyOptions); + + if (isOAuth && isAuthExpiredResult(consumeResult) && connection.refreshToken) { + try { + const retryResult = await refreshAndUpdateCredentials(connection, true, proxyOptions); + connection = retryResult.connection; + consumeResult = await consumeCodexRateLimitResetCredit(connection.accessToken, redeemRequestId, proxyOptions); + } catch (retryError) { + console.warn(`[Codex Reset Credits] force refresh failed: ${retryError.message}`); + } + } + + return getResponseForConsumeResult(consumeResult, redeemRequestId); + } catch (error) { + const provider = connection?.provider ?? "unknown"; + console.warn(`[Codex Reset Credits] ${provider}: ${error.message}`); + return Response.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/usage/[connectionId]/route.js b/src/app/api/usage/[connectionId]/route.js index 99953dea..8ccdc015 100644 --- a/src/app/api/usage/[connectionId]/route.js +++ b/src/app/api/usage/[connectionId]/route.js @@ -20,7 +20,7 @@ function isAuthExpiredMessage(usage) { * @param {boolean} force - Skip needsRefresh check and always attempt refresh * @returns Promise<{ connection, refreshed: boolean }> */ -async function refreshAndUpdateCredentials(connection, force = false, proxyOptions = null) { +export async function refreshAndUpdateCredentials(connection, force = false, proxyOptions = null) { const executor = getExecutor(connection.provider); // Build credentials object from connection @@ -131,11 +131,14 @@ export async function GET(request, { params }) { return Response.json({ error: "Connection not found" }, { status: 404 }); } - // Allow OAuth connections, plus whitelisted apikey providers (glm/minimax/...) + // Allow OAuth connections, plus whitelisted apikey providers (glm/minimax/kiro/...) + // Kiro's headless api-key flow persists authType "api_key" (underscore) while + // generic apikey providers persist "apikey" — accept both spellings here. const isOAuth = connection.authType === "oauth"; + const isApikeyAuth = + connection.authType === "apikey" || connection.authType === "api_key"; const isApikeyEligible = - connection.authType === "apikey" && - USAGE_APIKEY_PROVIDERS.includes(connection.provider); + isApikeyAuth && USAGE_APIKEY_PROVIDERS.includes(connection.provider); if (!isOAuth && !isApikeyEligible) { return Response.json({ message: "Usage not available for this connection" }); diff --git a/src/app/api/v1/models/info/route.js b/src/app/api/v1/models/info/route.js index 1993af96..551c1222 100644 --- a/src/app/api/v1/models/info/route.js +++ b/src/app/api/v1/models/info/route.js @@ -1,5 +1,6 @@ import { PROVIDER_MODELS } from "open-sse/config/providerModels.js"; import { AI_PROVIDERS, ALIAS_TO_ID } from "@/shared/constants/providers"; +import { getModelKind } from "@/shared/constants/models"; const KIND_ENDPOINT = { llm: "/v1/chat/completions", @@ -40,7 +41,8 @@ function buildInfo({ alias, providerId, model, kind, providerInfo }) { } // id format: "{alias}/{modelId}" - alias may also be providerId -function lookup(fullId) { +// requestedKind: optional, disambiguates duplicate ids across kinds (e.g. gemini-2.5-pro llm vs stt) +function lookup(fullId, requestedKind) { if (!fullId || !fullId.includes("/")) return null; const slash = fullId.indexOf("/"); const alias = fullId.slice(0, slash); @@ -50,23 +52,14 @@ function lookup(fullId) { // PROVIDER_MODELS lookup (by alias key, fallback to providerId) const list = PROVIDER_MODELS[alias] || PROVIDER_MODELS[providerId] || []; - const m = list.find((x) => x.id === modelId); + const m = requestedKind + ? list.find((x) => x.id === modelId && getModelKind(x, "llm") === requestedKind) + : list.find((x) => x.id === modelId); if (m) { - const kind = m.type || "llm"; + const kind = getModelKind(m, "llm"); return buildInfo({ alias, providerId, model: m, kind, providerInfo }); } - // Sub-configs (TTS/STT/embedding only-in-config) - const subs = [ - ["tts", providerInfo?.ttsConfig], - ["stt", providerInfo?.sttConfig], - ["embedding", providerInfo?.embeddingConfig], - ]; - for (const [kind, cfg] of subs) { - const sm = cfg?.models?.find((x) => x.id === modelId); - if (sm) return buildInfo({ alias, providerId, model: sm, kind, providerInfo }); - } - // Web search/fetch — virtual model id "search" / "fetch" if (modelId === "search" && providerInfo?.searchConfig) { return buildInfo({ @@ -93,13 +86,14 @@ export async function OPTIONS() { export async function GET(request) { const { searchParams } = new URL(request.url); const id = searchParams.get("id"); + const kind = searchParams.get("kind"); if (!id) { return Response.json( { error: { message: "Missing required query param: id (e.g. ?id=openai/dall-e-3)", type: "invalid_request_error" } }, { status: 400, headers: { "Access-Control-Allow-Origin": "*" } }, ); } - const info = lookup(id); + const info = lookup(id, kind); if (!info) { return Response.json( { error: { message: `Model not found: ${id}`, type: "not_found" } }, diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index 59fa550e..0c5dffbc 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -1,4 +1,4 @@ -import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; +import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS, getModelKind } from "@/shared/constants/models"; import { AI_PROVIDERS, getProviderAlias, @@ -9,6 +9,9 @@ import { getProviderConnections, getCombos, getCustomModels, getModelAliases } f import { getDisabledModels } from "@/lib/disabledModelsDb"; import { resolveKiroModels } from "open-sse/services/kiroModels.js"; import { resolveQoderModels } from "open-sse/services/qoderModels.js"; +import { resolveCopilotModels } from "open-sse/services/copilotModels.js"; +import { updateProviderCredentials } from "@/sse/services/tokenRefresh"; +import { capabilitiesFromServiceKind } from "open-sse/providers/capabilities.js"; // Per-provider live model resolvers. Each receives a connection record and // returns { models: [{ id, name? }, ...] } | null on failure. @@ -34,6 +37,23 @@ const LIVE_MODEL_RESOLVERS = { return { models: result.models.map((m) => ({ id: m.id, name: m.name })), }; + }, + github: async (conn) => { + const result = await resolveCopilotModels({ + accessToken: conn.accessToken, + refreshToken: conn.refreshToken, + providerSpecificData: conn.providerSpecificData || {} + }, { + log: console, + onCredentialsRefreshed: async (refreshed) => { + await updateProviderCredentials(conn.id, { + copilotToken: refreshed.copilotToken, + copilotTokenExpiresAt: refreshed.copilotTokenExpiresAt, + existingProviderSpecificData: conn.providerSpecificData || {}, + }); + }, + }); + return result?.models?.length ? { models: result.models } : null; } }; @@ -59,8 +79,9 @@ const MODEL_TYPE_TO_KIND = { }; function modelKind(model) { - if (!model?.type) return LLM_KIND; - return MODEL_TYPE_TO_KIND[model.type] || LLM_KIND; + const k = model?.kind || model?.type; + if (!k) return LLM_KIND; + return MODEL_TYPE_TO_KIND[k] || LLM_KIND; } // For dynamic/unknown model IDs (compatible providers, alias map, custom models) @@ -313,13 +334,22 @@ export async function buildModelsList(kindFilter) { }) .filter((modelId) => typeof modelId === "string" && modelId.trim() !== ""); + const customModelKindById = new Map(); const customModelIds = customModels .filter((m) => { - if (!m?.id || (m.type && m.type !== "llm")) return false; + if (!m?.id) return false; + const kind = getModelKind(m) || LLM_KIND; + // imageToText custom models are vision-capable chat models: expose them + // both in the default LLM list and in /v1/models/image-to-text. + if (!kindFilter.includes(kind) && !(kind === "imageToText" && kindFilter.includes(LLM_KIND))) return false; const alias = m.providerAlias; return alias === staticAlias || alias === outputAlias || alias === providerId; }) - .map((m) => String(m.id).trim()) + .map((m) => { + const modelId = String(m.id).trim(); + if (modelId) customModelKindById.set(modelId, getModelKind(m) || LLM_KIND); + return modelId; + }) .filter((modelId) => modelId !== ""); const aliasModelIds = Object.values(modelAliases || {}) @@ -348,41 +378,26 @@ export async function buildModelsList(kindFilter) { const mergedModelIds = Array.from(new Set([...modelIds, ...customModelIds, ...aliasModelIds])); for (const modelId of mergedModelIds) { - // Resolve kind: prefer static metadata, otherwise infer from ID heuristics - const kind = staticModelKindById.get(modelId) || inferKindFromUnknownModelId(modelId); - if (!kindFilter.includes(kind)) continue; + // Resolve kind: prefer static/custom metadata, otherwise infer from ID heuristics + const customKind = customModelKindById.get(modelId); + const kind = staticModelKindById.get(modelId) || customKind || inferKindFromUnknownModelId(modelId); + // imageToText custom models stay in the LLM list (vision-capable chat models) + const allowAsLlm = kind === "imageToText" && kindFilter.includes(LLM_KIND); + if (!kindFilter.includes(kind) && !allowAsLlm) continue; if (isDisabled(outputAlias, modelId) || isDisabled(staticAlias, modelId)) continue; - models.push({ + const model = { id: `${outputAlias}/${modelId}`, object: "model", owned_by: outputAlias, - }); - } - - // Merge sub-config models (TTS / embedding) that live on AI_PROVIDERS, not PROVIDER_MODELS - const providerInfo = AI_PROVIDERS[providerId]; - const subConfigModels = []; - if (kindFilter.includes("tts") && Array.isArray(providerInfo?.ttsConfig?.models)) { - for (const m of providerInfo.ttsConfig.models) { - if (m?.id) subConfigModels.push(m.id); - } - } - if (kindFilter.includes("embedding") && Array.isArray(providerInfo?.embeddingConfig?.models)) { - for (const m of providerInfo.embeddingConfig.models) { - if (m?.id) subConfigModels.push(m.id); - } - } - for (const subId of subConfigModels) { - if (isDisabled(outputAlias, subId) || isDisabled(staticAlias, subId)) continue; - models.push({ - id: `${outputAlias}/${subId}`, - object: "model", - owned_by: outputAlias, - }); + }; + const caps = capabilitiesFromServiceKind(customKind); + if (caps) model.capabilities = caps; + models.push(model); } // Web search/fetch — provider IS the model, expose as {alias}/search and/or {alias}/fetch with explicit kind + const providerInfo = AI_PROVIDERS[providerId]; if (kindFilter.includes("webSearch") && providerInfo?.searchConfig) { models.push({ id: `${outputAlias}/search`, diff --git a/src/app/api/v1beta/models/[...path]/route.js b/src/app/api/v1beta/models/[...path]/route.js index aef74b8a..12846312 100644 --- a/src/app/api/v1beta/models/[...path]/route.js +++ b/src/app/api/v1beta/models/[...path]/route.js @@ -1,7 +1,19 @@ import { handleChat } from "@/sse/handlers/chat.js"; +import { + clearAccountError, + getProviderCredentials, + isValidApiKey, + markAccountUnavailable, +} from "@/sse/services/auth.js"; +import { getSettings } from "@/lib/localDb"; +import { PROVIDER_MODELS } from "@/shared/constants/models"; +import { GEMINI_NATIVE_TTS_FETCH_TIMEOUT_MS } from "open-sse/config/runtimeConfig.js"; import { initTranslators } from "open-sse/translator/index.js"; let initialized = false; +const GEMINI_NATIVE_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models"; +// Gemini model id charset (matches sanitizeGeminiFunctionName); blocks path traversal in upstream URL. +const GEMINI_NATIVE_MODEL_PATTERN = /^[a-zA-Z0-9_.:-]+$/; /** * Initialize translators once @@ -72,6 +84,10 @@ export async function POST(request, { params }) { const body = await request.json(); + if (isGeminiNativeTtsRequest(model, body)) { + return await forwardGeminiNativeRequest(request, body, model, action); + } + // Streaming is determined by URL action suffix: // :streamGenerateContent => stream: true (SSE) // :generateContent => stream: false (plain JSON) @@ -107,6 +123,247 @@ export async function POST(request, { params }) { } } +function extractGeminiClientApiKey(request) { + const authHeader = request.headers.get("Authorization"); + if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7); + + const googleApiKey = request.headers.get("x-goog-api-key"); + if (googleApiKey) return googleApiKey; + + const url = new URL(request.url); + return url.searchParams.get("key"); +} + +function normalizeGeminiNativeModel(model) { + return String(model || "") + .replace(/^models\//, "") + .replace(/^gemini\//, ""); +} + +function getGeminiTtsModelIds() { + return new Set([ + ...(PROVIDER_MODELS.gemini || []) + .filter((model) => (model.kind || model.type) === "tts") + .map((model) => model.id), + ...(PROVIDER_MODELS["gemini-tts-models"] || []).map((model) => model.id), + ]); +} + +function hasAudioResponseModality(body) { + const modalities = body?.generationConfig?.responseModalities; + return Array.isArray(modalities) + && modalities.some((modality) => String(modality).toUpperCase() === "AUDIO"); +} + +function isGeminiNativeTtsRequest(model, body) { + const rawModel = String(model || ""); + if (rawModel.includes("/") && !rawModel.startsWith("gemini/") && !rawModel.startsWith("models/")) { + return false; + } + + const modelId = normalizeGeminiNativeModel(model); + return hasAudioResponseModality(body) || getGeminiTtsModelIds().has(modelId); +} + +function buildGeminiNativeUrl(requestUrl, model, action) { + const sourceUrl = new URL(requestUrl); + const upstreamUrl = new URL(`${GEMINI_NATIVE_BASE_URL}/${normalizeGeminiNativeModel(model)}${action}`); + + for (const [key, value] of sourceUrl.searchParams.entries()) { + if (key === "key") continue; + upstreamUrl.searchParams.append(key, value); + } + + return upstreamUrl.toString(); +} + +async function validateGeminiNativeClientKey(request) { + const settings = await getSettings(); + if (!settings.requireApiKey) return null; + + const apiKey = extractGeminiClientApiKey(request); + if (!apiKey) { + return Response.json({ error: { message: "Missing API key" } }, { status: 401 }); + } + + const valid = await isValidApiKey(apiKey); + if (!valid) { + return Response.json({ error: { message: "Invalid API key" } }, { status: 401 }); + } + + return null; +} + +function buildGeminiNativeAuthHeaders(credentials) { + if (credentials?.apiKey) return { "x-goog-api-key": credentials.apiKey }; + if (credentials?.accessToken) return { Authorization: `Bearer ${credentials.accessToken}` }; + return null; +} + +function corsHeadersFrom(response) { + const headers = new Headers(response.headers); + // Node fetch may expose a decoded body while preserving upstream compression + // headers. Forwarding those headers makes clients decompress plain bytes again. + headers.delete("content-encoding"); + headers.delete("content-length"); + headers.delete("transfer-encoding"); + headers.set("Access-Control-Allow-Origin", "*"); + return headers; +} + +function getSafeGeminiConnectionLabel(credentials) { + const connectionId = String(credentials?.connectionId || "unknown"); + const shortId = connectionId.slice(0, 8); + const connectionName = String(credentials?.connectionName || ""); + if (!connectionName || connectionName.includes("@")) return shortId; + return `${connectionName}:${shortId}`; +} + +function getGeminiNativeErrorCode(error) { + return error?.cause?.code || error?.code || error?.cause?.name || error?.name || "UNKNOWN"; +} + +function isGeminiNativeTimeoutError(error, timedOut) { + if (timedOut) return true; + const code = getGeminiNativeErrorCode(error); + return code === "UND_ERR_HEADERS_TIMEOUT" || code === "HeadersTimeoutError"; +} + +function getSafeGeminiNativeErrorText(error) { + const message = error?.message || String(error); + const code = getGeminiNativeErrorCode(error); + return `${message} (${code})`; +} + +async function forwardGeminiNativeRequest(request, body, model, action) { + const authError = await validateGeminiNativeClientKey(request); + if (authError) return authError; + + const modelId = normalizeGeminiNativeModel(model); + if (!GEMINI_NATIVE_MODEL_PATTERN.test(modelId)) { + return Response.json({ error: { message: "Invalid model" } }, { status: 400 }); + } + const excludeConnectionIds = new Set(); + const bodyText = JSON.stringify(body); + let lastError = null; + let lastStatus = null; + + while (true) { + const credentials = await getProviderCredentials("gemini", excludeConnectionIds, modelId); + if (!credentials || credentials.allRateLimited) { + console.log(`[GEMINI_NATIVE] exhausted model=${modelId} status=${lastStatus || Number(credentials?.lastErrorCode) || 503} error=${lastError || credentials?.lastError || "No active credentials for provider: gemini"}`); + return Response.json( + { error: { message: lastError || credentials?.lastError || "No active credentials for provider: gemini" } }, + { status: lastStatus || Number(credentials?.lastErrorCode) || 503 } + ); + } + + const authHeaders = buildGeminiNativeAuthHeaders(credentials); + if (!authHeaders) { + return Response.json( + { error: { message: "No Gemini API key configured" } }, + { status: 404 } + ); + } + + const safeConnection = getSafeGeminiConnectionLabel(credentials); + const startedAt = Date.now(); + const upstreamUrl = buildGeminiNativeUrl(request.url, modelId, action); + const attemptController = new AbortController(); + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + attemptController.abort(); + }, GEMINI_NATIVE_TTS_FETCH_TIMEOUT_MS); + const abortAttempt = () => attemptController.abort(); + + if (request.signal?.aborted) { + console.log(`[GEMINI_NATIVE] client aborted model=${modelId} ms=0 conn=${safeConnection}`); + return Response.json({ error: { message: "Client closed request" } }, { status: 499 }); + } + + request.signal?.addEventListener("abort", abortAttempt, { once: true }); + console.log(`[GEMINI_NATIVE] start model=${modelId} action=${action} conn=${safeConnection} body=${Buffer.byteLength(bodyText)}B timeout=${GEMINI_NATIVE_TTS_FETCH_TIMEOUT_MS}`); + + let upstreamResponse; + try { + upstreamResponse = await fetch(upstreamUrl, { + method: "POST", + headers: { + "Content-Type": request.headers.get("Content-Type") || "application/json", + ...authHeaders, + }, + body: bodyText, + signal: attemptController.signal, + }); + } catch (error) { + const durationMs = Date.now() - startedAt; + if (request.signal?.aborted && !timedOut) { + console.log(`[GEMINI_NATIVE] client aborted model=${modelId} ms=${durationMs} conn=${safeConnection}`); + return Response.json({ error: { message: "Client closed request" } }, { status: 499 }); + } + + const status = isGeminiNativeTimeoutError(error, timedOut) ? 504 : 502; + const errorText = getSafeGeminiNativeErrorText(error); + console.log(`[GEMINI_NATIVE] fetch failed model=${modelId} status=${status} ms=${durationMs} conn=${safeConnection} error=${errorText}`); + + const { shouldFallback } = await markAccountUnavailable( + credentials.connectionId, + status, + errorText, + "gemini", + modelId + ); + + if (shouldFallback) { + excludeConnectionIds.add(credentials.connectionId); + lastError = errorText; + lastStatus = status; + console.log(`[GEMINI_NATIVE] fallback model=${modelId} status=${status} conn=${safeConnection} exclude=${excludeConnectionIds.size}`); + continue; + } + + return Response.json({ error: { message: errorText } }, { status }); + } finally { + clearTimeout(timeout); + request.signal?.removeEventListener("abort", abortAttempt); + } + + console.log(`[GEMINI_NATIVE] upstream model=${modelId} status=${upstreamResponse.status} ms=${Date.now() - startedAt} conn=${safeConnection} ct=${upstreamResponse.headers.get("content-type") || "?"} cl=${upstreamResponse.headers.get("content-length") || "?"}`); + + if (upstreamResponse.ok) { + await clearAccountError(credentials.connectionId, credentials, modelId); + return new Response(upstreamResponse.body, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers: corsHeadersFrom(upstreamResponse), + }); + } + + const errorText = await upstreamResponse.text(); + const { shouldFallback } = await markAccountUnavailable( + credentials.connectionId, + upstreamResponse.status, + errorText, + "gemini", + modelId + ); + + if (shouldFallback) { + excludeConnectionIds.add(credentials.connectionId); + lastError = errorText; + lastStatus = upstreamResponse.status; + continue; + } + + return new Response(errorText, { + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + headers: corsHeadersFrom(upstreamResponse), + }); + } +} + /** * Convert Gemini request format to OpenAI/internal format. * diff --git a/src/app/api/v1beta/models/route.js b/src/app/api/v1beta/models/route.js index 806ffa54..b5b47a98 100644 --- a/src/app/api/v1beta/models/route.js +++ b/src/app/api/v1beta/models/route.js @@ -19,19 +19,38 @@ export async function OPTIONS() { */ export async function GET() { try { - // Collect all models from all providers const models = []; + const seen = new Set(); + + function addModel({ name, displayName, description, methods = ["generateContent"] }) { + if (seen.has(name)) return; + seen.add(name); + models.push({ + name, + displayName, + description, + supportedGenerationMethods: methods, + inputTokenLimit: 128000, + outputTokenLimit: 8192, + }); + } for (const [provider, providerModels] of Object.entries(PROVIDER_MODELS)) { for (const model of providerModels) { - models.push({ + addModel({ name: `models/${provider}/${model.id}`, displayName: model.name || model.id, description: `${provider} model: ${model.name || model.id}`, - supportedGenerationMethods: ["generateContent"], - inputTokenLimit: 128000, - outputTokenLimit: 8192, }); + + if (provider === "gemini") { + addModel({ + name: `models/${model.id}`, + displayName: model.name || model.id, + description: `Gemini model: ${model.name || model.id}`, + methods: ["generateContent", "streamGenerateContent"], + }); + } } } @@ -41,4 +60,3 @@ export async function GET() { return Response.json({ error: { message: error.message } }, { status: 500 }); } } - diff --git a/src/app/globals.css b/src/app/globals.css index d2951537..d96cac7e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -324,6 +324,20 @@ button { user-select: none; } +/* Tailwind v4 dropped default button cursor — restore globally */ +button:not(:disabled), +[role="button"]:not([aria-disabled="true"]), +label[for], +summary, +select { + cursor: pointer; +} + +button:disabled, +[role="button"][aria-disabled="true"] { + cursor: not-allowed; +} + /* ============================================================ Animations ============================================================ */ diff --git a/src/app/layout.js b/src/app/layout.js index b6c125c9..5ea6c11d 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -1,4 +1,5 @@ import { Inter } from "next/font/google"; +import { GoogleAnalytics } from "@next/third-parties/google"; import "material-symbols/outlined.css"; import "./globals.css"; import { ThemeProvider } from "@/shared/components/ThemeProvider"; @@ -43,6 +44,7 @@ export default function RootLayout({ children }) { {children} + ); diff --git a/src/app/login/page.js b/src/app/login/page.js index 38cf033d..8e50a191 100644 --- a/src/app/login/page.js +++ b/src/app/login/page.js @@ -2,7 +2,6 @@ import { useState, useEffect } from "react"; import { Card, Button, Input } from "@/shared/components"; -import { useRouter } from "next/navigation"; export default function LoginPage() { const [password, setPassword] = useState(""); @@ -16,7 +15,6 @@ export default function LoginPage() { const [oidcLoginLabel, setOidcLoginLabel] = useState("Sign in with OIDC"); const [mustChange, setMustChange] = useState(false); const [newPassword, setNewPassword] = useState(""); - const router = useRouter(); // Countdown for rate-limit useEffect(() => { @@ -40,8 +38,7 @@ export default function LoginPage() { if (res.ok) { const data = await res.json(); if (data.requireLogin === false) { - router.push("/dashboard"); - router.refresh(); + window.location.assign("/dashboard"); return; } setHasPassword(!!data.hasPassword); @@ -58,7 +55,7 @@ export default function LoginPage() { } } checkAuth(); - }, [router]); + }, []); const handleLogin = async (e) => { e.preventDefault(); @@ -79,8 +76,7 @@ export default function LoginPage() { setMustChange(true); return; } - router.push("/dashboard"); - router.refresh(); + window.location.assign("/dashboard"); } else { const data = await res.json(); setError(data.error || "Invalid password"); @@ -106,8 +102,7 @@ export default function LoginPage() { body: JSON.stringify({ currentPassword: password, newPassword }), }); if (res.ok) { - router.push("/dashboard"); - router.refresh(); + window.location.assign("/dashboard"); } else { const data = await res.json(); setError(data.error || "Failed to set password"); diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 76275db4..83e4364e 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -32,7 +32,7 @@ const PUBLIC_API_PATHS = [ ]; // Public top-level prefixes (LLM API endpoints with their own API key auth). -const PUBLIC_PREFIXES = ["/v1", "/v1beta", "/api/v1", "/api/v1beta"]; +const PUBLIC_PREFIXES = ["/v1", "/v1beta", "/api/v1", "/api/v1beta", "/codex"]; // Always require JWT token regardless of requireLogin setting const ALWAYS_PROTECTED = [ @@ -79,6 +79,8 @@ const LOCAL_ONLY_PATHS = [ "/api/oauth/cursor/auto-import", "/api/oauth/kiro/auto-import", "/api/auth/reset-password", + "/api/headroom/start", + "/api/headroom/stop", ]; const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); @@ -90,7 +92,17 @@ function isLoopbackHostname(h) { } export function isLocalRequest(request) { - if (!isLoopbackHostname(request.headers.get("host"))) return false; + // Stamped by custom-server.js when forwarding headers exist: request came through + // a reverse proxy, so the loopback socket is the proxy hop, not the end-user. + if (request.headers.get("x-9r-via-proxy")) return false; + // Trusted peer IP from TCP socket (custom-server.js); unspoofable. Primary anchor for "local". + const realIp = request.headers.get("x-9r-real-ip"); + if (realIp) { + if (!isLoopbackHostname(realIp)) return false; + } else if (!isLoopbackHostname(request.headers.get("host"))) { + // Fallback for bare server.js (dev) without custom-server: legacy Host-based check. + return false; + } const origin = request.headers.get("origin"); if (origin) { try { @@ -107,7 +119,11 @@ function isPublicLlmApi(pathname) { function extractApiKey(request) { const authHeader = request.headers.get("Authorization"); if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7); - return request.headers.get("x-api-key"); + const apiKeyHeader = request.headers.get("x-api-key"); + if (apiKeyHeader) return apiKeyHeader; + const googleApiKeyHeader = request.headers.get("x-goog-api-key"); + if (googleApiKeyHeader) return googleApiKeyHeader; + return request.nextUrl.searchParams?.get("key") || null; } async function hasValidApiKey(request) { diff --git a/src/lib/consoleLogBuffer.js b/src/lib/consoleLogBuffer.js index afff411f..9ace30fb 100644 --- a/src/lib/consoleLogBuffer.js +++ b/src/lib/consoleLogBuffer.js @@ -21,6 +21,26 @@ if (!state.emitter) { state.emitter.setMaxListeners(50); } +if (!state.pendingLines) state.pendingLines = []; +if (!state.flushTimer) state.flushTimer = null; + +const FLUSH_INTERVAL_MS = 100; +const MAX_BATCH_LINES = 50; + +function flushPendingLines() { + state.flushTimer = null; + if (!state.pendingLines.length) return; + + const lines = state.pendingLines.splice(0, state.pendingLines.length); + state.emitter.emit("lines", lines); +} + +function scheduleFlush() { + if (state.flushTimer) return; + state.flushTimer = setTimeout(flushPendingLines, FLUSH_INTERVAL_MS); + state.flushTimer?.unref?.(); +} + function toLogLine(level, args) { return args.map(formatArg).join(" "); } @@ -48,7 +68,16 @@ function appendLine(line) { if (state.logs.length > maxLines) { state.logs = state.logs.slice(-maxLines); } - state.emitter.emit("line", line); + state.pendingLines.push(line); + if (state.pendingLines.length >= MAX_BATCH_LINES) { + if (state.flushTimer) { + clearTimeout(state.flushTimer); + state.flushTimer = null; + } + flushPendingLines(); + } else { + scheduleFlush(); + } } export function initConsoleLogCapture() { diff --git a/src/lib/db/repos/pricingRepo.js b/src/lib/db/repos/pricingRepo.js index 8b39295c..6467274b 100644 --- a/src/lib/db/repos/pricingRepo.js +++ b/src/lib/db/repos/pricingRepo.js @@ -20,7 +20,7 @@ export async function getPricing() { if (cache.value && cache.expiresAt > now) return cache.value; const userPricing = await getUserPricing(); - const { PROVIDER_PRICING } = await import("@/shared/constants/pricing.js"); + const { PROVIDER_PRICING } = await import("open-sse/providers/pricing.js"); const merged = {}; for (const [provider, models] of Object.entries(PROVIDER_PRICING)) { @@ -52,7 +52,7 @@ export async function getPricingForModel(provider, model) { if (!model) return null; const userPricing = await getUserPricing(); if (provider && userPricing[provider]?.[model]) return userPricing[provider][model]; - const { getPricingForModel: resolveConst } = await import("@/shared/constants/pricing.js"); + const { getPricingForModel: resolveConst } = await import("open-sse/providers/pricing.js"); return resolveConst(provider, model); } diff --git a/src/lib/db/repos/requestDetailsRepo.js b/src/lib/db/repos/requestDetailsRepo.js index 6828a574..813974ae 100644 --- a/src/lib/db/repos/requestDetailsRepo.js +++ b/src/lib/db/repos/requestDetailsRepo.js @@ -16,8 +16,8 @@ async function getObservabilityConfig() { const { getSettings } = await import("./settingsRepo.js"); const settings = await getSettings(); const envEnabled = process.env.OBSERVABILITY_ENABLED !== "false"; - const enabled = typeof settings.enableObservability === "boolean" - ? settings.enableObservability + const enabled = typeof settings.enableObservability2 === "boolean" + ? settings.enableObservability2 : envEnabled; cachedConfig = { enabled, diff --git a/src/lib/db/repos/settingsRepo.js b/src/lib/db/repos/settingsRepo.js index 7201a760..0057cc1c 100644 --- a/src/lib/db/repos/settingsRepo.js +++ b/src/lib/db/repos/settingsRepo.js @@ -2,6 +2,7 @@ import { getAdapter } from "../driver.js"; import { parseJson, stringifyJson } from "../helpers/jsonCol.js"; const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128"; +const DEFAULT_HEADROOM_URL = process.env.HEADROOM_URL || "http://localhost:8787"; const DEFAULT_SETTINGS = { cloudEnabled: false, @@ -34,8 +35,13 @@ const DEFAULT_SETTINGS = { mitmRouterBaseUrl: DEFAULT_MITM_ROUTER_BASE, dnsToolEnabled: {}, rtkEnabled: true, + headroomEnabled: false, + headroomUrl: DEFAULT_HEADROOM_URL, + headroomCompressUserMessages: false, cavemanEnabled: false, cavemanLevel: "full", + ponytailEnabled: false, + ponytailLevel: "full", }; async function readRaw() { diff --git a/src/lib/db/repos/usageRepo.js b/src/lib/db/repos/usageRepo.js index 63d0494e..b0d6bff0 100644 --- a/src/lib/db/repos/usageRepo.js +++ b/src/lib/db/repos/usageRepo.js @@ -3,6 +3,12 @@ import { getAdapter } from "../driver.js"; import { parseJson, stringifyJson } from "../helpers/jsonCol.js"; import { getMeta, setMeta } from "../helpers/metaStore.js"; +function maskApiKey(key) { + if (!key || typeof key !== "string") return null; + if (key.length <= 8) return key.charAt(0) + "***"; + return key.slice(0, 8) + "***"; +} + const PENDING_TIMEOUT_MS = 60 * 1000; const RING_CAP = 50; const CONN_CACHE_TTL_MS = 30 * 1000; @@ -18,15 +24,27 @@ if (!global._statsEmitter) { if (!global._pendingTimers) global._pendingTimers = {}; if (!global._recentRing) global._recentRing = { items: [], initialized: false }; if (!global._connectionMapCache) global._connectionMapCache = { map: {}, ts: 0 }; +if (!global._statsEmitTimers) global._statsEmitTimers = { pending: null, update: null }; const pendingRequests = global._pendingRequests; const lastErrorProvider = global._lastErrorProvider; const pendingTimers = global._pendingTimers; const recentRing = global._recentRing; const connCache = global._connectionMapCache; +const statsEmitTimers = global._statsEmitTimers; export const statsEmitter = global._statsEmitter; +function scheduleStatsEvent(event, delayMs = 150) { + const key = event === "update" ? "update" : "pending"; + if (statsEmitTimers[key]) return; + statsEmitTimers[key] = setTimeout(() => { + statsEmitTimers[key] = null; + statsEmitter.emit(event); + }, delayMs); + statsEmitTimers[key]?.unref?.(); +} + function getLocalDateKey(timestamp) { const d = timestamp ? new Date(timestamp) : new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; @@ -178,7 +196,7 @@ export function trackPendingRequest(model, provider, connectionId, started, erro if (connectionId && pendingRequests.byAccount[connectionId]?.[modelKey] > 0) { pendingRequests.byAccount[connectionId][modelKey] = 0; } - statsEmitter.emit("pending"); + scheduleStatsEvent("pending"); }, PENDING_TIMEOUT_MS); } else { clearTimeout(pendingTimers[timerKey]); @@ -192,7 +210,7 @@ export function trackPendingRequest(model, provider, connectionId, started, erro const t = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); console.log(`[${t}] [PENDING] ${started ? "START" : "END"}${error ? " (ERROR)" : ""} | provider=${provider} | model=${model}`); - statsEmitter.emit("pending"); + scheduleStatsEvent("pending"); } export async function getActiveRequests() { @@ -251,9 +269,35 @@ export async function saveRequestUsage(entry) { const promptTokens = tokens.prompt_tokens || tokens.input_tokens || 0; const completionTokens = tokens.completion_tokens || tokens.output_tokens || 0; + let inserted = false; + // All 3 writes (history insert, daily upsert, lifetime counter) in ONE transaction. // better-sqlite3 is sync → no JS yield mid-transaction → no race in same process. db.transaction(() => { + const existing = db.get( + `SELECT id, endpoint FROM usageHistory + WHERE timestamp = ? + AND COALESCE(provider, '') = COALESCE(?, '') + AND COALESCE(model, '') = COALESCE(?, '') + AND COALESCE(connectionId, '') = COALESCE(?, '') + AND COALESCE(apiKey, '') = COALESCE(?, '') + AND promptTokens = ? + AND completionTokens = ? + ORDER BY id DESC LIMIT 1`, + [ + entry.timestamp, entry.provider || null, entry.model || null, + entry.connectionId || null, entry.apiKey || null, + promptTokens, completionTokens, + ] + ); + + if (existing) { + if (!existing.endpoint && entry.endpoint) { + db.run(`UPDATE usageHistory SET endpoint = ? WHERE id = ?`, [entry.endpoint, existing.id]); + } + return; + } + db.run( `INSERT INTO usageHistory(timestamp, provider, model, connectionId, apiKey, endpoint, promptTokens, completionTokens, cost, status, tokens, meta) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ @@ -277,10 +321,13 @@ export async function saveRequestUsage(entry) { const cur = db.get(`SELECT value FROM _meta WHERE key = 'totalRequestsLifetime'`); const next = (cur ? parseInt(cur.value, 10) : 0) + 1; db.run(`INSERT INTO _meta(key, value) VALUES('totalRequestsLifetime', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, [String(next)]); + inserted = true; }); - pushToRing(entry); - statsEmitter.emit("update"); + if (inserted) { + pushToRing(entry); + scheduleStatsEvent("update", 250); + } } catch (e) { console.error("Failed to save usage stats:", e); } @@ -301,7 +348,7 @@ export async function getUsageHistory(filter = {}) { return rows.map((r) => ({ timestamp: r.timestamp, provider: r.provider, model: r.model, - connectionId: r.connectionId, apiKey: r.apiKey, endpoint: r.endpoint, + connectionId: r.connectionId, apiKeyMasked: maskApiKey(r.apiKey), endpoint: r.endpoint, cost: r.cost, status: r.status, tokens: parseJson(r.tokens, {}), })); } @@ -475,9 +522,10 @@ export async function getUsageStats(period = "all") { const apiKeyVal = ak.apiKey; const keyInfo = apiKeyVal ? apiKeyMap[apiKeyVal] : null; const keyName = keyInfo?.name || (apiKeyVal ? apiKeyVal.slice(0, 8) + "..." : "Local (No API Key)"); - const apiKeyKey = apiKeyVal || "local-no-key"; + const apiKeyMasked = maskApiKey(apiKeyVal); + const apiKeyKey = apiKeyMasked || "local-no-key"; if (!stats.byApiKey[akKey]) { - stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel, provider: providerDisplayName, apiKey: apiKeyVal, keyName, apiKeyKey, lastUsed: dateKey }; + stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel, provider: providerDisplayName, apiKeyMasked, keyName, apiKeyKey, lastUsed: dateKey }; } stats.byApiKey[akKey].requests += ak.requests || 0; stats.byApiKey[akKey].promptTokens += ak.promptTokens || 0; @@ -586,16 +634,17 @@ export async function getUsageStats(period = "all") { if (r.apiKey && typeof r.apiKey === "string") { const keyInfo = apiKeyMap[r.apiKey]; const keyName = keyInfo?.name || r.apiKey.slice(0, 8) + "..."; - const akKey = `${r.apiKey}|${r.model}|${r.provider || "unknown"}`; + const apiKeyMasked = maskApiKey(r.apiKey); + const akKey = `${apiKeyMasked}|${r.model}|${r.provider || "unknown"}`; if (!stats.byApiKey[akKey]) { - stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKey: r.apiKey, keyName, apiKeyKey: r.apiKey, lastUsed: r.timestamp }; + stats.byApiKey[akKey] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKeyMasked, keyName, apiKeyKey: apiKeyMasked, lastUsed: r.timestamp }; } const ake = stats.byApiKey[akKey]; ake.requests++; ake.promptTokens += promptTokens; ake.completionTokens += completionTokens; ake.cost += entryCost; if (new Date(r.timestamp) > new Date(ake.lastUsed)) ake.lastUsed = r.timestamp; } else { if (!stats.byApiKey["local-no-key"]) { - stats.byApiKey["local-no-key"] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKey: null, keyName: "Local (No API Key)", apiKeyKey: "local-no-key", lastUsed: r.timestamp }; + stats.byApiKey["local-no-key"] = { requests: 0, promptTokens: 0, completionTokens: 0, cost: 0, rawModel: r.model, provider: providerDisplayName, apiKeyMasked: null, keyName: "Local (No API Key)", apiKeyKey: "local-no-key", lastUsed: r.timestamp }; } const ake = stats.byApiKey["local-no-key"]; ake.requests++; ake.promptTokens += promptTokens; ake.completionTokens += completionTokens; ake.cost += entryCost; @@ -700,7 +749,7 @@ export async function appendRequestLog() {} export async function getRecentLogs(limit = 200) { try { - const db = getAdapter(); + const db = await getAdapter(); const rows = db.all( `SELECT timestamp, provider, model, connectionId, promptTokens, completionTokens, status, tokens FROM usageHistory ORDER BY id DESC LIMIT ?`, [limit], diff --git a/src/lib/headroom/detect.js b/src/lib/headroom/detect.js new file mode 100644 index 00000000..ac64dc87 --- /dev/null +++ b/src/lib/headroom/detect.js @@ -0,0 +1,102 @@ +import { execSync } from "child_process"; +import path from "path"; + +const IS_WIN = process.platform === "win32"; +const WHICH_CMD = IS_WIN ? "where" : "which"; + +// Extra bin dirs often missing from a packaged/launchd PATH (Python installs headroom here). +const EXTRA_BINS = IS_WIN + ? [ + `${process.env.LOCALAPPDATA || ""}\\Programs\\Python\\Python313\\Scripts`, + `${process.env.LOCALAPPDATA || ""}\\Programs\\Python\\Python312\\Scripts`, + `${process.env.LOCALAPPDATA || ""}\\Programs\\Python\\Python311\\Scripts`, + `${process.env.LOCALAPPDATA || ""}\\Programs\\Python\\Python310\\Scripts`, + `${process.env.APPDATA || ""}\\Python\\Python313\\Scripts`, + ] + : [ + "/usr/local/bin", + "/opt/homebrew/bin", + "/Library/Frameworks/Python.framework/Versions/3.13/bin", + "/Library/Frameworks/Python.framework/Versions/3.12/bin", + "/Library/Frameworks/Python.framework/Versions/3.11/bin", + "/Library/Frameworks/Python.framework/Versions/3.10/bin", + `${process.env.HOME || ""}/.local/bin`, + "/usr/bin", + "/bin", + ]; + +const EXTENDED_PATH = [...EXTRA_BINS, process.env.PATH || ""].filter(Boolean).join(path.delimiter); +const PYTHON_CANDIDATES = ["python3.13", "python3.12", "python3.11", "python3.10", "python3", "python"]; +const MIN_VERSION = [3, 10]; +const HEADROOM_HEALTH_TIMEOUT_MS = 1500; +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"]); + +export const DEFAULT_HEADROOM_URL = process.env.HEADROOM_URL || "http://localhost:8787"; + +// Detect whether the headroom CLI is installed and where its binary lives. +export function findHeadroomBinary() { + try { + const out = execSync(`${WHICH_CMD} headroom`, { + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + env: { ...process.env, PATH: EXTENDED_PATH }, + }).toString().trim(); + // Windows `where` may return multiple lines — take the first. + return out ? out.split(/\r?\n/)[0].trim() : null; + } catch { + return null; + } +} + +// Find a Python interpreter >= 3.10 (headroom-ai requires it). Returns null if none. +export function findPython310() { + for (const candidate of PYTHON_CANDIDATES) { + try { + const ver = execSync(`${candidate} --version`, { + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + env: { ...process.env, PATH: EXTENDED_PATH }, + }).toString().trim(); + const match = ver.match(/(\d+)\.(\d+)/); + if (!match) continue; + const [major, minor] = [parseInt(match[1], 10), parseInt(match[2], 10)]; + if (major > MIN_VERSION[0] || (major === MIN_VERSION[0] && minor >= MIN_VERSION[1])) { + return candidate; + } + } catch { + // candidate not present, try next + } + } + return null; +} + +// Probe whether a Headroom proxy is reachable at the given URL by hitting /health. +export async function probeProxyRunning(url) { + if (!url) return false; + const base = String(url).replace(/\/$/, ""); + try { + const res = await fetch(`${base}/health`, { signal: AbortSignal.timeout(HEADROOM_HEALTH_TIMEOUT_MS) }); + return res.ok; + } catch { + return false; + } +} + +export function isLoopbackHeadroomUrl(url) { + try { + const parsed = new URL(url); + return LOOPBACK_HOSTS.has(parsed.hostname); + } catch { + return false; + } +} + +// Aggregate status for the dashboard: installed, running, python interpreter. +export async function getHeadroomStatus(url) { + const path = findHeadroomBinary(); + const python = findPython310(); + const installed = Boolean(path); + const running = await probeProxyRunning(url); + const localUrl = isLoopbackHeadroomUrl(url); + return { installed, path, running, python, localUrl, canStart: installed && localUrl }; +} diff --git a/src/lib/headroom/process.js b/src/lib/headroom/process.js new file mode 100644 index 00000000..d50bc7ec --- /dev/null +++ b/src/lib/headroom/process.js @@ -0,0 +1,128 @@ +import fs from "fs"; +import path from "path"; +import { spawn } from "child_process"; +import { DATA_DIR } from "@/lib/dataDir.js"; +import { findHeadroomBinary } from "./detect.js"; + +const HEADROOM_DIR = path.join(DATA_DIR, "headroom"); +const PID_FILE = path.join(HEADROOM_DIR, "proxy.pid"); +const LOG_FILE = path.join(HEADROOM_DIR, "proxy.log"); +const DEFAULT_PORT = 8787; +const STARTUP_TIMEOUT_MS = 8000; + +function ensureDir() { + if (!fs.existsSync(HEADROOM_DIR)) fs.mkdirSync(HEADROOM_DIR, { recursive: true }); +} + +function readPid() { + try { + if (fs.existsSync(PID_FILE)) return parseInt(fs.readFileSync(PID_FILE, "utf8"), 10); + } catch { /* ignore */ } + return null; +} + +function writePid(pid) { + ensureDir(); + fs.writeFileSync(PID_FILE, String(pid)); +} + +function clearPid() { + try { if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE); } catch { /* ignore */ } +} + +// process.kill throws if pid is dead — use this to probe. +export function isPidAlive(pid) { + if (!pid || typeof pid !== "number") return false; + try { process.kill(pid, 0); return true; } catch { return false; } +} + +export function getManagedPid() { + const pid = readPid(); + return pid && isPidAlive(pid) ? pid : null; +} + +export async function startHeadroomProxy({ port = DEFAULT_PORT } = {}) { + const safePort = Number(port) > 0 && Number(port) < 65536 ? Number(port) : DEFAULT_PORT; + const binary = findHeadroomBinary(); + if (!binary) { + const err = new Error("Headroom CLI not installed"); + err.code = "NOT_INSTALLED"; + throw err; + } + + const existing = getManagedPid(); + if (existing) return { pid: existing, alreadyRunning: true }; + + ensureDir(); + // spawn stdio requires fd numbers, not WriteStream objects. + const outFd = fs.openSync(LOG_FILE, "a"); + + const child = spawn(binary, ["proxy", "--port", String(safePort)], { + stdio: ["ignore", outFd, outFd], + detached: true, + windowsHide: true, + env: { ...process.env }, + }); + + if (!child.pid) { + fs.closeSync(outFd); + const err = new Error("Failed to spawn headroom proxy"); + err.code = "SPAWN_FAILED"; + throw err; + } + + child.unref(); + writePid(child.pid); + + // Wait until the process either stays alive briefly (success) or exits fast (failure). + await new Promise((resolve, reject) => { + const startupTimer = setTimeout(() => { + if (isPidAlive(child.pid)) resolve(); + else reject(new Error("headroom proxy exited during startup — see proxy.log")); + }, STARTUP_TIMEOUT_MS); + + child.once("exit", (code) => { + clearTimeout(startupTimer); + clearPid(); + fs.closeSync(outFd); + const e = new Error(`headroom proxy exited early (code=${code}) — see proxy.log`); + e.code = "EARLY_EXIT"; + reject(e); + }); + }); + + // Close parent's copy of the fd; child retains its own after unref. + fs.closeSync(outFd); + + return { pid: child.pid, alreadyRunning: false }; +} + +export function stopHeadroomProxy() { + const pid = getManagedPid(); + if (!pid) return { stopped: false, reason: "not_running" }; + try { + process.kill(pid, "SIGTERM"); + // Give it a moment, then force if still alive. + setTimeout(() => { + if (isPidAlive(pid)) { + try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ } + } + }, 2000); + clearPid(); + return { stopped: true, pid }; + } catch (e) { + clearPid(); + const err = new Error(`Failed to stop headroom proxy: ${e.message}`); + err.code = "STOP_FAILED"; + throw err; + } +} + +export function getHeadroomLogTail(maxLines = 200) { + try { + if (!fs.existsSync(LOG_FILE)) return ""; + const content = fs.readFileSync(LOG_FILE, "utf8"); + const lines = content.split(/\r?\n/).filter(Boolean); + return lines.slice(-maxLines).join("\n"); + } catch { return ""; } +} diff --git a/src/lib/network/outboundProxy.js b/src/lib/network/outboundProxy.js index 9c99dd3a..f85c63a2 100644 --- a/src/lib/network/outboundProxy.js +++ b/src/lib/network/outboundProxy.js @@ -3,6 +3,20 @@ function normalizeString(value) { return String(value).trim(); } +const ALLOWED_PROXY_SCHEMES = ["http:", "https:", "socks5:", "socks4:", "socks5h:", "socks4a:"]; + +function validateProxyUrl(url) { + if (!url) return null; + if (/[\n\r`$]/.test(url)) return null; + try { + const parsed = new URL(url); + if (!ALLOWED_PROXY_SCHEMES.includes(parsed.protocol)) return null; + return parsed.href; + } catch { + return null; + } +} + export function applyOutboundProxyEnv( { outboundProxyEnabled, outboundProxyUrl, outboundNoProxy } = {} ) { @@ -46,11 +60,14 @@ export function applyOutboundProxyEnv( } if (proxyUrl) { - process.env.HTTP_PROXY = proxyUrl; - process.env.HTTPS_PROXY = proxyUrl; - process.env.ALL_PROXY = proxyUrl; - process.env.NINE_ROUTER_PROXY_URL = proxyUrl; - managed = true; + const validated = validateProxyUrl(proxyUrl); + if (validated) { + process.env.HTTP_PROXY = validated; + process.env.HTTPS_PROXY = validated; + process.env.ALL_PROXY = validated; + process.env.NINE_ROUTER_PROXY_URL = validated; + managed = true; + } } if (noProxy) { diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js index dc825b30..4ce0eb7d 100644 --- a/src/lib/oauth/constants/oauth.js +++ b/src/lib/oauth/constants/oauth.js @@ -1,7 +1,9 @@ /** - * OAuth Configuration Constants + * OAuth Configuration Constants — static data lives in registry, re-exported here for consumers. */ import { platform, arch } from "os"; +import { ANTIGRAVITY_OAUTH_CLIENT, GOOGLE_OAUTH_CLIENT } from "open-sse/providers/shared.js"; +import { PROVIDER_OAUTH, PROVIDERS as REGISTRY_PROVIDERS } from "open-sse/providers/index.js"; /** * Get the platform enum value based on the current OS. @@ -17,103 +19,34 @@ function getOAuthPlatformEnum() { } // Claude OAuth Configuration (Authorization Code Flow with PKCE) -export const CLAUDE_CONFIG = { - 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", -}; +export const CLAUDE_CONFIG = { ...PROVIDER_OAUTH["claude"] }; // Codex (OpenAI) OAuth Configuration (Authorization Code Flow with PKCE) -export const CODEX_CONFIG = { - 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", - // Additional OpenAI-specific params - extraParams: { - id_token_add_organizations: "true", - codex_cli_simplified_flow: "true", - originator: "codex_cli_rs", - }, -}; +export const CODEX_CONFIG = { ...PROVIDER_OAUTH["codex"] }; // Gemini (Google) OAuth Configuration (Standard OAuth2) -export const GEMINI_CONFIG = { - clientId: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com", - clientSecret: "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", - 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", - ], -}; +// clientId/clientSecret from GOOGLE_OAUTH_CLIENT (shared.js) — not stored in registry +export const GEMINI_CONFIG = { ...GOOGLE_OAUTH_CLIENT, ...PROVIDER_OAUTH["gemini-cli"] }; // Qwen OAuth Configuration (Device Code Flow with PKCE) -export const QWEN_CONFIG = { - clientId: "f0304373b74a44d2b584a3fb70ca9e56", - deviceCodeUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code", - tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token", - scope: "openid profile email model.completion", - codeChallengeMethod: "S256", -}; +export const QWEN_CONFIG = { ...PROVIDER_OAUTH["qwen"] }; // Qoder OAuth Configuration (Device Token Flow with PKCE). // Device tokens are long-lived (~30 days for access, ~360 for refresh). // The upstream refresh endpoint at center.qoder.sh returns 403 for our // flow — we accept that and surface it to the user as "re-login" instead // of attempting to silently rotate. -export const QODER_CONFIG = { - openApiBaseUrl: "https://openapi.qoder.sh", - centerBaseUrl: "https://center.qoder.sh", - chatBaseUrl: "https://api3.qoder.sh", - deviceTokenUrl: "https://openapi.qoder.sh/api/v1/deviceToken/poll", - refreshUrl: "https://center.qoder.sh/algo/api/v3/user/refresh_token", - userInfoUrl: "https://openapi.qoder.sh/api/v1/userinfo", - quotaUsageUrl: "https://openapi.qoder.sh/api/v2/quota/usage", - loginUrl: "https://qoder.com/device/selectAccounts", -}; +export const QODER_CONFIG = { ...PROVIDER_OAUTH["qoder"] }; // iFlow OAuth Configuration (Authorization Code) -export const IFLOW_CONFIG = { - clientId: "10009311001", - clientSecret: "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW", - authorizeUrl: "https://iflow.cn/oauth", - tokenUrl: "https://iflow.cn/oauth/token", - userInfoUrl: "https://iflow.cn/api/oauth/getUserInfo", - extraParams: { - loginMethod: "phone", - type: "phone", - }, -}; +export const IFLOW_CONFIG = { ...PROVIDER_OAUTH["iflow"] }; // Antigravity OAuth Configuration (Standard OAuth2 with Google) +// clientId/clientSecret from ANTIGRAVITY_OAUTH_CLIENT (shared.js) — not stored in registry +// loadCodeAssistClientMetadata is dynamic (runtime platform detection) export const ANTIGRAVITY_CONFIG = { - clientId: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com", - clientSecret: "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", - 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", - ], - // Antigravity specific - 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", - // Numeric enums matching Antigravity binary ClientMetadata (see getOAuthClientMetadata below) + ...ANTIGRAVITY_OAUTH_CLIENT, + ...PROVIDER_OAUTH["antigravity"], loadCodeAssistClientMetadata: JSON.stringify({ ideType: 9, platform: getOAuthPlatformEnum(), pluginType: 2 }), }; @@ -126,136 +59,54 @@ export function getOAuthClientMetadata() { } // OpenAI OAuth Configuration (Authorization Code Flow with PKCE) -export const OPENAI_CONFIG = { - 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", - extraParams: { - id_token_add_organizations: "true", - originator: "openai_native", - }, -}; +export const OPENAI_CONFIG = { ...PROVIDER_OAUTH["openai"] }; // GitHub Copilot OAuth Configuration (Device Code Flow) -export const GITHUB_CONFIG = { - clientId: "Iv1.b507a08c87ecfe98", - deviceCodeUrl: "https://github.com/login/device/code", - tokenUrl: "https://github.com/login/oauth/access_token", - userInfoUrl: "https://api.github.com/user", - scopes: "read:user", - apiVersion: "2022-11-28", // Updated to supported version - copilotTokenUrl: "https://api.github.com/copilot_internal/v2/token", - userAgent: "GitHubCopilotChat/0.26.7", - editorVersion: "vscode/1.85.0", - editorPluginVersion: "copilot-chat/0.26.7", -}; +export const GITHUB_CONFIG = { ...PROVIDER_OAUTH["github"] }; -// Kiro OAuth Configuration -// Supports multiple auth methods: -// 1. AWS Builder ID (Device Code Flow) -// 2. AWS IAM Identity Center/IDC (Device Code Flow with custom startUrl/region) -// 3. Google/GitHub Social Login (Authorization Code Flow - manual callback) -// 4. Import Token (paste refresh token from Kiro IDE) -export const KIRO_CONFIG = { - // AWS SSO OIDC endpoints for Builder ID/IDC (Device Code Flow) - ssoOidcEndpoint: "https://oidc.us-east-1.amazonaws.com", - registerClientUrl: "https://oidc.us-east-1.amazonaws.com/client/register", - deviceAuthUrl: "https://oidc.us-east-1.amazonaws.com/device_authorization", - tokenUrl: "https://oidc.us-east-1.amazonaws.com/token", - // AWS Builder ID default start URL - startUrl: "https://view.awsapps.com/start", - // Client registration params - clientName: "kiro-oauth-client", - clientType: "public", - scopes: ["codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations"], - grantTypes: ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"], - issuerUrl: "https://identitycenter.amazonaws.com/ssoins-722374e8c3c8e6c6", - // Social auth endpoints (Google/GitHub via AWS Cognito) - socialAuthEndpoint: "https://prod.us-east-1.auth.desktop.kiro.dev", - socialLoginUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/login", - socialTokenUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/oauth/token", - socialRefreshUrl: "https://prod.us-east-1.auth.desktop.kiro.dev/refreshToken", - // Auth methods - authMethods: ["builder-id", "idc", "google", "github", "import"], -}; +// Kiro OAuth Configuration (multi-method: AWS Builder ID / IDC / Social / Import Token) +export const KIRO_CONFIG = { ...PROVIDER_OAUTH["kiro"] }; + +// AWS region allowlist pattern — prevents SSRF via region injection into upstream URLs (GHSA-6mwv-4mrm-5p3m) +export const AWS_REGION_PATTERN = /^[a-z]{2}-[a-z]+-\d{1,2}$/; + +// Reject any region that is not a valid AWS region before interpolating it into a URL +export function assertValidAwsRegion(region) { + if (typeof region !== "string" || !AWS_REGION_PATTERN.test(region)) { + throw new Error("Invalid region"); + } + return region; +} // Cursor OAuth Configuration (Import Token from Cursor IDE) -// Cursor stores credentials in SQLite database: state.vscdb -// Keys: cursorAuth/accessToken, storage.serviceMachineId +// tokenStoragePaths: user-reference only, not stored in registry export const CURSOR_CONFIG = { - // API endpoints - apiEndpoint: "https://api2.cursor.sh", - chatEndpoint: "/aiserver.v1.ChatService/StreamUnifiedChatWithTools", - modelsEndpoint: "/aiserver.v1.AiService/GetDefaultModelNudgeData", - // Additional endpoints - api3Endpoint: "https://api3.cursor.sh", // Telemetry - agentEndpoint: "https://agent.api5.cursor.sh", // Privacy mode - agentNonPrivacyEndpoint: "https://agentn.api5.cursor.sh", // Non-privacy mode - // Client metadata - clientVersion: "3.1.0", - clientType: "ide", - // Token storage locations (for user reference) + ...PROVIDER_OAUTH["cursor"], tokenStoragePaths: { linux: "~/.config/Cursor/User/globalStorage/state.vscdb", macos: "/Users//Library/Application Support/Cursor/User/globalStorage/state.vscdb", windows: "%APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb", }, - // Database keys - dbKeys: { - accessToken: "cursorAuth/accessToken", - machineId: "storage.serviceMachineId", - }, }; // Kimi Coding OAuth Configuration (Device Code Flow) +// clientId uses env override — dynamic, not stored in registry export const KIMI_CODING_CONFIG = { - clientId: process.env.KIMI_CODING_OAUTH_CLIENT_ID || "17e5f671-d194-4dfb-9706-5516cb48c098", - deviceCodeUrl: "https://auth.kimi.com/api/oauth/device_authorization", - tokenUrl: "https://auth.kimi.com/api/oauth/token", + ...PROVIDER_OAUTH["kimi-coding"], + clientId: process.env.KIMI_CODING_OAUTH_CLIENT_ID || REGISTRY_PROVIDERS["kimi-coding"]?.clientId, }; // KiloCode OAuth Configuration (Custom Device Auth Flow) -export const KILOCODE_CONFIG = { - apiBaseUrl: "https://api.kilo.ai", - initiateUrl: "https://api.kilo.ai/api/device-auth/codes", - pollUrlBase: "https://api.kilo.ai/api/device-auth/codes", -}; +export const KILOCODE_CONFIG = { ...PROVIDER_OAUTH["kilocode"] }; // Cline OAuth Configuration (Local Callback Flow via app.cline.bot) -export const CLINE_CONFIG = { - 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", -}; +export const CLINE_CONFIG = { ...PROVIDER_OAUTH["cline"] }; // GitLab Duo OAuth Configuration (Authorization Code Flow with PKCE) -// Supports both OAuth (PKCE) and Personal Access Token (PAT) modes -export const GITLAB_CONFIG = { - defaultBaseUrl: "https://gitlab.com", - authorizeUrlPath: "/oauth/authorize", - tokenUrlPath: "/oauth/token", - userInfoUrlPath: "/api/v4/user", - scope: "api read_user", - codeChallengeMethod: "S256", -}; +export const GITLAB_CONFIG = { ...PROVIDER_OAUTH["gitlab"] }; // CodeBuddy (Tencent) OAuth Configuration (Browser OAuth Polling Flow) -// Step 1: POST /v2/plugin/auth/state?platform=CLI → get { state, authUrl } -// Step 2: Open authUrl in browser -// Step 3: Poll POST /v2/plugin/auth/token with state until success -export const CODEBUDDY_CONFIG = { - 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, -}; +export const CODEBUDDY_CONFIG = { ...PROVIDER_OAUTH["codebuddy-cn"] }; // OAuth timeout (5 minutes) export const OAUTH_TIMEOUT = 300000; @@ -277,5 +128,5 @@ export const PROVIDERS = { KILOCODE: "kilocode", CLINE: "cline", GITLAB: "gitlab", - CODEBUDDY: "codebuddy", + CODEBUDDY: "codebuddy-cn", }; diff --git a/src/lib/oauth/constants/xai.js b/src/lib/oauth/constants/xai.js index 9e45b123..4bb22e3e 100644 --- a/src/lib/oauth/constants/xai.js +++ b/src/lib/oauth/constants/xai.js @@ -4,9 +4,10 @@ * Source of truth: router-for-me/CLIProxyAPI internal/auth/xai/types.go * Mirrors the upstream Go constants 1:1. */ +import { PROVIDERS } from "open-sse/providers/index.js"; -// xAI client_id for OAuth (PKCE public client) -export const XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"; +// xAI client_id for OAuth (PKCE public client) — single source: registry xai.transport +export const XAI_CLIENT_ID = PROVIDERS["xai"]?.clientId; // OAuth issuer + endpoints export const XAI_ISSUER = "https://auth.x.ai"; diff --git a/src/lib/oauth/kiroExternalIdp.js b/src/lib/oauth/kiroExternalIdp.js new file mode 100644 index 00000000..d07fb46f --- /dev/null +++ b/src/lib/oauth/kiroExternalIdp.js @@ -0,0 +1,155 @@ +const MICROSOFT_TOKEN_ENDPOINT_HOSTS = new Set([ + "login.microsoftonline.com", + "login.microsoft.com", + "login.windows.net", +]); + +const DEFAULT_REGION = "us-east-1"; +const DEFAULT_EXPIRES_IN = 3600; + +function normalizeString(value) { + return typeof value === "string" ? value.trim() : ""; +} + +export function validateMicrosoftTokenEndpoint(rawEndpoint) { + const tokenEndpoint = normalizeString(rawEndpoint); + if (!tokenEndpoint) throw new Error("token_endpoint is required"); + + let parsed; + try { + parsed = new URL(tokenEndpoint); + } catch { + throw new Error("token_endpoint must be a valid URL"); + } + + if (parsed.protocol !== "https:") { + throw new Error("token_endpoint must use https"); + } + + const host = parsed.hostname.toLowerCase(); + if (!MICROSOFT_TOKEN_ENDPOINT_HOSTS.has(host)) { + throw new Error("token_endpoint must be a Microsoft login endpoint"); + } + + return parsed.toString(); +} + +export function normalizeScope(scopes) { + if (Array.isArray(scopes)) { + return scopes.map(normalizeString).filter(Boolean).join(" "); + } + return normalizeString(scopes); +} + +export function decodeJwtPayload(jwt) { + try { + if (!jwt || typeof jwt !== "string") return null; + const parts = jwt.split("."); + if (parts.length !== 3) return null; + const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padding = (4 - (base64.length % 4)) % 4; + return JSON.parse(Buffer.from(`${base64}${"=".repeat(padding)}`, "base64").toString("utf8")); + } catch { + return null; + } +} + +function resolveExpiresAt(input) { + const explicit = input.expired || input.expires_at || input.expiresAt; + if (explicit) { + const ms = new Date(explicit).getTime(); + if (Number.isFinite(ms)) return new Date(ms).toISOString(); + } + + const expiresIn = Number(input.expires_in || input.expiresIn || 0); + if (Number.isFinite(expiresIn) && expiresIn > 0) { + return new Date(Date.now() + expiresIn * 1000).toISOString(); + } + + const payload = decodeJwtPayload(input.access_token || input.accessToken); + if (payload?.exp) { + return new Date(payload.exp * 1000).toISOString(); + } + + return new Date(Date.now() + DEFAULT_EXPIRES_IN * 1000).toISOString(); +} + +export function normalizeKiroExternalIdpAuth(rawAuth) { + let input = rawAuth; + if (typeof input === "string") { + try { + input = JSON.parse(input); + } catch { + throw new Error("CLIProxyAPI auth JSON is invalid"); + } + } + + if (!input || typeof input !== "object") { + throw new Error("CLIProxyAPI auth JSON is required"); + } + + const authMethod = normalizeString(input.auth_method || input.authMethod); + if (authMethod && authMethod !== "external_idp") { + throw new Error("Only external_idp Kiro auth is supported by this importer"); + } + + const accessToken = normalizeString(input.access_token || input.accessToken); + const refreshToken = normalizeString(input.refresh_token || input.refreshToken); + const clientId = normalizeString(input.client_id || input.clientId); + const tokenEndpoint = validateMicrosoftTokenEndpoint(input.token_endpoint || input.tokenEndpoint); + const profileArn = normalizeString(input.profile_arn || input.profileArn); + const region = normalizeString(input.region) || DEFAULT_REGION; + const scope = normalizeScope(input.scopes || input.scope); + + if (!accessToken) throw new Error("access_token is required"); + if (!refreshToken) throw new Error("refresh_token is required"); + if (!clientId) throw new Error("client_id is required"); + if (!scope) throw new Error("scopes is required"); + if (!profileArn) throw new Error("profile_arn is required"); + + const payload = decodeJwtPayload(accessToken); + const email = input.email || payload?.email || payload?.preferred_username || payload?.upn || payload?.sub || null; + + return { + accessToken, + refreshToken, + expiresAt: resolveExpiresAt(input), + email, + providerSpecificData: { + profileArn, + region, + authMethod: "external_idp", + provider: "CLIProxyAPI", + clientId, + tokenEndpoint, + scope, + }, + }; +} + +export function buildExternalIdpRefreshParams(refreshToken, providerSpecificData = {}) { + const clientId = normalizeString(providerSpecificData.clientId || providerSpecificData.client_id); + const tokenEndpoint = validateMicrosoftTokenEndpoint(providerSpecificData.tokenEndpoint || providerSpecificData.token_endpoint); + const scope = normalizeScope(providerSpecificData.scope || providerSpecificData.scopes); + + if (!refreshToken) throw new Error("refresh token is required"); + if (!clientId) throw new Error("clientId is required for external_idp refresh"); + if (!scope) throw new Error("scope is required for external_idp refresh"); + + return { + tokenEndpoint, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: clientId, + refresh_token: refreshToken, + scope, + }), + providerSpecificData: { + ...providerSpecificData, + authMethod: "external_idp", + clientId, + tokenEndpoint, + scope, + }, + }; +} diff --git a/src/lib/oauth/providerHelpers.js b/src/lib/oauth/providerHelpers.js new file mode 100644 index 00000000..0cb46933 --- /dev/null +++ b/src/lib/oauth/providerHelpers.js @@ -0,0 +1,90 @@ +const BASE64_BLOCK_SIZE = 4; + +function validateXaiOAuthEndpoint(rawUrl, field) { + const value = String(rawUrl || "").trim(); + if (!value) throw new Error(`xai discovery ${field} is empty`); + let parsed; + try { parsed = new URL(value); } catch (err) { + throw new Error(`xai discovery ${field} is invalid: ${err.message}`); + } + if (parsed.protocol !== "https:") throw new Error(`xai discovery ${field} must use https: ${value}`); + const host = parsed.hostname.toLowerCase().trim(); + if (host !== "x.ai" && !host.endsWith(".x.ai")) { + throw new Error(`xai discovery ${field} host ${host} is not on x.ai`); + } + return value; +} + +function decodeXaiIdTokenEmail(idToken) { + if (!idToken || typeof idToken !== "string") return undefined; + const parts = idToken.split("."); + if (parts.length !== 3) return undefined; + try { + const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padding = (BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE; + const json = Buffer.from(base64 + "=".repeat(padding), "base64").toString("utf8"); + const payload = JSON.parse(json); + return payload.email || payload.preferred_username || payload.sub || undefined; + } catch { + return undefined; + } +} + +function decodeJwtPayload(jwt) { + try { + if (!jwt || typeof jwt !== "string") return null; + const parts = jwt.split("."); + if (parts.length !== 3) return null; + const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const missingPadding = (BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE; + const padded = base64 + "=".repeat(missingPadding); + return JSON.parse(Buffer.from(padded, "base64").toString("utf8")); + } catch { + return null; + } +} + +function extractEmailFromAccessToken(accessToken) { + const payload = decodeJwtPayload(accessToken); + if (!payload) return undefined; + return payload.email || payload.preferred_username || payload.sub || undefined; +} + +export async function fetchKiroProfileArn(accessToken) { + if (!accessToken) return null; + try { + const response = await fetch("https://codewhisperer.us-east-1.amazonaws.com/ListAvailableProfiles", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ maxResults: 10 }), + }); + if (!response.ok) return null; + const data = await response.json(); + return data.profiles?.find((p) => p.arn?.trim())?.arn?.trim() || null; + } catch { + return null; + } +} + +export function extractCodexAccountInfo(idToken) { + const payload = decodeJwtPayload(idToken); + if (!payload) return {}; + const chatgpt = payload["https://api.openai.com/auth"] || {}; + return { + email: payload.email, + chatgptAccountId: chatgpt.chatgpt_account_id || payload.account_id, + chatgptPlanType: chatgpt.chatgpt_plan_type || payload.plan_type, + }; +} + +export { + BASE64_BLOCK_SIZE, + validateXaiOAuthEndpoint, + decodeXaiIdTokenEmail, + decodeJwtPayload, + extractEmailFromAccessToken, +}; diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index 956a53d5..c66d25bb 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -18,6 +18,7 @@ import { ANTIGRAVITY_CONFIG, GITHUB_CONFIG, KIRO_CONFIG, + assertValidAwsRegion, CURSOR_CONFIG, KIMI_CODING_CONFIG, KILOCODE_CONFIG, @@ -27,25 +28,19 @@ import { getOAuthClientMetadata, } from "./constants/oauth"; import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "./constants/xai"; +import { + validateXaiOAuthEndpoint, + decodeXaiIdTokenEmail, + extractEmailFromAccessToken, + extractCodexAccountInfo, + fetchKiroProfileArn, +} from "./providerHelpers"; + +export { extractCodexAccountInfo, fetchKiroProfileArn }; // Inlined from services/xai.js to keep web route bundle free of `open` (CLI-only) package let cachedXaiDiscovery = null; -function validateXaiOAuthEndpoint(rawUrl, field) { - const value = String(rawUrl || "").trim(); - if (!value) throw new Error(`xai discovery ${field} is empty`); - let parsed; - try { parsed = new URL(value); } catch (err) { - throw new Error(`xai discovery ${field} is invalid: ${err.message}`); - } - if (parsed.protocol !== "https:") throw new Error(`xai discovery ${field} must use https: ${value}`); - const host = parsed.hostname.toLowerCase().trim(); - if (host !== "x.ai" && !host.endsWith(".x.ai")) { - throw new Error(`xai discovery ${field} host ${host} is not on x.ai`); - } - return value; -} - async function discoverXaiEndpoints() { if (cachedXaiDiscovery) return cachedXaiDiscovery; try { @@ -63,81 +58,6 @@ async function discoverXaiEndpoints() { return cachedXaiDiscovery; } -function decodeXaiIdTokenEmail(idToken) { - if (!idToken || typeof idToken !== "string") return undefined; - const parts = idToken.split("."); - if (parts.length !== 3) return undefined; - try { - const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); - const padding = (BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE; - const json = Buffer.from(base64 + "=".repeat(padding), "base64").toString("utf8"); - const payload = JSON.parse(json); - return payload.email || payload.preferred_username || payload.sub || undefined; - } catch { - return undefined; - } -} - -const BASE64_BLOCK_SIZE = 4; - -/** - * Decode JWT access token and extract a stable account identifier for display/upsert. - * @param {string} accessToken - * @returns {string|undefined} - */ -function decodeJwtPayload(jwt) { - try { - if (!jwt || typeof jwt !== "string") return null; - const parts = jwt.split("."); - if (parts.length !== 3) return null; - const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); - const missingPadding = (BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE; - const padded = base64 + "=".repeat(missingPadding); - return JSON.parse(Buffer.from(padded, "base64").toString("utf8")); - } catch { - return null; - } -} - -function extractEmailFromAccessToken(accessToken) { - const payload = decodeJwtPayload(accessToken); - if (!payload) return undefined; - return payload.email || payload.preferred_username || payload.sub || undefined; -} - -// Resolve Kiro profileArn via CodeWhisperer (IDC/Builder-ID tokens omit it, causing 403) -export async function fetchKiroProfileArn(accessToken) { - if (!accessToken) return null; - try { - const response = await fetch("https://codewhisperer.us-east-1.amazonaws.com/ListAvailableProfiles", { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ maxResults: 10 }), - }); - if (!response.ok) return null; - const data = await response.json(); - return data.profiles?.find((p) => p.arn?.trim())?.arn?.trim() || null; - } catch { - return null; - } -} - -// Extract codex account info from id_token or access token -export function extractCodexAccountInfo(idToken) { - const payload = decodeJwtPayload(idToken); - if (!payload) return {}; - const chatgpt = payload["https://api.openai.com/auth"] || {}; - return { - email: payload.email, - chatgptAccountId: chatgpt.chatgpt_account_id || payload.account_id, - chatgptPlanType: chatgpt.chatgpt_plan_type || payload.plan_type, - }; -} - // Provider configurations const PROVIDERS = { claude: { @@ -200,8 +120,8 @@ const PROVIDERS = { codex: { config: CODEX_CONFIG, flowType: "authorization_code_pkce", - fixedPort: 1455, - callbackPath: "/auth/callback", + fixedPort: CODEX_CONFIG.fixedPort, + callbackPath: CODEX_CONFIG.callbackPath, buildAuthUrl: (config, redirectUri, state, codeChallenge) => { const params = { response_type: "code", @@ -873,6 +793,7 @@ const PROVIDERS = { requestDeviceCode: async (config, codeChallenge, options = {}) => { const trimmedRegion = typeof options.region === "string" ? options.region.trim() : ""; const region = trimmedRegion || "us-east-1"; + assertValidAwsRegion(region); const trimmedStartUrl = typeof options.startUrl === "string" ? options.startUrl.trim() : ""; const startUrl = trimmedStartUrl || config.startUrl; const authMethod = options.authMethod === "idc" ? "idc" : "builder-id"; @@ -941,6 +862,7 @@ const PROVIDERS = { }, pollToken: async (config, deviceCode, codeVerifier, extraData) => { const region = extraData?._region || "us-east-1"; + assertValidAwsRegion(region); const tokenUrl = `https://oidc.${region}.amazonaws.com/token`; const response = await fetch(tokenUrl, { method: "POST", @@ -1258,7 +1180,7 @@ const PROVIDERS = { // 1. POST stateUrl → get { state, authUrl } // 2. Open authUrl in browser // 3. Poll tokenUrl with state until success (code 0) or timeout - codebuddy: { + "codebuddy-cn": { config: CODEBUDDY_CONFIG, flowType: "device_code", requestDeviceCode: async (config) => { @@ -1290,23 +1212,25 @@ const PROVIDERS = { }; }, pollToken: async (config, deviceCode) => { - const response = await fetch(config.tokenUrl, { - method: "POST", + // CodeBuddy polls the token endpoint via GET with the state as a query + // param (not POST/body) — matches the official CLI's /v2/plugin/auth/token?state=... + const response = await fetch(`${config.tokenUrl}?state=${encodeURIComponent(deviceCode)}`, { + method: "GET", headers: { - "Content-Type": "application/json", Accept: "application/json", "User-Agent": config.userAgent, "X-Requested-With": "XMLHttpRequest", "X-Domain": "copilot.tencent.com", "X-No-Authorization": "true", "X-No-User-Id": "true", + "X-No-Enterprise-Id": "true", + "X-No-Department-Info": "true", "X-Product": "SaaS", }, - body: JSON.stringify({ state: deviceCode }), }); if (!response.ok) return { ok: false, data: { error: "request_failed" } }; const data = await response.json(); - // code 11217 = pending, code 0 = success + // code 11217 = pending (RetryFetchToken), code 0 = success if (data.code === 0 && data.data?.accessToken) { return { ok: true, @@ -1314,6 +1238,7 @@ const PROVIDERS = { access_token: data.data.accessToken, refresh_token: data.data.refreshToken || "", token_type: data.data.tokenType || "Bearer", + expires_in: data.data.expiresIn, }, }; } @@ -1323,7 +1248,7 @@ const PROVIDERS = { mapTokens: (tokens) => ({ accessToken: tokens.access_token, refreshToken: tokens.refresh_token, - expiresIn: 86400, + expiresIn: tokens.expires_in || 86400, providerSpecificData: {}, }), }, diff --git a/src/lib/oauth/services/codex.js b/src/lib/oauth/services/codex.js index 72417aa6..fafa3c6a 100644 --- a/src/lib/oauth/services/codex.js +++ b/src/lib/oauth/services/codex.js @@ -76,7 +76,7 @@ export class CodexService extends OAuthService { spinner.text = "Starting local server..."; // Start local server for callback (use fixed port 1455 like real Codex CLI) - const fixedPort = 1455; + const fixedPort = CODEX_CONFIG.fixedPort; let callbackParams = null; const { port, close } = await startLocalServer((params) => { callbackParams = params; diff --git a/src/lib/oauth/services/kiro.js b/src/lib/oauth/services/kiro.js index 3c8f9905..a739661e 100644 --- a/src/lib/oauth/services/kiro.js +++ b/src/lib/oauth/services/kiro.js @@ -1,4 +1,4 @@ -import { KIRO_CONFIG } from "../constants/oauth.js"; +import { KIRO_CONFIG, assertValidAwsRegion } from "../constants/oauth.js"; /** * Kiro OAuth Service @@ -17,6 +17,7 @@ export class KiroService { * Returns clientId and clientSecret for device code flow */ async registerClient(region = "us-east-1") { + assertValidAwsRegion(region); const endpoint = `https://oidc.${region}.amazonaws.com/client/register`; const response = await fetch(endpoint, { @@ -50,6 +51,7 @@ export class KiroService { * Start device authorization for AWS Builder ID or IDC */ async startDeviceAuthorization(clientId, clientSecret, startUrl, region = "us-east-1") { + assertValidAwsRegion(region); const endpoint = `https://oidc.${region}.amazonaws.com/device_authorization`; const response = await fetch(endpoint, { @@ -84,6 +86,7 @@ export class KiroService { * Poll for token using device code (AWS Builder ID/IDC) */ async pollDeviceToken(clientId, clientSecret, deviceCode, region = "us-east-1") { + assertValidAwsRegion(region); const endpoint = `https://oidc.${region}.amazonaws.com/token`; const response = await fetch(endpoint, { @@ -176,7 +179,9 @@ export class KiroService { // AWS SSO OIDC refresh (Builder ID or IDC) if (clientId && clientSecret) { - const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`; + const safeRegion = region || "us-east-1"; + assertValidAwsRegion(safeRegion); + const endpoint = `https://oidc.${safeRegion}.amazonaws.com/token`; const response = await fetch(endpoint, { method: "POST", @@ -254,6 +259,68 @@ export class KiroService { } } + /** + * List available CodeWhisperer profiles for a token (or API key) and return + * the best-matching profileArn. AWS SSO OIDC logins return no profileArn, so + * it must be fetched separately — the same call works for API-key auth. + * Accepts both `arn` and `profileArn` response field names (the API-key + * JSON-1.0 surface returns `arn`). + */ + async listAvailableProfiles(accessToken, region = "us-east-1") { + assertValidAwsRegion(region); + const endpoint = `https://codewhisperer.${region}.amazonaws.com`; + + const response = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-amz-json-1.0", + "x-amz-target": "AmazonCodeWhispererService.ListAvailableProfiles", + "Authorization": `Bearer ${accessToken}`, + "Accept": "application/json", + }, + body: JSON.stringify({ maxResults: 10 }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to list profiles: ${error}`); + } + + const data = await response.json(); + const profiles = Array.isArray(data?.profiles) ? data.profiles : []; + const arnOf = (p) => p?.arn || p?.profileArn || null; + const match = profiles.find((p) => arnOf(p)?.split(":")[3] === region) || profiles[0]; + return arnOf(match); + } + + /** + * Validate an API-key credential by listing profiles with it. API keys are + * long-lived bearer tokens (no refresh), so the only way to validate one is + * to make an authenticated CodeWhisperer call. Returns a credential object + * ready to persist as a "kiro" connection with authMethod="api_key". + */ + async validateApiKey(apiKey, region = "us-east-1") { + if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) { + throw new Error("API key is required"); + } + const trimmed = apiKey.trim(); + + let profileArn = null; + try { + profileArn = await this.listAvailableProfiles(trimmed, region); + } catch (error) { + throw new Error(`API key validation failed: ${error.message}`); + } + + return { + accessToken: trimmed, + refreshToken: null, + profileArn, + region, + authMethod: "api_key", + }; + } + /** * List available models from CodeWhisperer API */ diff --git a/src/lib/oauth/utils/server.js b/src/lib/oauth/utils/server.js index ff5400a9..b11a9a44 100644 --- a/src/lib/oauth/utils/server.js +++ b/src/lib/oauth/utils/server.js @@ -1,5 +1,6 @@ import http from "http"; import { URL } from "url"; +import { CODEX_CONFIG } from "../constants/oauth.js"; /** * Start a local HTTP server to receive OAuth callback @@ -119,7 +120,7 @@ let codexProxyServer = null; let codexProxyTimeout = null; const CODEX_PROXY_TIMEOUT_MS = 300000; // 5 minutes -const CODEX_PORT = 1455; +const CODEX_PORT = CODEX_CONFIG.fixedPort; // Pending exchange sessions keyed by state — used by server-side exchange mode const pendingExchanges = new Map(); @@ -153,14 +154,24 @@ export function clearCodexSession(state) { pendingExchanges.delete(state); } +function escapeHtml(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + function renderCodexResultPage(success, message) { const color = success ? "#22c55e" : "#ef4444"; const icon = success ? "✓" : "✗"; const title = success ? "Authentication Successful" : "Authentication Failed"; + const safeMessage = escapeHtml(message); return ` ${title} -
${icon}

${title}

${message}

Closing in 3s...

+
${icon}

${title}

${safeMessage}

Closing in 3s...

`; } diff --git a/src/lib/qoder/constants.js b/src/lib/qoder/constants.js index 1d9ce303..bd1fc983 100644 --- a/src/lib/qoder/constants.js +++ b/src/lib/qoder/constants.js @@ -1,64 +1,2 @@ -/** - * Qoder API constants ported from CLIProxyAPIPlus qoder-provider branch. - * - * Endpoint set: - * openapi.qoder.sh - device flow + userinfo + quota usage - * center.qoder.sh - token refresh (best-effort, currently 403 for device tokens) - * api3.qoder.sh - inference (chat) + model list, requires COSY signing - * qoder.com/device - browser landing page for device authorization - */ - -export const QODER_OPENAPI_BASE = "https://openapi.qoder.sh"; -export const QODER_CENTER_BASE = "https://center.qoder.sh"; -export const QODER_CHAT_BASE = "https://api3.qoder.sh"; - -export const QODER_LOGIN_URL = "https://qoder.com/device/selectAccounts"; - -// Device flow endpoints -export const QODER_DEVICE_TOKEN_URL = `${QODER_OPENAPI_BASE}/api/v1/deviceToken/poll`; -export const QODER_USERINFO_URL = `${QODER_OPENAPI_BASE}/api/v1/userinfo`; -export const QODER_QUOTA_USAGE_URL = `${QODER_OPENAPI_BASE}/api/v2/quota/usage`; -export const QODER_REFRESH_TOKEN_URL = `${QODER_CENTER_BASE}/algo/api/v3/user/refresh_token`; - -// Inference endpoints (under /algo on api3.qoder.sh, all COSY-signed) -export const QODER_CHAT_SIG_PATH = "/api/v2/service/pro/sse/agent_chat_generation"; -export const QODER_CHAT_URL = `${QODER_CHAT_BASE}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common`; -export const QODER_CHAT_URL_ENCODED = `${QODER_CHAT_URL}&Encode=1`; -export const QODER_MODEL_LIST_URL = `${QODER_CHAT_BASE}/algo/api/v2/model/list`; - -// COSY header constants. These are not arbitrary — the upstream signature -// validation matches them against the values used at signing time. -export const QODER_IDE_VERSION = "1.0.0"; -export const QODER_CLIENT_TYPE = "5"; -export const QODER_DATA_POLICY = "disagree"; -export const QODER_LOGIN_VERSION = "v2"; -export const QODER_MACHINE_OS = "x86_64_windows"; -export const QODER_MACHINE_TYPE = "5"; - -// Canonical model identifiers. Identity map — keep as a map so callers can -// cheaply test "is this a known qoder model?" before sending the request. -export const QODER_MODEL_MAP = { - // Tier models - auto: "auto", - ultimate: "ultimate", - performance: "performance", - efficient: "efficient", - lite: "lite", - // Frontier models - qmodel: "qmodel", - qmodel_latest: "qmodel_latest", - dmodel: "dmodel", - dfmodel: "dfmodel", - gm51model: "gm51model", - kmodel: "kmodel", - mmodel: "mmodel", -}; - -// RSA public key for COSY encryption (extracted from Qoder IDE v0.9). -// Matches the CLIProxyAPIPlus branch and live qodercli traffic. -export const QODER_RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- -MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc -4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l -6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17 -XcW+ML9FoCI6AOvOzwIDAQAB ------END PUBLIC KEY-----`; +// Re-export: qoder constants moved to open-sse/shared/qoder (open-sse self-contained, docs 00 §1b). +export * from "../../../open-sse/shared/qoder/constants.js"; diff --git a/src/lib/qoder/cosy.js b/src/lib/qoder/cosy.js index d5d59af7..02b9c336 100644 --- a/src/lib/qoder/cosy.js +++ b/src/lib/qoder/cosy.js @@ -1,175 +1,2 @@ -/** - * Qoder COSY (hybrid RSA+AES+MD5) signing, ported from CLIProxyAPIPlus - * qoder-provider branch (internal/auth/qoder/cosy.go). - * - * Every signed request carries: - * - an AES-128-CBC payload of the user info, the AES key wrapped in RSA - * - an MD5 signature over `payload || cosyKey || timestamp || body || sigPath` - * - the body's MD5 hash + length so the server can validate integrity - * - 17 Cosy-* / X-* headers fingerprinting the client (machine id, IDE - * version, organization id, etc.) - * - * The on-the-wire header keys use the same casing as qodercli: - * Cosy-Machineid, not Cosy-MachineID. - */ - -import crypto from "crypto"; -import { v4 as uuidv4 } from "uuid"; - -import { - QODER_CLIENT_TYPE, - QODER_DATA_POLICY, - QODER_IDE_VERSION, - QODER_LOGIN_VERSION, - QODER_MACHINE_OS, - QODER_MACHINE_TYPE, - QODER_RSA_PUBLIC_KEY, -} from "./constants.js"; - -// AES-128 wants a 16-byte key. Match qodercli/Veria: take the first 16 chars -// of a fresh UUID's canonical string (hyphens included). The key is fresh -// per request so even though the IV reuses the key bytes, each request still -// has a unique IV. -function generateAesKey() { - return uuidv4().slice(0, 16); -} - -function pkcs7Pad(data, blockSize) { - const padding = blockSize - (data.length % blockSize); - const padded = Buffer.alloc(data.length + padding, padding); - data.copy(padded, 0); - return padded; -} - -function aesEncryptCbcBase64(plaintext, keyStr) { - const keyBytes = Buffer.from(keyStr, "utf8"); - if (keyBytes.length !== 16) { - throw new Error(`aes key must be 16 bytes, got ${keyBytes.length}`); - } - const iv = keyBytes.subarray(0, 16); - const cipher = crypto.createCipheriv("aes-128-cbc", keyBytes, iv); - cipher.setAutoPadding(false); - const padded = pkcs7Pad(Buffer.from(plaintext, "utf8"), 16); - const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]); - return encrypted.toString("base64"); -} - -function rsaEncryptBase64(data) { - const encrypted = crypto.publicEncrypt( - { key: QODER_RSA_PUBLIC_KEY, padding: crypto.constants.RSA_PKCS1_PADDING }, - Buffer.from(data, "utf8"), - ); - return encrypted.toString("base64"); -} - -function encryptUserInfo(userInfo) { - const aesKey = generateAesKey(); - const plaintext = JSON.stringify(userInfo); - const infoB64 = aesEncryptCbcBase64(plaintext, aesKey); - const cosyKeyB64 = rsaEncryptBase64(aesKey); - return { cosyKey: cosyKeyB64, info: infoB64 }; -} - -function md5Hex(input) { - return crypto.createHash("md5").update(input).digest("hex"); -} - -/** - * Strip the leading "/algo" prefix from the request path. Matches qodercli - * convention. Empty input returns "". - */ -function computeSigPath(requestUrl) { - let pathname; - try { - pathname = new URL(requestUrl).pathname || ""; - } catch { - return ""; - } - if (pathname.startsWith("/algo")) { - return pathname.slice("/algo".length); - } - return pathname; -} - -/** - * Generate a fresh machine UUID. Persisted on the connection record so - * every request from the same auth carries the same machineId. - */ -export function generateMachineId() { - return uuidv4(); -} - -/** - * Build the full Cosy-* header set for a single Qoder request. - * - * @param {Buffer|Uint8Array|string} body The exact bytes that will be sent. - * For GET requests pass an empty Buffer / "". - * @param {string} requestUrl Full request URL (used for sigPath). - * @param {object} creds - * @param {string} creds.userId Stable Qoder user id. - * @param {string} creds.authToken Device access token (`dt-...`). - * @param {string} [creds.name] Display name (optional). - * @param {string} [creds.email] Email (optional, can be empty). - * @param {string} [creds.machineId] Persisted machine UUID. - * @returns {Record} Header map ready to merge onto fetch(). - */ -export function buildCosyHeaders(body, requestUrl, creds) { - if (!creds?.userId) throw new Error("cosy: user id is empty"); - if (!creds?.authToken) throw new Error("cosy: auth token is empty"); - - const bodyBuf = Buffer.isBuffer(body) - ? body - : typeof body === "string" - ? Buffer.from(body, "latin1") - : Buffer.from(body || []); - - const { cosyKey, info } = encryptUserInfo({ - uid: creds.userId, - security_oauth_token: creds.authToken, - name: creds.name || "", - aid: "", - email: creds.email || "", - }); - - const timestamp = String(Math.floor(Date.now() / 1000)); - const requestId = uuidv4(); - - const payloadJson = JSON.stringify({ - version: "v1", - requestId, - info, - cosyVersion: QODER_IDE_VERSION, - ideVersion: "", - }); - const payloadB64 = Buffer.from(payloadJson, "utf8").toString("base64"); - - const sigPath = computeSigPath(requestUrl); - const sigInput = `${payloadB64}\n${cosyKey}\n${timestamp}\n${bodyBuf.toString("latin1")}\n${sigPath}`; - const sig = md5Hex(Buffer.from(sigInput, "latin1")); - - const machineId = creds.machineId || generateMachineId(); - const bodyHash = md5Hex(bodyBuf); - const bodyLength = String(bodyBuf.length); - - return { - Authorization: `Bearer COSY.${payloadB64}.${sig}`, - "Cosy-Key": cosyKey, - "Cosy-User": creds.userId, - "Cosy-Date": timestamp, - "Cosy-Version": QODER_IDE_VERSION, - "Cosy-Machineid": machineId, - "Cosy-Machinetoken": machineId, - "Cosy-Machinetype": QODER_MACHINE_TYPE, - "Cosy-Machineos": QODER_MACHINE_OS, - "Cosy-Clienttype": QODER_CLIENT_TYPE, - "Cosy-Clientip": "127.0.0.1", - "Cosy-Bodyhash": bodyHash, - "Cosy-Bodylength": bodyLength, - "Cosy-Sigpath": sigPath, - "Cosy-Data-Policy": QODER_DATA_POLICY, - "Cosy-Organization-Id": "", - "Cosy-Organization-Tags": "", - "Login-Version": QODER_LOGIN_VERSION, - "X-Request-Id": uuidv4(), - }; -} +// Re-export: qoder cosy helpers moved to open-sse/shared/qoder (docs 00 §1b). +export * from "../../../open-sse/shared/qoder/cosy.js"; diff --git a/src/lib/qoder/encoding.js b/src/lib/qoder/encoding.js index 31449e85..a96fb9ad 100644 --- a/src/lib/qoder/encoding.js +++ b/src/lib/qoder/encoding.js @@ -1,55 +1,2 @@ -/** - * Qoder body encoding ported from qoder2api's QoderEncoding.java (via the - * CLIProxyAPIPlus qoder-provider branch). - * - * Algorithm: - * 1. base64-encode the plaintext bytes (standard alphabet). - * 2. Rearrange: split into thirds, reorder as [tail][mid][head]. - * 3. Substitute each character via a custom alphabet mapping. - * - * The encoded body must be sent with `&Encode=1` appended to the URL so the - * server decodes in reverse. The obfuscation prevents Alibaba Cloud WAF from - * pattern-matching the plaintext request body. - */ - -const QODER_STD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; -const QODER_CUSTOM_ALPHABET = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!"; - -const QODER_S2C = (() => { - const table = new Int16Array(128).fill(-1); - for (let i = 0; i < 64; i++) { - table[QODER_STD_ALPHABET.charCodeAt(i)] = QODER_CUSTOM_ALPHABET.charCodeAt(i); - } - table["=".charCodeAt(0)] = "$".charCodeAt(0); - return table; -})(); - -/** - * Encode plaintext bytes/string using Qoder's WAF-bypass scheme. - * @param {Buffer|Uint8Array|string} plaintext - * @returns {string} encoded string - */ -export function qoderEncodeBody(plaintext) { - const buf = Buffer.isBuffer(plaintext) - ? plaintext - : typeof plaintext === "string" - ? Buffer.from(plaintext, "utf8") - : Buffer.from(plaintext); - - const std = buf.toString("base64"); - const n = std.length; - const a = Math.floor(n / 3); - // [tail][mid][head] - const rearranged = std.slice(n - a) + std.slice(a, n - a) + std.slice(0, a); - - const out = Buffer.alloc(n); - for (let i = 0; i < n; i++) { - const c = rearranged.charCodeAt(i); - if (c < 128 && QODER_S2C[c] >= 0) { - out[i] = QODER_S2C[c]; - } else { - out[i] = c; - } - } - return out.toString("latin1"); -} +// Re-export: qoder encoding moved to open-sse/shared/qoder (docs 00 §1b). +export * from "../../../open-sse/shared/qoder/encoding.js"; diff --git a/src/lib/usage/fetcher.js b/src/lib/usage/fetcher.js deleted file mode 100644 index d24acc97..00000000 --- a/src/lib/usage/fetcher.js +++ /dev/null @@ -1,208 +0,0 @@ -/** - * Usage Fetcher - Get usage data from provider APIs - */ - -import { GITHUB_CONFIG, GEMINI_CONFIG, ANTIGRAVITY_CONFIG } from "@/lib/oauth/constants/oauth"; - -/** - * Get usage data for a provider connection - * @param {Object} connection - Provider connection with accessToken - * @returns {Object} Usage data with quotas - */ -export async function getUsageForProvider(connection) { - const { provider, accessToken, providerSpecificData } = connection; - - switch (provider) { - case "github": - return await getGitHubUsage(accessToken, providerSpecificData); - case "gemini-cli": - return await getGeminiUsage(accessToken); - case "antigravity": - return await getAntigravityUsage(accessToken); - case "claude": - return await getClaudeUsage(accessToken); - case "codex": - return await getCodexUsage(accessToken); - case "qwen": - return await getQwenUsage(accessToken, providerSpecificData); - case "iflow": - return await getIflowUsage(accessToken); - default: - return { message: `Usage API not implemented for ${provider}` }; - } -} - -/** - * GitHub Copilot Usage - */ -async function getGitHubUsage(accessToken, providerSpecificData) { - try { - // Use copilotToken for copilot_internal API, not GitHub OAuth accessToken - const copilotToken = providerSpecificData?.copilotToken; - if (!copilotToken) { - throw new Error("Copilot token not found. Please refresh token first."); - } - - const response = await fetch("https://api.github.com/copilot_internal/user", { - headers: { - Authorization: `Bearer ${copilotToken}`, - Accept: "application/json", - "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, - "User-Agent": GITHUB_CONFIG.userAgent, - }, - }); - - if (!response.ok) { - const error = await response.text(); - throw new Error(`GitHub API error: ${error}`); - } - - const data = await response.json(); - - // Handle different response formats (paid vs free) - if (data.quota_snapshots) { - // Paid plan format - const snapshots = data.quota_snapshots; - return { - plan: data.copilot_plan, - resetDate: data.quota_reset_date, - quotas: { - chat: formatGitHubQuotaSnapshot(snapshots.chat), - completions: formatGitHubQuotaSnapshot(snapshots.completions), - premium_interactions: formatGitHubQuotaSnapshot(snapshots.premium_interactions), - }, - }; - } else if (data.monthly_quotas || data.limited_user_quotas) { - // Free/limited plan format - const monthlyQuotas = data.monthly_quotas || {}; - const usedQuotas = data.limited_user_quotas || {}; - - return { - plan: data.copilot_plan || data.access_type_sku, - resetDate: data.limited_user_reset_date, - quotas: { - chat: { - used: usedQuotas.chat || 0, - total: monthlyQuotas.chat || 0, - unlimited: false, - }, - completions: { - used: usedQuotas.completions || 0, - total: monthlyQuotas.completions || 0, - unlimited: false, - }, - }, - }; - } - - return { message: "GitHub Copilot connected. Unable to parse quota data." }; - } catch (error) { - throw new Error(`Failed to fetch GitHub usage: ${error.message}`); - } -} - -function formatGitHubQuotaSnapshot(quota) { - if (!quota) return { used: 0, total: 0, unlimited: true }; - - return { - used: quota.entitlement - quota.remaining, - total: quota.entitlement, - remaining: quota.remaining, - unlimited: quota.unlimited || false, - }; -} - -/** - * Gemini CLI Usage (Google Cloud) - */ -async function getGeminiUsage(accessToken) { - try { - // Gemini CLI uses Google Cloud quotas - // Try to get quota info from Cloud Resource Manager - const response = await fetch( - "https://cloudresourcemanager.googleapis.com/v1/projects?filter=lifecycleState:ACTIVE", - { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: "application/json", - }, - } - ); - - if (!response.ok) { - // Quota API may not be accessible, return generic message - return { message: "Gemini CLI uses Google Cloud quotas. Check Google Cloud Console for details." }; - } - - return { message: "Gemini CLI connected. Usage tracked via Google Cloud Console." }; - } catch (error) { - return { message: "Unable to fetch Gemini usage. Check Google Cloud Console." }; - } -} - -/** - * Antigravity Usage - */ -async function getAntigravityUsage(accessToken) { - try { - // Similar to Gemini, uses Google Cloud - return { message: "Antigravity connected. Usage tracked via Google Cloud Console." }; - } catch (error) { - return { message: "Unable to fetch Antigravity usage." }; - } -} - -/** - * Claude Usage - */ -async function getClaudeUsage(accessToken) { - try { - // Claude OAuth doesn't expose usage API directly - // Could potentially check via inference endpoint - return { message: "Claude connected. Usage tracked per request." }; - } catch (error) { - return { message: "Unable to fetch Claude usage." }; - } -} - -/** - * Codex (OpenAI) Usage - */ -async function getCodexUsage(accessToken) { - try { - // OpenAI usage requires organization API access - return { message: "Codex connected. Check OpenAI dashboard for usage." }; - } catch (error) { - return { message: "Unable to fetch Codex usage." }; - } -} - -/** - * Qwen Usage - */ -async function getQwenUsage(accessToken, providerSpecificData) { - try { - const resourceUrl = providerSpecificData?.resourceUrl; - if (!resourceUrl) { - return { message: "Qwen connected. No resource URL available." }; - } - - // Qwen may have usage endpoint at resource URL - return { message: "Qwen connected. Usage tracked per request." }; - } catch (error) { - return { message: "Unable to fetch Qwen usage." }; - } -} - -/** - * iFlow Usage - */ -async function getIflowUsage(accessToken) { - try { - // iFlow may have usage endpoint - return { message: "iFlow connected. Usage tracked per request." }; - } catch (error) { - return { message: "Unable to fetch iFlow usage." }; - } -} - diff --git a/src/mitm/manager.js b/src/mitm/manager.js index bd6f99fe..b70fbdf0 100644 --- a/src/mitm/manager.js +++ b/src/mitm/manager.js @@ -41,6 +41,7 @@ async function resolveMitmRouterBaseUrl() { const MITM_PORT = 443; const MITM_WIN_NODE_PORT = 8443; const PID_FILE = path.join(MITM_DIR, ".mitm.pid"); +const LOCK_FILE = path.join(MITM_DIR, ".mitm.lock"); const MITM_MAX_RESTARTS = 5; const MITM_RESTART_DELAYS_MS = [5000, 10000, 20000, 30000, 60000]; @@ -400,19 +401,22 @@ async function getMitmStatus() { async function scheduleMitmRestart(apiKey) { if (mitmIsRestarting) return; + // Set guard synchronously before any await to prevent concurrent calls + // from passing the check above. + mitmIsRestarting = true; const aliveMs = Date.now() - mitmLastStartTime; if (aliveMs >= MITM_RESTART_RESET_MS) mitmRestartCount = 0; if (mitmRestartCount >= MITM_MAX_RESTARTS) { err("Max restart attempts reached. Giving up."); + mitmIsRestarting = false; return; } const attempt = mitmRestartCount; const delay = MITM_RESTART_DELAYS_MS[Math.min(attempt, MITM_RESTART_DELAYS_MS.length - 1)]; mitmRestartCount++; - mitmIsRestarting = true; log(`Restarting in ${delay / 1000}s... (${mitmRestartCount}/${MITM_MAX_RESTARTS})`); await new Promise((r) => setTimeout(r, delay)); @@ -486,7 +490,19 @@ async function startServer(apiKey, sudoPassword, forceKillPort443 = false) { throw new Error("MITM server is already running"); } - await killLeftoverMitm(sudoPassword); + // Atomically claim lock to prevent concurrent startServer across processes. + // O_EXCL (flag: "wx") fails with EEXIST if the file already exists. + try { + fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: "wx" }); + } catch (e) { + if (e.code === "EEXIST") { + throw new Error("MITM server is already starting (lock contention)"); + } + throw e; + } + + try { + await killLeftoverMitm(sudoPassword); if (!IS_WIN) { const portStatus = await checkPort443Free(); @@ -679,6 +695,7 @@ async function startServer(apiKey, sudoPassword, forceKillPort443 = false) { serverProcess = null; serverPid = null; try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ } + try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ } // Auto-restart on unexpected exit if (code !== 0 && !mitmIsRestarting) scheduleMitmRestart(apiKey); }); @@ -706,7 +723,15 @@ async function startServer(apiKey, sudoPassword, forceKillPort443 = false) { await saveMitmSettings(true, sudoPassword); if (sudoPassword) setCachedPassword(sudoPassword); + // Server is healthy — remove lock file (PID file persists as the marker) + try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ } + return { running: true, pid: serverPid }; + } catch (e) { + // Clean up lock on any failure + try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ } + throw e; + } } /** @@ -779,6 +804,7 @@ async function stopServer(sudoPassword) { } try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ } + try { fs.unlinkSync(LOCK_FILE); } catch { /* ignore */ } await saveMitmSettings(false, null); mitmIsRestarting = false; diff --git a/src/shared/components/CapacityBadges.js b/src/shared/components/CapacityBadges.js new file mode 100644 index 00000000..81c34188 --- /dev/null +++ b/src/shared/components/CapacityBadges.js @@ -0,0 +1,28 @@ +"use client"; + +import { CAPACITY_META } from "@/shared/constants/models"; +import Tooltip from "./Tooltip"; + +// Render small icon badges for a model's capabilities (only those set true). +// colorOverride: force a single color class for all badges (default: per-cap color). +// size: icon font-size in px (default 16). +export default function CapacityBadges({ caps, className = "", colorOverride, size = 16 }) { + if (!caps) return null; + const active = Object.keys(CAPACITY_META).filter((k) => caps[k]); + if (active.length === 0) return null; + + return ( + + {active.map((k) => ( + + + {CAPACITY_META[k].icon} + + + ))} + + ); +} diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index f8427aa2..f70a0128 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { usePathname, useRouter } from "next/navigation"; +import { usePathname } from "next/navigation"; import Link from "next/link"; import PropTypes from "prop-types"; import ProviderIcon from "@/shared/components/ProviderIcon"; @@ -112,6 +112,13 @@ const getPageInfo = (pathname) => { icon: "security", breadcrumbs: [], }; + if (pathname.includes("/token-saver")) + return { + title: "Token Saver", + description: "Compress prompts and outputs to save tokens", + icon: "savings", + breadcrumbs: [], + }; if (pathname.includes("/cli-tools")) return { title: "CLI Tools", @@ -173,7 +180,6 @@ const getPageInfo = (pathname) => { export default function Header({ onMenuClick, showMenuButton = true }) { const pathname = usePathname(); - const router = useRouter(); const [displayName, setDisplayName] = useState(""); const [loginMethod, setLoginMethod] = useState(""); const [donateOpen, setDonateOpen] = useState(false); @@ -212,8 +218,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) { try { const res = await fetch("/api/auth/logout", { method: "POST" }); if (res.ok) { - router.push("/login"); - router.refresh(); + window.location.assign("/login"); } } catch (err) { console.error("Failed to logout:", err); diff --git a/src/shared/components/KiroAuthModal.js b/src/shared/components/KiroAuthModal.js index 6f567c6d..dcc56cc4 100644 --- a/src/shared/components/KiroAuthModal.js +++ b/src/shared/components/KiroAuthModal.js @@ -13,10 +13,14 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) { const [idcStartUrl, setIdcStartUrl] = useState(""); const [idcRegion, setIdcRegion] = useState("us-east-1"); const [refreshToken, setRefreshToken] = useState(""); + const [cliProxyJson, setCliProxyJson] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [apiKeyRegion, setApiKeyRegion] = useState("us-east-1"); const [error, setError] = useState(null); const [importing, setImporting] = useState(false); const [autoDetecting, setAutoDetecting] = useState(false); const [autoDetected, setAutoDetected] = useState(false); + const [idcCredentials, setIdcCredentials] = useState(null); // Auto-detect token when import method is selected useEffect(() => { @@ -26,6 +30,7 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) { setAutoDetecting(true); setError(null); setAutoDetected(false); + setIdcCredentials(null); try { const res = await fetch("/api/oauth/kiro/auto-import"); @@ -34,6 +39,16 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) { if (data.found) { setRefreshToken(data.refreshToken); setAutoDetected(true); + // Store IDC/organization credentials if present + if (data.clientId && data.clientSecret) { + setIdcCredentials({ + clientId: data.clientId, + clientSecret: data.clientSecret, + region: data.region, + authMethod: data.authMethod, + profileArn: data.profileArn, + }); + } } else { setError(data.error || "Could not auto-detect token"); } @@ -70,7 +85,10 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) { const res = await fetch("/api/oauth/kiro/import", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refreshToken: refreshToken.trim() }), + body: JSON.stringify({ + refreshToken: refreshToken.trim(), + ...(idcCredentials || {}), + }), }); const data = await res.json(); @@ -88,6 +106,36 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) { } }; + const handleImportCliProxyJson = async () => { + if (!cliProxyJson.trim()) { + setError("Please paste CLIProxyAPI auth JSON"); + return; + } + + setImporting(true); + setError(null); + + try { + const res = await fetch("/api/oauth/kiro/import-cli-proxy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ json: cliProxyJson.trim() }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "CLIProxyAPI import failed"); + } + + onMethodSelect("import-cli-proxy"); + } catch (err) { + setError(err.message); + } finally { + setImporting(false); + } + }; + const handleIdcContinue = () => { if (!idcStartUrl.trim()) { setError("Please enter your IDC start URL"); @@ -96,6 +144,40 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) { onMethodSelect("idc", { startUrl: idcStartUrl.trim(), region: idcRegion }); }; + const handleApiKeyImport = async () => { + if (!apiKey.trim()) { + setError("Please enter an API key"); + return; + } + + setImporting(true); + setError(null); + + try { + const res = await fetch("/api/oauth/kiro/api-key", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + apiKey: apiKey.trim(), + region: apiKeyRegion.trim() || "us-east-1", + }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Import failed"); + } + + // Success - notify parent to refresh connections + onMethodSelect("api-key"); + } catch (err) { + setError(err.message); + } finally { + setImporting(false); + } + }; + const handleSocialLogin = (provider) => { onMethodSelect("social", { provider }); }; @@ -142,6 +224,22 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) {
+ {/* AWS API Key */} + + {/* Google Social Login - HIDDEN */}
+ + {/* Import CLIProxyAPI JSON */} + )} @@ -240,6 +354,63 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) { )} + {/* API Key */} + {selectedMethod === "api-key" && ( +
+
+
+ info +

+ Paste a long-lived Kiro/CodeWhisperer API key. It is validated + against AWS and stored directly as a bearer credential (no refresh). +

+
+
+ +
+ + setApiKey(e.target.value)} + placeholder="Paste your Kiro API key..." + className="font-mono text-sm" + /> +
+ +
+ + setApiKeyRegion(e.target.value)} + placeholder="us-east-1" + className="font-mono text-sm" + /> +

+ AWS region for the key (default: us-east-1) +

+
+ + {error && ( +
+

{error}

+
+ )} + +
+ + +
+
+ )} + {/* Social Login Info (Google) */} {selectedMethod === "social-google" && (
@@ -371,6 +542,47 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) { )}
)} + + {/* Import CLIProxyAPI JSON */} + {selectedMethod === "import-cli-proxy" && ( +
+
+
+ info +

+ Paste the Kiro CLIProxyAPI auth JSON containing auth_method=external_idp. Only Microsoft login token endpoints are accepted. +

+
+
+ +
+ +