diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..58e79cabf7518f47ac021a4e13bec695d7c8ce23 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# Dockerfile for 9Router - HF Spaces 版本 +# 基于 original Dockerfile,适配 HF Spaces 环境(端口 7860) +ARG NODE_IMAGE=node:22-alpine +FROM ${NODE_IMAGE} AS base +WORKDIR /app + +FROM base AS builder + +RUN apk --no-cache upgrade && apk --no-cache add python3 make g++ linux-headers + +COPY package.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm install + +COPY . ./ +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM ${NODE_IMAGE} AS runner +WORKDIR /app + +LABEL org.opencontainers.image.title="9router" + +ENV NODE_ENV=production +ENV PORT=7860 +ENV HOSTNAME=0.0.0.0 +ENV NEXT_TELEMETRY_DISABLED=1 +ENV DATA_DIR=/app/data + +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/custom-server.js ./custom-server.js +COPY --from=builder /app/open-sse ./open-sse +COPY --from=builder /app/src/mitm ./src/mitm +COPY --from=builder /app/node_modules/node-forge ./node_modules/node-forge +COPY --from=builder /app/node_modules/next ./node_modules/next + +RUN mkdir -p /app/data && chown -R node:node /app && \ + mkdir -p /app/data-home && chown node:node /app/data-home && \ + ln -sf /app/data-home /root/.9router 2>/dev/null || true + +RUN apk --no-cache upgrade && apk --no-cache add su-exec && \ + printf '#!/bin/sh\nchown -R node:node /app/data /app/data-home 2>/dev/null\nexec su-exec node "$@"\n' > /entrypoint.sh && \ + chmod +x /entrypoint.sh + +EXPOSE 7860 + +ENTRYPOINT ["/entrypoint.sh"] +CMD ["node", "custom-server.js"] diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..55fd8c4900b3e6e76831aa9fe06ac4da907f0350 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,2 @@ +app/* +node_modules/* diff --git a/cli/.npmignore b/cli/.npmignore new file mode 100644 index 0000000000000000000000000000000000000000..231c93633d61acf920dda86b684fefd284391dd5 --- /dev/null +++ b/cli/.npmignore @@ -0,0 +1,9 @@ +# Ignore everything except what's in package.json "files" +* +!cli.js +!hooks/ +!app/ +!package.json +!README.md +!LICENSE + diff --git a/cli/LICENSE b/cli/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..ddd5dd4518292497cfeb727e6ba619d3ad496cdb --- /dev/null +++ b/cli/LICENSE @@ -0,0 +1,42 @@ +MIT License + +Copyright (c) 2026 9Router Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + + + + + + + + + + + + + + + + + + + + diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000000000000000000000000000000000000..050d996a87a698a21b4c6ea55b31a25f591f22ba --- /dev/null +++ b/cli/README.md @@ -0,0 +1,125 @@ +# 9Router - FREE AI Router & Token Saver + +**Never stop coding. Save 20-40% tokens with RTK + auto-fallback to FREE & cheap AI models.** + +**Connect All AI Code Tools (Claude Code, Cursor, Antigravity, Copilot, Codex, Gemini, OpenCode, Cline, OpenClaw...) to 40+ AI Providers & 100+ Models.** + +[![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) +[![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) +[![Docker Pulls](https://img.shields.io/docker/pulls/decolua/9router.svg?logo=docker&label=Docker%20pulls)](https://hub.docker.com/r/decolua/9router) +[![GHCR](https://img.shields.io/badge/GHCR-decolua%2F9router-blue?logo=github)](https://github.com/decolua/9router/pkgs/container/9router) +[![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) + +decolua%2F9router | Trendshift + +[🌐 Website](https://9router.com) • [📖 Full Docs](https://github.com/decolua/9router) + +--- + +## 🤔 Why 9Router? + +**Stop wasting money, tokens and hitting limits:** + +- ❌ Subscription quota expires unused every month +- ❌ Rate limits stop you mid-coding +- ❌ Tool outputs (git diff, grep, ls...) burn tokens fast +- ❌ Expensive APIs ($20-50/month per provider) + +**9Router solves this:** + +- ✅ **RTK Token Saver** - Auto-compress tool_result, save 20-40% tokens +- ✅ **Maximize subscriptions** - Track quota, use every bit before reset +- ✅ **Auto fallback** - Subscription → Cheap → Free, zero downtime +- ✅ **Multi-account** - Round-robin between accounts per provider +- ✅ **Universal** - Works with any OpenAI/Claude-compatible CLI + +--- + +## ⚡ Quick Start + +**Option 1 — npm (recommended for desktop):** + +```bash +npm install -g 9router +9router + +# Or run directly with npx +npx 9router +``` + +**Option 2 — Docker (server/VPS):** + +```bash +docker run -d --name 9router -p 20128:20128 \ + -v "$HOME/.9router:/app/data" -e DATA_DIR=/app/data \ + decolua/9router:latest +``` + +Published images: [Docker Hub](https://hub.docker.com/r/decolua/9router) • [GHCR](https://github.com/decolua/9router/pkgs/container/9router) (multi-platform amd64/arm64). + +🎉 Dashboard opens at `http://localhost:20128` + +**2. Connect a FREE provider (no signup needed):** + +Dashboard → Providers → Connect **Kiro AI** (free Claude unlimited) or **OpenCode Free** (no auth) → Done! + +**3. Use in your CLI tool:** + +``` +Claude Code/Codex/OpenClaw/Cursor/Cline Settings: + Endpoint: http://localhost:20128/v1 + API Key: [copy from dashboard] + Model: kr/claude-sonnet-4.5 +``` + +That's it! Start coding with FREE AI models. + +--- + +## 🚀 CLI Options + +```bash +9router # Start with default settings +9router --port 8080 # Custom port +9router --no-browser # Don't open browser +9router --skip-update # Skip auto-update check +9router --help # Show all options +``` + +**Dashboard**: `http://localhost:20128/dashboard` + +--- + +## 🛠️ Supported CLI Tools + +Claude-Code • OpenClaw • Codex • OpenCode • Cursor • Antigravity • Cline • Continue • Droid • Roo • Copilot • Kilo Code • Gemini CLI • Qwen Code • iFlow • Crush • Crusher • Aider + +Any tool supporting OpenAI/Claude-compatible API works. + +--- + +## 💾 Data Location + +- **macOS/Linux**: `~/.9router/db/data.sqlite` +- **Windows**: `%APPDATA%/9router/db/data.sqlite` +- **Docker**: `/app/data/db/data.sqlite` (mount `$HOME/.9router` to persist) + +--- + +## 📚 Documentation + +Full docs, advanced setup, video tutorials & development guide: + +- **GitHub**: https://github.com/decolua/9router +- **Full README**: https://github.com/decolua/9router/blob/main/app/README.md +- **Website**: https://9router.com + +--- + +## 🙏 Acknowledgments + +- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)** - Original Go implementation + +## 📄 License + +MIT License - see [LICENSE](LICENSE) for details. diff --git a/cli/cli.js b/cli/cli.js new file mode 100755 index 0000000000000000000000000000000000000000..09057b2e6d4276de293e26d00362fc45c6efb6ef --- /dev/null +++ b/cli/cli.js @@ -0,0 +1,830 @@ +#!/usr/bin/env node + +const { spawn, exec, execSync } = require("child_process"); +const path = require("path"); +const fs = require("fs"); +const https = require("https"); +const os = require("os"); + +// Native spinner - no external dependency +function createSpinner(text) { + const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let i = 0; + let interval = null; + let currentText = text; + return { + start() { + if (process.stdout.isTTY) { + process.stdout.write(`\r${frames[0]} ${currentText}`); + interval = setInterval(() => { + process.stdout.write(`\r${frames[i++ % frames.length]} ${currentText}`); + }, 80); + } + return this; + }, + stop() { + if (interval) { + clearInterval(interval); + interval = null; + } + if (process.stdout.isTTY) { + process.stdout.write("\r\x1b[K"); + } + }, + succeed(msg) { + this.stop(); + console.log(`✅ ${msg}`); + }, + fail(msg) { + this.stop(); + console.log(`❌ ${msg}`); + } + }; +} + +const pkg = require("./package.json"); +const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRuntime"); +const { ensureTrayRuntime } = require("./hooks/trayRuntime"); +const args = process.argv.slice(2); + +// Self-heal SQLite runtime deps (sql.js + better-sqlite3) into ~/.9router/runtime +// so the server can resolve them via NODE_PATH. Best-effort — sql.js is required, +// better-sqlite3 is optional. Logs to stderr only on failure. +try { ensureSqliteRuntime({ silent: true }); } catch {} + +// Self-heal tray runtime (systray for macOS/Linux only). Windows skipped. +try { ensureTrayRuntime({ silent: true }); } catch {} + +// Configuration constants +const APP_NAME = pkg.name; // Use from package.json +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 = [ + '9router' // Only package name - avoid killing other apps +]; + +// Parse arguments +let port = DEFAULT_PORT; +let host = DEFAULT_HOST; +let noBrowser = false; +let skipUpdate = false; +let showLog = false; +let trayMode = false; + +for (let i = 0; i < args.length; i++) { + if (args[i] === "--port" || args[i] === "-p") { + port = parseInt(args[i + 1], 10) || DEFAULT_PORT; + i++; + } else if (args[i] === "--host" || args[i] === "-H") { + host = args[i + 1] || DEFAULT_HOST; + i++; + } else if (args[i] === "--no-browser" || args[i] === "-n") { + noBrowser = true; + } else if (args[i] === "--log" || args[i] === "-l") { + showLog = true; + } else if (args[i] === "--skip-update") { + skipUpdate = true; + } else if (args[i] === "--tray" || args[i] === "-t") { + trayMode = true; + process.env.TRAY_MODE = "1"; + } else if (args[i] === "--help" || args[i] === "-h") { + console.log(` +Usage: ${APP_NAME} [options] + +Options: + -p, --port Port to run the server (default: ${DEFAULT_PORT}) + -H, --host Host to bind (default: ${DEFAULT_HOST}) + -n, --no-browser Don't open browser automatically + -l, --log Show server logs (default: hidden) + -t, --tray Run in system tray mode (background) + --skip-update Skip auto-update check + -h, --help Show this help message + -v, --version Show version +`); + process.exit(0); + } else if (args[i] === "--version" || args[i] === "-v") { + console.log(pkg.version); + process.exit(0); + } +} + +// Auto-relaunch after update: detached process has no TTY → fallback to tray +if (skipUpdate && !trayMode && !process.stdin.isTTY) { + trayMode = true; + process.env.TRAY_MODE = "1"; +} + +// Always use Node.js runtime with absolute path +const RUNTIME = process.execPath; + +// Compare semver versions: returns 1 if a > b, -1 if a < b, 0 if equal +function compareVersions(a, b) { + const partsA = a.split(".").map(Number); + const partsB = b.split(".").map(Number); + for (let i = 0; i < 3; i++) { + if (partsA[i] > partsB[i]) return 1; + if (partsA[i] < partsB[i]) return -1; + } + return 0; +} + +// Get app data dir (matches app/src/lib/dataDir.js convention) +function getAppDataDir() { + return process.platform === "win32" + ? path.join(process.env.APPDATA || "", "9router") + : path.join(os.homedir(), ".9router"); +} + +// Kill PID from file (best-effort, removes file after) +function killByPidFile(pidFile) { + try { + if (!fs.existsSync(pidFile)) return; + const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10); + if (!pid) return; + try { + if (process.platform === "win32") { + execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); + } else { + process.kill(pid, "SIGKILL"); + } + } catch { } + try { fs.unlinkSync(pidFile); } catch { } + } catch { } +} + +// Kill tunnel processes (cloudflared/tailscale) by their PID files +function killTunnelByPidFile() { + const tunnelDir = path.join(getAppDataDir(), "tunnel"); + killByPidFile(path.join(tunnelDir, "cloudflared.pid")); + killByPidFile(path.join(tunnelDir, "tailscale.pid")); +} + +// Kill cloudflared whose --url targets this app's port (covers stale PID file case) +function killCloudflaredByAppPort(appPort) { + if (!appPort) return []; + const portMatchers = [`localhost:${appPort}`, `127.0.0.1:${appPort}`]; + const pids = []; + try { + if (process.platform === "win32") { + const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"cloudflared.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`; + const output = execSync(psCmd, { encoding: "utf8", windowsHide: true, timeout: 5000 }); + const lines = output.split("\n").slice(1).filter(l => l.trim()); + lines.forEach(line => { + if (portMatchers.some(m => line.includes(m))) { + const match = line.match(/^"(\d+)"/); + if (match && match[1]) pids.push(match[1]); + } + }); + } else { + const output = execSync("ps -eo pid,command 2>/dev/null", { encoding: "utf8", timeout: 5000 }); + output.split("\n").forEach(line => { + if (line.includes("cloudflared") && portMatchers.some(m => line.includes(m))) { + const parts = line.trim().split(/\s+/); + const pid = parts[0]; + if (pid && !isNaN(pid)) pids.push(pid); + } + }); + } + } catch { } + return pids; +} + +// Kill all 9router processes +function killAllAppProcesses(appPort) { + return new Promise((resolve) => { + try { + // Kill MIT first (privileged process, needs special handling) + killProxyByPidFile(); + // Kill cloudflared/tailscale by PID file (precise, only this app's tunnel) + killTunnelByPidFile(); + + const platform = process.platform; + let pids = []; + + // Catch stale PID files: kill cloudflared bound to this app's port + pids.push(...killCloudflaredByAppPort(appPort)); + + if (platform === "win32") { + // Windows: use WMI to get full CommandLine (tasklist /V doesn't include it) + try { + const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "Get-WmiObject Win32_Process -Filter 'Name=\\"node.exe\\"' | Select-Object ProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"`; + const output = execSync(psCmd, { + encoding: "utf8", + windowsHide: true, + timeout: 5000 + }); + const lines = output.split("\n").slice(1).filter(l => l.trim()); + lines.forEach(line => { + // Whitelist: real node process running 9router/cli.js, or next-server. + // Avoids killing editors/grep/strace/cursor that just have "9router" in cmdline. + const cmd = line.toLowerCase(); + const isAppProcess = + (cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("\\9router") || cmd.includes("/9router"))) + || cmd.includes("next-server"); + if (isAppProcess) { + const match = line.match(/^"(\d+)"/); + if (match && match[1] && match[1] !== process.pid.toString()) { + pids.push(match[1]); + } + } + }); + } catch (e) { + // No processes found or error - continue + } + } else { + // macOS/Linux: use ps to find all matching processes + try { + const output = execSync('ps aux 2>/dev/null', { + encoding: 'utf8', + timeout: 5000 + }); + const lines = output.split('\n'); + + lines.forEach(line => { + // Whitelist: real node process running 9router/cli.js, or next-server. + // Avoids killing grep/strace/editors/cursor that incidentally match "9router". + const cmd = line.toLowerCase(); + const isAppProcess = + (cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("/9router"))) + || cmd.includes("next-server"); + if (isAppProcess) { + const parts = line.trim().split(/\s+/); + const pid = parts[1]; + if (pid && !isNaN(pid) && pid !== process.pid.toString()) { + pids.push(pid); + } + } + }); + } catch (e) { + // No processes found or error - continue + } + } + + // Kill all found processes + if (pids.length > 0) { + pids.forEach(pid => { + try { + if (platform === "win32") { + execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 }); + } else { + execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 }); + } + } catch (err) { + // Process already dead or can't kill - continue + } + }); + + // Wait for processes to fully terminate + setTimeout(() => resolve(), 1000); + } else { + resolve(); + } + } catch (err) { + // Silent fail - continue anyway + resolve(); + } + }); +} + +// Sleep helper using SharedArrayBuffer wait (sync, no busy-loop) +function sleepSync(ms) { + try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* ignore */ } +} + +// Wait until process dies or timeout reached +function waitForExit(pid, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { process.kill(pid, 0); } catch { return true; } + sleepSync(100); + } + return false; +} + +// Kill MIT server by PID file (runs privileged, needs special handling) +// Sends SIGTERM first so MIT can clean up host entries before dying. +function killProxyByPidFile() { + try { + const pidFile = path.join(getAppDataDir(), "mitm", ".mitm.pid"); + if (!fs.existsSync(pidFile)) return; + const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10); + if (!pid) return; + + if (process.platform === "win32") { + // Graceful first (lets server cleanup hosts), then force + try { execSync(`taskkill /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 2000 }); } catch { } + if (!waitForExit(pid, 1500)) { + try { execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { } + } + // Last-resort: PowerShell Stop-Process (sometimes succeeds where taskkill fails on admin processes) + if (!waitForExit(pid, 500)) { + try { execSync(`powershell -NonInteractive -WindowStyle Hidden -Command "Stop-Process -Id ${pid} -Force"`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { } + } + } else { + // SIGTERM via cached sudo token first + try { execSync(`sudo -n kill -TERM ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); } + catch { try { process.kill(pid, "SIGTERM"); } catch { } } + if (!waitForExit(pid, 1500)) { + try { execSync(`sudo -n kill -9 ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); } + catch { try { process.kill(pid, "SIGKILL"); } catch { } } + } + } + try { fs.unlinkSync(pidFile); } catch { } + } catch { } +} + +// Kill any process on specific port +function killProcessOnPort(port) { + return new Promise((resolve) => { + try { + const platform = process.platform; + let pid; + + if (platform === "win32") { + try { + const output = execSync(`netstat -ano | findstr :${port}`, { + encoding: 'utf8', + shell: true, + windowsHide: true, + timeout: 5000 + }).trim(); + const lines = output.split('\n').filter(l => l.includes('LISTENING')); + if (lines.length > 0) { + pid = lines[0].trim().split(/\s+/).pop(); + execSync(`taskkill /F /PID ${pid} 2>nul`, { stdio: 'ignore', shell: true, windowsHide: true, timeout: 3000 }); + } + } catch (e) { + // Port is free or error + } + } else { + // macOS/Linux + try { + const pidOutput = execSync(`lsof -ti:${port}`, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'] + }).trim(); + if (pidOutput) { + pid = pidOutput.split('\n')[0]; + execSync(`kill -9 ${pid} 2>/dev/null`, { stdio: 'ignore', timeout: 3000 }); + } + } catch (e) { + // Port is free or error + } + } + + // Wait for port to be released + setTimeout(() => resolve(), 500); + } catch (err) { + // Silent fail - continue anyway + resolve(); + } + }); +} + + +// Detect if running in restricted environment (Codespaces, Docker) +function isRestrictedEnvironment() { + // Check for Codespaces + if (process.env.CODESPACES === "true" || process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN) { + return "GitHub Codespaces"; + } + + // Check for Docker + if (fs.existsSync("/.dockerenv") || (fs.existsSync("/proc/1/cgroup") && fs.readFileSync("/proc/1/cgroup", "utf8").includes("docker"))) { + return "Docker"; + } + + return null; +} + +// Check if new version available, return latest version or null +function checkForUpdate() { + return new Promise((resolve) => { + if (skipUpdate) { + resolve(null); + return; + } + + const spinner = createSpinner("Checking for updates...").start(); + let resolved = false; + + const safetyTimeout = setTimeout(() => { + if (!resolved) { + resolved = true; + spinner.stop(); + resolve(null); + } + }, 8000); + + const done = (version) => { + if (resolved) return; + resolved = true; + clearTimeout(safetyTimeout); + spinner.stop(); + resolve(version); + }; + + const req = https.get(`https://registry.npmjs.org/${pkg.name}/latest`, { timeout: 3000 }, (res) => { + let data = ""; + res.on("data", chunk => data += chunk); + res.on("end", () => { + try { + const latest = JSON.parse(data); + if (latest.version && compareVersions(latest.version, pkg.version) > 0) { + done(latest.version); + } else { + done(null); + } + } catch (e) { + done(null); + } + }); + }); + + req.on("error", () => done(null)); + req.on("timeout", () => { req.destroy(); done(null); }); + }); +} + +// Open browser +function openBrowser(url) { + const platform = process.platform; + let cmd; + + if (platform === "darwin") { + cmd = `open "${url}"`; + } else if (platform === "win32") { + cmd = `start "" "${url}"`; + } else { + cmd = `xdg-open "${url}"`; + } + + exec(cmd, { windowsHide: true }, (err) => { + if (err) { + console.log(`Open browser manually: ${url}`); + } + }); +} + +// Find standalone server (bundled in bin/app for published package). +// Prefer custom-server.js (injects real socket IP) when present. +const standaloneDir = path.join(__dirname, "app"); +const customServerPath = path.join(standaloneDir, "custom-server.js"); +const serverPath = fs.existsSync(customServerPath) + ? customServerPath + : path.join(standaloneDir, "server.js"); + +if (!fs.existsSync(serverPath)) { + console.error("Error: Standalone build not found."); + console.error("Please run 'npm run build:cli' first."); + process.exit(1); +} + +// Check for updates FIRST, then start server +checkForUpdate().then((latestVersion) => { + killAllAppProcesses(port).then(() => { + return killProcessOnPort(port); + }).then(() => { + startServer(latestVersion); + }); +}); + +// Show interface selection menu +async function showInterfaceMenu(latestVersion) { + const { selectMenu } = require("./src/cli/utils/input"); + const { clearScreen } = require("./src/cli/utils/display"); + const { getEndpoint } = require("./src/cli/utils/endpoint"); + + clearScreen(); + + const displayHost = getDisplayHost(); + + // Detect tunnel/local mode for server URL display + let serverUrl; + try { + const { endpoint, tunnelEnabled } = await getEndpoint(port); + serverUrl = tunnelEnabled ? endpoint.replace(/\/v1$/, "") : `http://${displayHost}:${port}`; + } catch (e) { + serverUrl = `http://${displayHost}:${port}`; + } + + const subtitle = `🚀 Server: \x1b[32m${serverUrl}\x1b[0m`; + + const menuItems = []; + + if (latestVersion) { + menuItems.push({ label: `Update to v${latestVersion} (current: v${pkg.version})`, icon: "⬆" }); + } + + menuItems.push( + { label: "Web UI (Open in Browser)", icon: "🌐" }, + { label: "Terminal UI (Interactive CLI)", icon: "💻" }, + { label: "Hide to Tray (Background)", icon: "🔔" }, + { label: "Exit", icon: "🚪" } + ); + + const selected = await selectMenu(`Choose Interface (v${pkg.version})`, menuItems, 0, subtitle); + + const offset = latestVersion ? 1 : 0; + + if (latestVersion && selected === 0) return "update"; + if (selected === offset) return "web"; + if (selected === offset + 1) return "terminal"; + if (selected === offset + 2) return "hide"; + return "exit"; +} + +const MAX_RESTARTS = 2; +const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s + +function startServer(latestVersion) { + 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(); + + const CRASH_LOG_LINES = 50; + let crashLog = []; + + function spawnServer() { + serverStartTime = Date.now(); + crashLog = []; + const child = spawn(RUNTIME, ["--max-old-space-size=6144", serverPath], { + cwd: standaloneDir, + stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"], + detached: true, + windowsHide: true, + env: { + ...buildEnvWithRuntime(process.env), + PORT: port.toString(), + HOSTNAME: host + } + }); + if (!showLog && child.stderr) { + child.stderr.on("data", (data) => { + const lines = data.toString().split("\n").filter(Boolean); + crashLog.push(...lines); + if (crashLog.length > CRASH_LOG_LINES) crashLog = crashLog.slice(-CRASH_LOG_LINES); + }); + } + return child; + } + + let server = spawnServer(); + + // Cleanup function - force kill server process + let isCleaningUp = false; + function cleanup() { + if (isCleaningUp) return; + isCleaningUp = true; + try { + // Kill tray if running + try { + const { killTray } = require("./src/cli/tray/tray"); + killTray(); + } catch (e) { } + // Kill MIT server (privileged process) via PID file + killProxyByPidFile(); + // Kill cloudflared/tailscale via PID file (only this app's tunnel) + killTunnelByPidFile(); + // Kill server process directly + if (server.pid) { + process.kill(server.pid, "SIGKILL"); + } + // Also try to kill process group + process.kill(-server.pid, "SIGKILL"); + } catch (e) { } + } + + // Suppress all errors during shutdown (systray lib throws JSON parse errors) + let isShuttingDown = false; + process.on("uncaughtException", (err) => { + if (isShuttingDown) return; + console.error("Error:", err.message); + }); + + // Handle all exit scenarios + process.on("SIGINT", () => { + if (isShuttingDown) return; + isShuttingDown = true; + console.log("\nExiting..."); + cleanup(); + setTimeout(() => process.exit(0), 100); + }); + process.on("SIGTERM", () => { + if (isShuttingDown) return; + isShuttingDown = true; + cleanup(); + setTimeout(() => process.exit(0), 100); + }); + process.on("SIGHUP", () => { + if (isShuttingDown) return; + isShuttingDown = true; + cleanup(); + setTimeout(() => process.exit(0), 100); + }); + + // Initialize tray icon (runs alongside TUI) + const initTrayIcon = () => { + try { + const { initTray } = require("./src/cli/tray/tray"); + initTray({ + port, + onQuit: () => { + isShuttingDown = true; + console.log("\n👋 Shutting down from tray..."); + cleanup(); + setTimeout(() => process.exit(0), 100); + }, + onOpenDashboard: () => openBrowser(url) + }); + } catch (err) { + // Tray not available - continue without it + } + }; + + // Tray-only mode: no TUI, just tray icon + if (trayMode) { + // Ignore SIGHUP so macOS terminal close doesn't kill the background tray process + process.removeAllListeners("SIGHUP"); + process.on("SIGHUP", () => {}); + + console.log(`\n🚀 ${pkg.name} v${pkg.version}`); + console.log(`Server: http://${displayHost}:${port}`); + + setTimeout(() => { + initTrayIcon(); + console.log("\n💡 Router is now running in system tray. Close this terminal if you want."); + console.log(" Right-click tray icon to open dashboard or quit.\n"); + }, 2000); + + return; + } + + // Wait for server to be ready, then show interface menu loop + tray + setTimeout(async () => { + // Start tray icon alongside TUI + initTrayIcon(); + + try { + while (true) { + const choice = await showInterfaceMenu(latestVersion); + + if (choice === "update") { + isShuttingDown = true; + const { clearScreen } = require("./src/cli/utils/display"); + clearScreen(); + console.log(`\n⬆ Update v${pkg.version} → v${latestVersion}\n`); + console.log(`Run this after exit:\n`); + console.log(` \x1b[33m${INSTALL_CMD_LATEST}\x1b[0m\n`); + cleanup(); + await killAllAppProcesses(port); + await killProcessOnPort(port); + setTimeout(() => process.exit(0), 200); + return; + } else if (choice === "web") { + openBrowser(url); + // Wait for user to come back + const { pause } = require("./src/cli/utils/input"); + await pause("\nPress Enter to go back to menu..."); + } else if (choice === "terminal") { + // Start Terminal UI - it will return when user selects Back + const { startTerminalUI } = require("./src/cli/terminalUI"); + await startTerminalUI(port); + // Loop continues, show menu again + } else if (choice === "hide") { + const { clearScreen } = require("./src/cli/utils/display"); + clearScreen(); + + // Enable auto startup on OS boot + try { + const { enableAutoStart } = require("./src/cli/tray/autostart"); + enableAutoStart(__filename); + } catch (e) { } + + if (process.platform === "darwin") { + // macOS: keep current process alive — spawning a detached child puts + // it outside the login session so NSStatusItem silently fails. + process.removeAllListeners("SIGHUP"); + process.on("SIGHUP", () => {}); + + console.log(`\n⏳ Switching to tray mode... (icon already visible in menu bar)`); + console.log(`🔔 9Router is running in tray (PID: ${process.pid})`); + console.log(` Server: http://${displayHost}:${port}`); + console.log(`\n💡 You can close this terminal. Right-click tray icon to quit.\n`); + + // Tray already init'd at startup — just keep event loop alive. + return; + } + + // Windows/Linux: spawn detached bgProcess (systray works fine in child) + console.log(`\n⏳ Starting background process... (tray icon will appear in ~3s)`); + + const bgProcess = spawn(process.execPath, [__filename, "--tray", "--skip-update", "-p", port.toString()], { + detached: true, + stdio: "ignore", + windowsHide: true, + env: { ...process.env } + }); + bgProcess.unref(); + + console.log(`🔔 9Router is now running in background (PID: ${bgProcess.pid})`); + console.log(` Server: http://${displayHost}:${port}`); + console.log(`\n💡 You can close this terminal. Right-click tray icon to quit.\n`); + + // cleanup() kills server so bgProcess can claim the port fresh + cleanup(); + process.exit(0); + } else if (choice === "exit") { + isShuttingDown = true; + console.log("\nExiting..."); + cleanup(); + setTimeout(() => process.exit(0), 100); + } + } + } catch (err) { + console.error("Error:", err.message); + cleanup(); + process.exit(1); + } + }, 3000); + + function attachServerEvents() { + server.on("error", (err) => { + console.error("Failed to start server:", err.message); + if (!isShuttingDown) tryRestart(); + else { cleanup(); process.exit(1); } + }); + + server.on("close", (code) => { + if (isShuttingDown || code === 0) { + process.exit(code || 0); + return; + } + tryRestart(code); + }); + } + + function tryRestart(code) { + const aliveMs = Date.now() - serverStartTime; + // Reset counter if last run was stable + if (aliveMs >= RESTART_RESET_MS) restartCount = 0; + + if (restartCount >= MAX_RESTARTS) { + console.error(`\n⚠️ Server crashed ${MAX_RESTARTS} times. Disabling MIT and restarting...`); + try { + const dbPath = path.join(os.homedir(), process.platform === "win32" ? path.join("AppData", "Roaming", "9router", "db.json") : path.join(".9router", "db.json")); + if (fs.existsSync(dbPath)) { + const db = JSON.parse(fs.readFileSync(dbPath, "utf-8")); + if (db.settings) db.settings.mitmEnabled = false; + fs.writeFileSync(dbPath, JSON.stringify(db, null, 2)); + } + } catch { /* best effort */ } + restartCount = 0; + server = spawnServer(); + attachServerEvents(); + return; + } + + restartCount++; + const delay = Math.min(1000 * restartCount, 10000); + console.error(`\n⚠️ Server exited (code=${code ?? "unknown"}). Restarting in ${delay / 1000}s... (${restartCount}/${MAX_RESTARTS})`); + if (crashLog.length) { + console.error("\n--- Server crash log ---"); + crashLog.forEach(l => console.error(l)); + console.error("--- End crash log ---\n"); + } + + setTimeout(() => { + server = spawnServer(); + attachServerEvents(); + }, delay); + } + + attachServerEvents(); +} diff --git a/cli/hooks/postinstall.js b/cli/hooks/postinstall.js new file mode 100644 index 0000000000000000000000000000000000000000..3a59332f0dc86fe645f192da72e10a5d542d0e9e --- /dev/null +++ b/cli/hooks/postinstall.js @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +// Postinstall: warm-up SQLite deps into ~/.9router/runtime so the first +// `9router` start doesn't need network. Failure here is non-fatal — +// cli.js will retry at runtime if anything is missing. +const { ensureSqliteRuntime } = require("./sqliteRuntime"); +const { ensureTrayRuntime } = require("./trayRuntime"); + +try { + ensureSqliteRuntime({ silent: false }); + console.log("[9router] runtime SQLite deps ready"); +} catch (e) { + console.warn(`[9router] runtime warm-up skipped: ${e.message}`); +} + +try { + ensureTrayRuntime({ silent: false }); +} catch (e) { + console.warn(`[9router] tray runtime skipped: ${e.message}`); +} + +process.exit(0); diff --git a/cli/hooks/sqliteRuntime.js b/cli/hooks/sqliteRuntime.js new file mode 100644 index 0000000000000000000000000000000000000000..50e62629f81411891b753ee0c05c185dfa2bb032 --- /dev/null +++ b/cli/hooks/sqliteRuntime.js @@ -0,0 +1,139 @@ +// Ensure better-sqlite3 is installed in USER_DATA_DIR/runtime/node_modules +// (user-writable, avoids Windows EBUSY locks during npm i -g updates). +// sql.js is bundled in bin/app already; node:sqlite / bun:sqlite are built-in. +const { execSync, spawnSync } = require("child_process"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const BETTER_SQLITE3_VERSION = "12.6.2"; + +function getDataDir() { + if (process.env.DATA_DIR) return process.env.DATA_DIR; + return process.platform === "win32" + ? path.join(process.env.APPDATA || os.homedir(), "9router") + : path.join(os.homedir(), ".9router"); +} + +function getRuntimeDir() { + return path.join(getDataDir(), "runtime"); +} + +function getRuntimeNodeModules() { + return path.join(getRuntimeDir(), "node_modules"); +} + +function ensureRuntimeDir() { + const dir = getRuntimeDir(); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + // Minimal package.json so npm treats it as a project root + const pkgPath = path.join(dir, "package.json"); + if (!fs.existsSync(pkgPath)) { + fs.writeFileSync(pkgPath, JSON.stringify({ + name: "9router-runtime", + version: "1.0.0", + private: true, + description: "User-writable runtime deps for 9router (better-sqlite3 native binary)", + }, null, 2)); + } + return dir; +} + +function hasModule(name) { + return fs.existsSync(path.join(getRuntimeNodeModules(), name, "package.json")); +} + +function isBetterSqliteBinaryValid() { + const binary = path.join(getRuntimeNodeModules(), "better-sqlite3", "build", "Release", "better_sqlite3.node"); + if (!fs.existsSync(binary)) return false; + try { + const fd = fs.openSync(binary, "r"); + const buf = Buffer.alloc(4); + fs.readSync(fd, buf, 0, 4, 0); + fs.closeSync(fd); + const magic = buf.toString("hex"); + if (process.platform === "linux") return magic.startsWith("7f454c46"); + if (process.platform === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); + if (process.platform === "win32") return magic.startsWith("4d5a"); + return true; + } catch { return false; } +} + +// Extract a short, user-friendly reason from npm stderr. +function summarizeNpmError(stderr = "") { + const text = String(stderr); + if (/ENOTFOUND|ETIMEDOUT|EAI_AGAIN|network|getaddrinfo/i.test(text)) return "No internet connection or registry unreachable"; + if (/EACCES|EPERM|permission denied/i.test(text)) return "Permission denied (check folder permissions)"; + if (/ENOSPC|no space/i.test(text)) return "Not enough disk space"; + if (/node-gyp|gyp ERR|python|MSBuild|Visual Studio|Xcode/i.test(text)) return "Missing build tools (Xcode CLT / Python / VS Build Tools)"; + if (/ETARGET|version.*not found/i.test(text)) return "Package version not found on registry"; + const m = text.match(/npm ERR! (.+)/); + if (m) return m[1].slice(0, 200); + const lastLine = text.trim().split(/\r?\n/).filter(Boolean).pop(); + return lastLine ? lastLine.slice(0, 200) : "Unknown error"; +} + +function runNpmInstall({ cwd, pkgs, extraArgs = [], timeout = 180000 }) { + const args = ["install", ...pkgs, "--no-audit", "--no-fund", "--prefer-online", ...extraArgs]; + const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"; + const res = spawnSync(npmCmd, args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + timeout, + shell: process.platform === "win32", + encoding: "utf8", + }); + return { ok: res.status === 0, code: res.status, stderr: res.stderr || "", stdout: res.stdout || "" }; +} + +function npmInstall(pkgs, opts = {}) { + const cwd = ensureRuntimeDir(); + const extra = opts.optional ? ["--no-save"] : []; + if (!opts.silent) console.log("⏳ Installing SQLite engine (first run)..."); + const res = runNpmInstall({ cwd, pkgs, extraArgs: extra, timeout: opts.timeout || 180000 }); + if (!res.ok && !opts.silent) { + const reason = summarizeNpmError(res.stderr); + console.warn("⚠️ SQLite engine install failed — using fallback"); + console.warn(` Reason: ${reason}`); + console.warn(` Retry: cd "${cwd}" && npm install ${pkgs.join(" ")}`); + } + return res.ok; +} + +// 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. +function ensureSqliteRuntime({ silent = false } = {}) { + ensureRuntimeDir(); + + const needBetterSqlite = !hasModule("better-sqlite3") || !isBetterSqliteBinaryValid(); + if (!needBetterSqlite) { + if (!silent) console.log("✅ SQLite engine ready"); + return { betterSqlite: true }; + } + + const ok = npmInstall([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { optional: true, silent }); + return { + betterSqlite: ok && hasModule("better-sqlite3") && isBetterSqliteBinaryValid(), + }; +} + +// Inject runtime + bundled node_modules into NODE_PATH so child Node processes +// resolve sql.js (bundled in bin/app/node_modules) and better-sqlite3 (runtime). +function buildEnvWithRuntime(baseEnv = process.env) { + const runtimeNm = getRuntimeNodeModules(); + const bundledNm = path.join(__dirname, "..", "app", "node_modules"); + const existing = baseEnv.NODE_PATH || ""; + const NODE_PATH = [runtimeNm, bundledNm, existing].filter(Boolean).join(path.delimiter); + return { ...baseEnv, NODE_PATH }; +} + +module.exports = { + ensureSqliteRuntime, + buildEnvWithRuntime, + getRuntimeDir, + getRuntimeNodeModules, + runNpmInstall, + summarizeNpmError, +}; diff --git a/cli/hooks/trayRuntime.js b/cli/hooks/trayRuntime.js new file mode 100644 index 0000000000000000000000000000000000000000..dafb21541e3fd11a5aa86980dc624ae485371551 --- /dev/null +++ b/cli/hooks/trayRuntime.js @@ -0,0 +1,107 @@ +// Lazy install systray2 for macOS/Linux into USER_DATA_DIR/runtime/node_modules. +// Windows uses PowerShell NotifyIcon (no binary) → no systray needed. +// This keeps the published npm tarball free of unsigned Go binaries that +// trigger antivirus false positives (e.g. Kaspersky flagging tray_windows.exe). +// +// We use the maintained `systray2` fork. The original `systray@1.0.5` package +// bundles a 2017 x86_64 Go binary whose Mach-O headers are rejected by modern +// dyld (macOS 14+), so the tray silently fails to register on Apple Silicon. +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); +const { getRuntimeDir, getRuntimeNodeModules, runNpmInstall, summarizeNpmError } = require("./sqliteRuntime"); + +const SYSTRAY_PKG = "systray2"; +const SYSTRAY_VERSION = "2.1.4"; +const LEGACY_SYSTRAY_PKG = "systray"; + +function hasSystray() { + return fs.existsSync(path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "package.json")); +} + +// Remove the legacy `systray` package from all known locations. +// On Windows it was an AV false-positive risk; on macOS/Linux its bundled +// binary is broken on modern OS versions. +function cleanupLegacySystray({ silent = false } = {}) { + // 1) Runtime dir: ~/.9router/runtime/node_modules/systray (or %APPDATA% on Win) + // 2) npm global nested: /node_modules/9router/node_modules/systray + // __dirname here = /hooks → up 1 = pkg root + const targets = [ + path.join(getRuntimeNodeModules(), LEGACY_SYSTRAY_PKG), + path.join(__dirname, "..", "node_modules", LEGACY_SYSTRAY_PKG) + ]; + for (const dir of targets) { + if (fs.existsSync(dir)) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + if (!silent) console.log(`[9router][runtime] removed legacy systray: ${dir}`); + } catch (e) { + if (!silent) console.warn(`[9router][runtime] failed to remove ${dir}: ${e.message}`); + } + } + } +} + +// systray2's npm tarball sometimes ships the bundled Go binary without the +// executable bit set on macOS, causing spawn() to fail with EACCES. Set +x +// best-effort so the tray actually starts. +function chmodSystrayBin({ silent = false } = {}) { + if (process.platform === "win32") return; + const binName = process.platform === "darwin" ? "tray_darwin_release" : "tray_linux_release"; + const binPath = path.join(getRuntimeNodeModules(), SYSTRAY_PKG, "traybin", binName); + if (!fs.existsSync(binPath)) return; + try { + fs.chmodSync(binPath, 0o755); + } catch (e) { + if (!silent) console.warn(`[9router][runtime] chmod tray bin failed: ${e.message}`); + } +} + +function ensureRuntimeDir() { + const dir = getRuntimeDir(); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const pkgPath = path.join(dir, "package.json"); + if (!fs.existsSync(pkgPath)) { + fs.writeFileSync(pkgPath, JSON.stringify({ + name: "9router-runtime", + version: "1.0.0", + private: true + }, null, 2)); + } + return dir; +} + +function npmInstall(pkgs, { silent = false } = {}) { + const cwd = ensureRuntimeDir(); + if (!silent) console.log("⏳ Installing system tray (first run)..."); + const res = runNpmInstall({ cwd, pkgs, extraArgs: ["--no-save"], timeout: 120000 }); + if (!res.ok && !silent) { + const reason = summarizeNpmError(res.stderr); + console.warn("⚠️ System tray install failed — tray disabled"); + console.warn(` Reason: ${reason}`); + console.warn(` Retry: cd "${cwd}" && npm install ${pkgs.join(" ")}`); + } + return res.ok; +} + +// Public: ensure systray2 is installed on macOS/Linux only. +// Windows skips entirely (uses PowerShell tray). +function ensureTrayRuntime({ silent = false } = {}) { + // Always evict the legacy `systray` package — its binary is broken on + // modern macOS and an AV false-positive on Windows. + cleanupLegacySystray({ silent }); + + if (process.platform === "win32") { + return { systray: false, skipped: true }; + } + if (hasSystray()) { + chmodSystrayBin({ silent }); + if (!silent) console.log("✅ System tray ready"); + return { systray: true }; + } + const ok = npmInstall([`${SYSTRAY_PKG}@${SYSTRAY_VERSION}`], { silent }); + if (ok) chmodSystrayBin({ silent }); + return { systray: ok && hasSystray() }; +} + +module.exports = { ensureTrayRuntime }; diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000000000000000000000000000000000000..f910ed4f66e2ab5cfebac66d2fe3ab9c69ff4a17 --- /dev/null +++ b/cli/package.json @@ -0,0 +1,48 @@ +{ + "name": "9router", + "version": "0.5.4", + "description": "9Router CLI - Start and manage 9Router server", + "bin": { + "9router": "./cli.js" + }, + "files": [ + "cli.js", + "src", + "hooks", + "app", + "README.md", + "LICENSE" + ], + "scripts": { + "dev": "nodemon -I --watch cli.js --watch src --watch hooks --ext js,json cli.js", + "build": "node scripts/build-cli.js", + "pack:cli": "npm run build && npm pack --pack-destination ../..", + "publish:cli": "npm run build && npm publish", + "postinstall": "node hooks/postinstall.js", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "enquirer": "^2.4.1", + "node-forge": "^1.3.3", + "node-machine-id": "^1.1.12", + "react": "19.2.1", + "react-dom": "19.2.1" + }, + "comment_sqlite": "sql.js + better-sqlite3 are NOT bundled here. They are installed into ~/.9router/runtime/node_modules by hooks/postinstall.js (and re-checked at runtime by cli.js). This avoids Windows EBUSY errors when updating the global CLI, since native .node files no longer live under the locked install dir.", + "comment_systray": "systray2 is NOT bundled here. It is lazy-installed into ~/.9router/runtime/node_modules by hooks/postinstall.js on macOS/Linux only. Windows uses PowerShell NotifyIcon (zero binary). This avoids shipping unsigned Go binaries that trigger antivirus false positives (Kaspersky). We use the systray2 fork because the legacy systray@1.0.5 ships a 2017 x86_64 binary that fails on modern macOS dyld.", + "engines": { + "node": ">=18.0.0" + }, + "keywords": [ + "9router", + "cli", + "proxy", + "ai", + "api" + ], + "license": "MIT", + "devDependencies": { + "esbuild": "^0.25.12", + "nodemon": "^3.1.14" + } +} diff --git a/cli/scripts/build-cli.js b/cli/scripts/build-cli.js new file mode 100644 index 0000000000000000000000000000000000000000..48dd09100a3e17a4c106d24d45220abaffc2bc53 --- /dev/null +++ b/cli/scripts/build-cli.js @@ -0,0 +1,273 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const path = require("path"); +const { execSync } = require("child_process"); + +const cliDir = path.resolve(__dirname, ".."); +const appDir = path.resolve(cliDir, ".."); +const rootDir = path.resolve(appDir, ".."); +const cliAppDir = path.join(cliDir, "app"); +const buildHomeDir = path.join(cliDir, ".build-home"); +const buildDistDirName = ".next-cli-build"; +const buildDistDir = path.join(appDir, buildDistDirName); + +// Exclude patterns for files/folders we don't want to copy +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 + ".env", // Environment files + ".env.local", + ".env.*.local", + "*.log", // Log files + "tmp", // Temp files + ".DS_Store", // macOS files +]; + +function shouldExclude(name) { + return EXCLUDE_PATTERNS.some(pattern => { + if (pattern.includes("*")) { + const regex = new RegExp("^" + pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$"); + return regex.test(name); + } + return name === pattern; + }); +} + +function copyRecursive(src, dest) { + if (!fs.existsSync(src)) { + console.warn(`Warning: Source ${src} does not exist`); + return; + } + + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } + + const entries = fs.readdirSync(src, { withFileTypes: true }); + for (const entry of entries) { + if (shouldExclude(entry.name)) { + continue; + } + + const srcPath = path.join(src, entry.name); + const destPath = path.join(dest, entry.name); + + // Skip broken symlinks (common in workspace setups) + try { + fs.accessSync(srcPath); + } catch { + continue; + } + + if (entry.isDirectory()) { + copyRecursive(srcPath, destPath); + } else if (entry.isSymbolicLink()) { + // Resolve and copy target (avoid linking outside bundle) + try { + const real = fs.realpathSync(srcPath); + if (fs.statSync(real).isDirectory()) { + copyRecursive(real, destPath); + } else { + fs.copyFileSync(real, destPath); + } + } catch {} + } else { + try { + fs.copyFileSync(srcPath, destPath); + } catch {} + } + } +} + +console.log("📦 Building 9Router CLI package with Next.js...\n"); + +fs.mkdirSync(buildHomeDir, { recursive: true }); +fs.mkdirSync(path.join(buildHomeDir, "AppData", "Roaming"), { recursive: true }); +fs.mkdirSync(path.join(buildHomeDir, "AppData", "Local"), { recursive: true }); + +// Step 0: Sync version from app/cli/package.json to app/package.json +console.log("0️⃣ Syncing version to app/package.json..."); +const cliPkg = JSON.parse(fs.readFileSync(path.join(cliDir, "package.json"), "utf8")); +const appPkgPath = path.join(appDir, "package.json"); +const appPkg = JSON.parse(fs.readFileSync(appPkgPath, "utf8")); +if (appPkg.version !== cliPkg.version) { + appPkg.version = cliPkg.version; + fs.writeFileSync(appPkgPath, JSON.stringify(appPkg, null, 2) + "\n"); + console.log(`✅ Version synced: ${cliPkg.version}\n`); +} else { + console.log(`✅ Version already synced: ${cliPkg.version}\n`); +} + +// Step 1: Build app with Next.js (workspace tracing root → traced node_modules in standalone). +console.log("1️⃣ Building Next.js app..."); +try { + execSync("npm run build", { + stdio: "inherit", + cwd: appDir, + env: { + ...process.env, + HOME: buildHomeDir, + USERPROFILE: buildHomeDir, + APPDATA: path.join(buildHomeDir, "AppData", "Roaming"), + LOCALAPPDATA: path.join(buildHomeDir, "AppData", "Local"), + NEXT_DIST_DIR: buildDistDirName, + NEXT_TRACING_ROOT_MODE: "workspace", + } + }); + console.log("✅ Next.js build completed\n"); +} catch (error) { + console.error("❌ Next.js build failed"); + process.exit(1); +} + +// Step 2: Clean old app/cli/app if exists +console.log("2️⃣ Cleaning old app/cli/app..."); +if (fs.existsSync(cliAppDir)) { + fs.rmSync(cliAppDir, { recursive: true, force: true }); +} +console.log("✅ Cleaned\n"); + +// Step 3: Copy Next.js standalone build to app/cli/app. +// Newer Next.js standalone output writes server.js/package.json plus .next/, src/, and +// node_modules/ directly under .next/standalone. Older builds may still use a nested app/. +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; +const standaloneApp = fs.existsSync(path.join(standaloneRootToUse, "server.js")) + ? standaloneRootToUse + : path.join(standaloneRootToUse, "app"); +if (!fs.existsSync(standaloneApp)) { + console.error("❌ Next.js standalone build not found under .next/standalone"); + console.error("Expected either .next/standalone/server.js or .next/standalone/app/"); + process.exit(1); +} +copyRecursive(standaloneApp, cliAppDir); + +// Older nested-app layout stores traced node_modules at standalone root. +const standaloneNodeModules = path.join(standaloneRootToUse, "node_modules"); +if (standaloneApp !== standaloneRootToUse && fs.existsSync(standaloneNodeModules)) { + copyRecursive(standaloneNodeModules, path.join(cliAppDir, "node_modules")); +} +console.log("✅ Copied standalone build\n"); + +// Step 3a: Copy custom server (injects real socket IP, strips spoofable XFF). +const customServerSrc = path.join(appDir, "custom-server.js"); +if (fs.existsSync(customServerSrc)) { + fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js")); + console.log("✅ Copied custom-server.js\n"); +} else { + console.warn("⚠️ custom-server.js not found — server will run without real-IP injection\n"); +} + +// Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules. +// Strip better-sqlite3 (native) — it lives in ~/.9router/runtime to avoid +// Windows EBUSY during global CLI updates. node:sqlite (Node ≥22.5) is also +// available as a no-install middle tier. +console.log("3️⃣ b Configuring SQLite drivers..."); +function ensureModuleInBundle(pkg) { + const dest = path.join(cliAppDir, "node_modules", pkg); + if (fs.existsSync(dest)) { + console.log(`✅ ${pkg} already bundled`); + return; + } + const candidates = [ + path.join(appDir, "node_modules", pkg), + path.join(rootDir, "node_modules", pkg), + ]; + const src = candidates.find((p) => fs.existsSync(p)); + if (!src) { + console.warn(`⚠️ ${pkg} not found locally — bundle will rely on node:sqlite or runtime install`); + return; + } + fs.mkdirSync(path.dirname(dest), { recursive: true }); + copyRecursive(src, dest); + console.log(`✅ Bundled ${pkg}`); +} +ensureModuleInBundle("sql.js"); +const betterDir = path.join(cliAppDir, "node_modules", "better-sqlite3"); +if (fs.existsSync(betterDir)) { + fs.rmSync(betterDir, { recursive: true, force: true }); + console.log("✅ Stripped better-sqlite3 (lives in ~/.9router/runtime)"); +} +console.log(""); + +// Step 4: Copy static files +console.log("4️⃣ Copying static files..."); +const staticSrc = path.join(appDir, ".next", "static"); +const staticSrcResolved = path.join(buildDistDir, "static"); +const staticDest = path.join(cliAppDir, buildDistDirName, "static"); +if (fs.existsSync(staticSrcResolved) || fs.existsSync(staticSrc)) { + copyRecursive(fs.existsSync(staticSrcResolved) ? staticSrcResolved : staticSrc, staticDest); + console.log("✅ Copied static files\n"); +} else { + console.log("⏭️ No static files found\n"); +} + +// Step 5: Copy public folder if exists +console.log("5️⃣ Copying public folder..."); +const publicSrc = path.join(appDir, "public"); +const publicDest = path.join(cliAppDir, "public"); +if (fs.existsSync(publicSrc)) { + copyRecursive(publicSrc, publicDest); + console.log("✅ Copied public folder\n"); +} else { + console.log("⏭️ No public folder found\n"); +} + +// Step 6: Copy vendor-chunks (required for production) +console.log("6️⃣ Copying vendor-chunks..."); +const vendorChunksSrc = path.join(appDir, ".next", "server", "vendor-chunks"); +const vendorChunksSrcResolved = path.join(buildDistDir, "server", "vendor-chunks"); +const vendorChunksDest = path.join(cliAppDir, buildDistDirName, "server", "vendor-chunks"); +if (fs.existsSync(vendorChunksSrcResolved) || fs.existsSync(vendorChunksSrc)) { + copyRecursive(fs.existsSync(vendorChunksSrcResolved) ? vendorChunksSrcResolved : vendorChunksSrc, vendorChunksDest); + console.log("✅ Copied vendor-chunks\n"); +} else { + console.log("⏭️ No vendor-chunks found\n"); +} + +// Step 7: Copy MITM server files (not bundled by Next.js standalone) +console.log("7️⃣ Copying MITM server files..."); +const mitmSrc = path.join(appDir, "src", "mitm"); +const mitmDest = path.join(cliAppDir, "src", "mitm"); +if (fs.existsSync(mitmSrc)) { + copyRecursive(mitmSrc, mitmDest); + console.log("✅ Copied MITM files\n"); +} else { + console.log("⏭️ No MITM files found\n"); +} + +// Step 7b: Copy standalone updater (headless Node process for install progress) +console.log("7️⃣ b Copying updater files..."); +const updaterSrc = path.join(appDir, "src", "lib", "updater"); +const updaterDest = path.join(cliAppDir, "src", "lib", "updater"); +if (fs.existsSync(updaterSrc)) { + copyRecursive(updaterSrc, updaterDest); + console.log("✅ Copied updater files\n"); +} else { + console.log("⏭️ No updater files found\n"); +} + +// Step 8: Build MITM server (config driven - see app/cli/scripts/buildMitm.js) +console.log("8️⃣ Building MITM server..."); +try { + execSync("node scripts/buildMitm.js", { stdio: "inherit", cwd: cliDir }); + console.log("✅ MITM server build completed\n"); +} catch (error) { + console.error("❌ MITM build failed"); + process.exit(1); +} + +console.log("✨ CLI package build completed!"); +console.log(`📁 Output: ${cliAppDir}`); + +try { + const { execSync: exec } = require("child_process"); + const size = exec(`du -sh "${cliAppDir}"`, { encoding: "utf8" }).trim(); + console.log(`📊 Package size: ${size.split("\t")[0]}`); +} catch (e) { + // Silent fail on size check +} diff --git a/cli/scripts/buildMitm.js b/cli/scripts/buildMitm.js new file mode 100644 index 0000000000000000000000000000000000000000..45c1664c43253ea112e7e1120914957d2553d774 --- /dev/null +++ b/cli/scripts/buildMitm.js @@ -0,0 +1,70 @@ +const esbuild = require("esbuild"); +const fs = require("fs"); +const path = require("path"); + +// ── Build config ───────────────────────────────────────── +const BUILD_CONFIG = { + bundle: true, + minify: true, + cleanPlainFiles: true, +}; +// ───────────────────────────────────────────────────────── + +const cliDir = path.resolve(__dirname, ".."); +const appDir = path.resolve(cliDir, ".."); +const cliMitmDir = path.join(cliDir, "app", "src", "mitm"); +// Bundle everything — no externals. This keeps MITM runtime self-contained so +// it can be copied to DATA_DIR/runtime/ and spawned from there (escapes +// node_modules file locks that block `npm i -g 9router@latest` on Windows). +const EXTERNALS = []; +const ENTRIES = ["server.js"]; + +async function buildEntry(entry) { + const mitmSrc = path.join(appDir, "src", "mitm"); + const output = path.join(cliMitmDir, entry); + + const buildPlugin = { + name: "build-plugin", + setup(build) { + // Stub .git file scanned by esbuild + build.onResolve({ filter: /\.git/ }, args => ({ path: args.path, namespace: "git-stub" })); + build.onLoad({ filter: /.*/, namespace: "git-stub" }, () => ({ contents: "module.exports={}", loader: "js" })); + }, + }; + + const steps = []; + + if (BUILD_CONFIG.bundle) { + await esbuild.build({ + entryPoints: [path.join(mitmSrc, entry)], + bundle: true, + minify: BUILD_CONFIG.minify, + platform: "node", + target: "node18", + external: EXTERNALS, + plugins: [buildPlugin], + outfile: output, + }); + steps.push("bundled"); + if (BUILD_CONFIG.minify) steps.push("minified"); + } + + console.log(`✅ ${steps.join(" + ")} → ${output}`); +} + +async function run() { + const flags = Object.entries(BUILD_CONFIG).filter(([, v]) => v).map(([k]) => k).join(", "); + console.log(`⚙️ Config: ${flags}`); + + for (const entry of ENTRIES) await buildEntry(entry); + + if (BUILD_CONFIG.cleanPlainFiles) { + const keep = new Set(ENTRIES); + for (const name of fs.readdirSync(cliMitmDir)) { + if (!keep.has(name)) fs.rmSync(path.join(cliMitmDir, name), { recursive: true, force: true }); + } + console.log("✅ Removed plain MITM files from CLI bundle"); + } +} + +run().catch((e) => { console.error(e); process.exit(1); }); diff --git a/cli/src/cli/api/client.js b/cli/src/cli/api/client.js new file mode 100644 index 0000000000000000000000000000000000000000..257fcd226a5c4649239fd414792a57815d505dcf --- /dev/null +++ b/cli/src/cli/api/client.js @@ -0,0 +1,556 @@ +const http = require("http"); +const https = require("https"); +const crypto = require("crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); +const { machineIdSync } = require("node-machine-id"); + +// Default configuration +const DEFAULT_CONFIG = { + host: "localhost", + port: 20128, + protocol: "http:", +}; + +const CLI_TOKEN_HEADER = "x-9r-cli-token"; +const CLI_TOKEN_SALT = "9r-cli-auth"; +const APP_NAME = "9router"; + +function getDataDir() { + if (process.env.DATA_DIR) return process.env.DATA_DIR; + if (process.platform === "win32") { + return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), APP_NAME); + } + return path.join(os.homedir(), `.${APP_NAME}`); +} + +const MACHINE_ID_FILE = path.join(getDataDir(), "machine-id"); +const AUTH_DIR = path.join(getDataDir(), "auth"); +const CLI_SECRET_FILE = path.join(AUTH_DIR, "cli-secret"); + +let config = { ...DEFAULT_CONFIG }; +let cachedCliToken = null; +let cachedCliSecret = null; + +// Read raw machineId from shared file (written by server) → guarantees token match +function loadRawMachineId() { + try { + const raw = fs.readFileSync(MACHINE_ID_FILE, "utf8").trim(); + if (raw) return raw; + } catch {} + try { return machineIdSync(); } catch { return ""; } +} + +// Random secret shared with server via file → token unpredictable from machineId alone. +function loadCliSecret() { + if (cachedCliSecret) return cachedCliSecret; + try { + cachedCliSecret = fs.readFileSync(CLI_SECRET_FILE, "utf8").trim(); + if (cachedCliSecret) return cachedCliSecret; + } catch {} + cachedCliSecret = crypto.randomBytes(32).toString("hex"); + try { + fs.mkdirSync(AUTH_DIR, { recursive: true }); + fs.writeFileSync(CLI_SECRET_FILE, cachedCliSecret, { mode: 0o600 }); + } catch {} + return cachedCliSecret; +} + +function getCliToken() { + if (cachedCliToken !== null) return cachedCliToken; + const raw = loadRawMachineId(); + const secret = loadCliSecret(); + cachedCliToken = raw ? crypto.createHash("sha256").update(raw + CLI_TOKEN_SALT + secret).digest("hex").substring(0, 16) : ""; + return cachedCliToken; +} + +/** + * Configure API client + * @param {Object} options - Configuration options + * @param {string} options.host - API host + * @param {number} options.port - API port + * @param {string} options.protocol - Protocol (http: or https:) + */ +function configure(options = {}) { + config = { ...config, ...options }; +} + +/** + * Make HTTP request to API + * @param {string} method - HTTP method + * @param {string} path - API path + * @param {Object} body - Request body (optional) + * @returns {Promise} Response with { success, data/error } + */ +function makeRequest(method, path, body = null) { + return new Promise((resolve) => { + const httpModule = config.protocol === "https:" ? https : http; + + const options = { + hostname: config.host, + port: config.port, + path: path, + method: method, + headers: { + "Content-Type": "application/json", + [CLI_TOKEN_HEADER]: getCliToken(), + }, + }; + + // Add Content-Length for POST/PUT requests + if (body && (method === "POST" || method === "PUT" || method === "PATCH")) { + const bodyString = JSON.stringify(body); + options.headers["Content-Length"] = Buffer.byteLength(bodyString); + } + + const req = httpModule.request(options, (res) => { + let data = ""; + + res.on("data", (chunk) => { + data += chunk; + }); + + res.on("end", () => { + try { + const parsed = data ? JSON.parse(data) : {}; + + // Check if response indicates error + if (res.statusCode >= 400 || parsed.error) { + resolve({ + success: false, + error: parsed.error || `HTTP ${res.statusCode}`, + statusCode: res.statusCode, + }); + } else { + resolve({ + success: true, + data: parsed, + statusCode: res.statusCode, + }); + } + } catch (err) { + resolve({ + success: false, + error: `Failed to parse response: ${err.message}`, + }); + } + }); + }); + + req.on("error", (err) => { + resolve({ + success: false, + error: `Network error: ${err.message}`, + }); + }); + + req.on("timeout", () => { + req.destroy(); + resolve({ + success: false, + error: "Request timeout", + }); + }); + + // Set timeout (30 seconds) + req.setTimeout(30000); + + // Write body if present + if (body && (method === "POST" || method === "PUT" || method === "PATCH")) { + req.write(JSON.stringify(body)); + } + + req.end(); + }); +} + +// ============================================================================ +// PROVIDERS API +// ============================================================================ + +/** + * Get all providers + * @returns {Promise} { success, data: { connections } } + */ +async function getProviders() { + return makeRequest("GET", "/api/providers"); +} + +/** + * Get provider by ID + * @param {string} id - Provider ID + * @returns {Promise} { success, data: { connection } } + */ +async function getProviderById(id) { + return makeRequest("GET", `/api/providers/${id}`); +} + +/** + * Test provider connection + * @param {string} id - Provider ID + * @returns {Promise} { success, data: { valid, error } } + */ +async function testProvider(id) { + return makeRequest("POST", `/api/providers/${id}/test`); +} + +/** + * Delete provider + * @param {string} id - Provider ID + * @returns {Promise} { success, data: { message } } + */ +async function deleteProvider(id) { + return makeRequest("DELETE", `/api/providers/${id}`); +} + +/** + * Get provider models + * @param {string} id - Provider ID + * @returns {Promise} { success, data: { provider, connectionId, models } } + */ +async function getProviderModels(id) { + return makeRequest("GET", `/api/providers/${id}/models`); +} + +// ============================================================================ +// OAUTH API +// ============================================================================ + +/** + * Get OAuth authorization URL + * @param {string} provider - Provider ID + * @returns {Promise} { success, data: { authUrl, codeVerifier, state, redirectUri } } + */ +async function getOAuthAuthUrl(provider) { + // Codex requires fixed port 1455 and path /auth/callback + const redirectUri = provider === "codex" + ? "http://localhost:1455/auth/callback" + : "http://localhost:20128/callback"; + return makeRequest("GET", `/api/oauth/${provider}/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`); +} + +/** + * Exchange OAuth authorization code for token + * @param {string} provider - Provider ID + * @param {Object} data - { code, redirectUri, codeVerifier, state } + * @returns {Promise} { success, data } + */ +async function exchangeOAuthCode(provider, data) { + return makeRequest("POST", `/api/oauth/${provider}/exchange`, data); +} + +/** + * Get OAuth device code + * @param {string} provider - Provider ID + * @returns {Promise} { success, data: { device_code, user_code, verification_uri, verification_uri_complete, codeVerifier, extraData } } + */ +async function getOAuthDeviceCode(provider) { + return makeRequest("GET", `/api/oauth/${provider}/device-code`); +} + +/** + * Poll OAuth token using device code + * @param {string} provider - Provider ID + * @param {Object} data - { deviceCode, codeVerifier, extraData } + * @returns {Promise} { success, data: { pending } } + */ +async function pollOAuthToken(provider, data) { + return makeRequest("POST", `/api/oauth/${provider}/poll`, data); +} + +/** + * Create API key provider connection + * @param {Object} data - { provider, name, apiKey } + * @returns {Promise} { success, data } + */ +async function createApiKeyProvider(data) { + return makeRequest("POST", "/api/providers", data); +} + +/** + * Update provider connection + * @param {string} id - Connection ID + * @param {Object} data - { name, priority, defaultModel, isActive } + * @returns {Promise} { success, data: { connection } } + */ +async function updateConnection(id, data) { + return makeRequest("PUT", `/api/providers/${id}`, data); +} + +// ============================================================================ +// API KEYS API +// ============================================================================ + +/** + * Get all API keys + * @returns {Promise} { success, data: { keys } } + */ +async function getApiKeys() { + return makeRequest("GET", "/api/keys"); +} + +/** + * Create new API key + * @param {string} name - Key name + * @returns {Promise} { success, data: { key, name, id, machineId } } + */ +async function createApiKey(name) { + return makeRequest("POST", "/api/keys", { name }); +} + +/** + * Delete API key + * @param {string} id - Key ID + * @returns {Promise} { success, data: { success } } + */ +async function deleteApiKey(id) { + return makeRequest("DELETE", `/api/keys/${id}`); +} + +// ============================================================================ +// COMBOS API +// ============================================================================ + +/** + * Get all combos + * @returns {Promise} { success, data: { combos } } + */ +async function getCombos() { + return makeRequest("GET", "/api/combos"); +} + +/** + * Get combo by ID + * @param {string} id - Combo ID + * @returns {Promise} { success, data: combo } + */ +async function getComboById(id) { + return makeRequest("GET", `/api/combos/${id}`); +} + +/** + * Create new combo + * @param {Object} data - Combo data { name, models } + * @returns {Promise} { success, data: combo } + */ +async function createCombo(data) { + return makeRequest("POST", "/api/combos", data); +} + +/** + * Update combo + * @param {string} id - Combo ID + * @param {Object} data - Update data { name?, models? } + * @returns {Promise} { success, data: combo } + */ +async function updateCombo(id, data) { + return makeRequest("PUT", `/api/combos/${id}`, data); +} + +/** + * Delete combo + * @param {string} id - Combo ID + * @returns {Promise} { success, data: { success } } + */ +async function deleteCombo(id) { + return makeRequest("DELETE", `/api/combos/${id}`); +} + +// ============================================================================ +// CLI TOOLS API +// ============================================================================ + +/** + * Get CLI tool settings + * @param {string} tool - Tool name: claude | codex | droid | openclaw + * @returns {Promise} { success, data: { installed, has9Router, ... } } + */ +async function getCliToolSettings(tool) { + return makeRequest("GET", `/api/cli-tools/${tool}-settings`); +} + +/** + * Apply CLI tool settings (POST) + * @param {string} tool - Tool name: claude | codex | droid | openclaw + * @param {Object} body - Payload depends on tool + * @returns {Promise} { success, data } + */ +async function applyCliToolSettings(tool, body) { + return makeRequest("POST", `/api/cli-tools/${tool}-settings`, body); +} + +/** + * Reset CLI tool settings (DELETE) + * @param {string} tool - Tool name: claude | codex | droid | openclaw + * @returns {Promise} { success, data } + */ +async function resetCliToolSettings(tool) { + return makeRequest("DELETE", `/api/cli-tools/${tool}-settings`); +} + +// ============================================================================ +// SETTINGS API +// ============================================================================ + +/** + * Get settings + * @returns {Promise} { success, data: settings } + */ +async function getSettings() { + return makeRequest("GET", "/api/settings"); +} + +/** + * Update settings + * @param {Object} data - Settings data + * @returns {Promise} { success, data: settings } + */ +async function updateSettings(data) { + return makeRequest("PATCH", "/api/settings", data); +} + +/** + * Reset dashboard password to default (clears stored hash server-side) + * @returns {Promise} { success } + */ +async function resetPassword() { + return makeRequest("POST", "/api/auth/reset-password"); +} + +// ============================================================================ +// MODELS API +// ============================================================================ + +/** + * Get all models (internal API) + * @returns {Promise} { success, data: { models } } + */ +async function getModels() { + return makeRequest("GET", "/api/models"); +} + +/** + * Get available models from active providers + combos (OpenAI compatible) + * @returns {Promise} { success, data: { object, data: [...models] } } + */ +async function getAvailableModels() { + return makeRequest("GET", "/v1/models"); +} + +// ============================================================================ +// PROVIDER NODES API (custom providers) +// ============================================================================ + +async function getProviderNodes() { + return makeRequest("GET", "/api/provider-nodes"); +} + +async function createProviderNode(data) { + return makeRequest("POST", "/api/provider-nodes", data); +} + +async function updateProviderNode(id, data) { + return makeRequest("PUT", `/api/provider-nodes/${id}`, data); +} + +async function deleteProviderNode(id) { + return makeRequest("DELETE", `/api/provider-nodes/${id}`); +} + +async function validateProviderNode(data) { + return makeRequest("POST", "/api/provider-nodes/validate", data); +} + +// ============================================================================ +// TUNNEL API +// ============================================================================ + +/** + * Get tunnel status + * @returns {Promise} { success, data: { enabled, tunnelUrl, shortId, running } } + */ +async function getTunnelStatus() { + return makeRequest("GET", "/api/tunnel/status"); +} + +/** + * Enable tunnel + * @returns {Promise} { success, data: { tunnelUrl, shortId } } + */ +async function enableTunnel() { + return makeRequest("POST", "/api/tunnel/enable"); +} + +/** + * Disable tunnel + * @returns {Promise} { success, data: { success } } + */ +async function disableTunnel() { + return makeRequest("POST", "/api/tunnel/disable"); +} + +// ============================================================================ +// EXPORTS +// ============================================================================ + +module.exports = { + configure, + + // Providers + getProviders, + getProviderById, + testProvider, + deleteProvider, + getProviderModels, + + // Connection aliases + testConnection: testProvider, + deleteConnection: deleteProvider, + updateConnection, + + // OAuth + getOAuthAuthUrl, + exchangeOAuthCode, + getOAuthDeviceCode, + pollOAuthToken, + createApiKeyProvider, + + // API Keys + getApiKeys, + createApiKey, + deleteApiKey, + + // Combos + getCombos, + getComboById, + createCombo, + updateCombo, + deleteCombo, + + // CLI Tools + getCliToolSettings, + applyCliToolSettings, + resetCliToolSettings, + + // Settings + getSettings, + updateSettings, + resetPassword, + + // Tunnel + getTunnelStatus, + enableTunnel, + disableTunnel, + + // Models + getModels, + getAvailableModels, + + // Provider Nodes (custom providers) + getProviderNodes, + createProviderNode, + updateProviderNode, + deleteProviderNode, + validateProviderNode, +}; diff --git a/cli/src/cli/menus/apiKeys.js b/cli/src/cli/menus/apiKeys.js new file mode 100644 index 0000000000000000000000000000000000000000..ecd4e687d61bf1537bfa5a55d789f53a6cacefeb --- /dev/null +++ b/cli/src/cli/menus/apiKeys.js @@ -0,0 +1,233 @@ +const api = require("../api/client"); +const { prompt, confirm, pause } = require("../utils/input"); +const { clearScreen, showStatus, showHeader } = require("../utils/display"); +const { maskKey, formatDate, getRelativeTime } = require("../utils/format"); +const { showMenuWithBack } = require("../utils/menuHelper"); +const { copyToClipboard } = require("../utils/clipboard"); +const { getEndpoint } = require("../utils/endpoint"); + +/** + * Display API keys list with formatted output + * @param {Array} keys - Array of API key objects + * @param {number} port - Server port + */ +function displayApiKeys(keys, port) { + console.log("┌─────────────────────────────────────────────────────────┐"); + console.log("│ 🔑 API Keys Management │"); + console.log("├─────────────────────────────────────────────────────────┤"); + // Note: This function is legacy, endpoint shown in menu header instead + console.log("│ │"); + + if (keys.length === 0) { + console.log("│ No API keys found. │"); + } else { + console.log(`│ Your API Keys (${keys.length}):${" ".repeat(42 - String(keys.length).length)}│`); + + keys.forEach((key, index) => { + console.log("│ │"); + console.log(`│ ${index + 1}. ${key.name}${" ".repeat(52 - String(index + 1).length - key.name.length)}│`); + + const maskedKey = maskKey(key.key); + console.log(`│ Key: ${maskedKey}${" ".repeat(47 - maskedKey.length)}│`); + + const created = formatDate(key.createdAt); + console.log(`│ Created: ${created}${" ".repeat(43 - created.length)}│`); + + if (key.lastUsedAt) { + const lastUsed = getRelativeTime(key.lastUsedAt); + console.log(`│ Last used: ${lastUsed}${" ".repeat(41 - lastUsed.length)}│`); + } else { + console.log("│ Last used: Never │"); + } + }); + } + + console.log("│ │"); + console.log("│ Actions: │"); + console.log("│ 1. Create New API Key │"); + console.log("│ 2. View Full Key (by number) │"); + console.log("│ 3. Copy Key to Clipboard (by number) │"); + console.log("│ 4. Delete Key (by number) │"); + console.log("│ 0. ← Back to Main Menu │"); + console.log("└─────────────────────────────────────────────────────────┘"); +} + +/** + * Handle creating new API key + * @returns {Promise} Success status + */ +async function handleCreateKey() { + console.log("\n📝 Create New API Key"); + console.log("─".repeat(30)); + + const name = await prompt("Enter key name: "); + + if (!name) { + showStatus("Key name cannot be empty", "error"); + await pause(); + return false; + } + + const result = await api.createApiKey(name); + + if (!result.success) { + showStatus(`Failed to create key: ${result.error}`, "error"); + await pause(); + return false; + } + + console.log("\n✅ API Key created successfully!"); + console.log("\n⚠️ IMPORTANT: Save this key now. You won't be able to see it again!"); + console.log(`\nKey: ${result.data.key}`); + console.log(`Name: ${result.data.name}`); + console.log(`ID: ${result.data.id}`); + + const shouldCopy = await confirm("\nCopy key to clipboard?"); + if (shouldCopy) { + if (copyToClipboard(result.data.key)) { + showStatus("Key copied to clipboard!", "success"); + } else { + showStatus("Failed to copy to clipboard", "error"); + } + } + + await pause(); + return true; +} + +/** + * Handle viewing full API key + * @param {Object} key - API key object + */ +async function handleViewFullKey(key) { + console.log("\n🔍 Full API Key"); + console.log("─".repeat(30)); + console.log(`Name: ${key.name}`); + console.log(`Key: ${key.key}`); + console.log(`ID: ${key.id}`); + console.log(`Created: ${formatDate(key.createdAt)}`); + + if (key.lastUsedAt) { + console.log(`Last used: ${getRelativeTime(key.lastUsedAt)}`); + } else { + console.log("Last used: Never"); + } + + await pause(); +} + +/** + * Handle copying API key to clipboard + * @param {Object} key - API key object + */ +async function handleCopyKey(key) { + if (copyToClipboard(key.key)) { + showStatus(`Key "${key.name}" copied to clipboard!`, "success"); + } else { + showStatus("Failed to copy to clipboard", "error"); + } + await pause(); +} + +/** + * Handle deleting API key + * @param {Object} key - API key object + * @returns {Promise} Success status + */ +async function handleDeleteKey(key) { + console.log(`\n⚠️ Delete API Key: ${key.name}`); + console.log("─".repeat(30)); + console.log(`Key: ${maskKey(key.key)}`); + console.log(`Created: ${formatDate(key.createdAt)}`); + + const confirmed = await confirm("\nAre you sure you want to delete this key?"); + + if (!confirmed) { + showStatus("Deletion cancelled", "info"); + await pause(); + return false; + } + + const result = await api.deleteApiKey(key.id); + + if (!result.success) { + showStatus(`Failed to delete key: ${result.error}`, "error"); + await pause(); + return false; + } + + showStatus("API key deleted successfully", "success"); + await pause(); + return true; +} + +/** + * Show actions for a specific key + * @param {Object} key - API key object + * @param {number} port - Server port + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showKeyActions(key, port, breadcrumb = []) { + const { endpoint } = await getEndpoint(port); + await showMenuWithBack({ + title: `🔑 ${key.name}`, + breadcrumb: [...breadcrumb, key.name], + headerContent: `Name: ${key.name}\nKey: ${key.key}\nEndpoint: ${endpoint}`, + items: [ + { + label: "Copy to Clipboard", + action: async () => { + await handleCopyKey(key); + return true; + } + }, + { + label: "Delete Key", + action: async () => { + await handleDeleteKey(key); + return false; // Exit after delete + } + } + ] + }); +} + +/** + * Main API Keys menu + * @param {number} port - Server port number + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showApiKeysMenu(port, breadcrumb = []) { + const { showListMenu } = require("../utils/menuHelper"); + + const { endpoint } = await getEndpoint(port); + await showListMenu({ + title: "🔑 API Keys Management", + breadcrumb, + headerContent: `Endpoint: ${endpoint}`, + fetchItems: async () => { + const result = await api.getApiKeys(); + if (!result.success) { + clearScreen(); + showStatus(`Failed to fetch API keys: ${result.error}`, "error"); + await pause(); + return null; + } + return { items: result.data.keys || [] }; + }, + formatItem: (key) => `${key.name} (${maskKey(key.key)})`, + onSelect: async (key) => { + await showKeyActions(key, port, breadcrumb); + }, + createAction: { + label: "Create New API Key", + action: async () => { + await handleCreateKey(); + } + } + }); +} + +module.exports = { + showApiKeysMenu +}; diff --git a/cli/src/cli/menus/cliTools.js b/cli/src/cli/menus/cliTools.js new file mode 100644 index 0000000000000000000000000000000000000000..3a84a074e95f69853c33000323141df486fd0d3b --- /dev/null +++ b/cli/src/cli/menus/cliTools.js @@ -0,0 +1,618 @@ +const api = require("../api/client"); +const { pause, confirm } = require("../utils/input"); +const { showStatus } = require("../utils/display"); +const { selectModelFromList } = require("../utils/modelSelector"); +const { showMenuWithBack } = require("../utils/menuHelper"); +const { getEndpoint } = require("../utils/endpoint"); + +const COLORS = { + reset: "\x1b[0m", + green: "\x1b[32m", + red: "\x1b[31m", + dim: "\x1b[2m", + cyan: "\x1b[36m" +}; + +// Claude model types with defaults (matching Web UI) +const CLAUDE_MODEL_TYPES = [ + { id: "sonnet", name: "Sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-4-5-20250929" }, + { id: "opus", name: "Opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-5-20251101" }, + { id: "haiku", name: "Haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" }, +]; + +// ─── Shared helpers ─────────────────────────────────────────────────────────── + +/** + * Get first available API key from server + * @returns {Promise} + */ +async function getFirstApiKey() { + const result = await api.getApiKeys(); + const keys = result.success ? (result.data.keys || []) : []; + return keys.length > 0 ? keys[0].key : null; +} + +// ─── Claude Code ────────────────────────────────────────────────────────────── + +/** + * Build header showing current Claude config status + * @returns {Promise} + */ +async function buildClaudeHeader() { + const result = await api.getCliToolSettings("claude"); + if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`; + + const settings = result.data.settings; + const currentUrl = settings?.env?.ANTHROPIC_BASE_URL; + const currentKey = settings?.env?.ANTHROPIC_AUTH_TOKEN; + const lines = []; + + if (currentUrl) { + lines.push(`Status: ${COLORS.green}✓ Configured${COLORS.reset}`); + lines.push(`Endpoint: ${COLORS.cyan}${currentUrl}${COLORS.reset}`); + if (currentKey) { + lines.push(`API Key: ${COLORS.dim}${currentKey.substring(0, 10)}...${COLORS.reset}`); + } + } else { + lines.push(`Status: ${COLORS.red}✗ Not configured${COLORS.reset}`); + lines.push(`${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}`); + } + + return lines.join("\n"); +} + +/** + * Get current Claude model from settings + * @param {string} envKey + * @returns {Promise} + */ +async function getClaudeModel(envKey) { + const result = await api.getCliToolSettings("claude"); + return result.success ? (result.data.settings?.env?.[envKey] || "Not set") : "Not set"; +} + +/** + * Quick setup for Claude Code — sets endpoint, key, and all default models + * @param {number} port + */ +async function claudeQuickSetup(port) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + + if (!apiKey) { + showStatus("No API keys found. Create one in API Keys menu first.", "error"); + await pause(); + return; + } + + const env = { ANTHROPIC_BASE_URL: endpoint, ANTHROPIC_AUTH_TOKEN: apiKey, API_TIMEOUT_MS: "600000" }; + CLAUDE_MODEL_TYPES.forEach(t => { env[t.envKey] = t.defaultValue; }); + + const result = await api.applyCliToolSettings("claude", { env }); + showStatus(result.success ? "Quick Setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Select and save a specific Claude model type + * @param {Object} modelType + * @param {number} port + */ +async function claudeSelectModel(modelType, port) { + const current = await getClaudeModel(modelType.envKey); + const selected = await selectModelFromList(`Select ${modelType.name} Model`, current, { excludeCombos: true }); + if (!selected) return; + + const env = { [modelType.envKey]: selected }; + + // Also set base URL if not configured yet + const settingsResult = await api.getCliToolSettings("claude"); + if (!settingsResult.data?.settings?.env?.ANTHROPIC_BASE_URL) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + env.ANTHROPIC_BASE_URL = endpoint; + env.API_TIMEOUT_MS = "600000"; + if (apiKey) env.ANTHROPIC_AUTH_TOKEN = apiKey; + } + + const result = await api.applyCliToolSettings("claude", { env }); + showStatus(result.success ? `${modelType.name} → ${selected} saved!` : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Reset Claude Code settings + */ +async function claudeReset() { + const result = await api.resetCliToolSettings("claude"); + showStatus(result.success ? "Settings reset successfully!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Claude Code submenu + * @param {number} port + * @param {Array} breadcrumb + */ +async function showClaudeCodeMenu(port, breadcrumb = []) { + await showMenuWithBack({ + title: "🔧 Claude Code Settings", + breadcrumb, + headerContent: buildClaudeHeader, + refresh: async () => ({ + sonnet: await getClaudeModel("ANTHROPIC_DEFAULT_SONNET_MODEL"), + opus: await getClaudeModel("ANTHROPIC_DEFAULT_OPUS_MODEL"), + haiku: await getClaudeModel("ANTHROPIC_DEFAULT_HAIKU_MODEL"), + }), + items: [ + { + label: "⚡ Quick Setup (recommended)", + action: async () => { await claudeQuickSetup(port); return true; } + }, + { + label: (d) => `Sonnet → ${d.sonnet}`, + action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[0], port); return true; } + }, + { + label: (d) => `Opus → ${d.opus}`, + action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[1], port); return true; } + }, + { + label: (d) => `Haiku → ${d.haiku}`, + action: async () => { await claudeSelectModel(CLAUDE_MODEL_TYPES[2], port); return true; } + }, + { + label: "Reset to Default", + action: async () => { await claudeReset(); return true; } + } + ] + }); +} + +// ─── Codex CLI ──────────────────────────────────────────────────────────────── + +/** + * Build header showing current Codex config status + * @returns {Promise} + */ +async function buildCodexHeader() { + const result = await api.getCliToolSettings("codex"); + if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`; + + const { installed, has9Router, config } = result.data; + if (!installed) return `Status: ${COLORS.red}✗ Codex CLI not installed${COLORS.reset}`; + + if (!has9Router) { + return [ + `Status: ${COLORS.red}✗ Not configured${COLORS.reset}`, + `${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}` + ].join("\n"); + } + + // Parse base_url and model from raw TOML string + const baseUrlMatch = config && config.match(/base_url\s*=\s*"([^"]+)"/); + const modelMatch = config && config.match(/^model\s*=\s*"([^"]+)"/m); + const baseUrl = baseUrlMatch ? baseUrlMatch[1] : ""; + const model = modelMatch ? modelMatch[1] : ""; + + const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`]; + if (baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${baseUrl}${COLORS.reset}`); + if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`); + return lines.join("\n"); +} + +/** + * Quick setup for Codex CLI + * @param {number} port + */ +async function codexQuickSetup(port) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + + if (!apiKey) { + showStatus("No API keys found. Create one in API Keys menu first.", "error"); + await pause(); + return; + } + + // Get model selection + const model = await selectModelFromList("Select Codex Model", "cx/claude-sonnet-4-5-20250929", { excludeCombos: true }); + if (!model) return; + + const result = await api.applyCliToolSettings("codex", { baseUrl: endpoint, apiKey, model }); + showStatus(result.success ? "Codex setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Reset Codex CLI settings + */ +async function codexReset() { + const result = await api.resetCliToolSettings("codex"); + showStatus(result.success ? "Codex settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Codex CLI submenu + * @param {number} port + * @param {Array} breadcrumb + */ +async function showCodexMenu(port, breadcrumb = []) { + await showMenuWithBack({ + title: "🤖 Codex CLI Settings", + breadcrumb, + headerContent: buildCodexHeader, + refresh: async () => ({}), + items: [ + { + label: "⚡ Quick Setup", + action: async () => { await codexQuickSetup(port); return true; } + }, + { + label: "Reset to Default", + action: async () => { await codexReset(); return true; } + } + ] + }); +} + +// ─── Factory Droid ──────────────────────────────────────────────────────────── + +/** + * Build header showing current Droid config status + * @returns {Promise} + */ +async function buildDroidHeader() { + const result = await api.getCliToolSettings("droid"); + if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`; + + const { installed, has9Router, settings } = result.data; + if (!installed) return `Status: ${COLORS.red}✗ Factory Droid not installed${COLORS.reset}`; + + if (!has9Router) { + return [ + `Status: ${COLORS.red}✗ Not configured${COLORS.reset}`, + `${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}` + ].join("\n"); + } + + // Extract 9Router custom model config + const custom = settings?.customModels?.find(m => m.id === "custom:9Router-0"); + const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`]; + if (custom?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${custom.baseUrl}${COLORS.reset}`); + if (custom?.model) lines.push(`Model: ${COLORS.dim}${custom.model}${COLORS.reset}`); + return lines.join("\n"); +} + +/** + * Quick setup for Factory Droid + * @param {number} port + */ +async function droidQuickSetup(port) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + + if (!apiKey) { + showStatus("No API keys found. Create one in API Keys menu first.", "error"); + await pause(); + return; + } + + const model = await selectModelFromList("Select Droid Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true }); + if (!model) return; + + const result = await api.applyCliToolSettings("droid", { baseUrl: endpoint, apiKey, model }); + showStatus(result.success ? "Factory Droid setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Reset Factory Droid settings + */ +async function droidReset() { + const result = await api.resetCliToolSettings("droid"); + showStatus(result.success ? "Factory Droid settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Factory Droid submenu + * @param {number} port + * @param {Array} breadcrumb + */ +async function showDroidMenu(port, breadcrumb = []) { + await showMenuWithBack({ + title: "🤖 Factory Droid Settings", + breadcrumb, + headerContent: buildDroidHeader, + refresh: async () => ({}), + items: [ + { + label: "⚡ Quick Setup", + action: async () => { await droidQuickSetup(port); return true; } + }, + { + label: "Reset to Default", + action: async () => { await droidReset(); return true; } + } + ] + }); +} + +// ─── Open Claw ──────────────────────────────────────────────────────────────── + +/** + * Build header showing current OpenClaw config status + * @returns {Promise} + */ +async function buildOpenClawHeader() { + const result = await api.getCliToolSettings("openclaw"); + if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`; + + const { installed, has9Router, settings } = result.data; + if (!installed) return `Status: ${COLORS.red}✗ Open Claw not installed${COLORS.reset}`; + + if (!has9Router) { + return [ + `Status: ${COLORS.red}✗ Not configured${COLORS.reset}`, + `${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}` + ].join("\n"); + } + + // Extract 9Router provider config + const provider = settings?.models?.providers?.["9router"]; + const primary = settings?.agents?.defaults?.model?.primary || ""; + const model = primary.startsWith("9router/") ? primary.replace("9router/", "") : (provider?.models?.[0]?.id || ""); + const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`]; + if (provider?.baseUrl) lines.push(`Endpoint: ${COLORS.cyan}${provider.baseUrl}${COLORS.reset}`); + if (model) lines.push(`Model: ${COLORS.dim}${model}${COLORS.reset}`); + return lines.join("\n"); +} + +/** + * Quick setup for Open Claw + * @param {number} port + */ +async function openClawQuickSetup(port) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + + if (!apiKey) { + showStatus("No API keys found. Create one in API Keys menu first.", "error"); + await pause(); + return; + } + + const model = await selectModelFromList("Select OpenClaw Model", "cc/claude-sonnet-4-5-20250929", { excludeCombos: true }); + if (!model) return; + + const result = await api.applyCliToolSettings("openclaw", { baseUrl: endpoint, apiKey, model }); + showStatus(result.success ? "Open Claw setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Reset Open Claw settings + */ +async function openClawReset() { + const result = await api.resetCliToolSettings("openclaw"); + showStatus(result.success ? "Open Claw settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +/** + * Open Claw submenu + * @param {number} port + * @param {Array} breadcrumb + */ +async function showOpenClawMenu(port, breadcrumb = []) { + await showMenuWithBack({ + title: "🦞 Open Claw Settings", + breadcrumb, + headerContent: buildOpenClawHeader, + refresh: async () => ({}), + items: [ + { + label: "⚡ Quick Setup", + action: async () => { await openClawQuickSetup(port); return true; } + }, + { + label: "Reset to Default", + action: async () => { await openClawReset(); return true; } + } + ] + }); +} + +// ─── OpenCode CLI ───────────────────────────────────────────────────────────── + +async function buildOpenCodeHeader() { + const result = await api.getCliToolSettings("opencode"); + if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`; + + const { installed, has9Router, opencode } = result.data; + if (!installed) return `Status: ${COLORS.red}✗ OpenCode CLI not installed${COLORS.reset}`; + + if (!has9Router) { + return [ + `Status: ${COLORS.red}✗ Not configured${COLORS.reset}`, + `${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}` + ].join("\n"); + } + + const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`]; + if (opencode?.baseURL) lines.push(`Endpoint: ${COLORS.cyan}${opencode.baseURL}${COLORS.reset}`); + if (opencode?.activeModel) lines.push(`Active: ${COLORS.dim}${opencode.activeModel}${COLORS.reset}`); + if (Array.isArray(opencode?.models) && opencode.models.length > 0) { + lines.push(`Models: ${COLORS.dim}${opencode.models.join(", ")}${COLORS.reset}`); + } + return lines.join("\n"); +} + +async function openCodeQuickSetup(port) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + + if (!apiKey) { + showStatus("No API keys found. Create one in API Keys menu first.", "error"); + await pause(); + return; + } + + // Pick first model (also becomes active model by default) + const firstModel = await selectModelFromList("Select Active Model (OpenCode)", "", { excludeCombos: true }); + if (!firstModel) return; + + const models = [firstModel]; + + // Optionally add more models + while (true) { + const more = await confirm(`Add another model? (current: ${models.length})`); + if (!more) break; + const next = await selectModelFromList(`Add Model #${models.length + 1}`, models.join(", "), { excludeCombos: true }); + if (!next) break; + if (!models.includes(next)) models.push(next); + } + + // Optional subagent model + let subagentModel = firstModel; + const wantSubagent = await confirm(`Set a different subagent model? (default: ${firstModel})`); + if (wantSubagent) { + const picked = await selectModelFromList("Select Subagent Model", firstModel, { excludeCombos: true }); + if (picked) subagentModel = picked; + } + + const result = await api.applyCliToolSettings("opencode", { + baseUrl: endpoint, + apiKey, + models, + activeModel: firstModel, + subagentModel, + }); + showStatus(result.success ? "OpenCode setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +async function openCodeReset() { + const result = await api.resetCliToolSettings("opencode"); + showStatus(result.success ? "OpenCode settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +async function showOpenCodeMenu(port, breadcrumb = []) { + await showMenuWithBack({ + title: "💻 OpenCode CLI Settings", + breadcrumb, + headerContent: buildOpenCodeHeader, + refresh: async () => ({}), + items: [ + { label: "⚡ Quick Setup", action: async () => { await openCodeQuickSetup(port); return true; } }, + { label: "Reset to Default", action: async () => { await openCodeReset(); return true; } } + ] + }); +} + +// ─── Hermes Agent ───────────────────────────────────────────────────────────── + +async function buildHermesHeader() { + const result = await api.getCliToolSettings("hermes"); + if (!result.success) return ` ${COLORS.red}Failed to load settings${COLORS.reset}`; + + const { installed, has9Router, settings } = result.data; + if (!installed) return `Status: ${COLORS.red}✗ Hermes Agent not installed${COLORS.reset}`; + + if (!has9Router) { + return [ + `Status: ${COLORS.red}✗ Not configured${COLORS.reset}`, + `${COLORS.dim}Run "Quick Setup" to configure${COLORS.reset}` + ].join("\n"); + } + + const model = settings?.model || {}; + const lines = [`Status: ${COLORS.green}✓ Configured${COLORS.reset}`]; + if (model.base_url) lines.push(`Endpoint: ${COLORS.cyan}${model.base_url}${COLORS.reset}`); + if (model.default) lines.push(`Model: ${COLORS.dim}${model.default}${COLORS.reset}`); + return lines.join("\n"); +} + +async function hermesQuickSetup(port) { + const { endpoint } = await getEndpoint(port); + const apiKey = await getFirstApiKey(); + + if (!apiKey) { + showStatus("No API keys found. Create one in API Keys menu first.", "error"); + await pause(); + return; + } + + const model = await selectModelFromList("Select Hermes Model", "", { excludeCombos: true }); + if (!model) return; + + const result = await api.applyCliToolSettings("hermes", { baseUrl: endpoint, apiKey, model }); + showStatus(result.success ? "Hermes setup completed!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +async function hermesReset() { + const result = await api.resetCliToolSettings("hermes"); + showStatus(result.success ? "Hermes settings reset!" : `Failed: ${result.error}`, result.success ? "success" : "error"); + await pause(); +} + +async function showHermesMenu(port, breadcrumb = []) { + await showMenuWithBack({ + title: "⚡ Hermes Agent Settings", + breadcrumb, + headerContent: buildHermesHeader, + refresh: async () => ({}), + items: [ + { label: "⚡ Quick Setup", action: async () => { await hermesQuickSetup(port); return true; } }, + { label: "Reset to Default", action: async () => { await hermesReset(); return true; } } + ] + }); +} + +// ─── Main CLI Tools Menu ────────────────────────────────────────────────────── + +/** + * Main CLI Tools menu + * @param {number} port + * @param {Array} breadcrumb + */ +async function showCliToolsMenu(port, breadcrumb = []) { + const { endpoint } = await getEndpoint(port); + await showMenuWithBack({ + title: "🔧 CLI Tools", + breadcrumb, + headerContent: `Configure CLI tools to use 9Router\nEndpoint: ${endpoint}`, + items: [ + { + label: "Claude Code", + action: async () => { await showClaudeCodeMenu(port, [...breadcrumb, "Claude Code"]); return true; } + }, + { + label: "Codex CLI", + action: async () => { await showCodexMenu(port, [...breadcrumb, "Codex CLI"]); return true; } + }, + { + label: "Factory Droid", + action: async () => { await showDroidMenu(port, [...breadcrumb, "Factory Droid"]); return true; } + }, + { + label: "Open Claw", + action: async () => { await showOpenClawMenu(port, [...breadcrumb, "Open Claw"]); return true; } + }, + { + label: "OpenCode", + action: async () => { await showOpenCodeMenu(port, [...breadcrumb, "OpenCode"]); return true; } + }, + { + label: "Hermes", + action: async () => { await showHermesMenu(port, [...breadcrumb, "Hermes"]); return true; } + } + ] + }); +} + +module.exports = { showCliToolsMenu }; diff --git a/cli/src/cli/menus/combos.js b/cli/src/cli/menus/combos.js new file mode 100644 index 0000000000000000000000000000000000000000..5a8b202e070236478c2b791f0e1c6f3ee632996e --- /dev/null +++ b/cli/src/cli/menus/combos.js @@ -0,0 +1,477 @@ +const api = require("../api/client"); +const { prompt, confirm, pause } = require("../utils/input"); +const { clearScreen, showStatus, showHeader } = require("../utils/display"); +const { formatDate } = require("../utils/format"); +const { selectModelFromList } = require("../utils/modelSelector"); +const { showMenuWithBack } = require("../utils/menuHelper"); + +/** + * Format model to string (handle both string and object) + */ +function formatModel(model) { + if (typeof model === "string") return model; + if (model && typeof model === "object") { + return model.id || model.name || `${model.provider}/${model.model}` || JSON.stringify(model); + } + return String(model); +} + +/** + * Show actions for a specific combo + * @param {Object} combo - Combo object + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showComboActions(combo, breadcrumb = []) { + const modelsChain = Array.isArray(combo.models) + ? combo.models.map(formatModel).join(" → ") + : ""; + + await showMenuWithBack({ + title: `🔀 ${combo.name}`, + breadcrumb: [...breadcrumb, combo.name], + headerContent: `Name: ${combo.name}\nModels: ${modelsChain}`, + items: [ + { + label: "Edit Combo", + action: async () => { + await handleEditSingleCombo(combo); + return true; + } + }, + { + label: "Delete Combo", + action: async () => { + await handleDeleteSingleCombo(combo); + return false; // Exit after delete + } + } + ] + }); +} + +/** + * Handle editing a single combo + * @param {Object} combo - Combo to edit + */ +async function handleEditSingleCombo(combo) { + clearScreen(); + console.log(`\n✏️ Edit Combo: ${combo.name}\n`); + + const newName = await prompt(`New name (Enter to keep "${combo.name}"): `); + const name = newName || combo.name; + + console.log("\nCurrent models: " + (Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : "")); + console.log("\nSelect models for this combo (add one by one):"); + + const models = []; + let addMore = true; + + while (addMore) { + const currentChain = models.length > 0 ? models.join(" → ") : "None"; + const model = await selectModelFromList(`Add Model #${models.length + 1}`, `Chain: ${currentChain}`); + + if (model) { + models.push(model); + console.log(`\n✓ Added: ${model}`); + console.log(`Current chain: ${models.join(" → ")}\n`); + + const continueAdding = await confirm("Add another model?"); + addMore = continueAdding; + } else { + addMore = false; + } + } + + // Use new models if any were added, otherwise keep current + const finalModels = models.length > 0 ? models : combo.models; + + const result = await api.updateCombo(combo.id, { name, models: finalModels }); + + if (result.success) { + showStatus("Combo updated!", "success"); + } else { + showStatus(`Update failed: ${result.error}`, "error"); + } + await pause(); +} + +/** + * Handle deleting a single combo + * @param {Object} combo - Combo to delete + */ +async function handleDeleteSingleCombo(combo) { + const confirmed = await confirm(`Delete combo "${combo.name}"?`); + if (confirmed) { + const result = await api.deleteCombo(combo.id); + if (result.success) { + showStatus("Combo deleted!", "success"); + } else { + showStatus(`Delete failed: ${result.error}`, "error"); + } + await pause(); + } +} + +/** + * Main combos menu - list all combos and actions + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showCombosMenu(breadcrumb = []) { + const { showListMenu } = require("../utils/menuHelper"); + + await showListMenu({ + title: "🔀 Combos Management", + breadcrumb, + fetchItems: async () => { + const result = await api.getCombos(); + if (!result.success) { + clearScreen(); + showStatus(`Failed to load combos: ${result.error}`, "error"); + await pause(); + return null; + } + return { items: result.data.combos || [] }; + }, + formatItem: (combo) => { + const modelsChain = Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : ""; + const maxLen = 35; + const displayModels = modelsChain.length > maxLen + ? modelsChain.substring(0, maxLen - 3) + "..." + : modelsChain; + return `${combo.name}: ${displayModels}`; + }, + onSelect: async (combo) => { + await showComboActions(combo, breadcrumb); + }, + createAction: { + label: "Create New Combo", + action: async () => { + await handleCreateCombo(); + } + } + }); +} + +/** + * Show combo detail with stats + */ +async function showComboDetail(comboId) { + clearScreen(); + + const result = await api.getComboById(comboId); + + if (!result.success) { + showStatus(`Failed to load combo: ${result.error}`, "error"); + await pause(); + return; + } + + const combo = result.data; + + console.log("┌─────────────────────────────────────────────────────────┐"); + console.log(`│ 🔀 Combo: ${combo.name.padEnd(46)} │`); + console.log("├─────────────────────────────────────────────────────────┤"); + console.log("│ │"); + console.log(`│ ID: ${combo.id.padEnd(51)} │`); + console.log(`│ Created: ${formatDate(combo.createdAt).padEnd(46)} │`); + console.log(`│ Updated: ${formatDate(combo.updatedAt).padEnd(46)} │`); + console.log("│ │"); + console.log("│ Model Chain: │"); + + // Models is array of strings like ["ag/claude-sonnet-4-5", "kr/claude-sonnet-4.5"] + const models = Array.isArray(combo.models) ? combo.models : []; + models.forEach((modelStr, index) => { + const arrow = index < models.length - 1 ? " →" : " "; + const displayText = `${index + 1}. ${modelStr}${arrow}`; + const padding = Math.max(0, 54 - displayText.length); + console.log(`│ ${displayText}${" ".repeat(padding)} │`); + }); + + console.log("│ │"); + console.log("└─────────────────────────────────────────────────────────┘"); + + await pause(); +} + +/** + * Format combo for menu display + */ +function formatComboLabel(combo) { + const modelsChain = Array.isArray(combo.models) ? combo.models.map(formatModel).join(" → ") : ""; + const maxLen = 40; + const displayModels = modelsChain.length > maxLen + ? modelsChain.substring(0, maxLen - 3) + "..." + : modelsChain; + return `${combo.name}: ${displayModels}`; +} + +/** + * Create new combo + */ +async function handleCreateCombo() { + clearScreen(); + + showStatus("Create New Combo", "info"); + console.log(); + + // Get combo name + const name = await prompt("Combo name: "); + if (!name) { + showStatus("Combo name is required", "error"); + await pause(); + return; + } + + // Fetch available models + showStatus("Loading available models...", "info"); + const modelsResult = await api.getModels(); + + if (!modelsResult.success) { + showStatus(`Failed to load models: ${modelsResult.error}`, "error"); + await pause(); + return; + } + + const availableModels = modelsResult.data.models || []; + + if (availableModels.length === 0) { + showStatus("No models available. Please add providers first.", "warning"); + await pause(); + return; + } + + // Select models for chain + const selectedModels = []; + + console.log(); + showStatus("Select models for the chain (minimum 2)", "info"); + + while (true) { + clearScreen(); + console.log(`Creating combo: ${name}`); + console.log(`Selected models (${selectedModels.length}):`); + + if (selectedModels.length > 0) { + selectedModels.forEach((m, i) => { + console.log(` ${i + 1}. ${m.provider}/${m.model}`); + }); + } else { + console.log(" (none)"); + } + + console.log(); + console.log("Available models:"); + availableModels.forEach((m, i) => { + console.log(` ${i + 1}. ${m.provider}/${m.model}`); + }); + + console.log(); + console.log("Actions:"); + console.log(" - Enter number to add model"); + console.log(" - Type 'done' to finish (min 2 models)"); + console.log(" - Type 'cancel' to abort"); + + const input = await prompt("\nAction: "); + + if (input.toLowerCase() === "cancel") { + showStatus("Cancelled", "warning"); + await pause(); + return; + } + + if (input.toLowerCase() === "done") { + if (selectedModels.length < 2) { + showStatus("Please select at least 2 models", "error"); + await pause(); + continue; + } + break; + } + + const num = parseInt(input, 10); + if (isNaN(num) || num < 1 || num > availableModels.length) { + showStatus("Invalid model number", "error"); + await pause(); + continue; + } + + selectedModels.push(availableModels[num - 1]); + } + + // Create combo + showStatus("Creating combo...", "info"); + + const createResult = await api.createCombo({ + name, + models: selectedModels + }); + + if (!createResult.success) { + showStatus(`Failed to create combo: ${createResult.error}`, "error"); + await pause(); + return; + } + + showStatus(`Combo "${name}" created successfully!`, "success"); + await pause(); +} + +/** + * Edit combo - select which combo to edit + */ +async function handleEditCombo(combos) { + if (combos.length === 0) { + showStatus("No combos available", "warning"); + await pause(); + return; + } + + let selectedCombo = null; + + await showMenuWithBack({ + title: "✏️ Select Combo to Edit", + items: combos.map(combo => ({ + label: formatComboLabel(combo), + action: async () => { + selectedCombo = combo; + return false; + } + })) + }); + + if (!selectedCombo) return; + await editSingleCombo(selectedCombo); +} + +/** + * Edit a single combo + */ +async function editSingleCombo(combo) { + clearScreen(); + showStatus(`Editing combo: ${combo.name}`, "info"); + console.log(); + + const newName = await prompt(`New name (current: ${combo.name}, press Enter to keep): `); + const editModels = await confirm("Edit model chain?"); + + let newModels = combo.models; + + if (editModels) { + newModels = []; + + while (true) { + clearScreen(); + console.log(`Editing combo: ${combo.name}`); + console.log(`Selected models (${newModels.length}):`); + + if (newModels.length > 0) { + newModels.forEach((m, i) => console.log(` ${i + 1}. ${m}`)); + } else { + console.log(" (none)"); + } + + console.log("\nType 'done' to finish (min 2 models) or 'cancel' to abort\n"); + + const model = await selectModelFromList("Add Model", ""); + + if (model === null) { + showStatus("Cancelled", "warning"); + await pause(); + return; + } + + if (model === "done") { + if (newModels.length < 2) { + showStatus("Please select at least 2 models", "error"); + await pause(); + continue; + } + break; + } + + newModels.push(model); + showStatus(`Added: ${model}`, "success"); + await pause(); + } + } + + const updateData = {}; + if (newName) updateData.name = newName; + if (editModels) updateData.models = newModels; + + if (Object.keys(updateData).length === 0) { + showStatus("No changes made", "warning"); + await pause(); + return; + } + + showStatus("Updating combo...", "info"); + + const updateResult = await api.updateCombo(combo.id, updateData); + + if (!updateResult.success) { + showStatus(`Failed to update combo: ${updateResult.error}`, "error"); + await pause(); + return; + } + + showStatus("Combo updated successfully!", "success"); + await pause(); +} + +/** + * Delete combo - select which combo to delete + */ +async function handleDeleteCombo(combos) { + if (combos.length === 0) { + showStatus("No combos available", "warning"); + await pause(); + return; + } + + let selectedCombo = null; + + await showMenuWithBack({ + title: "🗑️ Select Combo to Delete", + items: combos.map(combo => ({ + label: formatComboLabel(combo), + action: async () => { + selectedCombo = combo; + return false; + } + })) + }); + + if (!selectedCombo) return; + + clearScreen(); + showStatus(`Combo: ${selectedCombo.name}`, "warning"); + const modelsDisplay = Array.isArray(selectedCombo.models) + ? selectedCombo.models.map(formatModel).join(" → ") + : ""; + console.log(`Models: ${modelsDisplay}`); + console.log(); + + const confirmed = await confirm("Are you sure you want to delete this combo?"); + + if (!confirmed) { + showStatus("Cancelled", "info"); + await pause(); + return; + } + + showStatus("Deleting combo...", "info"); + + const deleteResult = await api.deleteCombo(selectedCombo.id); + + if (!deleteResult.success) { + showStatus(`Failed to delete combo: ${deleteResult.error}`, "error"); + await pause(); + return; + } + + showStatus("Combo deleted successfully!", "success"); + await pause(); +} + +module.exports = { showCombosMenu }; diff --git a/cli/src/cli/menus/providers.js b/cli/src/cli/menus/providers.js new file mode 100644 index 0000000000000000000000000000000000000000..d98e9d64bccc8c27b6f018ad1f160a387093482b --- /dev/null +++ b/cli/src/cli/menus/providers.js @@ -0,0 +1,846 @@ +const api = require("../api/client"); +const { prompt, confirm, pause } = require("../utils/input"); +const { clearScreen, showStatus, showHeader } = require("../utils/display"); +const { formatDate, getRelativeTime } = require("../utils/format"); +const { showMenuWithBack } = require("../utils/menuHelper"); +const { copyToClipboard } = require("../utils/clipboard"); + +// ANSI colors for styling +const COLORS = { + reset: "\x1b[0m", + bold: "\x1b[1m", + cyan: "\x1b[36m", + dim: "\x1b[2m" +}; + +// Provider models - static config (synced from open-sse/config/providerModels.js) +const PROVIDER_MODELS = { + cc: [ + { id: "claude-opus-4-5-20251101" }, + { id: "claude-sonnet-4-5-20250929" }, + { id: "claude-haiku-4-5-20251001" }, + ], + cx: [ + { id: "gpt-5.2-codex" }, + { id: "gpt-5.2" }, + { id: "gpt-5.1-codex-max" }, + { id: "gpt-5.1-codex" }, + { id: "gpt-5.1-codex-mini" }, + { id: "gpt-5.1" }, + { id: "gpt-5-codex" }, + { id: "gpt-5-codex-mini" }, + ], + gc: [ + { id: "gemini-3-flash-preview" }, + { id: "gemini-3-pro-preview" }, + { id: "gemini-2.5-pro" }, + { id: "gemini-2.5-flash" }, + { id: "gemini-2.5-flash-lite" }, + ], + qw: [ + { id: "qwen3-coder-plus" }, + { id: "qwen3-coder-flash" }, + { id: "vision-model" }, + ], + if: [ + { id: "qwen3-coder-plus" }, + { id: "kimi-k2" }, + { id: "kimi-k2-thinking" }, + { id: "deepseek-r1" }, + { id: "deepseek-v3.2-chat" }, + { id: "deepseek-v3.2-reasoner" }, + { id: "minimax-m2" }, + { id: "glm-4.7" }, + ], + ag: [ + { id: "gemini-3-flash-agent" }, + { id: "gemini-3.5-flash-low" }, + { id: "gemini-3.5-flash-extra-low" }, + { id: "gemini-pro-agent" }, + { id: "gemini-3.1-pro-low" }, + { id: "claude-sonnet-4-6" }, + { id: "claude-opus-4-6-thinking" }, + { id: "gpt-oss-120b-medium" }, + { id: "gemini-3-flash" }, + ], + gh: [ + { id: "gpt-5" }, + { id: "gpt-5-mini" }, + { id: "gpt-5.1-codex" }, + { id: "gpt-5.1-codex-max" }, + { id: "gpt-4.1" }, + { id: "claude-4.5-sonnet" }, + { id: "claude-4.5-opus" }, + { id: "claude-4.5-haiku" }, + { id: "gemini-3-pro" }, + { id: "gemini-3-flash" }, + { id: "gemini-2.5-pro" }, + { id: "grok-code-fast-1" }, + ], + kr: [ + { id: "claude-sonnet-4.5" }, + { id: "claude-haiku-4.5" }, + ], + openai: [ + { id: "gpt-4o" }, + { id: "gpt-4o-mini" }, + { id: "gpt-4-turbo" }, + { id: "o1" }, + { id: "o1-mini" }, + ], + anthropic: [ + { id: "claude-sonnet-4-20250514" }, + { id: "claude-opus-4-20250514" }, + { id: "claude-3-5-sonnet-20241022" }, + ], + gemini: [ + { id: "gemini-3-pro-preview" }, + { id: "gemini-2.5-pro" }, + { id: "gemini-2.5-flash" }, + { id: "gemini-2.5-flash-lite" }, + ], + openrouter: [ + { id: "auto" }, + ], + glm: [ + { id: "glm-4.7" }, + { id: "glm-4.6v" }, + ], + kimi: [ + { id: "kimi-latest" }, + ], + minimax: [ + { id: "MiniMax-M2.1" }, + ], +}; + +// Provider definitions +const OAUTH_PROVIDERS = { + claude: { id: "claude", alias: "cc", name: "Claude Code" }, + codex: { id: "codex", alias: "cx", name: "OpenAI Codex" }, + "gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI" }, + github: { id: "github", alias: "gh", name: "GitHub Copilot" }, + antigravity: { id: "antigravity", alias: "ag", name: "Antigravity" }, + iflow: { id: "iflow", alias: "if", name: "iFlow AI" }, + qwen: { id: "qwen", alias: "qw", name: "Qwen Code" }, + kiro: { id: "kiro", alias: "kr", name: "Kiro AI" }, +}; + +const APIKEY_PROVIDERS = { + openrouter: { id: "openrouter", name: "OpenRouter" }, + glm: { id: "glm", name: "GLM Coding" }, + minimax: { id: "minimax", name: "Minimax Coding" }, + kimi: { id: "kimi", name: "Kimi Coding" }, + openai: { id: "openai", name: "OpenAI" }, + anthropic: { id: "anthropic", name: "Anthropic" }, + gemini: { id: "gemini", name: "Gemini" }, +}; + +const ALL_PROVIDERS = { ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS }; + +/** + * Get auth type for provider + * @param {string} providerId - Provider ID + * @returns {string} "oauth" or "apikey" + */ +function getAuthType(providerId) { + return OAUTH_PROVIDERS[providerId] ? "oauth" : "apikey"; +} + +/** + * Count connections by provider + * @param {Array} connections - Array of connection objects + * @returns {Object} Map of providerId -> count + */ +function countConnectionsByProvider(connections) { + const counts = {}; + connections.forEach(conn => { + const providerId = conn.provider || conn.providerId; + counts[providerId] = (counts[providerId] || 0) + 1; + }); + return counts; +} + +/** + * Show main providers menu + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showProvidersMenu(breadcrumb = []) { + // Build provider items list + const providerItems = []; + + Object.values(OAUTH_PROVIDERS).forEach(provider => { + providerItems.push({ + provider, + authType: "oauth", + label: (data) => { + const count = data.counts[provider.id] || 0; + return `${provider.name} (OAuth) - ${count} Connected`; + }, + action: async (data) => { + await showProviderDetail(provider.id, "oauth", data.connections, [...breadcrumb, provider.name]); + return true; + } + }); + }); + + Object.values(APIKEY_PROVIDERS).forEach(provider => { + providerItems.push({ + provider, + authType: "apikey", + label: (data) => { + const count = data.counts[provider.id] || 0; + return `${provider.name} (API) - ${count} Connected`; + }, + action: async (data) => { + await showProviderDetail(provider.id, "apikey", data.connections, [...breadcrumb, provider.name]); + return true; + } + }); + }); + + // Custom provider nodes section + providerItems.push({ + label: () => `${COLORS.dim}── Custom Providers ──${COLORS.reset}`, + action: async () => true, // separator, no-op + isSeparator: true, + }); + providerItems.push({ + label: (data) => { + const count = data.nodeCount || 0; + return `Custom Providers - ${count} Configured`; + }, + action: async () => { + await showCustomProvidersMenu([...breadcrumb, "Custom Providers"]); + return true; + } + }); + + await showMenuWithBack({ + title: "🔌 Providers Management", + breadcrumb, + refresh: async () => { + const [provRes, nodeRes] = await Promise.all([api.getProviders(), api.getProviderNodes()]); + if (!provRes.success) { + showStatus(`Failed to fetch providers: ${provRes.error}`, "error"); + await pause(); + return null; + } + const connections = provRes.data.connections || []; + const nodes = nodeRes.success ? (nodeRes.data.nodes || nodeRes.data || []) : []; + return { + connections, + counts: countConnectionsByProvider(connections), + nodeCount: nodes.length, + }; + }, + items: providerItems + }); +} + +/** + * Build provider header with alias and models + * @param {string} providerId - Provider ID + * @returns {string} + */ +function buildProviderHeader(providerId) { + const provider = ALL_PROVIDERS[providerId]; + const alias = provider.alias || providerId; + + const lines = []; + lines.push(`Alias: ${COLORS.cyan}${alias}${COLORS.reset}`); + + // Get models from static config + const models = PROVIDER_MODELS[alias] || []; + if (models.length > 0) { + const modelList = models + .slice(0, 5) + .map(m => `${alias}/${m.id}`) + .join(", "); + const more = models.length > 5 ? ` (+${models.length - 5} more)` : ""; + lines.push(`Models: ${COLORS.dim}${modelList}${more}${COLORS.reset}`); + } else { + lines.push(`Models: ${COLORS.dim}No models configured${COLORS.reset}`); + } + + return lines.join("\n"); +} + +/** + * Show provider detail with connections and actions + * @param {string} providerId - Provider ID + * @param {string} authType - "oauth" or "apikey" + * @param {Array} allConnections - All connections + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showProviderDetail(providerId, authType, allConnections, breadcrumb = []) { + const provider = ALL_PROVIDERS[providerId]; + const { showListMenu } = require("../utils/menuHelper"); + + await showListMenu({ + title: `🔌 ${provider.name} (${authType.toUpperCase()})`, + breadcrumb, + backLabel: "← Back to Providers", + headerContent: buildProviderHeader(providerId), + fetchItems: async () => { + const response = await api.getProviders(); + if (response.success) { + allConnections.length = 0; + allConnections.push(...(response.data.connections || [])); + } + const providerConns = allConnections.filter(conn => + (conn.provider || conn.providerId) === providerId + ); + return { items: providerConns }; + }, + formatItem: (conn) => { + const status = conn.testStatus === "active" ? "✓" : conn.testStatus === "error" ? "✗" : "?"; + const name = conn.name || conn.email || conn.displayName || "Unnamed"; + return `${name} (${status})`; + }, + onSelect: async (conn) => { + await showConnectionActions(conn, providerId, breadcrumb); + }, + createAction: { + label: "Add New Connection", + action: async () => { + await handleAddConnection(providerId, authType); + } + } + }); +} + +/** + * Show actions for a specific connection + * @param {Object} connection - Connection object + * @param {string} providerId - Provider ID + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showConnectionActions(connection, providerId, breadcrumb = []) { + const name = connection.name || connection.email || connection.displayName || "Unnamed"; + const status = connection.testStatus === "active" ? "✓ Active" : + connection.testStatus === "error" ? "✗ Error" : "? Unknown"; + + await showMenuWithBack({ + title: `🔌 ${name}`, + breadcrumb: [...breadcrumb, name], + headerContent: `Connection: ${name}\nStatus: ${status}`, + items: [ + { + label: "Rename Connection", + action: async () => { + const newName = await prompt(`New name (current: ${name}): `); + if (newName && newName.trim()) { + showStatus("Renaming connection...", "info"); + const result = await api.updateConnection(connection.id, { name: newName.trim() }); + if (result.success) { + showStatus("Connection renamed!", "success"); + connection.name = newName.trim(); + } else { + showStatus(`Rename failed: ${result.error}`, "error"); + } + await pause(); + } + return true; + } + }, + { + label: "Test Connection", + action: async () => { + showStatus("Testing connection...", "info"); + const result = await api.testConnection(connection.id); + if (result.success) { + showStatus("Connection is working!", "success"); + } else { + showStatus(`Test failed: ${result.error}`, "error"); + } + await pause(); + return true; + } + }, + { + label: "Delete Connection", + action: async () => { + const confirmed = await confirm(`Delete connection "${name}"?`); + if (confirmed) { + const result = await api.deleteConnection(connection.id); + if (result.success) { + showStatus("Connection deleted!", "success"); + } else { + showStatus(`Delete failed: ${result.error}`, "error"); + } + await pause(); + return false; // Exit menu after delete + } + return true; + } + } + ] + }); +} + +/** + * Handle adding new connection + * @param {string} providerId - Provider ID + * @param {string} authType - "oauth" or "apikey" + */ +// Providers that use Device Code Flow (terminal-based polling) +const DEVICE_CODE_PROVIDERS = ["github", "qwen", "kiro"]; + +/** + * Handle adding new connection - auto-detect flow type + * @param {string} providerId - Provider ID + * @param {string} authType - "oauth" or "apikey" + */ +async function handleAddConnection(providerId, authType) { + if (authType === "apikey") { + await handleAddApiKeyConnection(providerId); + } else { + // OAuth: auto-detect flow type based on provider + if (DEVICE_CODE_PROVIDERS.includes(providerId)) { + // Device Code Flow for GitHub, Qwen, Kiro + await handleAddDeviceCodeConnection(providerId); + } else { + // Authorization Code Flow for Claude, Codex, Gemini, etc. + await handleAddOAuthConnection(providerId); + } + } +} + +/** + * Handle adding API Key connection + * @param {string} providerId - Provider ID + */ +async function handleAddApiKeyConnection(providerId) { + clearScreen(); + const provider = ALL_PROVIDERS[providerId]; + console.log(`\n➕ Add ${provider.name} API Key Connection\n`); + + const name = await prompt("Connection Name: "); + if (!name) { + showStatus("Cancelled", "warning"); + await pause(); + return; + } + + const apiKey = await prompt("API Key: "); + if (!apiKey) { + showStatus("Cancelled", "warning"); + await pause(); + return; + } + + showStatus("Creating connection...", "info"); + + const result = await api.createApiKeyProvider({ + provider: providerId, + name, + apiKey + }); + + if (result.success) { + showStatus("✓ Connection created successfully!", "success"); + } else { + showStatus(`✗ Failed: ${result.error}`, "error"); + } + + await pause(); +} + +/** + * Handle adding OAuth Authorization Code connection + * User opens URL manually and pastes callback URL + * @param {string} providerId - Provider ID + */ +async function handleAddOAuthConnection(providerId) { + clearScreen(); + const provider = ALL_PROVIDERS[providerId]; + + // Step 1: Get auth URL + showStatus("Requesting authorization URL...", "info"); + const authResult = await api.getOAuthAuthUrl(providerId); + + if (!authResult.success) { + showStatus(`Failed: ${authResult.error}`, "error"); + await pause(); + return; + } + + const authData = authResult.data || authResult; + const authUrl = authData.authUrl; + const codeVerifier = authData.codeVerifier; + const state = authData.state; + const redirectUri = authData.redirectUri; + + if (!authUrl) { + showStatus("Failed: No auth URL received", "error"); + await pause(); + return; + } + + // Step 2: Show URL and instructions + clearScreen(); + showHeader("🔐 OAuth Login", `Providers > ${provider.name} > Add Connection`); + + console.log(` ${COLORS.bold}${COLORS.cyan}1.${COLORS.reset} Open this URL in your browser:`); + console.log(` ${COLORS.dim}${authUrl}${COLORS.reset}`); + if (copyToClipboard(authUrl)) { + console.log(` \x1b[32m✓ Link copied to clipboard!\x1b[0m`); + } + console.log(); + console.log(` ${COLORS.bold}${COLORS.cyan}2.${COLORS.reset} Complete authorization in browser`); + console.log(); + console.log(` ${COLORS.bold}${COLORS.cyan}3.${COLORS.reset} Copy the callback URL from address bar`); + console.log(` ${COLORS.dim}(looks like: http://localhost:20128/callback?code=...)${COLORS.reset}`); + console.log(); + + const callbackUrl = await prompt(" Paste callback URL: "); + if (!callbackUrl) { + showStatus("Cancelled", "warning"); + await pause(); + return; + } + + // Step 3: Parse callback URL and extract code + let code, urlState, error; + try { + const url = new URL(callbackUrl.trim()); + code = url.searchParams.get("code"); + urlState = url.searchParams.get("state"); + error = url.searchParams.get("error"); + + if (error) { + const errorDesc = url.searchParams.get("error_description") || error; + showStatus(`Authorization failed: ${errorDesc}`, "error"); + await pause(); + return; + } + + if (!code) { + showStatus("No authorization code found in URL", "error"); + await pause(); + return; + } + } catch (err) { + showStatus("Invalid URL format", "error"); + await pause(); + return; + } + + // Step 4: Exchange code for tokens + console.log(); + showStatus("Exchanging code for tokens...", "info"); + const exchangeResult = await api.exchangeOAuthCode(providerId, { + code, + redirectUri, + codeVerifier, + state: urlState || state + }); + + if (exchangeResult.success) { + showStatus("Connection created successfully!", "success"); + } else { + showStatus(`Failed: ${exchangeResult.error}`, "error"); + } + + await pause(); +} + +/** + * Handle adding OAuth Device Code connection + * @param {string} providerId - Provider ID + */ +async function handleAddDeviceCodeConnection(providerId) { + clearScreen(); + const provider = ALL_PROVIDERS[providerId]; + + // Step 1: Request device code + showStatus("Requesting device code...", "info"); + const deviceResult = await api.getOAuthDeviceCode(providerId); + + if (!deviceResult.success) { + showStatus(`Failed: ${deviceResult.error}`, "error"); + await pause(); + return; + } + + const deviceData = deviceResult.data || deviceResult; + const device_code = deviceData.device_code; + const user_code = deviceData.user_code; + const verification_uri = deviceData.verification_uri; + const verification_uri_complete = deviceData.verification_uri_complete; + const codeVerifier = deviceData.codeVerifier; + const extraData = deviceData.extraData || deviceData; + + if (!device_code) { + showStatus("Failed: No device code received", "error"); + await pause(); + return; + } + + // Step 2: Show instructions + clearScreen(); + const deviceUrl = verification_uri_complete || verification_uri; + showHeader("📱 Device Login", `Providers > ${provider.name} > Add Connection`); + + console.log(` ${COLORS.bold}${COLORS.cyan}1.${COLORS.reset} Open: ${COLORS.dim}${deviceUrl}${COLORS.reset}`); + if (copyToClipboard(deviceUrl)) { + console.log(` \x1b[32m✓ Link copied to clipboard!\x1b[0m`); + } + console.log(); + if (!verification_uri_complete && user_code) { + console.log(` ${COLORS.bold}${COLORS.cyan}2.${COLORS.reset} Enter code: ${COLORS.bold}${user_code}${COLORS.reset}`); + console.log(); + } + console.log(` ${COLORS.dim}Waiting for authorization...${COLORS.reset}`); + console.log(); + + // Step 3: Poll for token + const maxAttempts = 60; // 5 minutes (5s interval) + for (let i = 0; i < maxAttempts; i++) { + await new Promise(resolve => setTimeout(resolve, 5000)); + + const pollResult = await api.pollOAuthToken(providerId, { + deviceCode: device_code, + codeVerifier, + extraData + }); + + if (pollResult.success) { + showStatus("\nConnection created successfully!", "success"); + await pause(); + return; + } + + // Check if still pending (pending flag is at root level, not in data) + const isPending = pollResult.pending || pollResult.error === "authorization_pending" || pollResult.error === "slow_down"; + if (!isPending) { + showStatus(`\nFailed: ${pollResult.error || "Unknown error"}`, "error"); + await pause(); + return; + } + + process.stdout.write("."); + } + + showStatus("\nTimeout waiting for authorization", "error"); + await pause(); +} + +// ============================================================================ +// CUSTOM PROVIDERS (provider nodes) +// ============================================================================ + +const CUSTOM_NODE_TYPES = ["openai-compatible", "anthropic-compatible"]; +const OPENAI_API_TYPES = ["chat", "responses"]; + +/** + * Show custom providers section in main providers menu + * @param {Array} nodes - List of provider nodes + * @param {Array} connections - All connections + * @param {Array} breadcrumb + */ +async function showCustomProvidersMenu(breadcrumb = []) { + const { showListMenu } = require("../utils/menuHelper"); + + await showListMenu({ + title: "🔧 Custom Providers", + breadcrumb, + backLabel: "← Back to Providers", + fetchItems: async () => { + const res = await api.getProviderNodes(); + if (!res.success) return { items: [] }; + return { items: res.data.nodes || res.data || [] }; + }, + formatItem: (node) => `[${node.prefix}] ${node.name} (${node.type})`, + onSelect: async (node) => { + await showCustomNodeDetail(node, [...breadcrumb, node.name]); + }, + createAction: { + label: "➕ Add Custom Provider", + action: async () => { + await handleAddCustomNode(); + } + } + }); +} + +/** + * Show detail menu for a custom provider node + */ +async function showCustomNodeDetail(node, breadcrumb = []) { + await showMenuWithBack({ + title: `🔧 ${node.name}`, + breadcrumb, + headerContent: [ + `Type: ${node.type}`, + `Prefix: ${COLORS.cyan}${node.prefix}${COLORS.reset}`, + `Base URL: ${COLORS.dim}${node.baseUrl}${COLORS.reset}`, + ].join("\n"), + items: [ + { + label: "Connections", + action: async () => { + await showCustomNodeConnections(node, breadcrumb); + return true; + } + }, + { + label: "Edit Node", + action: async () => { + await handleEditCustomNode(node); + return true; + } + }, + { + label: "Delete Node", + action: async () => { + const confirmed = await confirm(`Delete "${node.name}" and all its connections?`); + if (confirmed) { + const res = await api.deleteProviderNode(node.id); + if (res.success) { + showStatus("Node deleted!", "success"); + } else { + showStatus(`Delete failed: ${res.error}`, "error"); + } + await pause(); + return false; + } + return true; + } + } + ] + }); +} + +/** + * Show connections for a custom provider node + */ +async function showCustomNodeConnections(node, breadcrumb = []) { + const { showListMenu } = require("../utils/menuHelper"); + + await showListMenu({ + title: `🔌 ${node.name} – Connections`, + breadcrumb, + backLabel: "← Back", + fetchItems: async () => { + const res = await api.getProviders(); + if (!res.success) return { items: [] }; + const all = res.data.connections || []; + const items = all.filter(c => c.provider === node.id); + return { items }; + }, + formatItem: (conn) => { + const status = conn.testStatus === "active" ? "✓" : conn.testStatus === "error" ? "✗" : "?"; + return `${conn.name || "Unnamed"} (${status})`; + }, + onSelect: async (conn) => { + await showConnectionActions(conn, node.id, breadcrumb); + }, + createAction: { + label: "Add API Key Connection", + action: async () => { + await handleAddCustomNodeConnection(node); + } + } + }); +} + +/** + * Add API key connection to a custom provider node + */ +async function handleAddCustomNodeConnection(node) { + clearScreen(); + console.log(`\n➕ Add Connection to ${node.name}\n`); + + const name = await prompt("Connection Name: "); + if (!name) { showStatus("Cancelled", "warning"); await pause(); return; } + + const apiKey = await prompt("API Key: "); + if (!apiKey) { showStatus("Cancelled", "warning"); await pause(); return; } + + showStatus("Creating connection...", "info"); + const res = await api.createApiKeyProvider({ provider: node.id, name, apiKey }); + + showStatus(res.success ? "✓ Connection created!" : `✗ Failed: ${res.error}`, res.success ? "success" : "error"); + await pause(); +} + +/** + * Handle adding a new custom provider node + */ +async function handleAddCustomNode() { + clearScreen(); + console.log("\n➕ Add Custom Provider\n"); + + // Step 1: Select type + const typeChoices = CUSTOM_NODE_TYPES.map((t, i) => ` ${i + 1}. ${t}`).join("\n"); + console.log(`Select type:\n${typeChoices}\n`); + const typeInput = await prompt("Type (1/2): "); + const typeIdx = parseInt(typeInput) - 1; + if (isNaN(typeIdx) || !CUSTOM_NODE_TYPES[typeIdx]) { + showStatus("Cancelled", "warning"); await pause(); return; + } + const type = CUSTOM_NODE_TYPES[typeIdx]; + + // Step 2: Inputs + const name = await prompt("Name: "); + if (!name) { showStatus("Cancelled", "warning"); await pause(); return; } + + const prefix = await prompt("Prefix (used in model IDs, e.g. myapi): "); + if (!prefix) { showStatus("Cancelled", "warning"); await pause(); return; } + + const baseUrl = await prompt("Base URL (e.g. https://api.example.com/v1): "); + if (!baseUrl) { showStatus("Cancelled", "warning"); await pause(); return; } + + // Step 3: API type (OpenAI only) + let apiType; + if (type === "openai-compatible") { + const apiTypeChoices = OPENAI_API_TYPES.map((t, i) => ` ${i + 1}. ${t}`).join("\n"); + console.log(`\nAPI Type:\n${apiTypeChoices}\n`); + const apiTypeInput = await prompt("API Type (1/2, default 1): "); + const apiTypeIdx = parseInt(apiTypeInput) - 1; + apiType = OPENAI_API_TYPES[apiTypeIdx] || "chat"; + } + + showStatus("Creating provider node...", "info"); + const body = { name, prefix, baseUrl, type, ...(apiType && { apiType }) }; + const res = await api.createProviderNode(body); + + showStatus(res.success ? "✓ Provider created!" : `✗ Failed: ${res.error}`, res.success ? "success" : "error"); + await pause(); +} + +/** + * Handle editing a custom provider node + */ +async function handleEditCustomNode(node) { + clearScreen(); + console.log(`\n✏️ Edit ${node.name}\n`); + console.log(`${COLORS.dim}Leave blank to keep current value${COLORS.reset}\n`); + + const name = await prompt(`Name (${node.name}): `); + const baseUrl = await prompt(`Base URL (${node.baseUrl}): `); + const prefix = await prompt(`Prefix (${node.prefix}): `); + + const updates = {}; + if (name && name.trim()) updates.name = name.trim(); + if (baseUrl && baseUrl.trim()) updates.baseUrl = baseUrl.trim(); + if (prefix && prefix.trim()) updates.prefix = prefix.trim(); + + if (!Object.keys(updates).length) { + showStatus("No changes", "warning"); await pause(); return; + } + + showStatus("Updating...", "info"); + const res = await api.updateProviderNode(node.id, updates); + if (res.success) { + Object.assign(node, updates); + showStatus("✓ Updated!", "success"); + } else { + showStatus(`✗ Failed: ${res.error}`, "error"); + } + await pause(); +} + +module.exports = { showProvidersMenu }; diff --git a/cli/src/cli/menus/settings.js b/cli/src/cli/menus/settings.js new file mode 100644 index 0000000000000000000000000000000000000000..a86a7c490f461b2f8e2a58d63d4a3af169afe91e --- /dev/null +++ b/cli/src/cli/menus/settings.js @@ -0,0 +1,184 @@ +const api = require("../api/client"); +const { confirm, pause } = require("../utils/input"); +const { showStatus } = require("../utils/display"); +const { showMenuWithBack } = require("../utils/menuHelper"); + +// ANSI colors +const COLORS = { + reset: "\x1b[0m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + dim: "\x1b[2m", + cyan: "\x1b[36m" +}; + +const DEFAULT_PASSWORD = "123456"; + +/** + * Show settings menu (tunnel + RTK + reset password) + * @param {Array} breadcrumb - Breadcrumb path + */ +async function showSettingsMenu(breadcrumb = []) { + await showMenuWithBack({ + title: "⚙️ Settings", + breadcrumb, + headerContent: async (data) => { + const lines = []; + + // Tunnel section + const tunnel = data?.tunnel || {}; + if (tunnel.enabled && tunnel.publicUrl) { + lines.push(` Endpoint: ${COLORS.green}${tunnel.publicUrl}/v1${COLORS.reset}`); + lines.push(` Tunnel: ${COLORS.green}ON${COLORS.reset} ${COLORS.dim}(${tunnel.shortId})${COLORS.reset}`); + } else { + lines.push(` Endpoint: http://localhost:20128/v1`); + lines.push(` Tunnel: ${COLORS.red}OFF${COLORS.reset} ${COLORS.dim}(local only)${COLORS.reset}`); + } + + // 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}`); + + // Auth mode section + const authMode = data?.settings?.authMode || "password"; + const authColor = authMode === "password" ? COLORS.green : COLORS.yellow; + lines.push(` Auth: ${authColor}${authMode.toUpperCase()}${COLORS.reset} ${COLORS.dim}(login mode)${COLORS.reset}`); + + return lines.join("\n"); + }, + refresh: async () => { + const [tunnelRes, settingsRes] = await Promise.all([ + api.getTunnelStatus(), + api.getSettings() + ]); + return { + tunnel: tunnelRes.success ? (tunnelRes.data || {}) : {}, + settings: settingsRes.success ? (settingsRes.data || {}) : {} + }; + }, + items: [ + { + label: "Tunnel ON", + action: async () => { await enableTunnel(); return true; } + }, + { + label: "Tunnel OFF", + action: async () => { await disableTunnel(); return true; } + }, + { + label: (d) => { + const on = d?.settings?.rtkEnabled !== false; + return `Token Saver (RTK): ${on ? "ON" : "OFF"} → toggle`; + }, + action: async (d) => { await toggleRtk(d?.settings?.rtkEnabled !== false); return true; } + }, + { + label: "🔑 Reset Password to Default", + action: async () => { await resetPassword(); return true; } + }, + { + label: (d) => { + const mode = d?.settings?.authMode || "password"; + return mode === "password" ? "🔓 Reset Auth Mode (already password)" : `🔓 Reset Auth Mode to Password (current: ${mode})`; + }, + action: async () => { await resetAuthMode(); return true; } + } + ] + }); +} + +/** + * Reset authMode to "password" via API. Used when OIDC is misconfigured + * and user is locked out of dashboard. CLI bypasses auth via x-9r-cli-token. + */ +async function resetAuthMode() { + const ok = await confirm("Reset auth mode to PASSWORD (disable OIDC)?"); + if (!ok) { + showStatus("Cancelled", "info"); + await pause(); + return; + } + + const result = await api.updateSettings({ authMode: "password" }); + if (result.success) { + showStatus("Auth mode reset to password. OIDC disabled.", "success"); + } else { + showStatus(`Failed: ${result.error}`, "error"); + } + await pause(); +} + +/** + * Enable tunnel via API + */ +async function enableTunnel() { + showStatus("Creating tunnel...", "info"); + const result = await api.enableTunnel(); + + if (result.success) { + const { publicUrl, shortId, alreadyRunning } = result.data || {}; + if (alreadyRunning) { + showStatus(`Tunnel already running: ${publicUrl}`, "success"); + } else { + showStatus(`Tunnel enabled: ${publicUrl} (${shortId})`, "success"); + } + } else { + showStatus(`Failed: ${result.error}`, "error"); + } + + await pause(); +} + +/** + * Disable tunnel via API + */ +async function disableTunnel() { + const result = await api.disableTunnel(); + + if (result.success) { + showStatus("Tunnel disabled", "success"); + } else { + showStatus(`Failed: ${result.error}`, "error"); + } + + await pause(); +} + +/** + * Toggle RTK (Token Saver) via API + * @param {boolean} currentlyOn + */ +async function toggleRtk(currentlyOn) { + const next = !currentlyOn; + const result = await api.updateSettings({ rtkEnabled: next }); + if (result.success) { + showStatus(`Token Saver ${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". + */ +async function resetPassword() { + const ok = await confirm(`Reset dashboard password to default "${DEFAULT_PASSWORD}"?`); + if (!ok) { + showStatus("Cancelled", "info"); + await pause(); + return; + } + + const result = await api.resetPassword(); + if (result.success) { + showStatus(`Password reset. Default: ${DEFAULT_PASSWORD}`, "success"); + } else { + showStatus(`Failed to reset password: ${result.error}`, "error"); + } + await pause(); +} + +module.exports = { showSettingsMenu }; diff --git a/cli/src/cli/terminalUI.js b/cli/src/cli/terminalUI.js new file mode 100644 index 0000000000000000000000000000000000000000..fb28330e8ed14e07294dcef87d225972fc93e94a --- /dev/null +++ b/cli/src/cli/terminalUI.js @@ -0,0 +1,121 @@ +const api = require("./api/client"); +const { showMenuWithBack } = require("./utils/menuHelper"); +const { showProvidersMenu } = require("./menus/providers"); +const { showApiKeysMenu } = require("./menus/apiKeys"); +const { showCombosMenu } = require("./menus/combos"); +const { showSettingsMenu } = require("./menus/settings"); +const { showCliToolsMenu } = require("./menus/cliTools"); + +const COLORS = { + reset: "\x1b[0m", + green: "\x1b[32m", + red: "\x1b[31m", + dim: "\x1b[2m", + cyan: "\x1b[36m" +}; + +// Cached header (SWR): show last value instantly, refresh in background. +let cachedHeader = ""; +let fetchingHeader = false; + +function renderHeader(port, keys, tunnel) { + const tunnelEnabled = tunnel && tunnel.enabled === true; + const lines = []; + if (tunnelEnabled && tunnel.publicUrl) { + lines.push(`Endpoint: ${COLORS.green}${tunnel.publicUrl}/v1${COLORS.reset}`); + lines.push(`Tunnel: ${COLORS.green}ON${COLORS.reset} ${COLORS.dim}(${tunnel.shortId})${COLORS.reset}`); + } else { + lines.push(`Endpoint: http://localhost:${port}/v1`); + lines.push(`Tunnel: ${COLORS.red}OFF${COLORS.reset} ${COLORS.dim}(local only)${COLORS.reset}`); + } + if (!keys || keys.length === 0) { + lines.push(`Key: ${COLORS.dim}No API keys yet${COLORS.reset}`); + } else { + lines.push(`Key: ${COLORS.cyan}${keys[0].key}${COLORS.reset}`); + keys.slice(1).forEach(k => lines.push(` ${COLORS.cyan}${k.key}${COLORS.reset}`)); + } + return lines.join("\n"); +} + +async function refreshHeaderBg(port) { + if (fetchingHeader) return; + fetchingHeader = true; + try { + const [keysResult, tunnelResult] = await Promise.all([ + api.getApiKeys(), + api.getTunnelStatus() + ]); + const keys = keysResult.success ? (keysResult.data.keys || []) : []; + const tunnel = tunnelResult.success ? (tunnelResult.data || {}) : {}; + cachedHeader = renderHeader(port, keys, tunnel); + } finally { + fetchingHeader = false; + } +} + +function getHeader(port) { + // Kick off background refresh; return cache (or placeholder on first call). + refreshHeaderBg(port); + return cachedHeader || `Endpoint: http://localhost:${port}/v1\nTunnel: ${COLORS.dim}...${COLORS.reset}\nKey: ${COLORS.dim}...${COLORS.reset}`; +} + +/** + * Start Terminal UI + * @param {number} port - Server port number + */ +async function startTerminalUI(port) { + // Configure API client + api.configure({ port }); + + const basePath = ["9Router"]; + + // Prime header cache before first render + await refreshHeaderBg(port); + + // Main menu + await showMenuWithBack({ + title: "📡 9Router Terminal UI", + breadcrumb: basePath, + headerContent: () => getHeader(port), + items: [ + { + label: "Providers", + action: async () => { + await showProvidersMenu([...basePath, "Providers"]); + return true; // Continue + } + }, + { + label: "API Keys", + action: async () => { + await showApiKeysMenu(port, [...basePath, "API Keys"]); + return true; + } + }, + { + label: "Combos", + action: async () => { + await showCombosMenu([...basePath, "Combos"]); + return true; + } + }, + { + label: "CLI Tools", + action: async () => { + await showCliToolsMenu(port, [...basePath, "CLI Tools"]); + return true; + } + }, + { + label: "Settings", + action: async () => { + await showSettingsMenu([...basePath, "Settings"]); + return true; + } + } + ], + backLabel: "← Back to Interface Menu" + }); +} + +module.exports = { startTerminalUI }; diff --git a/cli/src/cli/tray/autostart.js b/cli/src/cli/tray/autostart.js new file mode 100644 index 0000000000000000000000000000000000000000..4ab93cf2e3e9e82353b1157658aedc95e567225e --- /dev/null +++ b/cli/src/cli/tray/autostart.js @@ -0,0 +1,306 @@ +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { execSync } = require("child_process"); + +const APP_NAME = "9router"; +const APP_LABEL = "com.9router.autostart"; + +/** + * Resolve the absolute path to this package's cli.js. + * + * Order of preference: + * 1. Explicit `cliPath` argument — cleanest, used when called from running + * cli.js with `__filename`. + * 2. `process.argv[1]` if it's our cli.js — true when 9router is currently + * running and the tray menu fires this code path. + * 3. Compute relative to this file's own location. autostart.js lives at + * `/src/cli/tray/autostart.js`, so cli.js is three levels up. + * This works for any global install layout (nvm, Volta, asdf, Homebrew, + * /usr/local, etc.) without depending on `npm bin -g` (removed in npm 9) + * or a hardcoded `/usr/local/...` path. + * + * Returns null if no candidate exists — callers should not write an autostart + * entry pointing at a non-existent script. + */ +function getCliJsPath(cliPath) { + if (cliPath) { + const resolved = path.resolve(cliPath); + if (fs.existsSync(resolved)) return resolved; + } + if (process.argv[1]) { + const resolved = path.resolve(process.argv[1]); + if (path.basename(resolved) === "cli.js" && fs.existsSync(resolved)) { + return resolved; + } + } + const computed = path.resolve(__dirname, "..", "..", "..", "cli.js"); + if (fs.existsSync(computed)) return computed; + return null; +} + +/** + * Enable auto startup on OS boot + * @param {string} cliPath - Optional path to cli.js (defaults to auto-detect) + * @returns {boolean} success + */ +function enableAutoStart(cliPath) { + const platform = process.platform; + + if (!["darwin", "win32", "linux"].includes(platform)) return false; + if (platform === "linux" && !process.env.DISPLAY) return false; + + try { + if (platform === "darwin") return enableMacOS(cliPath); + if (platform === "win32") return enableWindows(cliPath); + if (platform === "linux") return enableLinux(cliPath); + } catch (err) { + // Silent fail — autostart is optional + } + return false; +} + +/** + * Disable auto startup + * @returns {boolean} success + */ +function disableAutoStart() { + const platform = process.platform; + try { + if (platform === "darwin") return disableMacOS(); + if (platform === "win32") return disableWindows(); + if (platform === "linux") return disableLinux(); + } catch (err) {} + return false; +} + +/** + * Check if autostart is enabled. + * + * On macOS, both the plist file and the launchd registration must be present — + * otherwise the tray menu would lie about the state (showing "✓ Enabled" even + * when launchd has the agent in a failed state or hasn't loaded it). + */ +function isAutoStartEnabled() { + const platform = process.platform; + + try { + if (platform === "darwin") { + const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`); + if (!fs.existsSync(plistPath)) return false; + try { + execSync(`launchctl list ${APP_LABEL}`, { + stdio: ["ignore", "ignore", "ignore"], + timeout: 3000 + }); + return true; + } catch (e) { + return false; + } + } else if (platform === "win32") { + const startupPath = path.join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${APP_NAME}.vbs`); + return fs.existsSync(startupPath); + } else if (platform === "linux") { + const desktopPath = path.join(os.homedir(), ".config", "autostart", `${APP_NAME}.desktop`); + return fs.existsSync(desktopPath); + } + } catch (e) {} + return false; +} + +// ============ macOS ============ + +/** + * Returns true when the current Node process IS the running instance that + * launchd is managing under our agent label. + * + * `launchctl unload ` (and `load`) for an Aqua user-domain agent sends + * SIGTERM to the running process. When the running 9router cli.js was itself + * spawned by the autostart launchd agent (i.e. user enabled autostart at + * some point, then rebooted, then clicked the tray icon's "Disable + * Auto-start" menu item), an unload would kill the very process executing + * the click handler — and the tray icon would disappear instead of the menu + * label flipping back to "Enable Auto-start". This helper lets the enable + * and disable paths sidestep that by skipping launchctl when we'd otherwise + * be killing ourselves. + */ +function isAgentSelfMacOS() { + try { + const output = execSync(`launchctl list ${APP_LABEL}`, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 3000 + }); + const match = output.match(/"PID"\s*=\s*(\d+)/); + return !!(match && parseInt(match[1], 10) === process.pid); + } catch (e) { + return false; + } +} + +function enableMacOS(cliPath) { + const launchAgentsDir = path.join(os.homedir(), "Library", "LaunchAgents"); + const plistPath = path.join(launchAgentsDir, `${APP_LABEL}.plist`); + + if (!fs.existsSync(launchAgentsDir)) { + fs.mkdirSync(launchAgentsDir, { recursive: true }); + } + + const nodePath = process.execPath; + const routerScript = getCliJsPath(cliPath); + // Don't write a broken plist that references a non-existent script. + if (!routerScript) return false; + + // Invoke node + cli.js directly with absolute paths — no shell wrapper. + // The previous design ran `zsh -l -c "..."` so a login shell would source + // nvm/.zshrc and set PATH; that's fragile (nvm.sh sourcing varies by user, + // some setups don't put node on PATH from a non-interactive login shell). + // EnvironmentVariables.PATH explicitly includes node's bin dir so child + // processes spawned by cli.js (npm install at runtime, etc.) resolve. + const launchPath = `${path.dirname(nodePath)}:/usr/local/bin:/usr/bin:/bin`; + + const plistContent = ` + + + + Label + ${APP_LABEL} + ProgramArguments + + ${nodePath} + ${routerScript} + --tray + --skip-update + + EnvironmentVariables + + PATH + ${launchPath} + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/9router.log + StandardErrorPath + /tmp/9router.error.log + +`; + + fs.writeFileSync(plistPath, plistContent); + + // If we're the running agent already, launchctl unload/load would send + // ourselves SIGTERM. Skip it — the plist file is updated on disk and + // launchd will pick it up at next login. isAutoStartEnabled() will still + // return true because launchctl already has the agent loaded. + if (isAgentSelfMacOS()) { + return true; + } + + // Register with launchd in the current session. Without this, the agent + // only takes effect on the next user login and the user has no signal that + // anything actually happened. `unload` first defends against re-enable + // replacing an existing plist. + try { + execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" }); + } catch (e) {} + try { + execSync(`launchctl load -w "${plistPath}"`, { stdio: "ignore" }); + } catch (e) { + // Even if load fails, the plist is on disk and will be picked up at next + // login; report success based on the file write. + } + return true; +} + +function disableMacOS() { + const plistPath = path.join(os.homedir(), "Library", "LaunchAgents", `${APP_LABEL}.plist`); + + // Don't kill ourselves: when the current process is the running agent, + // `launchctl unload` would send SIGTERM and the user clicking + // "Disable Auto-start" from the tray menu would lose their tray icon + // instead of just flipping the menu label. Skip the unload — removing the + // plist file is enough to prevent the agent from starting on next login. + if (!isAgentSelfMacOS()) { + try { + execSync(`launchctl unload "${plistPath}"`, { stdio: "ignore" }); + } catch (e) {} + } + + if (fs.existsSync(plistPath)) { + fs.unlinkSync(plistPath); + } + return true; +} + +// ============ Windows ============ + +function enableWindows(cliPath) { + const startupDir = path.join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs", "Startup"); + const vbsPath = path.join(startupDir, `${APP_NAME}.vbs`); + + if (!fs.existsSync(startupDir)) return false; + + const nodePath = process.execPath; + const routerScript = getCliJsPath(cliPath); + if (!routerScript) return false; + + // Run node + cli.js directly, hidden window. Avoids the fragile + // `9router.cmd` lookup that depended on the npm prefix path. + const vbsContent = `Set WshShell = CreateObject("WScript.Shell") +WshShell.Run """${nodePath}"" ""${routerScript}"" --tray --skip-update", 0, False +`; + fs.writeFileSync(vbsPath, vbsContent); + return true; +} + +function disableWindows() { + const vbsPath = path.join(process.env.APPDATA || "", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${APP_NAME}.vbs`); + if (fs.existsSync(vbsPath)) { + fs.unlinkSync(vbsPath); + } + return true; +} + +// ============ Linux ============ + +function enableLinux(cliPath) { + const autostartDir = path.join(os.homedir(), ".config", "autostart"); + const desktopPath = path.join(autostartDir, `${APP_NAME}.desktop`); + + if (!fs.existsSync(autostartDir)) { + try { fs.mkdirSync(autostartDir, { recursive: true }); } + catch (e) { return false; } + } + + const nodePath = process.execPath; + const routerScript = getCliJsPath(cliPath); + if (!routerScript) return false; + + const desktopContent = `[Desktop Entry] +Type=Application +Name=9Router +Comment=9Router API Proxy +Exec=${nodePath} ${routerScript} --tray --skip-update +Hidden=false +NoDisplay=false +X-GNOME-Autostart-enabled=true +`; + fs.writeFileSync(desktopPath, desktopContent); + return true; +} + +function disableLinux() { + const desktopPath = path.join(os.homedir(), ".config", "autostart", `${APP_NAME}.desktop`); + if (fs.existsSync(desktopPath)) { + fs.unlinkSync(desktopPath); + } + return true; +} + +module.exports = { + enableAutoStart, + disableAutoStart, + isAutoStartEnabled +}; diff --git a/cli/src/cli/tray/icon.ico b/cli/src/cli/tray/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..9cf0e3d1a889f82a6ae42d765b712a9e85a8241e Binary files /dev/null and b/cli/src/cli/tray/icon.ico differ diff --git a/cli/src/cli/tray/icon.png b/cli/src/cli/tray/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..55bf5151f0fbd069f15bebcec89ea36f6a937c9e Binary files /dev/null and b/cli/src/cli/tray/icon.png differ diff --git a/cli/src/cli/tray/tray.js b/cli/src/cli/tray/tray.js new file mode 100644 index 0000000000000000000000000000000000000000..6658e948905f90ee32c355ab10d0b617d7a7b36f --- /dev/null +++ b/cli/src/cli/tray/tray.js @@ -0,0 +1,322 @@ +const { exec } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +let trayInstance = null; +let isWinTray = false; + +/** + * Get icon base64 from file — used for systray (mac/linux) + */ +function getIconBase64() { + const isWin = process.platform === "win32"; + const iconFile = isWin ? "icon.ico" : "icon.png"; + try { + const iconPath = path.join(__dirname, iconFile); + if (fs.existsSync(iconPath)) { + return fs.readFileSync(iconPath).toString("base64"); + } + } catch (e) {} + // Fallback: minimal green dot icon (PNG) + return "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAALEwAACxMBAJqcGAAAAHpJREFUOE9jYBgFgwEwMjIy/Gdg+P8fyP4PxP8ZGBgEcBnGyMjIsICBgSEAhyH/gfgBUNN8XJoZsdkCVL8Ah+b/QPwbqvkBMvk/AwMDAzYX/GdgYAhAN+A/SICRWAMYGfFEJSMjzriEiwDR/xmIa2RkZCSqnZERb3QCAAo3KxzxbKe1AAAAAElFTkSuQmCC"; +} + +/** + * Check if system tray is supported on current OS + * Supported: macOS, Windows, Linux (with GUI) + */ +function isTraySupported() { + const platform = process.platform; + if (!["darwin", "win32", "linux"].includes(platform)) { + return false; + } + if (platform === "linux" && !process.env.DISPLAY) { + return false; + } + return true; +} + +/** + * Initialize system tray with menu + * @param {Object} options - { port, onQuit, onOpenDashboard } + * @returns {Object|null} tray instance or null if not supported/failed + */ +function initTray(options) { + if (!isTraySupported()) { + return null; + } + + // Windows uses PowerShell NotifyIcon (AV-safe), others use systray + if (process.platform === "win32") { + return initWindowsTray(options); + } + return initUnixTray(options); +} + +/** + * Build menu items array shared between platforms + */ +function buildMenuItems(port, autostartEnabled) { + return [ + { title: `9Router (Port ${port})`, tooltip: "Server is running", enabled: false }, + { title: "Open Dashboard", tooltip: "Open in browser", enabled: true }, + { + title: autostartEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start", + tooltip: "Run on OS startup", + enabled: true + }, + { title: "Quit", tooltip: "Stop server and exit", enabled: true } + ]; +} + +// Menu item indexes +const MENU_INDEX = { STATUS: 0, DASHBOARD: 1, AUTOSTART: 2, QUIT: 3 }; + +/** + * Get current autostart state + */ +function getAutostartEnabled() { + try { + const { isAutoStartEnabled } = require("./autostart"); + return isAutoStartEnabled(); + } catch (e) { + return false; + } +} + +/** + * Handle menu item click (shared logic) + */ +function handleClick(index, options, onAutostartToggle) { + const { onQuit, onOpenDashboard, port } = options; + if (index === MENU_INDEX.DASHBOARD) { + if (onOpenDashboard) onOpenDashboard(); + else openBrowser(`http://localhost:${port}/dashboard`); + } else if (index === MENU_INDEX.AUTOSTART) { + const enabled = getAutostartEnabled(); + try { + const { enableAutoStart, disableAutoStart } = require("./autostart"); + if (enabled) disableAutoStart(); + else enableAutoStart(); + onAutostartToggle(!enabled); + } catch (e) {} + } else if (index === MENU_INDEX.QUIT) { + console.log("\n👋 Shutting down..."); + if (onQuit) onQuit(); + killTray(); + setTimeout(() => process.exit(0), 500); + } +} + +/** + * Windows tray via PowerShell NotifyIcon + */ +function initWindowsTray(options) { + const { port } = options; + try { + const { initWinTray } = require("./trayWin"); + const iconPath = path.join(__dirname, "icon.ico"); + const autostartEnabled = getAutostartEnabled(); + const items = buildMenuItems(port, autostartEnabled); + + trayInstance = initWinTray({ + iconPath, + tooltip: `9Router - Port ${port}`, + items, + onClick: (index) => { + handleClick(index, options, (newEnabled) => { + const newTitle = newEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start"; + trayInstance.updateItem(MENU_INDEX.AUTOSTART, newTitle, true); + }); + } + }); + + isWinTray = true; + return trayInstance; + } catch (err) { + return null; + } +} + +/** + * macOS/Linux tray via systray binary + * + * Prefers `systray2` (active fork of `systray`, ships newer + * getlantern/systray-portable binaries that work on macOS 14+ and Apple + * Silicon under Rosetta). Falls back to legacy `systray@1.0.5` if systray2 + * is not available, though that binary's Mach-O headers are rejected by + * modern dyld and the icon will not appear. + */ +function resolveSystray() { + let runtimeDir = null; + try { + const { getRuntimeNodeModules } = require("../../../hooks/sqliteRuntime"); + runtimeDir = getRuntimeNodeModules(); + } catch (e) {} + + // 1) systray2 in runtime dir (where ensureTrayRuntime installs it) + if (runtimeDir) { + try { return { mod: require(path.join(runtimeDir, "systray2")).default, isV2: true }; } catch (e) {} + } + // 2) systray2 resolvable from the package's own node_modules / NODE_PATH + try { return { mod: require("systray2").default, isV2: true }; } catch (e) {} + // 3) Legacy systray fallback (unlikely to render on modern macOS) + try { return { mod: require("systray").default, isV2: false }; } catch (e) {} + if (runtimeDir) { + try { return { mod: require(path.join(runtimeDir, "systray")).default, isV2: false }; } catch (e) {} + } + return null; +} + +function chmodTrayBin(pkgName) { + // systray2's npm tarball occasionally lands without +x on the bundled Go + // binary (observed on macOS). spawn() then fails with EACCES. Best-effort + // chmod on every init avoids a hard-to-diagnose silent tray failure. + try { + const { getRuntimeNodeModules } = require("../../../hooks/sqliteRuntime"); + const binName = process.platform === "darwin" ? "tray_darwin_release" : "tray_linux_release"; + const candidates = [ + path.join(getRuntimeNodeModules(), pkgName, "traybin", binName), + path.join(__dirname, "..", "..", "..", "node_modules", pkgName, "traybin", binName) + ]; + for (const p of candidates) { + if (fs.existsSync(p)) fs.chmodSync(p, 0o755); + } + } catch (e) {} +} + +function initUnixTray(options) { + const { port } = options; + try { + const resolved = resolveSystray(); + if (!resolved) return null; + const { mod: SysTray, isV2 } = resolved; + + chmodTrayBin(isV2 ? "systray2" : "systray"); + + const autostartEnabled = getAutostartEnabled(); + const items = buildMenuItems(port, autostartEnabled); + + const menu = { + icon: getIconBase64(), + // The bundled icon.png is a full-color RGBA logo. Don't mark it as a + // template icon: macOS would then render it as a solid white square + // because template mode only uses the alpha channel. + isTemplateIcon: false, + title: "", + tooltip: `9Router - Port ${port}`, + items + }; + + trayInstance = new SysTray({ menu, debug: false, copyDir: true }); + isWinTray = false; + + trayInstance.onClick((action) => { + handleClick(action.seq_id, options, (newEnabled) => { + trayInstance.sendAction({ + type: "update-item", + item: { + title: newEnabled ? "✓ Auto-start Enabled" : "Enable Auto-start", + tooltip: "Run on OS startup", + enabled: true + }, + seq_id: MENU_INDEX.AUTOSTART + }); + }); + }); + + if (isV2) { + // systray2 exposes a ready() promise instead of onReady/onError. Surface + // failures (binary crash, EACCES, etc.) so users can see why the icon + // didn't appear instead of getting a misleading "running in tray" log. + trayInstance.ready().catch((err) => { + process.stderr.write(`[9router] tray failed to start: ${err && err.message ? err.message : err}\n`); + }); + } else { + trayInstance.onReady(() => {}); + trayInstance.onError(() => {}); + } + + return trayInstance; + } catch (err) { + process.stderr.write(`[9router] tray init error: ${err.message}\n`); + return null; + } +} + +/** + * Kill tray, wait Go binary fully exit (returns Promise). + * Critical for hide-to-tray: macOS must release NSStatusItem before bgProcess + * spawns a new tray, otherwise the new icon silently fails to register. + */ +function killTray() { + const instance = trayInstance; + const wasWin = isWinTray; + trayInstance = null; + if (!instance) return Promise.resolve(); + + if (wasWin) { + try { instance.kill(); } catch (e) {} + return Promise.resolve(); + } + + // Unix: get the Go tray child process handle. + let proc = null; + try { + proc = instance._process || (typeof instance.process === "function" ? instance.process() : null); + } catch (e) {} + + // Graceful shutdown: send {type:"exit"} via IPC so the Go binary can call + // systray.Quit() and release NSStatusItem. SIGKILL leaves a ghost icon on + // the macOS menubar until logout, causing duplicate icons after re-spawn. + const gracefulQuit = () => { try { instance.kill(true); } catch (e) {} }; + const closeIpc = () => { try { instance.kill(false); } catch (e) {} }; + + if (!proc || !proc.pid) { + gracefulQuit(); + closeIpc(); + return Promise.resolve(); + } + + return new Promise((resolve) => { + let done = false; + const finish = () => { if (done) return; done = true; closeIpc(); resolve(); }; + + proc.once("exit", finish); + gracefulQuit(); + + // Escalate: SIGTERM after 800ms, SIGKILL after 1600ms if still alive. + setTimeout(() => { try { process.kill(proc.pid, 0); proc.kill("SIGTERM"); } catch (e) {} }, 800); + setTimeout(() => { try { process.kill(proc.pid, 0); proc.kill("SIGKILL"); } catch (e) {} }, 1600); + + // Fallback poll in case "exit" never fires (detached child, pipe closed) + const deadline = Date.now() + 3000; + const poll = setInterval(() => { + try { process.kill(proc.pid, 0); } catch { clearInterval(poll); finish(); return; } + if (Date.now() > deadline) { clearInterval(poll); finish(); } + }, 50); + }); +} + +/** + * Open browser + */ +function openBrowser(url) { + const platform = process.platform; + let cmd; + + if (platform === "darwin") { + cmd = `open "${url}"`; + } else if (platform === "win32") { + cmd = `start "" "${url}"`; + } else { + cmd = `xdg-open "${url}"`; + } + + exec(cmd); +} + +module.exports = { + initTray, + killTray +}; diff --git a/cli/src/cli/tray/tray.ps1 b/cli/src/cli/tray/tray.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..30562e318bebb44493b0bf5fd2e5ac01b00126ef --- /dev/null +++ b/cli/src/cli/tray/tray.ps1 @@ -0,0 +1,79 @@ +# 9Router tray icon for Windows using NotifyIcon +# IPC: stdin JSON commands, stdout JSON events +param([string]$IconPath, [string]$Tooltip) + +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +[Console]::InputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 + +$script:notifyIcon = New-Object System.Windows.Forms.NotifyIcon +$script:notifyIcon.Icon = New-Object System.Drawing.Icon($IconPath) +$script:notifyIcon.Text = $Tooltip +$script:notifyIcon.Visible = $true + +$script:menu = New-Object System.Windows.Forms.ContextMenuStrip +$script:notifyIcon.ContextMenuStrip = $script:menu +$script:items = @() + +function Write-Event($obj) { + $json = $obj | ConvertTo-Json -Compress + [Console]::Out.WriteLine($json) + [Console]::Out.Flush() +} + +function Add-MenuItem($index, $title, $enabled) { + $item = New-Object System.Windows.Forms.ToolStripMenuItem + $item.Text = $title + $item.Enabled = $enabled + $idx = $index + $item.Add_Click({ Write-Event @{ type = "click"; index = $idx } }.GetNewClosure()) + $script:menu.Items.Add($item) | Out-Null + $script:items += $item +} + +function Update-MenuItem($index, $title, $enabled) { + if ($index -lt $script:items.Count) { + $script:items[$index].Text = $title + $script:items[$index].Enabled = $enabled + } +} + +function Set-Tooltip($text) { + # NotifyIcon.Text max 63 chars + if ($text.Length -gt 63) { $text = $text.Substring(0, 63) } + $script:notifyIcon.Text = $text +} + +# Background reader thread polls stdin via timer on UI thread +$script:timer = New-Object System.Windows.Forms.Timer +$script:timer.Interval = 100 +$script:timer.Add_Tick({ + try { + while ([Console]::In.Peek() -ne -1) { + $line = [Console]::In.ReadLine() + if ([string]::IsNullOrWhiteSpace($line)) { continue } + $cmd = $line | ConvertFrom-Json + switch ($cmd.action) { + "add-item" { Add-MenuItem $cmd.index $cmd.title $cmd.enabled } + "update-item" { Update-MenuItem $cmd.index $cmd.title $cmd.enabled } + "set-tooltip" { Set-Tooltip $cmd.text } + "ready" { Write-Event @{ type = "ready" } } + "kill" { + $script:notifyIcon.Visible = $false + $script:notifyIcon.Dispose() + [System.Windows.Forms.Application]::Exit() + } + } + } + } catch { + Write-Event @{ type = "error"; message = $_.Exception.Message } + } +}) +$script:timer.Start() + +Write-Event @{ type = "started" } +[System.Windows.Forms.Application]::Run() diff --git a/cli/src/cli/tray/trayWin.js b/cli/src/cli/tray/trayWin.js new file mode 100644 index 0000000000000000000000000000000000000000..8c112808fa832e6e79efe34ee8d5078177ce1c81 --- /dev/null +++ b/cli/src/cli/tray/trayWin.js @@ -0,0 +1,89 @@ +const { spawn } = require("child_process"); +const path = require("path"); +const readline = require("readline"); + +// PowerShell-based tray for Windows (AV-safe, zero binary deps) + +let psProcess = null; +let clickHandler = null; + +/** + * Send JSON command to PowerShell tray process via stdin + */ +function sendCommand(cmd) { + if (psProcess && psProcess.stdin.writable) { + psProcess.stdin.write(`${JSON.stringify(cmd)}\n`, "utf8"); + } +} + +/** + * Initialize Windows tray using PowerShell NotifyIcon + * @param {Object} options - { iconPath, tooltip, items, onClick } + * items: [{ title, enabled }] + * @returns {Object|null} controller with sendAction/kill + */ +function initWinTray(options) { + const { iconPath, tooltip, items, onClick } = options; + clickHandler = onClick; + + const scriptPath = path.join(__dirname, "tray.ps1"); + + try { + psProcess = spawn( + "powershell.exe", + [ + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-WindowStyle", "Hidden", + "-InputFormat", "Text", + "-OutputFormat", "Text", + "-File", scriptPath, + "-IconPath", iconPath, + "-Tooltip", tooltip + ], + { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] } + ); + } catch (err) { + return null; + } + + const rl = readline.createInterface({ input: psProcess.stdout }); + rl.on("line", (line) => { + try { + const evt = JSON.parse(line); + if (evt.type === "click" && clickHandler) { + clickHandler(evt.index); + } + } catch (e) {} + }); + + psProcess.on("error", () => {}); + psProcess.stderr.on("data", () => {}); + + // Send initial menu items + items.forEach((item, index) => { + sendCommand({ action: "add-item", index, title: item.title, enabled: item.enabled }); + }); + + return { + updateItem(index, title, enabled) { + sendCommand({ action: "update-item", index, title, enabled }); + }, + setTooltip(text) { + sendCommand({ action: "set-tooltip", text }); + }, + kill() { + try { + sendCommand({ action: "kill" }); + } catch (e) {} + setTimeout(() => { + if (psProcess && !psProcess.killed) { + try { psProcess.kill(); } catch (e) {} + } + psProcess = null; + }, 300); + } + }; +} + +module.exports = { initWinTray }; diff --git a/cli/src/cli/utils/clipboard.js b/cli/src/cli/utils/clipboard.js new file mode 100644 index 0000000000000000000000000000000000000000..c9cc5b570f81b9682c73de2e9051f0e09a102113 --- /dev/null +++ b/cli/src/cli/utils/clipboard.js @@ -0,0 +1,30 @@ +const { execSync } = require("child_process"); + +/** + * Copy text to clipboard based on OS + * @param {string} text - Text to copy + * @returns {boolean} Success status + */ +function copyToClipboard(text) { + try { + const platform = process.platform; + + if (platform === "darwin") { + execSync("pbcopy", { input: text }); + } else if (platform === "win32") { + execSync("clip", { input: text }); + } else { + // Linux - try xclip first, then xsel + try { + execSync("xclip -selection clipboard", { input: text }); + } catch { + execSync("xsel --clipboard --input", { input: text }); + } + } + return true; + } catch (error) { + return false; + } +} + +module.exports = { copyToClipboard }; diff --git a/cli/src/cli/utils/display.js b/cli/src/cli/utils/display.js new file mode 100644 index 0000000000000000000000000000000000000000..a68d830444cc0f3867f7f560375ab13e5a90649b --- /dev/null +++ b/cli/src/cli/utils/display.js @@ -0,0 +1,156 @@ +const { formatNumber } = require("./format"); + +// ANSI color codes +const COLORS = { + reset: "\x1b[0m", + success: "\x1b[32m", + error: "\x1b[31m", + warning: "\x1b[33m", + info: "\x1b[36m", + dim: "\x1b[2m", + bold: "\x1b[1m", + bright: "\x1b[1m", + cyan: "\x1b[36m" +}; + +// Box drawing characters +const BOX_CHARS = { + topLeft: "┌", + topRight: "┐", + bottomLeft: "└", + bottomRight: "┘", + horizontal: "─", + vertical: "│" +}; + +/** + * Draw a box with border around content + * @param {string} title - Box title + * @param {string} content - Content to display inside box + * @param {number} [width=60] - Box width + */ +function showBox(title, content, width = 60) { + const innerWidth = width - 4; + const lines = content.split("\n"); + + // Top border with title + const topBorder = BOX_CHARS.topLeft + BOX_CHARS.horizontal.repeat(2) + + ` ${title} ` + + BOX_CHARS.horizontal.repeat(Math.max(0, innerWidth - title.length - 3)) + + BOX_CHARS.topRight; + + console.log(topBorder); + + // Content lines + lines.forEach(line => { + const paddedLine = line.padEnd(innerWidth); + console.log(`${BOX_CHARS.vertical} ${paddedLine} ${BOX_CHARS.vertical}`); + }); + + // Bottom border + const bottomBorder = BOX_CHARS.bottomLeft + + BOX_CHARS.horizontal.repeat(innerWidth + 2) + + BOX_CHARS.bottomRight; + + console.log(bottomBorder); +} + +/** + * Display a menu with numbered items + * @param {string} title - Menu title + * @param {string[]} items - Array of menu items + * @param {string} [footer] - Optional footer text + */ +function showMenu(title, items, footer) { + console.log(`\n${COLORS.bold}${title}${COLORS.reset}`); + console.log(COLORS.dim + "─".repeat(title.length) + COLORS.reset); + + items.forEach((item, index) => { + console.log(` ${COLORS.info}${index + 1}.${COLORS.reset} ${item}`); + }); + + if (footer) { + console.log(`\n${COLORS.dim}${footer}${COLORS.reset}`); + } + console.log(); +} + +/** + * Display data in table format + * @param {string[]} headers - Array of column headers + * @param {Array>} rows - Array of row data + */ +function showTable(headers, rows) { + if (!headers.length || !rows.length) { + return; + } + + // Calculate column widths + const colWidths = headers.map((header, i) => { + const maxDataWidth = Math.max(...rows.map(row => String(row[i] || "").length)); + return Math.max(header.length, maxDataWidth); + }); + + // Print header + const headerRow = headers.map((h, i) => h.padEnd(colWidths[i])).join(" │ "); + console.log(COLORS.bold + headerRow + COLORS.reset); + + // Print separator + const separator = colWidths.map(w => "─".repeat(w)).join("─┼─"); + console.log(COLORS.dim + separator + COLORS.reset); + + // Print rows + rows.forEach(row => { + const rowStr = row.map((cell, i) => String(cell || "").padEnd(colWidths[i])).join(" │ "); + console.log(rowStr); + }); +} + +/** + * Show colored status message + * @param {string} message - Message to display + * @param {string} [type="info"] - Status type: success, error, warning, info + */ +function showStatus(message, type = "info") { + const symbols = { + success: "✓", + error: "✗", + warning: "⚠", + info: "ℹ" + }; + + const color = COLORS[type] || COLORS.info; + const symbol = symbols[type] || symbols.info; + + console.log(`${color}${symbol} ${message}${COLORS.reset}`); +} + +/** + * Clear the terminal screen + */ +function clearScreen() { + console.clear(); +} + +/** + * Show menu header with title and subtitle + * @param {string} title - Main title + * @param {string} subtitle - Optional subtitle + */ +function showHeader(title, subtitle) { + console.log(`\n${"=".repeat(60)}`); + console.log(` ${COLORS.bright}${COLORS.cyan}${title}${COLORS.reset}`); + if (subtitle) { + console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`); + } + console.log(`${"=".repeat(60)}\n`); +} + +module.exports = { + showBox, + showMenu, + showTable, + showStatus, + clearScreen, + showHeader +}; diff --git a/cli/src/cli/utils/endpoint.js b/cli/src/cli/utils/endpoint.js new file mode 100644 index 0000000000000000000000000000000000000000..20226e69459608d24cd74fcbadf3c8f90cf9ea03 --- /dev/null +++ b/cli/src/cli/utils/endpoint.js @@ -0,0 +1,32 @@ +const api = require("../api/client"); + +const COLORS = { + reset: "\x1b[0m", + green: "\x1b[32m" +}; + +/** + * Get endpoint URL based on tunnel status + * @param {number} port - Local server port + * @returns {Promise<{endpoint: string, tunnelEnabled: boolean}>} + */ +async function getEndpoint(port) { + const result = await api.getTunnelStatus(); + const tunnelEnabled = result.success && result.data?.enabled === true; + const publicUrl = result.success ? result.data?.publicUrl : ""; + + const endpoint = tunnelEnabled && publicUrl ? `${publicUrl}/v1` : `http://localhost:${port}/v1`; + return { endpoint, tunnelEnabled }; +} + +/** + * Get endpoint with color formatting + * @param {number} port - Local server port + * @returns {Promise} Colored endpoint string + */ +async function getEndpointColored(port) { + const { endpoint, tunnelEnabled } = await getEndpoint(port); + return tunnelEnabled ? `${COLORS.green}${endpoint}${COLORS.reset}` : endpoint; +} + +module.exports = { getEndpoint, getEndpointColored }; diff --git a/cli/src/cli/utils/format.js b/cli/src/cli/utils/format.js new file mode 100644 index 0000000000000000000000000000000000000000..5bf2ea4bf3366cd2d804fae1f2d6ab7d4ccf853b --- /dev/null +++ b/cli/src/cli/utils/format.js @@ -0,0 +1,125 @@ +/** + * Truncate text with ellipsis + * @param {string} text - Text to truncate + * @param {number} maxLength - Maximum length + * @returns {string} Truncated text + */ +function truncate(text, maxLength) { + if (!text || text.length <= maxLength) { + return text; + } + return text.substring(0, maxLength - 3) + "..."; +} + +/** + * Mask API key showing only first and last characters + * @param {string} key - API key to mask + * @returns {string} Masked key + */ +function maskKey(key) { + if (!key || key.length < 8) { + return "***"; + } + const firstChars = key.substring(0, 4); + const lastChars = key.substring(key.length - 4); + return `${firstChars}${"*".repeat(key.length - 8)}${lastChars}`; +} + +/** + * Format date to readable string + * @param {Date|string|number} date - Date to format + * @returns {string} Formatted date string + */ +function formatDate(date) { + const d = new Date(date); + if (isNaN(d.getTime())) { + return "Invalid Date"; + } + + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + const hours = String(d.getHours()).padStart(2, "0"); + const minutes = String(d.getMinutes()).padStart(2, "0"); + const seconds = String(d.getSeconds()).padStart(2, "0"); + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; +} + +/** + * Format number with commas + * @param {number} num - Number to format + * @returns {string} Formatted number + */ +function formatNumber(num) { + if (typeof num !== "number" || isNaN(num)) { + return "0"; + } + return num.toLocaleString("en-US"); +} + +/** + * Format bytes to human readable size + * @param {number} bytes - Bytes to format + * @returns {string} Formatted size string + */ +function formatBytes(bytes) { + if (typeof bytes !== "number" || isNaN(bytes) || bytes < 0) { + return "0 B"; + } + + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(2)} ${units[unitIndex]}`; +} + +/** + * Get relative time string + * @param {Date|string|number} date - Date to compare + * @returns {string} Relative time string + */ +function getRelativeTime(date) { + const d = new Date(date); + if (isNaN(d.getTime())) { + return "Invalid Date"; + } + + const now = new Date(); + const diffMs = now - d; + const diffSec = Math.floor(diffMs / 1000); + const diffMin = Math.floor(diffSec / 60); + const diffHour = Math.floor(diffMin / 60); + const diffDay = Math.floor(diffHour / 24); + const diffMonth = Math.floor(diffDay / 30); + const diffYear = Math.floor(diffDay / 365); + + if (diffSec < 60) { + return "just now"; + } else if (diffMin < 60) { + return `${diffMin} minute${diffMin > 1 ? "s" : ""} ago`; + } else if (diffHour < 24) { + return `${diffHour} hour${diffHour > 1 ? "s" : ""} ago`; + } else if (diffDay < 30) { + return `${diffDay} day${diffDay > 1 ? "s" : ""} ago`; + } else if (diffMonth < 12) { + return `${diffMonth} month${diffMonth > 1 ? "s" : ""} ago`; + } else { + return `${diffYear} year${diffYear > 1 ? "s" : ""} ago`; + } +} + +module.exports = { + truncate, + maskKey, + formatDate, + formatNumber, + formatBytes, + getRelativeTime +}; diff --git a/cli/src/cli/utils/input.js b/cli/src/cli/utils/input.js new file mode 100644 index 0000000000000000000000000000000000000000..5761a2bf336c25e28680f29016c0dc7b0d7b0257 --- /dev/null +++ b/cli/src/cli/utils/input.js @@ -0,0 +1,156 @@ +const readline = require("readline"); + +const COLORS = { + reset: "\x1b[0m", + bright: "\x1b[1m", + dim: "\x1b[2m", + underline: "\x1b[4m", + reverse: "\x1b[7m", + cyan: "\x1b[36m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + white: "\x1b[37m", + bgGreen: "\x1b[42m", + bgBlue: "\x1b[44m", + black: "\x1b[30m", + terracotta: "\x1b[38;2;217;119;87m", + bgTerracotta: "\x1b[48;2;217;119;87m" +}; + +// Prime stdin once globally. Toggling raw mode between menus adds latency on +// macOS, so we keep raw mode on for the whole TUI session. +let rawPrimed = false; +function primeRawOnce() { + if (rawPrimed || !process.stdin.isTTY) return; + try { + readline.emitKeypressEvents(process.stdin); + process.stdin.setRawMode(true); + process.stdin.setEncoding("utf8"); + process.stdin.resume(); + rawPrimed = true; + } catch {} +} + +function suspendRawFor(fn) { + // Temporarily drop raw mode so readline.question can buffer line input. + const wasPrimed = rawPrimed; + if (wasPrimed && process.stdin.isTTY) { + try { process.stdin.setRawMode(false); } catch {} + } + return fn().finally(() => { + if (wasPrimed && process.stdin.isTTY) { + try { process.stdin.setRawMode(true); } catch {} + process.stdin.resume(); + } + }); +} + +async function prompt(question) { + return suspendRawFor(() => new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(question, (answer) => { + rl.close(); + resolve((answer || "").trim()); + }); + })); +} + +async function select(question, options) { + console.log(question); + options.forEach((opt, i) => console.log(` ${i + 1}. ${opt}`)); + while (true) { + const answer = await prompt("\nSelect option (number): "); + const num = parseInt(answer, 10); + if (!isNaN(num) && num >= 1 && num <= options.length) return num - 1; + console.log(`Invalid selection. Please enter a number between 1 and ${options.length}`); + } +} + +async function confirm(question) { + while (true) { + const answer = await prompt(`${question} (y/n): `); + const lower = answer.toLowerCase(); + if (lower === "y" || lower === "yes") return true; + if (lower === "n" || lower === "no") return false; + console.log("Please answer 'y' or 'n'"); + } +} + +async function pause(message = "Press Enter to continue...") { + return suspendRawFor(() => new Promise((resolve) => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + rl.question(message, () => { rl.close(); resolve(); }); + })); +} + +/** + * Interactive arrow-key menu. Renders ★/☆ icons; selected line uses reverse+bright + * (no underline). Uses readline keypress + raw 'data' fallback to prevent + * arrow-key escape sequence leaks on macOS. + */ +async function selectMenu(title, items, defaultIndex = 0, subtitle = "", headerContent = "", breadcrumb = []) { + return new Promise((resolve) => { + let selectedIndex = defaultIndex; + let isActive = true; + + primeRawOnce(); + if (!process.stdin.isTTY) { resolve(-1); return; } + + const renderMenu = () => { + if (!isActive) return; + process.stdout.write("\x1b[2J\x1b[H"); + const width = Math.min(process.stdout.columns || 40, 40); + console.log(`\n${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`); + console.log(` ${COLORS.bright}${COLORS.terracotta}${title}${COLORS.reset}`); + if (subtitle) console.log(` ${COLORS.dim}${subtitle}${COLORS.reset}`); + console.log(`${COLORS.terracotta}${"=".repeat(width)}${COLORS.reset}`); + if (breadcrumb.length > 0) console.log(` ${COLORS.dim}${breadcrumb.join(" > ")}${COLORS.reset}`); + console.log(); + if (headerContent) { console.log(headerContent); console.log(); } + + const isWin = process.platform === "win32"; + items.forEach((item, index) => { + const isSelected = index === selectedIndex; + const icon = isSelected ? (isWin ? ">" : "★") : (isWin ? " " : "☆"); + if (isSelected) { + console.log(` ${COLORS.reverse}${COLORS.bright}${icon} ${item.label}${COLORS.reset}`); + } else { + console.log(` ${icon} ${item.label}`); + } + }); + }; + + const cleanup = () => { + if (!isActive) return; + isActive = false; + process.stdin.removeListener("keypress", onKeypress); + }; + + const move = (delta) => { + selectedIndex = (selectedIndex + delta + items.length) % items.length; + renderMenu(); + }; + + const onKeypress = (_str, key) => { + if (!isActive || !key) return; + if (key.name === "up") return move(-1); + if (key.name === "down") return move(1); + if (key.name === "return") { cleanup(); resolve(selectedIndex); return; } + if (key.name === "escape") { cleanup(); resolve(-1); return; } + if (key.ctrl && key.name === "c") { cleanup(); process.exit(0); } + }; + + process.stdin.on("keypress", onKeypress); + renderMenu(); + }); +} + +module.exports = { + prompt, + select, + confirm, + pause, + selectMenu, + COLORS +}; diff --git a/cli/src/cli/utils/menuHelper.js b/cli/src/cli/utils/menuHelper.js new file mode 100644 index 0000000000000000000000000000000000000000..41d290640e340a096a3283074c9956b009ad61c8 --- /dev/null +++ b/cli/src/cli/utils/menuHelper.js @@ -0,0 +1,156 @@ +const { selectMenu } = require("./input"); + +/** + * Show a menu with back button at top and handle selection + * @param {Object} config - Menu configuration + * @param {string} config.title - Menu title + * @param {string} config.headerContent - Optional header content + * @param {Array<{label: string, action: Function}>} config.items - Menu items with actions + * @param {string} config.backLabel - Back button label (default: "← Back") + * @param {number} config.defaultIndex - Default selected index (default: 0) + * @param {Function} config.refresh - Optional refresh function to call after each action + * @param {Array} config.breadcrumb - Optional breadcrumb path + * @returns {Promise} + */ +async function showMenuWithBack(config) { + const { + title, + headerContent = "", + items, + backLabel = "← Back", + defaultIndex = 0, + refresh = null, + breadcrumb = [] + } = config; + + while (true) { + // Call refresh if provided + let refreshedData = null; + if (refresh) { + refreshedData = await refresh(); + if (refreshedData === null) { + // Refresh failed, exit menu + return; + } + } + + // Build menu items with back at top + const menuItems = [ + { label: backLabel, icon: "☆" }, + ...items.map(item => ({ + label: typeof item.label === "function" ? item.label(refreshedData) : item.label, + icon: "☆" + })) + ]; + + // Resolve headerContent if it's a function + const resolvedHeader = typeof headerContent === "function" + ? await headerContent(refreshedData) + : headerContent; + + const selected = await selectMenu( + title, + menuItems, + defaultIndex, + "", + resolvedHeader, + breadcrumb + ); + + // Back or ESC + if (selected === -1 || selected === 0) { + return; + } + + // Execute action for selected item + const actionIndex = selected - 1; + const item = items[actionIndex]; + + if (item && item.action) { + const shouldContinue = await item.action(refreshedData); + // If action returns false, exit menu + if (shouldContinue === false) { + return; + } + } + } +} + +/** + * Show a list menu where items are fetched dynamically + * @param {Object} config - Menu configuration + * @param {string} config.title - Menu title + * @param {string} config.headerContent - Optional header content + * @param {Function} config.fetchItems - Async function to fetch items array + * @param {Function} config.formatItem - Function to format each item to {label, data} + * @param {Function} config.onSelect - Action when item is selected + * @param {Object} config.createAction - Optional create action {label, action} + * @param {string} config.backLabel - Back button label + * @param {Array} config.breadcrumb - Optional breadcrumb path + * @returns {Promise} + */ +async function showListMenu(config) { + const { + title, + headerContent = "", + fetchItems, + formatItem, + onSelect, + createAction = null, + backLabel = "← Back", + breadcrumb = [] + } = config; + + while (true) { + // Fetch items + const result = await fetchItems(); + if (!result) { + return; + } + + const items = result.items || []; + const metadata = result.metadata || {}; + + // Build menu items + const menuItems = [{ label: backLabel, icon: "☆" }]; + + if (createAction) { + menuItems.push({ label: createAction.label, icon: "☆" }); + } + + items.forEach(item => { + const formatted = formatItem(item); + menuItems.push({ label: formatted, icon: "☆" }); + }); + + const header = typeof headerContent === "function" + ? await headerContent(metadata) + : headerContent; + + const selected = await selectMenu(title, menuItems, 0, "", header, breadcrumb); + + // Back or ESC + if (selected === -1 || selected === 0) { + return; + } + + // Create action + if (createAction && selected === 1) { + await createAction.action(); + continue; + } + + // Select item + const offset = createAction ? 2 : 1; + const itemIndex = selected - offset; + + if (itemIndex >= 0 && itemIndex < items.length) { + await onSelect(items[itemIndex]); + } + } +} + +module.exports = { + showMenuWithBack, + showListMenu +}; diff --git a/cli/src/cli/utils/modelSelector.js b/cli/src/cli/utils/modelSelector.js new file mode 100644 index 0000000000000000000000000000000000000000..438220d14f511db76af71cd11e6db16c7b69df44 --- /dev/null +++ b/cli/src/cli/utils/modelSelector.js @@ -0,0 +1,136 @@ +const api = require("../api/client"); +const { prompt } = require("./input"); +const { clearScreen } = require("./display"); + +// Provider alias order: OAuth first, then API Key (matches ModelSelectModal) +const PROVIDER_ALIAS_ORDER = [ + "cc", "ag", "cx", "if", "qw", "gc", "gh", "kr", + "openrouter", "glm", "kimi", "minimax", "openai", "anthropic", "gemini" +]; + +// Alias to display name mapping +const PROVIDER_ALIAS_NAMES = { + cc: "Claude Code", + ag: "Antigravity", + cx: "OpenAI Codex", + if: "iFlow AI", + qw: "Qwen Code", + gc: "Gemini CLI", + gh: "GitHub Copilot", + kr: "Kiro AI", + openrouter: "OpenRouter", + glm: "GLM Coding", + kimi: "Kimi Coding", + minimax: "Minimax Coding", + openai: "OpenAI", + anthropic: "Anthropic", + gemini: "Gemini" +}; + +/** + * Get all available models grouped by provider + combos + * @returns {Promise<{combos: Array, groups: Object}>} + */ +async function getAvailableModelsGrouped() { + const result = await api.getAvailableModels(); + if (!result.success) return { combos: [], groups: {} }; + + const models = result.data?.data || []; + const combos = []; + const groups = {}; + + models.forEach(m => { + if (m.owned_by === "combo") { + combos.push(m.id); + } else { + const provider = m.owned_by; + if (!groups[provider]) { + groups[provider] = []; + } + groups[provider].push(m.id); + } + }); + + return { combos, groups }; +} + +/** + * Display model list and prompt for selection + * @param {string} title - Title to display + * @param {string} currentValue - Current selected value (optional) + * @param {Object} options - { excludeCombos?: boolean } + * @returns {Promise} Selected model ID or null if cancelled + */ +async function selectModelFromList(title, currentValue = "", options = {}) { + const { excludeCombos = false } = options; + const { combos: rawCombos, groups } = await getAvailableModelsGrouped(); + const combos = excludeCombos ? [] : rawCombos; + + const totalModels = combos.length + Object.values(groups).flat().length; + if (totalModels === 0) { + return null; + } + + // Build flat list for selection + const allModels = []; + + // Display + clearScreen(); + console.log(`\n🎯 ${title}`); + console.log("=".repeat(50)); + if (currentValue) { + console.log(`Current: ${currentValue}\n`); + } else { + console.log(); + } + + let idx = 1; + + // Combos first (skipped when excludeCombos is true) + if (combos.length > 0) { + console.log("[Combos]"); + combos.forEach(combo => { + console.log(` ${idx}. ${combo}`); + allModels.push(combo); + idx++; + }); + console.log(); + } + + // Provider groups in order (by alias) + const sortedProviders = Object.keys(groups).sort((a, b) => { + const idxA = PROVIDER_ALIAS_ORDER.indexOf(a); + const idxB = PROVIDER_ALIAS_ORDER.indexOf(b); + return (idxA === -1 ? 999 : idxA) - (idxB === -1 ? 999 : idxB); + }); + + sortedProviders.forEach(provider => { + const providerName = PROVIDER_ALIAS_NAMES[provider] || provider; + console.log(`[${providerName}]`); + groups[provider].forEach(model => { + console.log(` ${idx}. ${model}`); + allModels.push(model); + idx++; + }); + console.log(); + }); + + console.log(" 0. Cancel\n"); + + // Prompt for number input + const input = await prompt("Enter number: "); + const num = parseInt(input, 10); + + if (isNaN(num) || num === 0 || num < 0 || num > allModels.length) { + return null; + } + + return allModels[num - 1]; +} + +module.exports = { + selectModelFromList, + getAvailableModelsGrouped, + PROVIDER_ALIAS_ORDER, + PROVIDER_ALIAS_NAMES +}; diff --git a/custom-server.js b/custom-server.js new file mode 100644 index 0000000000000000000000000000000000000000..764a2df69807a92bbc77e2ded07f3d5ef21a040b --- /dev/null +++ b/custom-server.js @@ -0,0 +1,27 @@ +const http = require("http"); + +const origCreate = http.createServer.bind(http); + +// Wrap Next standalone HTTP server: derive client IP from the TCP socket +// (unspoofable) and strip client-supplied forwarding headers so downstream +// rate-limiting keys on the real peer address instead of attacker-controlled XFF. +http.createServer = (...args) => { + const handler = args.find((a) => typeof a === "function"); + 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 : ""; + // Forwarding headers present = request arrived via a reverse proxy; loopback + // socket is the proxy hop, not the end-user, so it must not be trusted as local. + const viaProxy = !!(req.headers["x-forwarded-for"] || req.headers["x-real-ip"]); + 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); +}; + +require("./server.js"); diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..e8d49426f3d915532939f0e2ceb85fff305ed524 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + "open-sse": ["./open-sse"], + "open-sse/*": ["./open-sse/*"] + }, + "module": "ESNext", + "moduleResolution": "bundler" + } +} diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..e71a530a0eac34c04b84ab4d58d3c6b968e28aa3 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,73 @@ +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const projectRoot = dirname(fileURLToPath(import.meta.url)); +// CLI bundling needs workspace root so tracing includes hoisted node_modules (slim ~50MB). +// Docker / default uses projectRoot so server.js lands at /app/server.js (not nested). +const tracingRoot = process.env.NEXT_TRACING_ROOT_MODE === "workspace" + ? join(projectRoot, "..") + : projectRoot; +const proxyClientMaxBodySize = process.env.NINEROUTER_PROXY_CLIENT_MAX_BODY_SIZE || "128mb"; + +/** @type {import('next').NextConfig} */ +const nextConfig = { + distDir: process.env.NEXT_DIST_DIR || ".next", + output: "standalone", + serverExternalPackages: ["better-sqlite3", "sql.js", "node:sqlite", "bun:sqlite"], + turbopack: { + root: tracingRoot + }, + outputFileTracingRoot: tracingRoot, + outputFileTracingExcludes: { + "*": ["./gitbook/**/*"] + }, + images: { + unoptimized: true + }, + env: {}, + 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 + if (!isServer) { + config.resolve.fallback = { + ...config.resolve.fallback, + fs: false, + path: false, + }; + } + // Exclude logs, .next, gitbook subapp from watcher + config.watchOptions = { ...config.watchOptions, ignored: /[\\/](logs|\.next|gitbook|cli)[\\/]/ }; + return config; + }, + async rewrites() { + return [ + { + source: "/v1/v1/:path*", + destination: "/api/v1/:path*" + }, + { + source: "/v1/v1", + destination: "/api/v1" + }, + { + source: "/codex/:path*", + destination: "/api/v1/responses" + }, + { + source: "/v1/:path*", + destination: "/api/v1/:path*" + }, + { + source: "/v1", + destination: "/api/v1" + } + ]; + } +}; + +export default nextConfig; diff --git a/open-sse/.npmignore b/open-sse/.npmignore new file mode 100644 index 0000000000000000000000000000000000000000..0b7b5690d9d55f3fb5bcd4fb05a7d8ea5e148a0d --- /dev/null +++ b/open-sse/.npmignore @@ -0,0 +1,8 @@ +node_modules/ +*.log +.DS_Store +test/ +*.test.js +.env +.env.* + diff --git a/open-sse/AGENTS.md b/open-sse/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..a4da782f0ffa5ae8f6b8cf5c9293f3cd14de6037 --- /dev/null +++ b/open-sse/AGENTS.md @@ -0,0 +1,35 @@ +# 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`) → `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/` (per-format). See `tests/translator/AGENTS.md`. +- `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. +- `services/` — `tokenRefresh/`, `usage/`, `combo.js`, `accountFallback.js`, `model.js`. +- `utils/` — streamHandler, error, sessionManager, claudeCloaking. + +## 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. diff --git a/open-sse/config/appConstants.js b/open-sse/config/appConstants.js new file mode 100644 index 0000000000000000000000000000000000000000..6ac9d324429861c2b35b35b60d309acfac6ffd11 --- /dev/null +++ b/open-sse/config/appConstants.js @@ -0,0 +1,181 @@ +import { platform, arch } from "os"; +import { PROVIDERS, PROVIDER_OAUTH } from "./providers.js"; + +// === 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() { + const a = arch(); + if (a === "ia32") return "x86"; + return a; +} + +export function geminiCLIUserAgent(model = "unknown") { + return `GeminiCLI/${GEMINI_CLI_VERSION}/${model || "unknown"} (${platform()}; ${geminiCLIArch()}; terminal)`; +} + +// === GitHub Copilot === +// Derive từ registry github.transport.copilot +const _ghCopilot = PROVIDERS.github?.copilot || {}; +export const GITHUB_COPILOT = { + VSCODE_VERSION: _ghCopilot.vscodeVersion, + COPILOT_CHAT_VERSION: _ghCopilot.chatVersion, + USER_AGENT: _ghCopilot.userAgent, + API_VERSION: _ghCopilot.apiVersion, +}; + +// === Antigravity enums === +export const IDE_TYPE = { + UNSPECIFIED: 0, + JETSKI: 10, + ANTIGRAVITY: 9, + PLUGINS: 7 +}; + +export const PLATFORM = { + UNSPECIFIED: 0, + DARWIN_AMD64: 1, + DARWIN_ARM64: 2, + LINUX_AMD64: 3, + LINUX_ARM64: 4, + WINDOWS_AMD64: 5 +}; + +export const PLUGIN_TYPE = { + UNSPECIFIED: 0, + CLOUD_CODE: 1, + GEMINI: 2 +}; + +export function getPlatformEnum() { + const os = platform(); + const architecture = arch(); + if (os === "darwin") return architecture === "arm64" ? PLATFORM.DARWIN_ARM64 : PLATFORM.DARWIN_AMD64; + if (os === "linux") return architecture === "arm64" ? PLATFORM.LINUX_ARM64 : PLATFORM.LINUX_AMD64; + if (os === "win32") return PLATFORM.WINDOWS_AMD64; + return PLATFORM.UNSPECIFIED; +} + +export function getPlatformUserAgent() { + return `antigravity/1.104.0 ${platform()}/${arch()}`; +} + +export const CLIENT_METADATA = { + ideType: IDE_TYPE.ANTIGRAVITY, + platform: getPlatformEnum(), + pluginType: PLUGIN_TYPE.GEMINI +}; + +// Internal anti-loop header +export const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" }; + +// Suffix added to client tools when forwarding to Antigravity provider (anti-ban cloaking) +export const AG_TOOL_SUFFIX = "_ide"; + +// Suffix added to client tools when forwarding to Claude provider (anti-ban cloaking) +export const CLAUDE_TOOL_SUFFIX = "_ide"; + +// CC native default tools — these are Claude Code's own tools, kept as decoys +// Client tools matching these names are skipped (not renamed), others get _cc suffix +export const CC_DEFAULT_TOOLS = new Set([ + "Task", + "TaskOutput", + "TaskStop", + "TaskCreate", + "TaskGet", + "TaskUpdate", + "TaskList", + "Bash", + "Glob", + "Grep", + "Read", + "Edit", + "Write", + "NotebookEdit", + "WebFetch", + "WebSearch", + "AskUserQuestion", + "Skill", + "EnterPlanMode", + "ExitPlanMode", +]); + +// AG native default tools — kept as decoys with neutral description/properties +// These names must match exactly what AG sends in the real request log +export const AG_DEFAULT_TOOLS = new Set([ + "browser_subagent", + "command_status", + "find_by_name", + "generate_image", + "grep_search", + "list_dir", + "list_resources", + "multi_replace_file_content", + "notify_user", + "read_resource", + "read_terminal", + "read_url_content", + "replace_file_content", + "run_command", + "search_web", + "send_command_input", + "task_boundary", + "view_content_chunk", + "view_file", + "write_to_file" +]); + +// Antigravity chat/stream headers +export const ANTIGRAVITY_HEADERS = { + "User-Agent": `antigravity/1.107.0 ${platform()}/${arch()}` +}; + +// Cloud Code Assist API +export const CLOUD_CODE_API = { + loadCodeAssist: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + onboardUser: "https://cloudcode-pa.googleapis.com/v1internal:onboardUser", +}; + +export const LOAD_CODE_ASSIST_HEADERS = { + "Content-Type": "application/json", + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "Client-Metadata": JSON.stringify({ ideType: IDE_TYPE.ANTIGRAVITY, platform: getPlatformEnum(), pluginType: PLUGIN_TYPE.GEMINI }), +}; + +export const LOAD_CODE_ASSIST_METADATA = { + ideType: IDE_TYPE.ANTIGRAVITY, + platform: getPlatformEnum(), + pluginType: PLUGIN_TYPE.GEMINI, +}; + +// System prompts +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**"; + +// 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: 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 +export function buildKimiHeaders() { + return { + "X-Msh-Platform": "9router", + "X-Msh-Version": "2.1.2", + "X-Msh-Device-Model": typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown", + "X-Msh-Device-Id": `kimi-${Date.now()}` + }; +} diff --git a/open-sse/config/codexInstructions.js b/open-sse/config/codexInstructions.js new file mode 100644 index 0000000000000000000000000000000000000000..75b691dd2f3f217984e6082169406844a502a0d5 --- /dev/null +++ b/open-sse/config/codexInstructions.js @@ -0,0 +1,119 @@ +// Default instructions for Codex models + +export const CODEX_DEFAULT_INSTRUCTIONS = `You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer. + +## General + +- When searching for text or files, prefer using \`rg\` or \`rg --files\` respectively because \`rg\` is much faster than alternatives like \`grep\`. (If the \`rg\` command is not found, then use alternatives.) + +## Editing constraints + +- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them. +- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare. +- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase). +- You may be in a dirty git worktree. + * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user. + * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes. + * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them. + * If the changes are in unrelated files, just ignore them and don't revert them. +- Do not amend a commit unless explicitly requested to do so. +- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed. +- **NEVER** use destructive commands like \`git reset --hard\` or \`git checkout --\` unless specifically requested or approved by the user. + +## Plan tool + +When using the planning tool: +- Skip using the planning tool for straightforward tasks (roughly the easiest 25%). +- Do not make single-step plans. +- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan. + +## Codex CLI harness, sandboxing, and approvals + +The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from. + +Filesystem sandboxing defines which files can be read or written. The options for \`sandbox_mode\` are: +- **read-only**: The sandbox only permits reading files. +- **workspace-write**: The sandbox permits reading files, and editing files in \`cwd\` and \`writable_roots\`. Editing files in other directories requires approval. +- **danger-full-access**: No filesystem sandboxing - all commands are permitted. + +Network sandboxing defines whether network can be accessed without approval. Options for \`network_access\` are: +- **restricted**: Requires approval +- **enabled**: No approval needed + +Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for \`approval_policy\` are +- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands. +- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox. +- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the \`shell\` command description.) +- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with \`danger-full-access\`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding. + +When you are running with \`approval_policy == on-request\`, and sandboxing enabled, here are scenarios where you'll need to request approval: +- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) +- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. +- You are running sandboxed and need to run a command that requires network access (e.g. installing packages) +- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the \`sandbox_permissions\` and \`justification\` parameters - do not message the user before requesting approval for the command. +- You are about to take a potentially destructive action such as an \`rm\` or \`git reset\` that the user did not explicitly ask for +- (for all of these, you should weigh alternative paths that do not require approval) + +When \`sandbox_mode\` is set to read-only, you'll need to request approval for any command that isn't a read. + +You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure. + +Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. + +When requesting approval to execute a command that will require escalated privileges: + - Provide the \`sandbox_permissions\` parameter with the value \`"require_escalated"\` + - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter + +## Special user requests + +- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as \`date\`), you should do so. +- If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps. + +## Frontend tasks +When doing frontend design tasks, avoid collapsing into "AI slop" or safe, average-looking layouts. +Aim for interfaces that feel intentional, bold, and a bit surprising. +- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system). +- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias. +- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions. +- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere. +- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs. +- Ensure the page loads properly on both desktop and mobile + +Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language. + +## Presenting your work and final message + +You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. + +- Default: be very concise; friendly coding teammate tone. +- Ask only when needed; suggest ideas; mirror the user's style. +- For substantial work, summarize clearly; follow final‑answer formatting. +- Skip heavy formatting for simple confirmations. +- Don't dump large files you've written; reference paths only. +- No "save/copy this file" - User is on the same machine. +- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something. +- For code changes: + * Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with "summary", just jump right in. + * If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps. + * When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number. +- The user does not command execution outputs. When asked to show the output of a command (e.g. \`git show\`), relay the important details in your answer or summarize the key lines so the user understands the result. + +### Final answer structure and style guidelines + +- Plain text; CLI handles styling. Use structure only when it helps scanability. +- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help. +- Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent. +- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **. +- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible. +- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task. +- Tone: collaborative, concise, factual; present tense, active voice; self‑contained; no "above/below"; parallel wording. +- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers. +- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets. +- File References: When referencing files in your response follow the below rules: + * Use inline code to make file paths clickable. + * Each reference should have a stand alone path. Even if it's the same file. + * Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix. + * Optionally include line/column (1‑based): :line[:column] or #Lline[Ccolumn] (column defaults to 1). + * Do not use URIs like file://, vscode://, or https://. + * Do not provide range of lines + * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\\repo\\project\\main.rs:12:5`; diff --git a/open-sse/config/constants.js b/open-sse/config/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..7607376d03fdf3fada2b0ef97615892e773710a1 --- /dev/null +++ b/open-sse/config/constants.js @@ -0,0 +1,4 @@ +// Barrel re-export — consumers can migrate to specific files over time +export * from "./providers.js"; +export * from "./appConstants.js"; +export * from "./runtimeConfig.js"; diff --git a/open-sse/config/defaultThinkingSignature.js b/open-sse/config/defaultThinkingSignature.js new file mode 100644 index 0000000000000000000000000000000000000000..50975a43ea8aeefc9845e729d86ec261c9adbfa4 --- /dev/null +++ b/open-sse/config/defaultThinkingSignature.js @@ -0,0 +1,12 @@ +// Default signature for thinking mode when no signature from thinkingStore +export const DEFAULT_THINKING_CLAUDE_SIGNATURE = "EpwGCkYIChgCKkCzVUuRrg7CcglSUWEef4rH6o35g9UYS8ZPe0/VomQTBsFx6sttYNj5l8GqgW6ejuHyYqpFToxIbZl0bw17l5dJEgzCnqDO0Z8fRlMrNgsaDLS1cnCjC53KBqE0CCIwAADQdo1eO+7qPAmo8J4WR3JPmr92S97kmvr5K1iPMiOpkZNj8mEXW8uzBoOJs/9ZKoMFiqHJ3UObwaJDqFOW70E9oCwDoc6jesaWVAEdN5vWfKMpIkjFJjECdjIdkxyJNJ8Ib8yXVal3qwE7uThoPRqSZDdHB5mmwPEjWE/90cSYCbtX2YsJki1265CabBb8/QEkODXg4kgRrL+c8e8rRXz/dr1RswvaPuzEdGKHRNi9UooNUeOK4/ebx1KkP9YZttyohN9GWqlts36kOoW0Cfie/ABDgF9g534BPth/sstxDM6d79QlRmh6NxizyTF74DXJI34u0M4tTRchqE5pAq85SgdJaa+dix1yJPMji8m6nZkwJbscJb9rdc2MKyKWjz8QL2+rTSSuZ2F1k1qSsW0xNcI7qLcI12Vncfn/VqY6YOIZy/saZBR0ezXvN6g+UYbuIdyVg7AyIFZt3nbrO7/kmOEb2VKzygwklHGEIJHfFgMpH3JSrAzbZIowVHOF7VaJ+KXRFDCFin7hHTOiOsdg+1ij1mML9Z/x/9CP4b7OUcaQm1llDZPSHc6rZMNL3DdB+fW5YfmNgKU35S+7AMtA10nVILzDAk1UV4T2K9Do09JlI6rjOs9UuULlIN2Z0eE8YTlANR6uQcw7lMcdfqYE8tke4rDKc2dDiaS5vVe45VewICNpdXGN11yw8QqH7p27CR1HtN30e0tHXOR3bIwWk/Yb6O5fTaKG6Ri8e5ZCPvdD9HqepVi188nM0iTjJqL58F3ni04ECIhcbyaQWnuTes1Kw4CMwiZDLQkk8Hgz7HkUOf1btQTF/0nhD7ry0n0hAEg2PaDM3V6TjOjf4hEldRmeqERcQF1PfgKb6ZM12rlIIfUqKACczWJSzTV158+47HX36o0cgux6nFlv/DE+sEiRVxgB"; + +export const DEFAULT_THINKING_AG_SIGNATURE = "EuwGCukGAXLI2nxwZIq54WWSoL/YN0P3TsDZ7zRnLi8g0S4aVr2HUGxvaHKySuY6HAVzcE0GPGjXrytLIldxthSvfxgUlJh6Qa9Z+Oj5QZBlYdg6HaJ6yuY5R7waE6rdwBsRf7Ft2j3DJ9rMi9qhWFqApewYtPhls3VHtuvND3l8Rm09+lbAXQs6KKWEWrxNLKTBkfpMgXhRERc/TQRMZu1twAablm6/Zk1tsYRvfWKLsNbeKF+CCojJdXJKvnR/8Ouuoa+Y2Ti20hcW7aZIIjZDFYPU//k6Ybmhg69J/imbFai2ckhfLaisqdDkdoIiBJScTOUvYqP6AE9d4MsydSC+UlhIMk4hoP76R8vUSCZRMkjOaDXstf/QoVZKbt94wyRZgAJ1G0BqI8L5ow86kLpA4wJEtxsRGymOE4bKUvApveBakYDNM9APkf+LbtbzWSseGjoZcSlycF9iN8Q2XNYKRrHbv3Lr5Y8JjdH/5y/6SHkNehTEZugaeGnSPSyCTWto1kQgHpxdWmhkLfJGNUGLmue7Mesj4TSms4J33mRpYVhNB/J333FCqIP0hr/E7BkkjEn7yZ4X7SQlh+xKPurapsnHRwiKmtsilmEFrnTE9iQr+pMr6M29qqFNv1tr5yumbaJw8JW9sB15tNsRv+dW6BjNanbsKz7HCgKUBc8tGy+7YuhXzAfViyRefcjK7eZW0Fbyt7AbybJTKz78W8NH7ye6LAwzOebXpeZ4D43fNIt8bKh26qgduSQv/7o+pAflkuqHZ99YWgHQ8h8OkZFi3eOiSYjsjhdZ/czWOdoPI/OnqIldzMPF5YlrKBLFX8VhRKVmqgsmWf5PHGulHhMkVlS+XG2UIseGy69ARa93D78Gsa+1n1kJr7EEB7Rh+27vUMxVYLdz1yMSvE5nalTAlg/ZeG8+XQ0cHuAI3KbQpHW2Q++RdXfm5JzD5WdJZUU+Zn8t8UUn85BH4RxZLeE0qJikgSsKoYVBc6YhiMjhPgkR95ReimY4Z0xCJdRo1gjexOFeODZMpQF6Yxnoic7IrdgsFA3iePTbFnPp3IAM1fAThWhXJUn3QInUOTd5o1qmTmn6REbL15g/JQNl+dqUoPkhleeb2V3kjqp1okmO3wMZbPknR3S1LZNmlS72/iBQUm+n2b/RCn4PjmM2"; + +export const DEFAULT_THINKING_VERTEX_SIGNATURE = "CloBjz1rX5+yg1ILh/Ag+suum5k1f/9m/hI0XDQ33lsQIYnOHLn9KZwN0C7E4jgep5MzZvz5Se1Z1xxYrA1+Iz0Il4tabBhaDfMKNa5dGdEA3KnikfjfIpMlPaAKaQGPPWtf2hodPdBgguiZqDn+Qz2LwGEqHVJ16LVBUpeSx7UnYBLSwio8cyNy0jPijOh5QXKLTeHVdO2tKKcCCrtG2JCW3dOSrW2qA8eyAg40iQUnMNECjbcjkqB1+zrab7jX9ILwg7L9OgqYAQGPPWtfT4nzaPzSkXePAa920abYxPs3fg/RHDlg8PUVFLa+ko6qOjt7nXJTMxN0cpCwUCFX7eHHcMnA6vApyA/rXvJiAABkHZ3HilAktXRtxr/thHU0H8/4H5gT3kzoQcq9aMznrKomd3ct0mFi0ioSKnOEfoY1Mrfj00p/ZWm0tT7Wrjcm3BQXZ+T9Vrb94k+6CjtcEBrGCq8BAY89a1/vTMczqwB1NP3HCCuBdnds2vDXkj6XAYaXjsjmik8tGqwMKHz8R9RAWsx6SO6pkGEpXXpRzAaUx6c+aofsL/z1xOcN7ArCAa6uEeQKEgNngZuCP05p4+9P95epVmgOjFa4KfsPnyg+NKUkEFmpPSDrIRyMT+xERlclVcCI98/u7i8a9+vTbgzl8TRFYryClNH37K1ye5i6kqSGDUcMyiEasjke5BxbUh3i6wqMAgGPPWtfk+A+iY38QAldu117FEkTIkzbYOIt67lk9c6Ou3Y3Ct8TFHFw5QwGfSFc0YWjeTFHdm9UdV5jPK35p6VfhiRSva3w2+JLIHb4jvv5HutZPOJ3yQTt/+hUDj80oMNMbwnxNZvCEdzKS+D9vwmTACAm5H0ZetBSH2gPJXnhhuQo9AegS3wIWVR2a5k643Vx9r4u4pOvij4476lxKswIHvqsjL4jnTzRCvd44G6dn7vD0ENGb1K/i+dMRQMcOBaOxPN0ynk9bKxXWRDbZ+Rhakfr+y74z+6eYCdRPVqO9I7s+riilFuRIfaQ+U6/vuVKGIWEVKCfZZi0z6H5Xgz1xmse0u0AsittDlIKxwEBjz1rX783A0vehvUeabRia+/pX46IsN5efTAxFEBUeUce3jLuXIghkMV2b8KNhUs2G0aZldDDewRQbkluQabBMDT82N5I7reJP0VZgLIKccCL5DoGv1J7YWM2npLMIgZ6aP8aSlT3PFFJ0IXbUZUrzduczmIm6nzAJf9zxmq1aIFYw8YrgW8RjUdy0UvUmRoBEShSGUrvsyaRTl7J//KJW5utIPunFMu53GPWLidCFHzM1QA3Cj1+4zv5UXajP/V92RQayWbzCvYBAY89a1+yzoVSWukUGH7kX71Tg9dx7HA7OyKYwnYaqekG98zJfcUM/3KoiiiotW5t4xYu//ksEl36bSWvUHsRnxGByg+3WYdnZqKg0AtdRB/EXbI5PsjvS5ko96bkjSuFkY3TjHGwAM2B94K6/t6OTE/NBbxCsY9sT4d+1sbFv/iyfmfCnfvJaSzGmC9CDWKy4iqQ/vBNWps9j1JXk0p5uPAYC2BaMkxl5xoTVZqI3zAuRtQF5JLmPPy+PdqOgFxMKcLGNhwp7dbhIFLF68vCYQ9CL0NnK2d3CFk1UFVYxsi1TsolR1xahe/Rxt5HZDz/z65nevrQ"; + +export const DEFAULT_THINKING_GEMINI_CLI_SIGNATURE = "CiQBjz1rX/AlslZWMe5RgBt4Tv9j4+YNZTTez+JH2/+5oAlICygKXgGPPWtf7/Sux9eLYap/bmYAdPqFThLXj+l7o0DLu/hdgU98MA9ZrlRDNHXx+T0tuY8AcnjPZbiDyOq2bE11Fjhsk6p5axqayaapC/Pt9GczcgIQf1z15WTxCeKWAPYKYQGPPWtfDYj0nlNFNoTlU39RC91Z16xFKJ2MLEmkm+NvimsoOJ6be3g2BssNPtJ/9BKDXRA5cVs17tBeeW72lH8TMB5999udtxHM2SiUsnWsrHlfVuGSCpNQQ+5REw8HNvEKkgEBjz1rXzBNWrqZGbjun55K+vgYPBhJO2qZ67uRWXUA5/qcU12U/mbi5XoA3swoxYE8LEXfZvFFC9WG/W28QNCA0Qd4Trk/WkWiAwZmB8a84Fs14rkv3wqyxwFavPkJorqurAfd2XzGiFy0sB0ITCOPYi1HzDGV5WfXk6b9k+jT66/RuzGa8EcSOWo/QtC3Bkhgowo4AY89a1/f/tw8A02zjIoK7JVDAbf8W4UfmbApJJhwXIiGtu1M0JItObx7g2reYqT+HHL2Q/R4VDc="; + + +export const DEFAULT_THINKING_TEXT = "..."; + diff --git a/open-sse/config/errorConfig.js b/open-sse/config/errorConfig.js new file mode 100644 index 0000000000000000000000000000000000000000..71491a4d2ccd9dd07ea35ce92ec32fb8eb89399e --- /dev/null +++ b/open-sse/config/errorConfig.js @@ -0,0 +1,85 @@ +// OpenAI-compatible error types mapping (client-facing) +export const ERROR_TYPES = { + 400: { type: "invalid_request_error", code: "bad_request" }, + 401: { type: "authentication_error", code: "invalid_api_key" }, + 402: { type: "billing_error", code: "payment_required" }, + 403: { type: "permission_error", code: "insufficient_quota" }, + 404: { type: "invalid_request_error", code: "model_not_found" }, + 406: { type: "invalid_request_error", code: "model_not_supported" }, + 429: { type: "rate_limit_error", code: "rate_limit_exceeded" }, + 500: { type: "server_error", code: "internal_server_error" }, + 502: { type: "server_error", code: "bad_gateway" }, + 503: { type: "server_error", code: "service_unavailable" }, + 504: { type: "server_error", code: "gateway_timeout" } +}; + +// Default error messages per status code (client-facing) +export const DEFAULT_ERROR_MESSAGES = { + 400: "Bad request", + 401: "Invalid API key provided", + 402: "Payment required", + 403: "You exceeded your current quota", + 404: "Model not found", + 406: "Model not supported", + 429: "Rate limit exceeded", + 500: "Internal server error", + 502: "Bad gateway - upstream provider error", + 503: "Service temporarily unavailable", + 504: "Gateway timeout" +}; + +// Exponential backoff config for rate limits +export const BACKOFF_CONFIG = { + base: 2000, + max: 5 * 60 * 1000, + maxLevel: 15 +}; + +// Default cooldown for transient/unknown errors +export const TRANSIENT_COOLDOWN_MS = 30 * 1000; + +// Hard cap for provider-reported rate limit cooldown (e.g. codex resets_at can be 5-6h) +export const MAX_RATE_LIMIT_COOLDOWN_MS = 30 * 60 * 1000; + +// Cooldown durations (ms) +const COOLDOWN = { + long: 2 * 60 * 1000, + short: 5 * 1000, +}; + +/** + * Unified error classification rules. + * Checked top-to-bottom: text rules first (by order), then status rules. + * Each rule: { text?, status?, cooldownMs?, backoff? } + * - text: substring match (case-insensitive) on error message + * - status: HTTP status code match + * - cooldownMs: fixed cooldown duration + * - backoff: true = use exponential backoff (rate limit) + */ +export const ERROR_RULES = [ + // --- Text-based rules (checked first, order = priority) --- + { text: "no credentials", cooldownMs: COOLDOWN.long }, + { text: "request not allowed", cooldownMs: COOLDOWN.short }, + { text: "improperly formed request", cooldownMs: COOLDOWN.long }, + { text: "rate limit", backoff: true }, + { text: "too many requests", backoff: true }, + { text: "quota exceeded", backoff: true }, + { text: "capacity", backoff: true }, + { text: "overloaded", backoff: true }, + + // --- Status-based rules (fallback when text doesn't match) --- + { status: 401, cooldownMs: COOLDOWN.long }, + { status: 402, cooldownMs: COOLDOWN.long }, + { status: 403, cooldownMs: COOLDOWN.long }, + { status: 404, cooldownMs: COOLDOWN.long }, + { status: 429, backoff: true }, +]; + +// Backward compat: COOLDOWN_MS object (used by index.js re-export) +export const COOLDOWN_MS = { + unauthorized: COOLDOWN.long, + paymentRequired: COOLDOWN.long, + notFound: COOLDOWN.long, + transient: TRANSIENT_COOLDOWN_MS, + requestNotAllowed: COOLDOWN.short, +}; diff --git a/open-sse/config/googleTtsLanguages.js b/open-sse/config/googleTtsLanguages.js new file mode 100644 index 0000000000000000000000000000000000000000..e7d35c2b216d25fdda273c32a524052a64ba39d8 --- /dev/null +++ b/open-sse/config/googleTtsLanguages.js @@ -0,0 +1,62 @@ +export const GOOGLE_TTS_LANGUAGES = [ + { id: "af", name: "Afrikaans", type: "tts" }, + { id: "ar", name: "Arabic", type: "tts" }, + { id: "bg", name: "Bulgarian", type: "tts" }, + { id: "bn", name: "Bengali", type: "tts" }, + { id: "bs", name: "Bosnian", type: "tts" }, + { id: "ca", name: "Catalan", type: "tts" }, + { id: "cs", name: "Czech", type: "tts" }, + { id: "cy", name: "Welsh", type: "tts" }, + { id: "da", name: "Danish", type: "tts" }, + { id: "de", name: "German", type: "tts" }, + { id: "el", name: "Greek", type: "tts" }, + { id: "en", name: "English", type: "tts" }, + { id: "eo", name: "Esperanto", type: "tts" }, + { id: "es", name: "Spanish", type: "tts" }, + { id: "et", name: "Estonian", type: "tts" }, + { id: "fi", name: "Finnish", type: "tts" }, + { id: "fr", name: "French", type: "tts" }, + { id: "gu", name: "Gujarati", type: "tts" }, + { id: "hi", name: "Hindi", type: "tts" }, + { id: "hr", name: "Croatian", type: "tts" }, + { id: "hu", name: "Hungarian", type: "tts" }, + { id: "hy", name: "Armenian", type: "tts" }, + { id: "id", name: "Indonesian", type: "tts" }, + { id: "is", name: "Icelandic", type: "tts" }, + { id: "it", name: "Italian", type: "tts" }, + { id: "ja", name: "Japanese", type: "tts" }, + { id: "jw", name: "Javanese", type: "tts" }, + { id: "km", name: "Khmer", type: "tts" }, + { id: "kn", name: "Kannada", type: "tts" }, + { id: "ko", name: "Korean", type: "tts" }, + { id: "la", name: "Latin", type: "tts" }, + { id: "lv", name: "Latvian", type: "tts" }, + { id: "mk", name: "Macedonian", type: "tts" }, + { id: "ml", name: "Malayalam", type: "tts" }, + { id: "mr", name: "Marathi", type: "tts" }, + { id: "my", name: "Myanmar (Burmese)", type: "tts" }, + { id: "ne", name: "Nepali", type: "tts" }, + { id: "nl", name: "Dutch", type: "tts" }, + { id: "no", name: "Norwegian", type: "tts" }, + { id: "pl", name: "Polish", type: "tts" }, + { id: "pt", name: "Portuguese", type: "tts" }, + { id: "ro", name: "Romanian", type: "tts" }, + { id: "ru", name: "Russian", type: "tts" }, + { id: "si", name: "Sinhala", type: "tts" }, + { id: "sk", name: "Slovak", type: "tts" }, + { id: "sq", name: "Albanian", type: "tts" }, + { id: "sr", name: "Serbian", type: "tts" }, + { id: "su", name: "Sundanese", type: "tts" }, + { id: "sv", name: "Swedish", type: "tts" }, + { id: "sw", name: "Swahili", type: "tts" }, + { id: "ta", name: "Tamil", type: "tts" }, + { id: "te", name: "Telugu", type: "tts" }, + { id: "th", name: "Thai", type: "tts" }, + { id: "tl", name: "Filipino", type: "tts" }, + { id: "tr", name: "Turkish", type: "tts" }, + { id: "uk", name: "Ukrainian", type: "tts" }, + { id: "ur", name: "Urdu", type: "tts" }, + { id: "vi", name: "Vietnamese", type: "tts" }, + { id: "zh-CN", name: "Chinese (Simplified)", type: "tts" }, + { id: "zh-TW", name: "Chinese (Traditional)", type: "tts" }, +]; diff --git a/open-sse/config/kiroConstants.js b/open-sse/config/kiroConstants.js new file mode 100644 index 0000000000000000000000000000000000000000..3ff6acbbfa22b785bd2d7dc208e15690ec5e9d50 --- /dev/null +++ b/open-sse/config/kiroConstants.js @@ -0,0 +1,277 @@ +/** + * Kiro-specific constants and helpers. + * + * Mirrors the behaviour of `internal/translator/kiro/common/constants.go` and + * `internal/translator/kiro/claude/kiro_claude_request.go` from the + * CLIProxyAPIPlus reference implementation, scoped down to what 9router needs: + * + * - `-agentic` model suffix detection + chunked-write system prompt + * - reasoning / thinking trigger detection (Anthropic-Beta header, + * Claude `thinking`, OpenAI `reasoning_effort`, AMP/Cursor magic tag) + * - the `enabled` system-prompt injection + * that turns Kiro reasoning on + * + * Kiro upstream does not advertise `-agentic` model IDs; they are a 9router + * 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"; + +// Public default CodeWhisperer profile ARNs (us-east-1), keyed by auth method. +// Used when an account cannot resolve its own profileArn. Builder ID and social +// (Google/GitHub) sign-ins map to different shared profiles. +export const KIRO_DEFAULT_PROFILE_ARNS = { + "builder-id": "arn:aws:codewhisperer:us-east-1:638616132270:profile/AAAACCCCXXXX", + social: "arn:aws:codewhisperer:us-east-1:699475941385:profile/EHGA3GRVQMUK", +}; + +// Back-compat single default (Builder ID). +export const KIRO_DEFAULT_PROFILE_ARN = KIRO_DEFAULT_PROFILE_ARNS["builder-id"]; + +/** Resolve the shared default profileArn for a given auth method. */ +export function resolveDefaultProfileArn(authMethod) { + const social = authMethod === "google" || authMethod === "github"; + return social ? KIRO_DEFAULT_PROFILE_ARNS.social : KIRO_DEFAULT_PROFILE_ARNS["builder-id"]; +} + +export const KIRO_THINKING_BUDGET_DEFAULT = 16000; + +export const KIRO_AGENTIC_SYSTEM_PROMPT = ` +# CRITICAL: CHUNKED WRITE PROTOCOL (MANDATORY) + +You MUST follow these rules for ALL file operations. Violation causes server timeouts and task failure. + +## ABSOLUTE LIMITS +- **MAXIMUM 350 LINES** per single write/edit operation - NO EXCEPTIONS +- **RECOMMENDED 300 LINES** or less for optimal performance +- **NEVER** write entire files in one operation if >300 lines + +## MANDATORY CHUNKED WRITE STRATEGY + +### For NEW FILES (>300 lines total): +1. FIRST: Write initial chunk (first 250-300 lines) using write_to_file/fsWrite +2. THEN: Append remaining content in 250-300 line chunks using file append operations +3. REPEAT: Continue appending until complete + +### For EDITING EXISTING FILES: +1. Use surgical edits (apply_diff/targeted edits) - change ONLY what's needed +2. NEVER rewrite entire files - use incremental modifications +3. Split large refactors into multiple small, focused edits + +### For LARGE CODE GENERATION: +1. Generate in logical sections (imports, types, functions separately) +2. Write each section as a separate operation +3. Use append operations for subsequent sections + +## EXAMPLES OF CORRECT BEHAVIOR + +CORRECT: Writing a 600-line file +- Operation 1: Write lines 1-300 (initial file creation) +- Operation 2: Append lines 301-600 + +CORRECT: Editing multiple functions +- Operation 1: Edit function A +- Operation 2: Edit function B +- Operation 3: Edit function C + +WRONG: Writing 500 lines in single operation -> TIMEOUT +WRONG: Rewriting entire file to change 5 lines -> TIMEOUT +WRONG: Generating massive code blocks without chunking -> TIMEOUT + +## WHY THIS MATTERS +- Server has 2-3 minute timeout for operations +- Large writes exceed timeout and FAIL completely +- Chunked writes are FASTER and more RELIABLE +- Failed writes waste time and require retry + +REMEMBER: When in doubt, write LESS per operation. Multiple small operations > one large operation. +`.trim(); + +/** + * Resolve the Kiro thinking budget requested by a client. + * + * 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) + * @param {string} [model] Model id the caller asked for (post-strip ok) + * @returns {boolean} + */ +export function isThinkingEnabled(body, headers, model) { + return resolveKiroThinkingBudget(body, headers, model) !== null; +} + +/** + * Detect whether a model id refers to a 9router synthetic agentic variant. + * Agentic variants share the same upstream model as the base; the only + * difference is the chunked-write system prompt this module injects. + * + * @param {string} model + * @returns {boolean} + */ +export function isAgenticModel(model) { + return typeof model === "string" && model.endsWith(KIRO_AGENTIC_SUFFIX); +} + +/** + * Strip the `-agentic` suffix from a model id, leaving the upstream-real id. + * + * @param {string} model + * @returns {string} + */ +export function stripAgenticSuffix(model) { + if (!isAgenticModel(model)) return model; + return model.slice(0, -KIRO_AGENTIC_SUFFIX.length); +} + +/** + * Detect whether a model id is a 9router synthetic thinking variant + * (e.g. `claude-sonnet-4.5-thinking`). Same upstream model as the base; the + * only difference is `enabled` injection. + * + * Note: real Kiro thinking-capable variants exist (e.g. `kimi-k2-thinking` in + * other providers), but for the `kr/` namespace there is no `-thinking` + * model on Kiro upstream. Treat the suffix as a synthetic alias. + * + * @param {string} model Model id with `-agentic` already stripped + * @returns {boolean} + */ +export function isThinkingModel(model) { + return typeof model === "string" && model.endsWith(KIRO_THINKING_SUFFIX); +} + +/** + * Strip the `-thinking` suffix from a model id. + * + * @param {string} model + * @returns {string} + */ +export function stripThinkingSuffix(model) { + if (!isThinkingModel(model)) return model; + return model.slice(0, -KIRO_THINKING_SUFFIX.length); +} + +/** + * Resolve a 9router model id to the real upstream Kiro model id, plus flags + * describing which behaviours the suffixes implied. + * + * resolveKiroModel("claude-sonnet-4.5-thinking-agentic") + * => { upstream: "claude-sonnet-4.5", agentic: true, thinking: true } + * resolveKiroModel("claude-sonnet-4.5-thinking") + * => { upstream: "claude-sonnet-4.5", agentic: false, thinking: true } + * resolveKiroModel("claude-sonnet-4.5-agentic") + * => { upstream: "claude-sonnet-4.5", agentic: true, thinking: false } + * resolveKiroModel("claude-sonnet-4.5") + * => { upstream: "claude-sonnet-4.5", agentic: false, thinking: false } + * + * @param {string} model + * @returns {{ upstream: string, agentic: boolean, thinking: boolean }} + */ +export function resolveKiroModel(model) { + let upstream = model; + let agentic = false; + let thinking = false; + if (isAgenticModel(upstream)) { + agentic = true; + upstream = stripAgenticSuffix(upstream); + } + if (isThinkingModel(upstream)) { + thinking = true; + upstream = stripThinkingSuffix(upstream); + } + return { upstream, agentic, thinking }; +} + +/** + * Build the magic system-prompt prefix that turns Kiro reasoning on. + * Same shape as CLIProxyAPIPlus. + * + * @param {number} [budget=KIRO_THINKING_BUDGET_DEFAULT] + */ +export function buildThinkingSystemPrefix(budget = KIRO_THINKING_BUDGET_DEFAULT) { + const safeBudget = Math.max(1, Math.min(32000, Number(budget) || KIRO_THINKING_BUDGET_DEFAULT)); + return `enabled\n${safeBudget}`; +} + +function pickHeader(headers, name) { + if (!headers) return undefined; + if (typeof headers.get === "function") { + return headers.get(name); + } + const lower = name.toLowerCase(); + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === lower) { + return headers[key]; + } + } + return undefined; +} + +function containsThinkingModeTag(body) { + const messages = Array.isArray(body?.messages) ? body.messages : []; + for (const msg of messages) { + if (!msg) continue; + if (msg.role !== "system" && msg.role !== "user") continue; + const content = msg.content; + if (typeof content === "string") { + if (containsTagInText(content)) return true; + } else if (Array.isArray(content)) { + for (const part of content) { + const text = part?.text; + if (typeof text === "string" && containsTagInText(text)) return true; + } + } + } + if (typeof body?.system === "string" && containsTagInText(body.system)) return true; + return false; +} + +function containsTagInText(text) { + if (!text) return false; + if (!text.includes("")) return false; + return text.includes("enabled") + || text.includes("interleaved"); +} diff --git a/open-sse/config/mediaConfig.js b/open-sse/config/mediaConfig.js new file mode 100644 index 0000000000000000000000000000000000000000..5b8fbb2a2ac435a7dadd1fde7484faea5787b7d5 --- /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/models.js b/open-sse/config/models.js new file mode 100644 index 0000000000000000000000000000000000000000..a1917cddc44eae98c6e00223126cb476fbe363be --- /dev/null +++ b/open-sse/config/models.js @@ -0,0 +1,13 @@ +// Model metadata registry +// Only define models that differ from DEFAULT_MODEL_INFO +// Custom entries are merged over default +const DEFAULT_MODEL_INFO = { + type: ["chat"], + contextWindow: 200000, +}; + +export const MODEL_INFO = {}; + +export function getModelInfo(modelId) { + return { ...DEFAULT_MODEL_INFO, ...MODEL_INFO[modelId] }; +} diff --git a/open-sse/config/ollamaModels.js b/open-sse/config/ollamaModels.js new file mode 100644 index 0000000000000000000000000000000000000000..2c04b4f09d6e4c8f497b3804f3ae30df85922c2f --- /dev/null +++ b/open-sse/config/ollamaModels.js @@ -0,0 +1,19 @@ +export const ollamaModels = { + models: [ + { + name: "llama3.2", + modified_at: "2025-12-26T00:00:00Z", + size: 2000000000, + digest: "abc123def456", + details: { format: "gguf", family: "llama", parameter_size: "3B", quantization_level: "Q4_K_M" } + }, + { + name: "qwen2.5", + modified_at: "2025-12-26T00:00:00Z", + size: 4000000000, + digest: "def456abc123", + details: { format: "gguf", family: "qwen", parameter_size: "7B", quantization_level: "Q4_K_M" } + } + ] +}; + diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js new file mode 100644 index 0000000000000000000000000000000000000000..c4cfa413e6a929c3f43728c987b8d2b1471b5713 --- /dev/null +++ b/open-sse/config/providerModels.js @@ -0,0 +1,83 @@ +import { PROVIDERS } from "./providers.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"; + +export { PROVIDER_MODELS }; + + +// Helper functions +export function getProviderModels(aliasOrId) { + return PROVIDER_MODELS[aliasOrId] || []; +} + +export function getDefaultModel(aliasOrId) { + const models = PROVIDER_MODELS[aliasOrId]; + return models?.[0]?.id || null; +} + +export function isValidModel(aliasOrId, modelId, passthroughProviders = new Set()) { + if (passthroughProviders.has(aliasOrId)) return true; + const models = PROVIDER_MODELS[aliasOrId]; + if (!models) return false; + return models.some(m => m.id === modelId); +} + +export function findModelName(aliasOrId, modelId) { + const models = PROVIDER_MODELS[aliasOrId]; + if (!models) return modelId; + const found = models.find(m => m.id === modelId); + return found?.name || modelId; +} + +export function getModelTargetFormat(aliasOrId, modelId) { + const models = PROVIDER_MODELS[aliasOrId]; + if (!models) return 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?.kind || found?.type || null; +} + +export function getModelUpstreamId(aliasOrId, modelId) { + const models = PROVIDER_MODELS[aliasOrId]; + const found = models?.find(m => m.id === modelId); + if (found?.upstreamModelId) return found.upstreamModelId; + if (aliasOrId === "cx" && typeof modelId === "string" && modelId.endsWith(CODEX_REVIEW_SUFFIX)) { + return modelId.slice(0, -CODEX_REVIEW_SUFFIX.length); + } + return modelId; +} + +export function getModelQuotaFamily(aliasOrId, modelId) { + const models = PROVIDER_MODELS[aliasOrId]; + return modelQuotaFamily(models?.find(m => m.id === modelId)); +} + +// 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( + Object.keys(PROVIDERS).map(id => [id, OAUTH_ALIASES[id] || id]) +); + +export function getModelsByProviderId(providerId) { + const alias = PROVIDER_ID_TO_ALIAS[providerId] || providerId; + return PROVIDER_MODELS[alias] || []; +} + +// 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) { + return modelStrip(PROVIDER_MODELS[alias]?.find(m => m.id === modelId)); +} diff --git a/open-sse/config/providers.js b/open-sse/config/providers.js new file mode 100644 index 0000000000000000000000000000000000000000..f24ab96e85562b234df53249a208e631c4595b3f --- /dev/null +++ b/open-sse/config/providers.js @@ -0,0 +1,19 @@ +// 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"; + +export function resolveOllamaLocalHost(credentials) { + const raw = credentials?.providerSpecificData?.baseUrl?.trim(); + return (raw || OLLAMA_LOCAL_DEFAULT_HOST).replace(/\/$/, ""); +} + +// 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; + return XIAOMI_TOKENPLAN_REGIONS[region] || XIAOMI_TOKENPLAN_REGIONS[XIAOMI_TOKENPLAN_DEFAULT_REGION]; +} diff --git a/open-sse/config/runtimeConfig.js b/open-sse/config/runtimeConfig.js new file mode 100644 index 0000000000000000000000000000000000000000..aeacd2db5ce206c67b33af7ade84168e1e83ccab --- /dev/null +++ b/open-sse/config/runtimeConfig.js @@ -0,0 +1,84 @@ +// HTTP status codes +export const HTTP_STATUS = { + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + PAYMENT_REQUIRED: 402, + FORBIDDEN: 403, + NOT_FOUND: 404, + NOT_ACCEPTABLE: 406, + REQUEST_TIMEOUT: 408, + RATE_LIMITED: 429, + SERVER_ERROR: 500, + BAD_GATEWAY: 502, + SERVICE_UNAVAILABLE: 503, + GATEWAY_TIMEOUT: 504 +}; + +// Re-export error config (backward compat) +export { ERROR_TYPES, DEFAULT_ERROR_MESSAGES, BACKOFF_CONFIG, COOLDOWN_MS } from "./errorConfig.js"; + +// Cache TTLs (seconds) +export const CACHE_TTL = { + userInfo: 300, // 5 minutes + modelAlias: 3600 // 1 hour +}; + +// Memory management config +export const MEMORY_CONFIG = { + sessionTtlMs: 2 * 60 * 60 * 1000, + sessionCleanupIntervalMs: 30 * 60 * 1000, + dnsCacheTtlMs: 5 * 60 * 1000, + proxyDispatchersMaxSize: 20, +}; + +// 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 = envMs("FETCH_CONNECT_TIMEOUT_MS", 60 * 1000); + +// Default token limits +export const DEFAULT_MAX_TOKENS = 64000; +export const DEFAULT_MIN_TOKENS = 32000; + +// Retry config for 429 responses (legacy - kept for backward compatibility) +export const RETRY_CONFIG = { + maxAttempts: 2, + delayMs: 2000 +}; + +// Default retry config by status code: { attempts, delayMs } +// Backward compat: if value is a number, treated as attempts with RETRY_CONFIG.delayMs +export const DEFAULT_RETRY_CONFIG = { + 429: { attempts: 0, delayMs: 0 }, + 502: { attempts: 3, delayMs: 3000 }, + 503: { attempts: 3, delayMs: 2000 }, + 504: { attempts: 2, delayMs: 3000 } +}; + +// Normalize a retry entry to { attempts, delayMs } +export function resolveRetryEntry(entry) { + if (entry == null) return { attempts: 0, delayMs: RETRY_CONFIG.delayMs }; + if (typeof entry === "number") return { attempts: entry, delayMs: RETRY_CONFIG.delayMs }; + return { + attempts: entry.attempts || 0, + delayMs: entry.delayMs != null ? entry.delayMs : RETRY_CONFIG.delayMs + }; +} + +// Requests containing these texts will bypass provider +export const SKIP_PATTERNS = [ + "Please write a 5-10 word title for the following conversation:" +]; diff --git a/open-sse/config/ttsModels.js b/open-sse/config/ttsModels.js new file mode 100644 index 0000000000000000000000000000000000000000..78ae7fbe897ea218bbd251cf8aed46b061a0f231 --- /dev/null +++ b/open-sse/config/ttsModels.js @@ -0,0 +1,129 @@ +import { GOOGLE_TTS_LANGUAGES } from "./googleTtsLanguages.js"; + +// ── Voice definitions (DRY — reused across providers) ────────────────────── +const VOICES = { + alloy: { id: "alloy", name: "Alloy" }, + ash: { id: "ash", name: "Ash" }, + ballad: { id: "ballad", name: "Ballad" }, + cedar: { id: "cedar", name: "Cedar" }, + coral: { id: "coral", name: "Coral" }, + echo: { id: "echo", name: "Echo" }, + fable: { id: "fable", name: "Fable" }, + marin: { id: "marin", name: "Marin" }, + nova: { id: "nova", name: "Nova" }, + onyx: { id: "onyx", name: "Onyx" }, + sage: { id: "sage", name: "Sage" }, + shimmer: { id: "shimmer", name: "Shimmer" }, + verse: { id: "verse", name: "Verse" }, +}; + +const v = (...keys) => keys.map((k) => ({ ...VOICES[k], type: "tts" })); + +// 9 voices for tts-1 / tts-1-hd +const VOICES_STANDARD = v("alloy", "ash", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer"); +// 13 voices for gpt-4o-mini-tts +const VOICES_FULL = v("alloy", "ash", "ballad", "cedar", "coral", "echo", "fable", "marin", "nova", "onyx", "sage", "shimmer", "verse"); + +// Gemini prebuilt voices (30 voices, multi-language auto-detect) +const GEMINI_VOICES = [ + "Zephyr", "Puck", "Charon", "Kore", "Fenrir", "Leda", "Orus", "Aoede", + "Callirrhoe", "Autonoe", "Enceladus", "Iapetus", "Umbriel", "Algieba", + "Despina", "Erinome", "Algenib", "Rasalgethi", "Laomedeia", "Achernar", + "Alnilam", "Schedar", "Gacrux", "Pulcherrima", "Achird", "Zubenelgenubi", + "Vindemiatrix", "Sadachbia", "Sadaltager", "Sulafat", +].map((id) => ({ id, name: id, type: "tts" })); + +// ── TTS Config (config-driven, single source of truth) ───────────────────── +export const TTS_MODELS_CONFIG = { + openai: { + models: [ + { id: "gpt-4o-mini-tts", name: "GPT-4o Mini TTS", type: "tts" }, + { id: "tts-1-hd", name: "TTS-1 HD", type: "tts" }, + { id: "tts-1", name: "TTS-1", type: "tts" }, + ], + voices: { + "gpt-4o-mini-tts": VOICES_FULL, + "tts-1": VOICES_STANDARD, + "tts-1-hd": VOICES_STANDARD, + }, + // Flat voice list (all unique voices) for backward compat + allVoices: VOICES_FULL, + }, + openrouter: { + 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" }, + ], + voices: { + "openai/gpt-4o-mini-tts": VOICES_FULL, + "openai/tts-1": VOICES_STANDARD, + "openai/tts-1-hd": VOICES_STANDARD, + }, + allVoices: VOICES_FULL, + }, + elevenlabs: { + models: [ + { id: "eleven_flash_v2_5", name: "Flash v2.5 (Fastest)", type: "tts" }, + { id: "eleven_turbo_v2_5", name: "Turbo v2.5 (Fast)", type: "tts" }, + { id: "eleven_multilingual_v2", name: "Multilingual v2 (Quality)", type: "tts" }, + { id: "eleven_monolingual_v1", name: "Monolingual v1 (English)", type: "tts" }, + ], + // voices come from API, not hardcoded + }, + "edge-tts": { + defaults: [ + { id: "en-US-AriaNeural", name: "Aria (en-US)", type: "tts" }, + { id: "en-US-GuyNeural", name: "Guy (en-US)", type: "tts" }, + { id: "en-GB-SoniaNeural", name: "Sonia (en-GB)", type: "tts" }, + { id: "vi-VN-HoaiMyNeural", name: "Hoai My (vi-VN)", type: "tts" }, + { id: "vi-VN-NamMinhNeural", name: "Nam Minh (vi-VN)", type: "tts" }, + { id: "zh-CN-XiaoxiaoNeural", name: "Xiaoxiao (zh-CN)", type: "tts" }, + { id: "zh-CN-YunxiNeural", name: "Yunxi (zh-CN)", type: "tts" }, + { id: "fr-FR-DeniseNeural", name: "Denise (fr-FR)", type: "tts" }, + { id: "de-DE-KatjaNeural", name: "Katja (de-DE)", type: "tts" }, + { id: "ja-JP-NanamiNeural", name: "Nanami (ja-JP)", type: "tts" }, + { id: "ko-KR-SunHiNeural", name: "SunHi (ko-KR)", type: "tts" }, + ], + }, + "local-device": { + defaults: [ + { id: "default", name: "System Default Voice", type: "tts" }, + ], + }, + "google-tts": { + defaults: GOOGLE_TTS_LANGUAGES, + }, + gemini: { + models: [ + { 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-2.5-flash-preview-tts": GEMINI_VOICES, + "gemini-2.5-pro-preview-tts": GEMINI_VOICES, + }, + allVoices: GEMINI_VOICES, + }, +}; + +// ── Helper: get voices for a specific model ──────────────────────────────── +export function getTtsVoicesForModel(provider, modelId) { + const cfg = TTS_MODELS_CONFIG[provider]; + if (!cfg?.voices) return null; + return cfg.voices[modelId] || cfg.allVoices || null; +} + +// ── Build flat entries for PROVIDER_MODELS backward compat ───────────────── +export function buildTtsProviderModels() { + const entries = {}; + for (const [provider, cfg] of Object.entries(TTS_MODELS_CONFIG)) { + if (cfg.models) entries[`${provider}-tts-models`] = cfg.models; + if (cfg.allVoices) entries[`${provider}-tts-voices`] = cfg.allVoices; + if (cfg.defaults) entries[provider] = cfg.defaults; + } + // Keep openai-tts-voices key pointing to full voice list for backward compat + entries["openai-tts-voices"] = TTS_MODELS_CONFIG.openai.allVoices; + entries["openrouter-tts-voices"] = TTS_MODELS_CONFIG.openrouter.allVoices; + return entries; +} diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js new file mode 100644 index 0000000000000000000000000000000000000000..3ab8abfef91a379f06ac443e99dcebbcbf2e6865 --- /dev/null +++ b/open-sse/executors/antigravity.js @@ -0,0 +1,439 @@ +import crypto from "crypto"; +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 { resolveSessionId } from "../utils/sessionManager.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.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) { + if (!name) return "_unknown"; + let s = name.replace(/[^a-zA-Z0-9_.:\-]/g, "_"); + if (!/^[a-zA-Z_]/.test(s)) s = "_" + s; + return s.substring(0, 64); +} + +const MAX_RETRY_AFTER_MS = 10000; +const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 16384; + +// Fields Google generateContent rejects (e.g. Claude adaptive output_config) — stripped from antigravity request envelope +const ANTIGRAVITY_REQUEST_BLACKLIST = ["output_config"]; + +export class AntigravityExecutor extends BaseExecutor { + constructor() { + super("antigravity", PROVIDERS.antigravity); + } + + buildUrl(model, stream, urlIndex = 0) { + const baseUrls = this.getBaseUrls(); + const baseUrl = baseUrls[urlIndex] || baseUrls[0]; + const action = stream ? "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, + ...(sid && { "X-Machine-Session-Id": sid }), + "Accept": stream ? "text/event-stream" : "application/json" + }; + } + + transformRequest(model, body, stream, credentials) { + const projectId = credentials?.projectId || this.generateProjectId(); + + // Fix contents for Claude models via Antigravity + const contents = body.request?.contents?.map(c => { + let role = c.role; + // functionResponse must be role "user" for Claude models + if (c.parts?.some(p => p.functionResponse)) { + role = "user"; + } + // Strip thought-only parts, keep thoughtSignature on functionCall parts (Gemini 3+ requires it) + const parts = c.parts?.filter(p => { + if (p.thought && !p.functionCall) return false; + if (p.thoughtSignature && !p.functionCall && !p.text) return false; + return true; + }); + if (role !== c.role || parts?.length !== c.parts?.length) { + return { ...c, role, parts }; + } + return c; + }); + + // Sanitize tool schemas and function names before sending to Antigravity. + let tools = body.request?.tools; + + 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"] } + })) + ); + 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 || {}; + for (const key of ANTIGRAVITY_REQUEST_BLACKLIST) delete requestWithoutTools[key]; + const generationConfig = { ...(requestWithoutTools.generationConfig || {}) }; + if (generationConfig.maxOutputTokens > MAX_ANTIGRAVITY_OUTPUT_TOKENS) { + generationConfig.maxOutputTokens = MAX_ANTIGRAVITY_OUTPUT_TOKENS; + } + + const transformedRequest = { + ...requestWithoutTools, + generationConfig, + ...(contents && { contents }), + ...(tools && { tools }), + 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" } } }) + }; + + this._lastSessionId = transformedRequest.sessionId; // cached for buildHeaders (base.execute order) + + return { + ...body, + project: projectId, + model: model, + userAgent: "antigravity", + requestType: "agent", + requestId: `agent-${crypto.randomUUID()}`, + request: transformedRequest + }; + } + + async refreshCredentials(credentials, log, proxyOptions = null) { + if (!credentials.refreshToken) return null; + + try { + 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: credentials.refreshToken, + client_id: this.config.clientId, + client_secret: this.config.clientSecret + }) + }, proxyOptions); + + if (!response.ok) return null; + + const tokens = await response.json(); + log?.info?.("TOKEN", "Antigravity refreshed"); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || credentials.refreshToken, + expiresIn: tokens.expires_in, + projectId: credentials.projectId + }; + } catch (error) { + log?.error?.("TOKEN", `Antigravity refresh error: ${error.message}`); + return null; + } + } + + generateProjectId() { + const adj = ["useful", "bright", "swift", "calm", "bold"][Math.floor(Math.random() * 5)]; + const noun = ["fuze", "wave", "spark", "flow", "core"][Math.floor(Math.random() * 5)]; + return `${adj}-${noun}-${crypto.randomUUID().slice(0, 5)}`; + } + + generateSessionId() { + return crypto.randomUUID() + Date.now().toString(); + } + + parseRetryHeaders(headers) { + if (!headers?.get) return null; + + const retryAfter = headers.get('retry-after'); + if (retryAfter) { + const seconds = parseInt(retryAfter, 10); + if (!isNaN(seconds) && seconds > 0) return seconds * 1000; + + const date = new Date(retryAfter); + if (!isNaN(date.getTime())) { + const diff = date.getTime() - Date.now(); + return diff > 0 ? diff : null; + } + } + + const resetAfter = headers.get('x-ratelimit-reset-after'); + if (resetAfter) { + const seconds = parseInt(resetAfter, 10); + if (!isNaN(seconds) && seconds > 0) return seconds * 1000; + } + + const resetTimestamp = headers.get('x-ratelimit-reset'); + if (resetTimestamp) { + const ts = parseInt(resetTimestamp, 10) * 1000; + const diff = ts - Date.now(); + return diff > 0 ? diff : null; + } + + return null; + } + + // Parse retry time from Antigravity error message body + // Format: "Your quota will reset after 2h7m23s" or "1h30m" or "45m" or "30s" + parseRetryFromErrorMessage(errorMessage) { + if (!errorMessage || typeof errorMessage !== "string") return null; + + const match = errorMessage.match(/reset after (\d+h)?(\d+m)?(\d+s)?/i); + if (!match) return null; + + let totalMs = 0; + if (match[1]) totalMs += parseInt(match[1]) * 3600 * 1000; // hours + if (match[2]) totalMs += parseInt(match[2]) * 60 * 1000; // minutes + if (match[3]) totalMs += parseInt(match[3]) * 1000; // seconds + + return totalMs > 0 ? totalMs : null; + } + + // Hook called by BaseExecutor.tryRetry: derive delay from Retry-After (header → body), + // cap at MAX_RETRY_AFTER_MS, else exponential backoff for 429. Return false to veto (fallback URL). + async computeRetryDelay(response, attempt) { + let retryMs = this.parseRetryHeaders(response.headers); + if (!retryMs) { + try { + const errorJson = JSON.parse(await response.clone().text()); + retryMs = this.parseRetryFromErrorMessage(errorJson?.error?.message || errorJson?.message || ""); + } catch { + // ignore parse errors → fall through to backoff + } + } + if (retryMs) return retryMs <= MAX_RETRY_AFTER_MS ? retryMs : false; + if (response.status === HTTP_STATUS.RATE_LIMITED) { + return Math.min(1000 * (2 ** attempt), MAX_RETRY_AFTER_MS); // exponential backoff + } + return false; + } + + /** + * Cloak tools before sending to Antigravity provider (anti-ban): + * - Rename client tools with _ide suffix + * - Inject AG default decoy tools after client tools + * Returns { cloakedBody, toolNameMap } where toolNameMap maps suffixed → original + */ + static cloakTools(body, clientTool = null) { + const tools = body.request?.tools; + if (!tools || tools.length === 0) { + return { cloakedBody: body, toolNameMap: null }; + } + + const isCopilot = clientTool === "github-copilot"; + const toolNameMap = new Map(); + const clientDeclarations = []; + const decoyNames = new Set(AG_DECOY_TOOLS.map(tool => tool.name)); + + // First: collect renamed client tools + for (const toolGroup of tools) { + if (!toolGroup.functionDeclarations) continue; + + for (const func of toolGroup.functionDeclarations) { + // For GitHub Copilot, avoid emitting duplicate native Antigravity tool names. + // Keep the decoys only once in the final declaration list. + if (isCopilot && AG_DEFAULT_TOOLS.has(func.name)) { + continue; + } + + // Skip if already covered by decoys for Copilot + if (isCopilot && decoyNames.has(func.name)) { + continue; + } + + // Preserve native AG names for non-Copilot clients + if (AG_DEFAULT_TOOLS.has(func.name)) { + clientDeclarations.push(func); + continue; + } + + const suffixed = `${func.name}${AG_TOOL_SUFFIX}`; + toolNameMap.set(suffixed, func.name); + clientDeclarations.push({ ...func, name: suffixed }); + } + } + + // Client tools first, then AG decoy tools + const allDeclarations = []; + const seenNames = new Set(); + for (const decl of [...clientDeclarations, ...AG_DECOY_TOOLS]) { + if (!decl?.name || seenNames.has(decl.name)) continue; + seenNames.add(decl.name); + allDeclarations.push(decl); + } + + // Rename tool names in conversation history (contents) + const cloakedContents = body.request?.contents?.map(msg => { + if (!msg.parts) return msg; + + const cloakedParts = msg.parts.map(part => { + // Rename functionCall.name + if (part.functionCall && !AG_DEFAULT_TOOLS.has(part.functionCall.name)) { + return { + ...part, + functionCall: { + ...part.functionCall, + name: `${part.functionCall.name}${AG_TOOL_SUFFIX}` + } + }; + } + + // Rename functionResponse.name + if (part.functionResponse && !AG_DEFAULT_TOOLS.has(part.functionResponse.name)) { + return { + ...part, + functionResponse: { + ...part.functionResponse, + name: `${part.functionResponse.name}${AG_TOOL_SUFFIX}` + } + }; + } + + return part; + }); + + return { ...msg, parts: cloakedParts }; + }); + + // Single functionDeclarations group: client tools first, then decoys + return { + cloakedBody: { + ...body, + request: { + ...body.request, + tools: [{ functionDeclarations: allDeclarations }], + contents: cloakedContents || body.request.contents + } + }, + toolNameMap + }; + } +} + +// AG decoy tools — same names as AG native defaults, redirect to _ide suffixed tools +const AG_DECOY_TOOLS = [ + { + name: "browser_subagent", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + }, + { + name: "command_status", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + }, + { + name: "find_by_name", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + }, + { + name: "generate_image", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + }, + { + name: "grep_search", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + }, + { + name: "list_dir", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + }, + { + name: "list_resources", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + }, + { + name: "mcp_sequential-thinking_sequentialthinking", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + }, + { + name: "multi_replace_file_content", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + }, + { + name: "notify_user", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + }, + { + name: "read_resource", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + }, + { + name: "read_terminal", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + }, + { + name: "read_url_content", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + }, + { + name: "replace_file_content", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + }, + { + name: "run_command", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + }, + { + name: "search_web", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + }, + { + name: "send_command_input", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + }, + { + name: "task_boundary", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + }, + { + name: "view_content_chunk", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + }, + { + name: "view_file", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + }, + { + name: "write_to_file", + description: "This tool is currently unavailable.", + parameters: { type: "OBJECT", properties: {}, required: [] } + } +]; + +export default AntigravityExecutor; diff --git a/open-sse/executors/azure.js b/open-sse/executors/azure.js new file mode 100644 index 0000000000000000000000000000000000000000..574cda22f9456660affccce62908d27b8ccb0632 --- /dev/null +++ b/open-sse/executors/azure.js @@ -0,0 +1,57 @@ +import { DefaultExecutor } from "./default.js"; + +export class AzureExecutor extends DefaultExecutor { + constructor() { + super("azure"); + } + + buildUrl(model, stream, urlIndex = 0, credentials = null) { + const azureEndpoint = credentials?.providerSpecificData?.azureEndpoint + || process.env.AZURE_ENDPOINT + || "https://api.openai.com"; + + const apiVersion = credentials?.providerSpecificData?.apiVersion + || process.env.AZURE_API_VERSION + || "2024-10-01-preview"; + + const deployment = credentials?.providerSpecificData?.deployment + || model + || process.env.AZURE_DEPLOYMENT + || "gpt-4"; + + const endpoint = azureEndpoint.replace(/\/$/, ""); + return `${endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${apiVersion}`; + } + + buildHeaders(credentials, stream = true) { + const headers = { + "Content-Type": "application/json", + ...this.config.headers + }; + + const apiKey = credentials?.apiKey + || credentials?.accessToken + || process.env.OPENAI_API_KEY; + + if (apiKey) { + headers["api-key"] = apiKey; + } + + const organization = credentials?.providerSpecificData?.organization + || process.env.AZURE_ORGANIZATION; + + if (organization) { + headers["OpenAI-Organization"] = organization; + } + + if (stream) { + headers["Accept"] = "text/event-stream"; + } + + return headers; + } + + transformRequest(model, body, stream, credentials) { + return body; + } +} diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js new file mode 100644 index 0000000000000000000000000000000000000000..71418deb5215365f6b7647a33e0707977566fd04 --- /dev/null +++ b/open-sse/executors/base.js @@ -0,0 +1,186 @@ +import { HTTP_STATUS, RETRY_CONFIG, DEFAULT_RETRY_CONFIG, resolveRetryEntry, FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js"; +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 + */ +export class BaseExecutor { + constructor(provider, config) { + this.provider = provider; + this.config = config; + this.noAuth = config?.noAuth || false; + } + + getProvider() { + return this.provider; + } + + getBaseUrls() { + return this.config.baseUrls || (this.config.baseUrl ? [this.config.baseUrl] : []); + } + + getFallbackCount() { + return this.getBaseUrls().length || 1; + } + + buildUrl(model, stream, urlIndex = 0, credentials = null) { + if (this.provider?.startsWith?.("openai-compatible-")) { + 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 || ANTHROPIC_COMPAT_BASE; + const normalized = baseUrl.replace(/\/$/, ""); + return `${normalized}/messages`; + } + const baseUrls = this.getBaseUrls(); + return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl; + } + + buildHeaders(credentials, stream = true) { + const headers = { + "Content-Type": "application/json", + ...this.config.headers + }; + + if (this.provider?.startsWith?.("anthropic-compatible-")) { + // Anthropic-compatible providers use x-api-key header + 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"] = ANTHROPIC_API_VERSION; + } + } else { + // Standard Bearer token auth for other providers + if (credentials.accessToken) { + headers["Authorization"] = `Bearer ${credentials.accessToken}`; + } else if (credentials.apiKey) { + headers["Authorization"] = `Bearer ${credentials.apiKey}`; + } + } + + if (stream) { + headers["Accept"] = "text/event-stream"; + } + + return headers; + } + + // Override in subclass for provider-specific transformations + transformRequest(model, body, stream, credentials) { + return body; + } + + shouldRetry(status, urlIndex) { + return status === HTTP_STATUS.RATE_LIMITED && urlIndex + 1 < this.getFallbackCount(); + } + + // Override in subclass for provider-specific refresh + async refreshCredentials(credentials, log, proxyOptions = null) { + return null; + } + + needsRefresh(credentials) { + return shouldRefreshCredentials(this.provider, credentials); + } + + parseError(response, bodyText) { + return { status: response.status, message: bodyText || `HTTP ${response.status}` }; + } + + async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + const fallbackCount = this.getFallbackCount(); + let lastError = null; + let lastStatus = 0; + const retryAttemptsByUrl = {}; + + // Merge default retry config with provider-specific config + const retryConfig = { ...DEFAULT_RETRY_CONFIG, ...this.config.retry }; + + // Schedule retry via retryConfig[statusKey]. Returns true when caller should `urlIndex--; continue` + // 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 ${waitMs / 1000}s`); + await new Promise(resolve => setTimeout(resolve, waitMs)); + return true; + }; + + for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { + const url = this.buildUrl(model, stream, urlIndex, credentials); + const transformedBody = this.transformRequest(model, body, stream, credentials); + const headers = this.buildHeaders(credentials, stream); + + if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0; + + // Abort if upstream doesn't return response headers within connection timeout + const connectCtrl = new AbortController(); + const timeoutMs = this.config?.timeoutMs || FETCH_CONNECT_TIMEOUT_MS; + const connectTimer = setTimeout(() => connectCtrl.abort(new Error("fetch connect timeout")), timeoutMs); + const mergedSignal = signal ? AbortSignal.any([signal, connectCtrl.signal]) : connectCtrl.signal; + + try { + const bodyStr = JSON.stringify(transformedBody); + const fetchT0 = Date.now(); + dbg("FETCH", `${this.provider.toUpperCase()} → ${url} | body=${bodyStr.length}B | connectTimeout=${timeoutMs}ms`); + const response = await proxyAwareFetch(url, { + method: "POST", + headers, + body: bodyStr, + signal: mergedSignal + }, proxyOptions); + clearTimeout(connectTimer); + const ct = response.headers?.get?.("content-type") || ""; + 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}`, response)) { urlIndex--; 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) { + clearTimeout(connectTimer); + lastError = error; + const isConnectTimeout = connectCtrl.signal.aborted && error.name === "AbortError"; + dbg("FETCH", `${this.provider.toUpperCase()} ✖ ${error.name}: ${error.message}${isConnectTimeout ? " (connect timeout)" : ""}`); + // Connect timeout is internal — convert to retryable network error, don't propagate AbortError + if (error.name === "AbortError" && !isConnectTimeout) throw error; + + // Map network/fetch exceptions to 502 retry config + if (await tryRetry(urlIndex, HTTP_STATUS.BAD_GATEWAY, `network "${error.message}"`)) { urlIndex--; continue; } + + if (urlIndex + 1 < fallbackCount) { + log?.debug?.("RETRY", `Error on ${url}, trying fallback ${urlIndex + 1}`); + continue; + } + throw error; + } + } + + throw lastError || new Error(`All ${fallbackCount} URLs failed with status ${lastStatus}`); + } +} + +export default BaseExecutor; diff --git a/open-sse/executors/codex.js b/open-sse/executors/codex.js new file mode 100644 index 0000000000000000000000000000000000000000..bfff245d95a97667340406a9e46d5a4485cf9c12 --- /dev/null +++ b/open-sse/executors/codex.js @@ -0,0 +1,395 @@ +import { BaseExecutor } from "./base.js"; +import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js"; +import { PROVIDERS } from "../config/providers.js"; +import { + refreshProviderCredentials, + shouldRefreshCredentials, +} from "../services/oauthCredentialManager.js"; +import { normalizeResponsesInput } from "../translator/formats/responsesApi.js"; +import { fetchImageAsBase64 } from "../translator/concerns/image.js"; +import { getModelUpstreamId } from "../config/providerModels.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; + +// 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" +]); + +// 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" +]); + +// Convert role=system → role=developer in body.input (keeps content in cacheable prefix) +function convertSystemToDeveloperRole(body) { + if (!Array.isArray(body.input)) return; + for (const item of body.input) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const isSystemMsg = item.role === "system" && (!item.type || item.type === "message"); + if (isSystemMsg) item.role = "developer"; + } +} + +// Strip server-generated item IDs (rs_/fc_/resp_/msg_) from input — avoids 404 with store=false +function stripStoredItemReferences(body) { + if (!Array.isArray(body.input)) return; + body.input = body.input.filter((item) => { + if (typeof item === "string" && SERVER_ID_PATTERN.test(item)) return false; + if (item && typeof item === "object" && !Array.isArray(item)) { + if (item.type === "item_reference") return false; + if (typeof item.id === "string" && SERVER_ID_PATTERN.test(item.id)) delete item.id; + } + return true; + }); +} + +// Flatten Chat-Completions tool shape into Responses flat format + filter unsupported tools +function normalizeCodexTools(body) { + if (!Array.isArray(body.tools)) return; + const validNames = new Set(); + body.tools = body.tools.filter((tool) => { + if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false; + const type = typeof tool.type === "string" ? tool.type : ""; + if (type === "namespace") { + if (Array.isArray(tool.tools)) { + for (const st of tool.tools) { + const n = typeof st?.name === "string" ? st.name.trim().slice(0, 128) : ""; + if (n) validNames.add(n); + } + } + return true; + } + if (type !== "function") { + if (!type || tool.function || typeof tool.name === "string") return false; + return CODEX_HOSTED_TOOL_TYPES.has(type); + } + const fn = tool.function && typeof tool.function === "object" && !Array.isArray(tool.function) ? tool.function : null; + const rawName = typeof tool.name === "string" ? tool.name : (typeof fn?.name === "string" ? fn.name : ""); + const name = rawName.trim(); + if (!name) return false; + const description = typeof tool.description === "string" ? tool.description : (typeof fn?.description === "string" ? fn.description : ""); + const parameters = (tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters)) + ? tool.parameters + : (fn?.parameters && typeof fn.parameters === "object" && !Array.isArray(fn.parameters) ? fn.parameters : { type: "object", properties: {} }); + for (const k of Object.keys(tool)) delete tool[k]; + tool.type = "function"; + tool.name = name.slice(0, 128); + if (description) tool.description = description; + tool.parameters = parameters; + validNames.add(name); + return true; + }); + // Drop tool_choice if it references an unknown function name + if (body.tool_choice && typeof body.tool_choice === "object" && !Array.isArray(body.tool_choice)) { + if (body.tool_choice.type === "function") { + const n = typeof body.tool_choice.name === "string" ? body.tool_choice.name.trim() : ""; + if (!n || !validNames.has(n)) delete body.tool_choice; + } + } +} + +// 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" + }); +} + +/** + * Codex Executor - handles OpenAI Codex API (Responses API format) + * Automatically injects default instructions if missing + */ +export class CodexExecutor extends BaseExecutor { + constructor() { + super("codex", PROVIDERS.codex); + this._currentSessionId = null; + } + + /** + * Override headers to add codex-specific identity headers. + * transformRequest runs BEFORE buildHeaders, sets this._currentSessionId. + */ + buildHeaders(credentials, stream = true) { + const headers = super.buildHeaders(credentials, stream); + headers["session_id"] = this._currentSessionId || credentials?.connectionId || "default"; + // Identify client type to Codex backend (matches official codex CLI) + if (!headers["originator"]) headers["originator"] = "codex_cli_rs"; + // Workspace binding header — improves account scope + cache affinity + const workspaceId = credentials?.providerSpecificData?.workspaceId; + if (typeof workspaceId === "string" && workspaceId && !headers["chatgpt-account-id"]) { + headers["chatgpt-account-id"] = workspaceId; + } + return headers; + } + + buildUrl(model, stream, urlIndex = 0, credentials = null) { + const base = super.buildUrl(model, stream, urlIndex, credentials); + return this._isCompact ? `${base}/compact` : base; + } + + async refreshCredentials(credentials, log) { + if (!credentials?.refreshToken) return null; + return refreshProviderCredentials("codex", credentials, log); + } + + needsRefresh(credentials) { + return shouldRefreshCredentials("codex", credentials); + } + + /** + * Prefetch remote image URLs and inline them as base64 data URIs. + * Runs before execute() because Codex backend cannot fetch remote images. + * Mutates body.input in place. + */ + async prefetchImages(body) { + if (!Array.isArray(body?.input)) return; + for (const item of body.input) { + if (!Array.isArray(item.content)) continue; + const pending = item.content.map(async (c) => { + if (c.type !== "image_url") return c; + const url = typeof c.image_url === "string" ? c.image_url : c.image_url?.url; + const detail = c.image_url?.detail || "auto"; + if (!url) return c; + if (url.startsWith("data:")) return { type: "input_image", image_url: url, detail }; + const fetched = await fetchImageAsBase64(url, { timeoutMs: 15000 }); + return { type: "input_image", image_url: fetched?.url || url, detail }; + }); + item.content = await Promise.all(pending); + } + } + + async execute(args) { + const imgCount = Array.isArray(args.body?.input) ? args.body.input.reduce((n, it) => n + (Array.isArray(it.content) ? it.content.filter(c => c.type === "image_url").length : 0), 0) : 0; + const inputLen = Array.isArray(args.body?.input) ? args.body.input.length : 0; + dbg("CODEX", `execute start | inputItems=${inputLen} | images=${imgCount} | sessionId=${this._currentSessionId || "pending"}`); + if (imgCount > 0) { + const t0 = Date.now(); + await this.prefetchImages(args.body); + dbg("CODEX", `prefetchImages done | ${Date.now() - t0}ms`); + } else { + await this.prefetchImages(args.body); + } + + // Retry loop for SSE-level overloaded errors (200 OK body contains event: error) + // Reuses 503 retry config — same semantic: upstream temporarily unavailable + const retryConfig = { ...DEFAULT_RETRY_CONFIG, ...this.config.retry }; + const { attempts, delayMs } = resolveRetryEntry(retryConfig[503]); + let attempt = 0; + while (true) { + const result = await super.execute(args); + const peek = await this._peekSseOverloaded(result.response); + if (!peek.matched) { + // Replace body with re-assembled stream (prefix bytes already read + rest) + if (peek.replacementBody) { + result.response = new Response(peek.replacementBody, { + status: result.response.status, + statusText: result.response.statusText, + headers: result.response.headers, + }); + } + return result; + } + if (attempt >= attempts) { + args.log?.warn?.("RETRY", `CODEX | SSE overloaded "${peek.matched}" — retries exhausted (${attempt}/${attempts})`); + // Out of retries → return with replacement body so client gets the error + if (peek.replacementBody) { + result.response = new Response(peek.replacementBody, { + status: result.response.status, + statusText: result.response.statusText, + headers: result.response.headers, + }); + } + return result; + } + attempt++; + args.log?.debug?.("RETRY", `CODEX | SSE "${peek.matched}" retry ${attempt}/${attempts} after ${delayMs / 1000}s`); + dbg("CODEX", `SSE overloaded "${peek.matched}" → retry ${attempt}/${attempts} in ${delayMs}ms`); + try { await result.response.body?.cancel?.(); } catch { /* noop */ } + await new Promise(r => setTimeout(r, delayMs)); + } + } + + // Peek first N bytes of SSE body to detect upstream "overloaded" errors. + // Returns { matched: string|null, replacementBody: ReadableStream|null }. + // Caller MUST use replacementBody (original body has been read). + async _peekSseOverloaded(response) { + if (!response || !response.ok || !response.body) return { matched: null, replacementBody: null }; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const chunks = []; + let text = ""; + let matched = null; + try { + while (text.length < CODEX_SSE_PEEK_BYTES) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + text += decoder.decode(value, { stream: true }); + const hit = CODEX_SSE_OVERLOADED_PATTERNS.find(p => text.includes(p)); + if (hit) { matched = hit; break; } + } + } catch (e) { + dbg("CODEX", `peek read error: ${e.message}`); + } + reader.releaseLock(); + + // Re-assemble stream: prefix chunks + remaining upstream body + const upstream = response.body; + let upstreamReader = null; + const replacementBody = new ReadableStream({ + start(controller) { + for (const c of chunks) controller.enqueue(c); + upstreamReader = upstream.getReader(); + }, + async pull(controller) { + try { + const { done, value } = await upstreamReader.read(); + if (done) { controller.close(); return; } + controller.enqueue(value); + } catch (e) { controller.error(e); } + }, + cancel(reason) { + try { upstreamReader?.cancel(reason); } catch { /* noop */ } + }, + }); + return { matched, replacementBody }; + } + + // Parse Codex usage_limit_reached to extract precise resetsAtMs; fallback to default otherwise + parseError(response, bodyText) { + if (response.status === 429 && bodyText) { + try { + const json = JSON.parse(bodyText); + const err = json?.error; + if (err?.type === "usage_limit_reached") { + const now = Date.now(); + let resetsAtMs = null; + if (typeof err.resets_at === "number" && err.resets_at > 0) { + const ms = err.resets_at * 1000; + if (ms > now) resetsAtMs = ms; + } + if (!resetsAtMs && typeof err.resets_in_seconds === "number" && err.resets_in_seconds > 0) { + resetsAtMs = now + err.resets_in_seconds * 1000; + } + if (resetsAtMs) { + return { status: 429, message: err.message || bodyText, resetsAtMs }; + } + } + } catch { /* fall through to default */ } + } + return super.parseError(response, bodyText); + } + + /** + * Transform request before sending - inject default instructions if missing. + * Image fetching is handled separately in prefetchImages() so this stays sync. + */ + transformRequest(model, body, stream, credentials) { + this._isCompact = !!body._compact; + delete body._compact; + // Resolve conversation-stable session_id (priority: body → assistant-text → workspace → machine) + 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; + + // Ensure input is present and non-empty (Codex API rejects empty input) + if (!body.input || (Array.isArray(body.input) && body.input.length === 0)) { + body.input = [{ type: "message", role: "user", content: [{ type: "input_text", text: "..." }] }]; + } + + // Keep system prompts in body.input as role=developer so they stay in the cacheable prefix + convertSystemToDeveloperRole(body); + // Strip server-generated item IDs (rs_/fc_/resp_/msg_) — Codex /responses can't resolve when store=false + stripStoredItemReferences(body); + // Flatten function tools + drop unsupported types + normalizeCodexTools(body); + + // Ensure streaming is enabled (Codex API requires it) + body.stream = true; + + // If no instructions provided, inject default Codex instructions + if (!body.instructions || body.instructions.trim() === "") { + body.instructions = CODEX_DEFAULT_INSTRUCTIONS; + } + + // Ensure store is false (Codex requirement) + body.store = false; + + // Inject prompt_cache_key for stable Codex prompt caching + if (!body.prompt_cache_key && this._currentSessionId) { + body.prompt_cache_key = this._currentSessionId; + } + + // Map virtual Codex review models to the upstream Codex model before suffix parsing. + body.model = getModelUpstreamId("cx", body.model || model); + + // Extract thinking level from model name suffix + // e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default) + const effortLevels = ['none', 'low', 'medium', 'high', 'xhigh']; + let modelEffort = null; + for (const level of effortLevels) { + if (body.model.endsWith(`-${level}`)) { + modelEffort = level; + // Strip suffix from model name for actual API call + body.model = body.model.replace(`-${level}`, ''); + break; + } + } + + // Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium) + if (!body.reasoning) { + const effort = body.reasoning_effort || modelEffort || 'low'; + body.reasoning = { effort, summary: "auto" }; + } else if (!body.reasoning.summary) { + body.reasoning.summary = "auto"; + } + delete body.reasoning_effort; + + // Include reasoning encrypted content (required by Codex backend for reasoning models) + if (body.reasoning && body.reasoning.effort && body.reasoning.effort !== 'none') { + body.include = ["reasoning.encrypted_content"]; + } + + // Remove unsupported parameters for Codex API + delete body.temperature; + delete body.top_p; + delete body.frequency_penalty; + delete body.presence_penalty; + delete body.logprobs; + delete body.top_logprobs; + delete body.n; + delete body.seed; + delete body.max_tokens; + delete body.max_completion_tokens; + delete body.max_output_tokens; // Responses API clients send this but Codex rejects it + delete body.user; // Cursor sends this but Codex doesn't support it + delete body.prompt_cache_retention; // Cursor sends this but Codex doesn't support it + delete body.metadata; // Cursor sends this but Codex doesn't support it + delete body.stream_options; // Cursor sends this but Codex doesn't support it + delete body.safety_identifier; // Droid CLI sends this but Codex doesn't support it + delete body.previous_response_id; // store=false → backend can't resolve previous resp; avoid 404 + + // Final allowlist filter — strip any unknown field that could trigger upstream "routing_unsupported" + for (const k of Object.keys(body)) { + if (!RESPONSES_API_ALLOWLIST.has(k)) delete body[k]; + } + + return body; + } +} diff --git a/open-sse/executors/commandcode.js b/open-sse/executors/commandcode.js new file mode 100644 index 0000000000000000000000000000000000000000..aad404398502de6682831720c95bb574a4cbc328 --- /dev/null +++ b/open-sse/executors/commandcode.js @@ -0,0 +1,94 @@ +import { randomUUID } from "crypto"; +import { BaseExecutor } from "./base.js"; +import { PROVIDERS } from "../config/providers.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 + * + * Auth: Bearer API key (stored as the connection's apiKey). + * Adds the per-request `x-session-id` header expected by CommandCode upstream. + * + * Upstream returns AI SDK v5 NDJSON (one JSON event per line, no `data:` prefix). + * We translate each event to an OpenAI chat.completion.chunk and emit it as SSE so + * both the streaming and non-streaming (forced SSE → JSON) downstream handlers in + * 9router can consume it without further format translation. + */ +export class CommandCodeExecutor extends BaseExecutor { + constructor() { + super("commandcode", PROVIDERS.commandcode); + } + + transformRequest(model, body, stream, credentials) { + body.stream = true; + return body; + } + + buildHeaders(credentials, stream = true) { + const headers = { + "Content-Type": "application/json", + ...(this.config.headers || {}), + "x-session-id": randomUUID(), + }; + + const token = credentials?.apiKey || credentials?.accessToken; + if (token) headers["Authorization"] = `Bearer ${token}`; + + if (stream) headers["Accept"] = "text/event-stream"; + return headers; + } + + async execute(opts) { + const result = await super.execute(opts); + if (!result?.response?.ok || !result.response.body) return result; + result.response = wrapNdjsonAsOpenAISse(result.response, opts.model); + return result; + } +} + +function wrapNdjsonAsOpenAISse(originalResponse, model) { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let buffer = ""; + const state = { model }; + + const emitChunks = (chunks, controller) => { + if (!chunks) return; + const list = Array.isArray(chunks) ? chunks : [chunks]; + for (const c of list) { + if (c == null) continue; + controller.enqueue(encoder.encode(`data: ${JSON.stringify(c)}\n\n`)); + } + }; + + const transform = new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + // Translate AI SDK v5 NDJSON line to one or more OpenAI chunks + emitChunks(commandCodeToOpenAIResponse(trimmed, state), controller); + } + }, + flush(controller) { + const trimmed = buffer.trim(); + if (trimmed) { + emitChunks(commandCodeToOpenAIResponse(trimmed, state), controller); + } + controller.enqueue(encoder.encode(SSE_DONE)); + }, + }); + + const newBody = originalResponse.body.pipeThrough(transform); + return new Response(newBody, { + status: originalResponse.status, + statusText: originalResponse.statusText, + headers: originalResponse.headers, + }); +} + +export default CommandCodeExecutor; diff --git a/open-sse/executors/cursor.js b/open-sse/executors/cursor.js new file mode 100644 index 0000000000000000000000000000000000000000..fe06d80db77a17d34f9a31ad15575f2d25c0c54a --- /dev/null +++ b/open-sse/executors/cursor.js @@ -0,0 +1,688 @@ +import { BaseExecutor } from "./base.js"; +import { PROVIDERS } from "../config/providers.js"; +import { HTTP_STATUS } from "../config/runtimeConfig.js"; +import { + generateCursorBody, + parseConnectRPCFrame, + extractTextFromResponse +} 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"; + +// Detect cloud environment +const isCloudEnv = () => { + if (typeof caches !== "undefined" && typeof caches === "object") return true; + if (typeof EdgeRuntime !== "undefined") return true; + return false; +}; + +// Lazy import http2 (only in Node.js environment) +let http2 = null; +if (!isCloudEnv()) { + try { + http2 = await import("http2"); + } catch { + // http2 not available + } +} + +const COMPRESS_FLAG = { + NONE: 0x00, + GZIP: 0x01, + TRAILER: 0x02, + GZIP_TRAILER: 0x03 +}; + +const CURSOR_STREAM_DEBUG = process.env.CURSOR_STREAM_DEBUG === "1"; +const debugLog = (...args) => { + if (CURSOR_STREAM_DEBUG) console.log(...args); +}; + +function isComposerModel(model) { + const modelId = String(model || "").split("/").pop(); + return /^composer(?:-|$)/i.test(modelId); +} + +function visibleComposerContentFromThinking(thinking) { + if (!thinking) return ""; + const endTag = ""; + const endIdx = thinking.lastIndexOf(endTag); + if (endIdx < 0) return ""; + return thinking.slice(endIdx + endTag.length).trimStart(); +} + +function decompressPayload(payload, flags) { + // Check if payload is JSON error (starts with {"error") + if (payload.length > 10 && payload[0] === 0x7b && payload[1] === 0x22) { + try { + const text = payload.toString("utf-8"); + if (text.startsWith('{"error"')) { + debugLog(`[DECOMPRESS] Detected JSON error, skipping decompression`); + return payload; + } + } catch {} + } + + if ( + flags === COMPRESS_FLAG.GZIP || + flags === COMPRESS_FLAG.TRAILER || + flags === COMPRESS_FLAG.GZIP_TRAILER + ) { + // Primary: try gzip decompression (standard gzip header 0x1f 0x8b) + try { + return zlib.gunzipSync(payload); + } catch (gzipErr) { + // Fallback: TRAILER and GZIP_TRAILER frames sometimes use raw zlib deflate format + try { + return zlib.inflateSync(payload); + } catch (deflateErr) { + // Last resort: try raw deflate (no zlib header) + try { + return zlib.inflateRawSync(payload); + } catch (rawErr) { + debugLog( + `[DECOMPRESS ERROR] flags=${flags}, payloadSize=${payload.length}, gzip=${gzipErr.message}, deflate=${deflateErr.message}, raw=${rawErr.message}` + ); + debugLog( + `[DECOMPRESS ERROR] First 50 bytes (hex):`, + payload.slice(0, 50).toString("hex") + ); + return payload; + } + } + } + } + return payload; +} + +// Read one cursor protobuf frame: header + bounds + decompress. Returns status + payload + new offset. +function readCursorFrame(buffer, offset, frameNum, tag) { + if (offset + 5 > buffer.length) { + debugLog(`[CURSOR BUFFER${tag}] Reached end, offset=${offset}, remaining=${buffer.length - offset}`); + return { status: "done" }; + } + + const flags = buffer[offset]; + const length = buffer.readUInt32BE(offset + 1); + debugLog(`[CURSOR BUFFER${tag}] Frame ${frameNum + 1}: flags=0x${flags.toString(16).padStart(2, "0")}, length=${length}`); + + if (offset + 5 + length > buffer.length) { + debugLog(`[CURSOR BUFFER${tag}] Incomplete frame, offset=${offset}, length=${length}, buffer.length=${buffer.length}`); + return { status: "done" }; + } + + let payload = buffer.slice(offset + 5, offset + 5 + length); + const newOffset = offset + 5 + length; + payload = decompressPayload(payload, flags); + if (!payload) { + debugLog(`[CURSOR BUFFER${tag}] Frame ${frameNum + 1}: decompression failed, skipping`); + return { status: "skip", offset: newOffset }; + } + return { status: "ok", payload, offset: newOffset }; +} + +function createErrorResponse(jsonError) { + const errorMsg = jsonError?.error?.details?.[0]?.debug?.details?.title + || jsonError?.error?.details?.[0]?.debug?.details?.detail + || jsonError?.error?.message + || "API Error"; + + const isRateLimit = jsonError?.error?.code === "resource_exhausted"; + + return new Response(JSON.stringify({ + error: { + message: errorMsg, + type: isRateLimit ? "rate_limit_error" : "api_error", + code: jsonError?.error?.details?.[0]?.debug?.error || "unknown" + } + }), { + status: isRateLimit ? HTTP_STATUS.RATE_LIMITED : HTTP_STATUS.BAD_REQUEST, + headers: { "Content-Type": "application/json" } + }); +} + +export class CursorExecutor extends BaseExecutor { + constructor() { + super("cursor", PROVIDERS.cursor); + } + + buildUrl() { + return `${this.config.baseUrl}${this.config.chatPath}`; + } + + buildHeaders(credentials) { + const accessToken = credentials.accessToken; + const machineId = credentials.providerSpecificData?.machineId; + const ghostMode = credentials.providerSpecificData?.ghostMode !== false; + + if (!machineId) { + throw new Error("Machine ID is required for Cursor API"); + } + + return buildCursorHeaders(accessToken, machineId, ghostMode); + } + + transformRequest(model, body, stream, credentials) { + // Messages are already translated by chatCore (claude→openai→cursor) + // Do NOT call openaiToCursorRequest again — double-translation drops tool_results + const messages = body.messages || []; + const tools = body.tools || []; + const reasoningEffort = body.reasoning_effort || null; + // Detect Claude Code UA to force Agent mode (issue #643) + const ua = credentials?.rawHeaders?.["user-agent"] || ""; + const forceAgentMode = ua.includes("claude-cli") || ua.includes("claude-code") || ua.includes("Claude Code"); + return generateCursorBody(messages, model, tools, reasoningEffort, forceAgentMode); + } + + async makeFetchRequest(url, headers, body, signal, proxyOptions = null) { + const response = await proxyAwareFetch(url, { + method: "POST", + headers, + body, + signal + }, proxyOptions); + + return { + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + body: Buffer.from(await response.arrayBuffer()) + }; + } + + makeHttp2Request(url, headers, body, signal) { + if (!http2) { + throw new Error("http2 module not available"); + } + + const HTTP2_TIMEOUT_MS = 60000; // 60s max — prevent hung sessions + + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const client = http2.connect(`https://${urlObj.host}`); + const chunks = []; + let responseHeaders = {}; + let settled = false; + + // Ensure client is always closed on settle + const finish = (fn) => (...args) => { + if (settled) return; + settled = true; + clearTimeout(hangTimeout); + client.close(); + fn(...args); + }; + + // Hard timeout: close session if server never responds + const hangTimeout = setTimeout(finish(() => { + reject(new Error("HTTP/2 request timed out")); + }), HTTP2_TIMEOUT_MS); + + client.on("error", finish(reject)); + + const req = client.request({ + ":method": "POST", + ":path": urlObj.pathname, + ":authority": urlObj.host, + ":scheme": "https", + ...headers + }); + + req.on("response", (hdrs) => { responseHeaders = hdrs; }); + req.on("data", (chunk) => { chunks.push(chunk); }); + req.on("end", finish(() => { + resolve({ + status: responseHeaders[":status"], + headers: responseHeaders, + body: Buffer.concat(chunks) + }); + })); + req.on("error", finish(reject)); + + if (signal) { + const onAbort = finish(() => reject(new Error("Request aborted"))); + signal.addEventListener("abort", onAbort, { once: true }); + } + + req.write(body); + req.end(); + }); + } + + async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + const url = this.buildUrl(); + const headers = this.buildHeaders(credentials); + const transformedBody = this.transformRequest(model, body, stream, credentials); + + try { + const shouldForceFetch = proxyOptions?.enabled === true || proxyOptions?.connectionProxyEnabled === true || !!proxyOptions?.vercelRelayUrl; + const response = (http2 && !shouldForceFetch) + ? await this.makeHttp2Request(url, headers, transformedBody, signal) + : await this.makeFetchRequest(url, headers, transformedBody, signal, proxyOptions); + + if (response.status !== 200) { + const errorText = response.body?.toString() || "Unknown error"; + const errorResponse = new Response(JSON.stringify({ + error: { + message: `[${response.status}]: ${errorText}`, + type: "invalid_request_error", + code: "" + } + }), { + status: response.status, + headers: { "Content-Type": "application/json" } + }); + return { response: errorResponse, url, headers, transformedBody: body }; + } + + const transformedResponse = stream !== false + ? this.transformProtobufToSSE(response.body, model, body) + : this.transformProtobufToJSON(response.body, model, body); + + return { response: transformedResponse, url, headers, transformedBody: body }; + } catch (error) { + const errorResponse = new Response(JSON.stringify({ + error: { + message: error.message, + type: "connection_error", + code: "" + } + }), { + status: HTTP_STATUS.SERVER_ERROR, + headers: { "Content-Type": "application/json" } + }); + return { response: errorResponse, url, headers, transformedBody: body }; + } + } + + transformProtobufToJSON(buffer, model, body) { + const responseId = `chatcmpl-cursor-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + + let offset = 0; + let totalContent = ""; + let totalThinking = ""; + const toolCalls = []; + const toolCallsMap = new Map(); // Track streaming tool calls by ID + const finalizedIds = new Set(); + let frameCount = 0; + + debugLog(`[CURSOR BUFFER] Total length: ${buffer.length} bytes`); + + while (offset < buffer.length) { + const frame = readCursorFrame(buffer, offset, frameCount, ""); + if (frame.status === "done") break; + offset = frame.offset; + frameCount++; + 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) { + try { + const text = payload.toString("utf-8"); + if (text.includes('"error"')) { + const hasContent = totalContent || toolCallsMap.size > 0; + debugLog( + `[CURSOR BUFFER] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}` + ); + if (hasContent) { + break; + } + return createErrorResponse(JSON.parse(text)); + } + } catch {} + } + + const result = extractTextFromResponse(new Uint8Array(payload)); + debugLog(`[CURSOR DECODED] Frame ${frameCount}:`, result); + + if (result.error) { + const hasContent = totalContent || toolCallsMap.size > 0; + debugLog(`[CURSOR BUFFER] Decoded error (hasContent=${hasContent}): ${result.error}`); + if (hasContent) { + break; + } + return new Response( + JSON.stringify({ + error: { + message: result.error, + type: "rate_limit_error", + code: "rate_limited" + } + }), + { + status: HTTP_STATUS.RATE_LIMITED, + headers: { "Content-Type": "application/json" } + } + ); + } + + if (result.toolCall) { + const tc = result.toolCall; + + if (toolCallsMap.has(tc.id)) { + // Accumulate arguments for existing tool call + const existing = toolCallsMap.get(tc.id); + existing.function.arguments += tc.function.arguments; + existing.isLast = tc.isLast; + } else { + // New tool call + toolCallsMap.set(tc.id, { ...tc }); + } + + // Push to final array when isLast is true + if (tc.isLast) { + const finalToolCall = toolCallsMap.get(tc.id); + finalizedIds.add(tc.id); + toolCalls.push({ + id: finalToolCall.id, + type: finalToolCall.type, + function: { + name: finalToolCall.function.name, + arguments: finalToolCall.function.arguments + } + }); + } + } + + if (result.text) totalContent += result.text; + if (result.thinking) totalThinking += result.thinking; + } + + const visibleComposerContent = isComposerModel(model) + ? visibleComposerContentFromThinking(totalThinking) + : ""; + const finalContent = totalContent || visibleComposerContent; + + debugLog( + `[CURSOR BUFFER] Parsed ${frameCount} frames, toolCallsMap size: ${toolCallsMap.size}, finalized toolCalls: ${toolCalls.length}` + ); + + // Finalize all remaining tool calls in map (in case stream ended without isLast=true) + for (const [id, tc] of toolCallsMap.entries()) { + // Check if already in final array + if (!finalizedIds.has(id)) { + debugLog(`[CURSOR BUFFER] Finalizing incomplete tool call: ${id}, isLast=${tc.isLast}`); + toolCalls.push({ + id: tc.id, + type: tc.type, + function: { + name: tc.function.name, + arguments: tc.function.arguments + } + }); + } + } + + debugLog(`[CURSOR BUFFER] Final toolCalls count: ${toolCalls.length}`); + + + const message = { + role: "assistant", + content: finalContent || null + }; + + if (toolCalls.length > 0) { + message.tool_calls = toolCalls; + } + + const usage = estimateUsage(body, finalContent.length, FORMATS.OPENAI); + + const completion = { + id: responseId, + object: "chat.completion", + created, + model, + choices: [{ + index: 0, + message, + finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop" + }], + usage + }; + + return new Response(JSON.stringify(completion), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + + transformProtobufToSSE(buffer, model, body) { + const responseId = `chatcmpl-cursor-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + + const chunks = []; + let offset = 0; + let totalContent = ""; + let totalThinking = ""; + let emittedComposerThinkingContentLength = 0; + const toolCalls = []; + const toolCallsMap = new Map(); // Track streaming tool calls by ID + const finalizedIds = new Set(); + const emittedToolCallIds = new Set(); + let frameCount = 0; + + debugLog(`[CURSOR BUFFER SSE] Total length: ${buffer.length} bytes`); + + while (offset < buffer.length) { + const frame = readCursorFrame(buffer, offset, frameCount, " SSE"); + if (frame.status === "done") break; + offset = frame.offset; + frameCount++; + 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) { + try { + const text = payload.toString("utf-8"); + if (text.includes('"error"')) { + const hasContent = chunks.length > 0 || totalContent || toolCallsMap.size > 0; + debugLog( + `[CURSOR BUFFER SSE] Error frame (hasContent=${hasContent}): ${text.slice(0, 500)}` + ); + if (hasContent) { + break; + } + return createErrorResponse(JSON.parse(text)); + } + } catch {} + } + + const result = extractTextFromResponse(new Uint8Array(payload)); + debugLog(`[CURSOR DECODED SSE] Frame ${frameCount}:`, result); + + if (result.error) { + const hasContent = chunks.length > 0 || totalContent || toolCallsMap.size > 0; + debugLog(`[CURSOR BUFFER SSE] Decoded error (hasContent=${hasContent}): ${result.error}`); + if (hasContent) { + break; + } + return new Response( + JSON.stringify({ + error: { + message: result.error, + type: "rate_limit_error", + code: "rate_limited" + } + }), + { + status: HTTP_STATUS.RATE_LIMITED, + headers: { "Content-Type": "application/json" } + } + ); + } + + if (result.toolCall) { + const tc = result.toolCall; + + if (chunks.length === 0) { + chunks.push(chatChunkSse({ id: responseId, created, model, delta: { role: "assistant", content: "" } })); + } + + if (toolCallsMap.has(tc.id)) { + // Accumulate arguments for existing tool call + const existing = toolCallsMap.get(tc.id); + const oldArgsLen = existing.function.arguments.length; + existing.function.arguments += tc.function.arguments; + existing.isLast = tc.isLast; + + // Stream the delta arguments + if (tc.function.arguments) { + emittedToolCallIds.add(tc.id); + chunks.push(chatChunkSse({ + id: responseId, created, model, + delta: { + tool_calls: [ + { + index: existing.index, + id: tc.id, + type: "function", + function: { + name: tc.function.name, + arguments: tc.function.arguments + } + } + ] + } + })); + } + } else { + // New tool call - assign index and add to map + const toolCallIndex = toolCalls.length; + finalizedIds.add(tc.id); + toolCalls.push({ ...tc, index: toolCallIndex }); + toolCallsMap.set(tc.id, { ...tc, index: toolCallIndex }); + + // Stream initial tool call with name + emittedToolCallIds.add(tc.id); + chunks.push(chatChunkSse({ + id: responseId, created, model, + delta: { + tool_calls: [ + { + index: toolCallIndex, + id: tc.id, + type: "function", + function: { + name: tc.function.name, + arguments: tc.function.arguments + } + } + ] + } + })); + } + } + + if (result.text) { + totalContent += result.text; + 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) { + totalThinking += result.thinking; + const visibleContent = visibleComposerContentFromThinking(totalThinking); + if (visibleContent.length > emittedComposerThinkingContentLength) { + const deltaContent = visibleContent.slice(emittedComposerThinkingContentLength); + emittedComposerThinkingContentLength = visibleContent.length; + totalContent += deltaContent; + chunks.push(chatChunkSse({ + id: responseId, created, model, + delta: + chunks.length === 0 && toolCalls.length === 0 + ? { role: "assistant", content: deltaContent } + : { content: deltaContent } + })); + } + } + } + + debugLog( + `[CURSOR BUFFER SSE] Parsed ${frameCount} frames, toolCallsMap size: ${toolCallsMap.size}, toolCalls array: ${toolCalls.length}` + ); + + // Finalize all remaining tool calls in map (stream may have ended without isLast=true) + for (const [id, tc] of toolCallsMap.entries()) { + if (!finalizedIds.has(id)) { + debugLog(`[CURSOR BUFFER SSE] Finalizing incomplete tool call: ${id}, isLast=${tc.isLast}`); + const toolCallIndex = toolCalls.length; + toolCalls.push({ + id: tc.id, + type: tc.type, + index: toolCallIndex, + function: { + name: tc.function.name, + arguments: tc.function.arguments + } + }); + + // Emit SSE chunk for the finalized tool call if not already emitted + if (!emittedToolCallIds.has(tc.id)) { + chunks.push(chatChunkSse({ + id: responseId, created, model, + delta: { + tool_calls: [ + { + index: toolCallIndex, + id: tc.id, + type: "function", + function: { + name: tc.function.name, + arguments: tc.function.arguments + } + } + ] + } + })); + } + } + } + + if (chunks.length === 0 && toolCalls.length === 0) { + chunks.push(chatChunkSse({ id: responseId, created, model, delta: { role: "assistant", content: "" } })); + } + + const usage = estimateUsage(body, totalContent.length, FORMATS.OPENAI); + + chunks.push( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: {}, + finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop" + } + ], + usage + })}\n\n` + ); + chunks.push(SSE_DONE); + + return new Response(chunks.join(""), { + status: 200, + headers: { ...SSE_HEADERS } + }); + } + + async refreshCredentials() { + return null; + } +} + +export default CursorExecutor; diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js new file mode 100644 index 0000000000000000000000000000000000000000..e80fe228633628cd8be0e77d96f3afbee51da7b6 --- /dev/null +++ b/open-sse/executors/default.js @@ -0,0 +1,321 @@ +import { BaseExecutor } from "./base.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 "../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) { + super(provider, PROVIDERS[provider] || PROVIDERS.openai); + } + + transformRequest(model, body) { + const transformed = this.applyJsonSchemaFallback(body); + + if (transformed && typeof transformed === "object") { + // 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 }); + } + + // Fallback json_schema → json_object for openai-compatible providers without native Structured Output. + applyJsonSchemaFallback(body) { + if (!this.provider?.startsWith?.("openai-compatible-")) return body; + const rf = body?.response_format; + if (rf?.type !== "json_schema" || !rf.json_schema?.schema) return body; + + const schemaJson = JSON.stringify(rf.json_schema.schema, null, 2); + const prompt = `You must respond with valid JSON that strictly follows this JSON schema:\n\`\`\`json\n${schemaJson}\n\`\`\`\nRespond ONLY with the JSON object, no other text.`; + + const messages = Array.isArray(body.messages) ? body.messages.map(m => ({ ...m })) : []; + const sys = messages.find(m => m.role === "system"); + if (sys) { + if (typeof sys.content === "string") sys.content = `${sys.content}\n\n${prompt}`; + else if (Array.isArray(sys.content)) sys.content.push({ type: "text", text: `\n\n${prompt}` }); + } else { + messages.unshift({ role: "system", content: prompt }); + } + return { ...body, messages, response_format: { type: "json_object" } }; + } + + buildUrl(model, stream, urlIndex = 0, credentials = null) { + if (this.provider?.startsWith?.("openai-compatible-")) { + 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 || ANTHROPIC_COMPAT_BASE; + const normalized = baseUrl.replace(/\/$/, ""); + return `${normalized}/messages`; + } + // 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 }; + const desc = 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-")) { + const baseUrl = credentials?.providerSpecificData?.baseUrl || ""; + const isOfficialAnthropic = baseUrl === "" || baseUrl.includes("api.anthropic.com"); + if (!isOfficialAnthropic) { + // Some third-party Anthropic-compatible gateways require Bearer auth in + // addition to x-api-key. Send both (x-api-key already set above) so + // gateways that read either header succeed. + if (credentials.apiKey && !headers["Authorization"]) { + headers["Authorization"] = `Bearer ${credentials.apiKey}`; + } + delete headers["anthropic-dangerous-direct-browser-access"]; + delete headers["Anthropic-Dangerous-Direct-Browser-Access"]; + delete headers["x-app"]; + delete headers["X-App"]; + // Strip claude-code-20250219 from Anthropic-Beta / anthropic-beta + for (const betaKey of ["anthropic-beta", "Anthropic-Beta"]) { + if (headers[betaKey]) { + const filtered = headers[betaKey] + .split(",") + .map(s => s.trim()) + .filter(f => f && f !== "claude-code-20250219") + .join(","); + if (filtered) { + headers[betaKey] = filtered; + } else { + delete headers[betaKey]; + } + } + } + } + } + + if (stream) headers["Accept"] = "text/event-stream"; + 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.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.refreshFromGrant(credentials, proxyOptions), + kiro: () => this.refreshKiro(credentials.refreshToken, proxyOptions), + cline: () => this.refreshCline(credentials.refreshToken, proxyOptions), + "kimi-coding": () => this.refreshKimiCoding(credentials.refreshToken, proxyOptions), + kilocode: () => this.refreshKilocode(credentials.refreshToken, proxyOptions) + }; + + const refresher = refreshers[this.provider]; + if (!refresher) return null; + + try { + const result = await refresher(); + if (result) log?.info?.("TOKEN", `${this.provider} refreshed`); + return result; + } catch (error) { + log?.error?.("TOKEN", `${this.provider} refresh error: ${error.message}`); + return null; + } + } + + async refreshWithJSON(url, body, proxyOptions = null) { + const response = await proxyAwareFetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", "Accept": "application/json" }, + body: JSON.stringify(body) + }, proxyOptions); + if (!response.ok) return null; + const tokens = await response.json(); + return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || body.refresh_token, expiresIn: tokens.expires_in }; + } + + async refreshWithForm(url, params, proxyOptions = null) { + const response = await proxyAwareFetch(url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" }, + body: new URLSearchParams(params) + }, proxyOptions); + if (!response.ok) return null; + const tokens = await response.json(); + return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || params.refresh_token, expiresIn: tokens.expires_in }; + } + + async refreshIflow(refreshToken, proxyOptions = null) { + const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`); + const response = await proxyAwareFetch(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 }) + }, 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", + headers: { "Content-Type": "application/json", "Accept": "application/json", "User-Agent": "kiro-cli/1.0.0" }, + body: JSON.stringify({ refreshToken }) + }, proxyOptions); + if (!response.ok) return null; + const tokens = await response.json(); + return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken || refreshToken, expiresIn: tokens.expiresIn }; + } + + async refreshCline(refreshToken, proxyOptions = null) { + 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); + if (!response.ok) return null; + const payload = await response.json(); + 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; + return { accessToken: data?.accessToken, refreshToken: data?.refreshToken || refreshToken, expiresIn }; + } + + async refreshKimiCoding(refreshToken, proxyOptions = null) { + const kimiHeaders = buildKimiHeaders(); + 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: PROVIDERS["kimi-coding"].clientId }) + }, 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 refreshKilocode(refreshToken, proxyOptions = null) { + // Kilocode uses device code flow, no refresh token support + return null; + } +} + +export default DefaultExecutor; diff --git a/open-sse/executors/gemini-cli.js b/open-sse/executors/gemini-cli.js new file mode 100644 index 0000000000000000000000000000000000000000..02b33bf854cfa0a45f23256ba4c97a5aeaedfe6d --- /dev/null +++ b/open-sse/executors/gemini-cli.js @@ -0,0 +1,89 @@ +import { BaseExecutor } from "./base.js"; +import { PROVIDERS } from "../config/providers.js"; +import { OAUTH_ENDPOINTS, GEMINI_CLI_API_CLIENT, geminiCLIUserAgent } from "../config/appConstants.js"; + +export class GeminiCLIExecutor extends BaseExecutor { + constructor() { + super("gemini-cli", PROVIDERS["gemini-cli"]); + } + + buildUrl(model, stream, urlIndex = 0) { + const action = stream ? "streamGenerateContent?alt=sse" : "generateContent"; + return `${this.config.baseUrl}:${action}`; + } + + buildHeaders(credentials, stream = true) { + return { + "Content-Type": "application/json", + "Authorization": `Bearer ${credentials.accessToken}`, + "User-Agent": geminiCLIUserAgent(this._currentModel), + "X-Goog-Api-Client": GEMINI_CLI_API_CLIENT, + "Accept": stream ? "text/event-stream" : "application/json" + }; + } + + transformRequest(model, body, stream, credentials) { + // Store model for use in buildHeaders (called by base.execute after transformRequest) + this._currentModel = model; + // Cloud Code Assist wraps the Gemini payload: { project, model, request: } + if (body && body.request && body.model) return body; + return { + project: credentials?.projectId || body?.project, + model, + request: body + }; + } + + // Parse RetryInfo.retryDelay from Google API 429 body to surface upstream retry hint + parseError(response, bodyText) { + const base = super.parseError(response, bodyText); + if (response.status !== 429 || !bodyText) return base; + try { + const parsed = JSON.parse(bodyText); + const details = parsed?.error?.details; + if (Array.isArray(details)) { + for (const d of details) { + if (d?.["@type"] === "type.googleapis.com/google.rpc.RetryInfo" && d?.retryDelay) { + base.retryAfter = d.retryDelay; + break; + } + } + } + } catch {} + return base; + } + + async refreshCredentials(credentials, log) { + if (!credentials.refreshToken) return null; + + 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: credentials.refreshToken, + client_id: this.config.clientId, + client_secret: this.config.clientSecret + }) + }); + + if (!response.ok) return null; + + const tokens = await response.json(); + log?.info?.("TOKEN", "Gemini CLI refreshed"); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || credentials.refreshToken, + expiresIn: tokens.expires_in, + projectId: credentials.projectId + }; + } catch (error) { + log?.error?.("TOKEN", `Gemini CLI refresh error: ${error.message}`); + return null; + } + } +} + +export default GeminiCLIExecutor; diff --git a/open-sse/executors/github.js b/open-sse/executors/github.js new file mode 100644 index 0000000000000000000000000000000000000000..2f4d68ba4968dad225a89219e3b25d02b73cb010 --- /dev/null +++ b/open-sse/executors/github.js @@ -0,0 +1,345 @@ +import { BaseExecutor } from "./base.js"; +import { PROVIDERS } from "../config/providers.js"; +import { OAUTH_ENDPOINTS, GITHUB_COPILOT } from "../config/appConstants.js"; +import { HTTP_STATUS } from "../config/runtimeConfig.js"; +import { openaiToOpenAIResponsesRequest } from "../translator/request/openai-responses.js"; +import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js"; +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 { + constructor() { + super("github", PROVIDERS.github); + this.knownCodexModels = new Set(); + } + + buildUrl(model, stream, urlIndex = 0) { + return this.config.baseUrl; + } + + buildHeaders(credentials, stream = true) { + const token = credentials.copilotToken || credentials.accessToken; + 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, + "openai-intent": "conversation-panel", + "x-github-api-version": GITHUB_COPILOT.API_VERSION, + "x-request-id": crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`, + "x-vscode-user-agent-library-version": "electron-fetch", + "X-Initiator": "user", + "Accept": stream ? "text/event-stream" : "application/json" + }; + } + + // Sanitize messages for GitHub Copilot /chat/completions endpoint. + // The endpoint only accepts 'text' and 'image_url' content part types. + // Tool-related content (tool_use, tool_result, thinking) must be serialized as text. + sanitizeMessagesForChatCompletions(body) { + if (!body?.messages) return body; + + const sanitized = { ...body }; + + // Handle response_format for Claude models via GitHub + // GitHub's internal translation doesn't respect response_format, so we inject it as a system prompt + // AND prepend a reminder to the last user message for maximum effectiveness + if (body.response_format && body.model?.includes('claude')) { + const responseFormat = body.response_format; + let systemInstruction = ''; + if (responseFormat.type === 'json_schema' && responseFormat.json_schema?.schema) { + systemInstruction = 'CRITICAL: You must ONLY output raw JSON. Never use markdown code blocks. Never use backticks. Never wrap JSON in triple backticks. Output ONLY the raw JSON object.'; + } else if (responseFormat.type === 'json_object') { + systemInstruction = 'CRITICAL: You must ONLY output raw JSON. Never use markdown code blocks. Never use backticks.'; + } + if (systemInstruction) { + // Add to system message + const systemIdx = body.messages.findIndex(m => m.role === 'system'); + if (systemIdx >= 0) { + body.messages[systemIdx].content = systemInstruction + '\n\n' + body.messages[systemIdx].content; + } else { + body.messages.unshift({ role: 'system', content: systemInstruction }); + } + + // Also prepend to the last user message as a reminder + const lastUserIdx = body.messages.map((m, i) => m.role === 'user' ? i : -1).filter(i => i >= 0).pop(); + if (lastUserIdx >= 0) { + const userMsg = body.messages[lastUserIdx]; + const userContent = typeof userMsg.content === 'string' ? userMsg.content : JSON.stringify(userMsg.content); + userMsg.content = 'Respond with ONLY raw JSON (no markdown, no backticks, no code blocks): ' + userContent; + } + } + } + sanitized.messages = body.messages.map(msg => { + // assistant messages with only tool_calls have content: null — leave as-is + if (!msg.content) return msg; + + // String content is always fine + if (typeof msg.content === "string") return msg; + + // Array content: filter/convert unsupported part types + if (Array.isArray(msg.content)) { + const cleanContent = msg.content + .map(part => { + if (part.type === "text") return part; + if (part.type === "image_url") return part; + // Serialize tool_use, tool_result, thinking, etc. as text + const text = part.text || part.content || JSON.stringify(part); + return { type: "text", text: typeof text === "string" ? text : JSON.stringify(text) }; + }) + .filter(part => part.text !== ""); // remove empty text parts + + // If all content was stripped (e.g. only tool_result with no text), drop content + return { ...msg, content: cleanContent.length > 0 ? cleanContent : null }; + } + + return msg; + }); + + return sanitized; + } + + // Newer OpenAI models (gpt-5+, o1, o3, o4) require max_completion_tokens instead of max_tokens + requiresMaxCompletionTokens(model) { + return /gpt-5|o[134]-/i.test(model); + } + + 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; + } + // "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; + } + // Config-driven strip of params unsupported by this provider/model + stripUnsupportedParams("github", model, transformed); + return transformed; + } + + // GitHub Copilot's /responses endpoint only serves OpenAI (gpt/codex) models. + // Gemini and Claude models are not available there and reject with a 400 + // "does not support Responses API" (unsupported_api_for_model). They must + // therefore never be escalated to /responses, even if /chat/completions + // returned a "not supported" error for an unrelated reason. Fixes #1062. + supportsResponsesEndpoint(model) { + const m = (model || "").toLowerCase(); + return !(m.includes("gemini") || m.includes("claude")); + } + + async execute(options) { + const { model, log } = options; + + // Only use /responses for models that are explicitly known to need it (e.g. gpt codex models) + // and that the /responses endpoint actually serves (excludes Gemini/Claude, see #1062). + if (this.knownCodexModels.has(model) && this.supportsResponsesEndpoint(model)) { + log?.debug("GITHUB", `Using cached /responses route for ${model}`); + return this.executeWithResponsesEndpoint(options); + } + + // Sanitize messages before sending to /chat/completions + // This handles Claude models on GitHub Copilot which reject non-text/image_url content types + const sanitizedOptions = { + ...options, + body: this.sanitizeMessagesForChatCompletions(options.body) + }; + + const result = await super.execute({ ...sanitizedOptions, proxyOptions: options.proxyOptions || null }); + + // Only escalate to /responses for models that endpoint can actually serve. + // Gemini/Claude would otherwise loop into a misleading "does not support + // Responses API" 400 instead of surfacing the real /chat/completions error (#1062). + if (result.response.status === HTTP_STATUS.BAD_REQUEST && this.supportsResponsesEndpoint(model)) { + const errorBody = await result.response.clone().text(); + + if (errorBody.includes("not accessible via the /chat/completions endpoint") || errorBody.includes("The requested model is not supported")) { + log?.warn("GITHUB", `Model ${model} requires /responses. Switching...`); + this.knownCodexModels.add(model); + return this.executeWithResponsesEndpoint(options); + } + } + + return result; + } + + async executeWithResponsesEndpoint({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + const url = this.config.responsesUrl; + const headers = this.buildHeaders(credentials, stream); + + const transformedBody = openaiToOpenAIResponsesRequest(model, body, stream, credentials); + + log?.debug("GITHUB", "Sending translated request to /responses"); + + const response = await proxyAwareFetch(url, { + method: "POST", + headers, + body: JSON.stringify(transformedBody), + signal + }, proxyOptions); + + if (!response.ok) { + return { response, url, headers, transformedBody }; + } + + const state = initState("openai-responses"); + state.model = model; + + const decoder = new TextDecoder(); + let buffer = ""; + + const transformStream = new TransformStream({ + async transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + const lines = buffer.split("\n"); + + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const parsed = parseSSELine(trimmed); + if (!parsed) continue; + + if (parsed.done && stream === true) { + controller.enqueue(new TextEncoder().encode(SSE_DONE)); + continue; + } + + const converted = openaiResponsesToOpenAIResponse(parsed, state); + if (converted) { + const sseString = formatSSE(converted, "openai"); + controller.enqueue(new TextEncoder().encode(sseString)); + } + } + }, + flush(controller) { + if (buffer.trim()) { + const parsed = parseSSELine(buffer.trim()); + if (parsed && !parsed.done) { + const converted = openaiResponsesToOpenAIResponse(parsed, state); + if (converted) { + controller.enqueue(new TextEncoder().encode(formatSSE(converted, "openai"))); + } + } + } + } + }); + + if (!response.body) { + return { response: new Response("", { status: response.status, headers: response.headers }), url, headers, transformedBody }; + } + const convertedStream = response.body.pipeThrough(transformStream); + + return { + response: new Response(convertedStream, { + status: response.status, + statusText: response.statusText, + headers: response.headers + }), + url, + headers, + transformedBody + }; + } + + async refreshCopilotToken(githubAccessToken, log, proxyOptions = null) { + try { + const response = await proxyAwareFetch("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 + } + }, proxyOptions); + if (!response.ok) { + const errorText = await response.text(); + log?.error?.("TOKEN", `Copilot token refresh failed: ${response.status} ${errorText}`); + return null; + } + const data = await response.json(); + log?.info?.("TOKEN", "Copilot token refreshed"); + return { token: data.token, expiresAt: data.expires_at }; + } catch (error) { + log?.error?.("TOKEN", `Copilot refresh error: ${error.message}`); + return null; + } + } + + async refreshGitHubToken(refreshToken, log, proxyOptions = null) { + try { + const params = { + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: this.config.clientId, + }; + if (this.config.clientSecret) { + params.client_secret = this.config.clientSecret; + } + + const response = await proxyAwareFetch(OAUTH_ENDPOINTS.github.token, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" }, + body: new URLSearchParams(params) + }, proxyOptions); + if (!response.ok) return null; + const tokens = await response.json(); + log?.info?.("TOKEN", "GitHub token refreshed"); + return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in }; + } catch (error) { + log?.error?.("TOKEN", `GitHub refresh error: ${error.message}`); + return null; + } + } + + async refreshCredentials(credentials, log, proxyOptions = null) { + let copilotResult = await this.refreshCopilotToken(credentials.accessToken, log, proxyOptions); + + if (!copilotResult && credentials.refreshToken) { + const githubTokens = await this.refreshGitHubToken(credentials.refreshToken, log, proxyOptions); + if (githubTokens?.accessToken) { + copilotResult = await this.refreshCopilotToken(githubTokens.accessToken, log, proxyOptions); + if (copilotResult) { + return { ...githubTokens, copilotToken: copilotResult.token, copilotTokenExpiresAt: copilotResult.expiresAt }; + } + return githubTokens; + } + } + + if (copilotResult) { + return { accessToken: credentials.accessToken, refreshToken: credentials.refreshToken, copilotToken: copilotResult.token, copilotTokenExpiresAt: copilotResult.expiresAt }; + } + + return null; + } + + needsRefresh(credentials) { + // Always refresh if no copilotToken + if (!credentials.copilotToken) return true; + + if (credentials.copilotTokenExpiresAt) { + // Handle both Unix timestamp (seconds) and ISO string + let expiresAtMs = credentials.copilotTokenExpiresAt; + if (typeof expiresAtMs === "number" && expiresAtMs < 1e12) { + expiresAtMs = expiresAtMs * 1000; // Convert seconds to ms + } else if (typeof expiresAtMs === "string") { + expiresAtMs = new Date(expiresAtMs).getTime(); + } + if (expiresAtMs - Date.now() < 5 * 60 * 1000) return true; + } + return super.needsRefresh(credentials); + } +} + +export default GithubExecutor; diff --git a/open-sse/executors/grok-web.js b/open-sse/executors/grok-web.js new file mode 100644 index 0000000000000000000000000000000000000000..9e6cdb12800d64160b932e5ad5fa949de50ea2c6 --- /dev/null +++ b/open-sse/executors/grok-web.js @@ -0,0 +1,343 @@ +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"; + +const MODEL_MAP = { + "grok-3": { grokModel: "grok-3", modelMode: "MODEL_MODE_GROK_3", isThinking: false }, + "grok-3-mini": { grokModel: "grok-3", modelMode: "MODEL_MODE_GROK_3_MINI_THINKING", isThinking: true }, + "grok-3-thinking": { grokModel: "grok-3", modelMode: "MODEL_MODE_GROK_3_THINKING", isThinking: true }, + "grok-4": { grokModel: "grok-4", modelMode: "MODEL_MODE_GROK_4", isThinking: false }, + "grok-4-mini": { grokModel: "grok-4-mini", modelMode: "MODEL_MODE_GROK_4_MINI_THINKING", isThinking: true }, + "grok-4-thinking": { grokModel: "grok-4", modelMode: "MODEL_MODE_GROK_4_THINKING", isThinking: true }, + "grok-4-heavy": { grokModel: "grok-4", modelMode: "MODEL_MODE_HEAVY", isThinking: true }, + "grok-4.1-mini": { grokModel: "grok-4-1-thinking-1129", modelMode: "MODEL_MODE_GROK_4_1_MINI_THINKING", isThinking: true }, + "grok-4.1-fast": { grokModel: "grok-4-1-thinking-1129", modelMode: "MODEL_MODE_FAST", isThinking: false }, + "grok-4.1-expert": { grokModel: "grok-4-1-thinking-1129", modelMode: "MODEL_MODE_EXPERT", isThinking: true }, + "grok-4.1-thinking": { grokModel: "grok-4-1-thinking-1129", modelMode: "MODEL_MODE_GROK_4_1_THINKING", isThinking: true }, + "grok-4.2": { grokModel: "grok-420", modelMode: "MODEL_MODE_GROK_420", isThinking: false }, + "grok-4.20": { grokModel: "grok-420", modelMode: "MODEL_MODE_GROK_420", isThinking: false }, + "grok-4.20-beta": { grokModel: "grok-420", modelMode: "MODEL_MODE_GROK_420", isThinking: false }, +}; + +function randomString(length, alphanumeric = false) { + const chars = alphanumeric ? "abcdefghijklmnopqrstuvwxyz0123456789" : "abcdefghijklmnopqrstuvwxyz"; + let result = ""; + for (let i = 0; i < length; i++) result += chars[Math.floor(Math.random() * chars.length)]; + return result; +} + +function generateStatsigId() { + const msg = Math.random() < 0.5 + ? `e:TypeError: Cannot read properties of null (reading 'children["${randomString(5, true)}"]')` + : `e:TypeError: Cannot read properties of undefined (reading '${randomString(10)}')`; + return btoa(msg); +} + +function randomHex(bytes) { + const arr = new Uint8Array(bytes); + crypto.getRandomValues(arr); + return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function parseOpenAIMessages(messages) { + const extracted = []; + for (const msg of messages) { + let role = String(msg.role || "user"); + if (role === "developer") role = "system"; + let content = ""; + if (typeof msg.content === "string") { + content = msg.content; + } else if (Array.isArray(msg.content)) { + content = msg.content.filter((c) => c.type === "text").map((c) => String(c.text || "")).join(" "); + } + if (!content.trim()) continue; + extracted.push({ role, text: content }); + } + + let lastUserIdx = -1; + for (let i = extracted.length - 1; i >= 0; i--) { + if (extracted[i].role === "user") { lastUserIdx = i; break; } + } + + const parts = []; + for (let i = 0; i < extracted.length; i++) { + const { role, text } = extracted[i]; + parts.push(i === lastUserIdx ? text : `${role}: ${text}`); + } + return parts.join("\n\n"); +} + +async function* readGrokNdjsonEvents(body, signal) { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + while (true) { + if (signal?.aborted) return; + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + while (true) { + const idx = buffer.indexOf("\n"); + if (idx < 0) break; + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + try { yield JSON.parse(line); } catch { /* skip */ } + } + } + buffer += decoder.decode(); + const remaining = buffer.trim(); + if (remaining) { + try { yield JSON.parse(remaining); } catch { /* skip */ } + } + } finally { + reader.releaseLock(); + } +} + +async function* extractContent(eventStream, isThinkingModel, signal) { + let fingerprint = ""; + let responseId = ""; + let thinkOpened = false; + + for await (const event of readGrokNdjsonEvents(eventStream, signal)) { + if (event.error) { + yield { error: event.error.message || `Grok error: ${event.error.code}`, done: true }; + return; + } + const resp = event.result?.response; + if (!resp) continue; + + if (resp.llmInfo?.modelHash && !fingerprint) fingerprint = resp.llmInfo.modelHash; + if (resp.responseId) responseId = resp.responseId; + + if (resp.modelResponse) { + const mr = resp.modelResponse; + if (thinkOpened && isThinkingModel) { + if (mr.message) yield { thinking: mr.message }; + thinkOpened = false; + } + if (mr.message) yield { fullMessage: mr.message, fingerprint, responseId }; + if (mr.metadata?.llm_info?.modelHash) fingerprint = mr.metadata.llm_info.modelHash; + continue; + } + + if (resp.token != null) yield { delta: resp.token, fingerprint, responseId }; + } + yield { done: true, fingerprint, responseId }; +} + +function buildStreamingResponse(eventStream, model, cid, created, isThinkingModel, signal) { + const encoder = new TextEncoder(); + return new ReadableStream({ + async start(controller) { + try { + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null, logprobs: null }], + }))); + + let fp = ""; + for await (const chunk of extractContent(eventStream, isThinkingModel, signal)) { + if (chunk.fingerprint) fp = chunk.fingerprint; + + if (chunk.error) { + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: fp || null, + choices: [{ index: 0, delta: { content: `[Error: ${chunk.error}]` }, finish_reason: null, logprobs: null }], + }))); + break; + } + if (chunk.thinking) { + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: fp || null, + choices: [{ index: 0, delta: { reasoning_content: chunk.thinking }, finish_reason: null, logprobs: null }], + }))); + continue; + } + if (chunk.done) break; + if (chunk.delta) { + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: fp || null, + choices: [{ index: 0, delta: { content: chunk.delta }, finish_reason: null, logprobs: null }], + }))); + } + } + + controller.enqueue(encoder.encode(sseChunk({ + 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(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(SSE_DONE)); + } finally { + controller.close(); + } + }, + }); +} + +async function buildNonStreamingResponse(eventStream, model, cid, created, isThinkingModel, signal) { + let fullContent = ""; + let fingerprint = ""; + const thinkingParts = []; + + for await (const chunk of extractContent(eventStream, isThinkingModel, signal)) { + if (chunk.fingerprint) fingerprint = chunk.fingerprint; + if (chunk.error) { + return new Response(JSON.stringify({ + error: { message: chunk.error, type: "upstream_error", code: "GROK_ERROR" }, + }), { status: 502, headers: { "Content-Type": "application/json" } }); + } + if (chunk.thinking) { thinkingParts.push(chunk.thinking); continue; } + if (chunk.done) break; + if (chunk.fullMessage) fullContent = chunk.fullMessage; + else if (chunk.delta) fullContent += chunk.delta; + } + + const msg = { role: "assistant", content: fullContent }; + if (thinkingParts.length > 0) msg.reasoning_content = thinkingParts.join("\n"); + + const promptTokens = Math.ceil(fullContent.length / 4); + const completionTokens = Math.ceil(fullContent.length / 4); + + return new Response(JSON.stringify({ + id: cid, object: "chat.completion", created, model, system_fingerprint: fingerprint || null, + choices: [{ index: 0, message: msg, finish_reason: "stop", logprobs: null }], + usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: promptTokens + completionTokens }, + }), { status: 200, headers: { "Content-Type": "application/json" } }); +} + +export class GrokWebExecutor extends BaseExecutor { + constructor() { + super("grok-web", PROVIDERS["grok-web"]); + } + + async execute({ model, body, stream, credentials, signal, log }) { + const messages = body?.messages; + if (!messages || !Array.isArray(messages) || messages.length === 0) { + const errResp = new Response(JSON.stringify({ + error: { message: "Missing or empty messages array", type: "invalid_request" }, + }), { status: 400, headers: { "Content-Type": "application/json" } }); + return { response: errResp, url: GROK_CHAT_API, headers: {}, transformedBody: body }; + } + + const modelInfo = MODEL_MAP[model]; + if (!modelInfo) log?.info?.("GROK-WEB", `Unmapped model ${model}, defaulting to grok-4.1-fast`); + const { grokModel, modelMode, isThinking } = modelInfo || MODEL_MAP["grok-4.1-fast"]; + + const message = parseOpenAIMessages(messages); + if (!message.trim()) { + const errResp = new Response(JSON.stringify({ + error: { message: "Empty query after processing", type: "invalid_request" }, + }), { status: 400, headers: { "Content-Type": "application/json" } }); + return { response: errResp, url: GROK_CHAT_API, headers: {}, transformedBody: body }; + } + + const grokPayload = { + temporary: true, modelName: grokModel, modelMode, message, + fileAttachments: [], imageAttachments: [], + disableSearch: false, enableImageGeneration: false, returnImageBytes: false, + returnRawGrokInXaiRequest: false, enableImageStreaming: false, imageGenerationCount: 0, + forceConcise: false, toolOverrides: {}, enableSideBySide: true, sendFinalMetadata: true, + isReasoning: false, disableTextFollowUps: false, disableMemory: true, + forceSideBySide: false, isAsyncChat: false, disableSelfHarmShortCircuit: false, + deviceEnvInfo: { + darkModeEnabled: false, devicePixelRatio: 2, + screenWidth: 2056, screenHeight: 1329, viewportWidth: 2056, viewportHeight: 1083, + }, + }; + + const traceId = randomHex(16); + const spanId = randomHex(8); + const headers = { + Accept: "*/*", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Accept-Language": "en-US,en;q=0.9", + Baggage: "sentry-environment=production,sentry-release=d6add6fb0460641fd482d767a335ef72b9b6abb8,sentry-public_key=b311e0f2690c81f25e2c4cf6d4f7ce1c", + "Cache-Control": "no-cache", + "Content-Type": "application/json", + Origin: "https://grok.com", + Pragma: "no-cache", + Referer: "https://grok.com/", + "Sec-Ch-Ua": '"Google Chrome";v="136", "Chromium";v="136", "Not(A:Brand";v="24"', + "Sec-Ch-Ua-Mobile": "?0", + "Sec-Ch-Ua-Platform": '"macOS"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + "User-Agent": GROK_USER_AGENT, + "x-statsig-id": generateStatsigId(), + "x-xai-request-id": crypto.randomUUID(), + traceparent: `00-${traceId}-${spanId}-00`, + }; + + // Strip "sso=" prefix if user pasted it + if (credentials.apiKey) { + let token = credentials.apiKey; + if (token.startsWith("sso=")) token = token.slice(4); + headers["Cookie"] = `sso=${token}`; + } + + log?.info?.("GROK-WEB", `Query to ${model} (grok=${grokModel}, mode=${modelMode}), len=${message.length}`); + + let response; + try { + response = await fetch(GROK_CHAT_API, { + method: "POST", headers, body: JSON.stringify(grokPayload), signal, + }); + } catch (err) { + log?.error?.("GROK-WEB", `Fetch failed: ${err.message || String(err)}`); + const errResp = new Response(JSON.stringify({ + error: { message: `Grok connection failed: ${err.message || String(err)}`, type: "upstream_error" }, + }), { status: 502, headers: { "Content-Type": "application/json" } }); + return { response: errResp, url: GROK_CHAT_API, headers, transformedBody: grokPayload }; + } + + if (!response.ok) { + const status = response.status; + let errMsg = `Grok returned HTTP ${status}`; + if (status === 401 || status === 403) errMsg = "Grok auth failed — SSO cookie may be expired. Re-paste your sso cookie value from grok.com."; + else if (status === 429) errMsg = "Grok rate limited. Wait a moment and retry, or rotate cookies."; + log?.warn?.("GROK-WEB", errMsg); + const errResp = new Response(JSON.stringify({ + error: { message: errMsg, type: "upstream_error", code: `HTTP_${status}` }, + }), { status, headers: { "Content-Type": "application/json" } }); + return { response: errResp, url: GROK_CHAT_API, headers, transformedBody: grokPayload }; + } + + if (!response.body) { + const errResp = new Response(JSON.stringify({ + error: { message: "Grok returned empty response body", type: "upstream_error" }, + }), { status: 502, headers: { "Content-Type": "application/json" } }); + return { response: errResp, url: GROK_CHAT_API, headers, transformedBody: grokPayload }; + } + + const cid = `chatcmpl-grok-${crypto.randomUUID().slice(0, 12)}`; + const created = Math.floor(Date.now() / 1000); + + let finalResponse; + if (stream) { + const sseStream = buildStreamingResponse(response.body, model, cid, created, isThinking, signal); + finalResponse = new Response(sseStream, { + status: 200, + headers: { ...SSE_HEADERS_NO_BUFFER }, + }); + } else { + finalResponse = await buildNonStreamingResponse(response.body, model, cid, created, isThinking, signal); + } + return { response: finalResponse, url: GROK_CHAT_API, headers, transformedBody: grokPayload }; + } +} + +export default GrokWebExecutor; diff --git a/open-sse/executors/iflow.js b/open-sse/executors/iflow.js new file mode 100644 index 0000000000000000000000000000000000000000..899477a8ce5a67c79c4e65344b73094a2a8ef40c --- /dev/null +++ b/open-sse/executors/iflow.js @@ -0,0 +1,108 @@ +import crypto from "crypto"; +import { BaseExecutor } from "./base.js"; +import { PROVIDERS } from "../config/providers.js"; + +/** + * IFlowExecutor - Executor for iFlow API with HMAC-SHA256 signature + */ +export class IFlowExecutor extends BaseExecutor { + constructor() { + super("iflow", PROVIDERS.iflow); + } + + /** + * Generate UUID v4 + * @returns {string} UUID v4 string + */ + generateUUID() { + return crypto.randomUUID(); + } + + /** + * Create iFlow signature using HMAC-SHA256 + * @param {string} userAgent - User agent string + * @param {string} sessionID - Session ID + * @param {number} timestamp - Unix timestamp in milliseconds + * @param {string} apiKey - API key for signing + * @returns {string} Hex-encoded signature + */ + createIFlowSignature(userAgent, sessionID, timestamp, apiKey) { + if (!apiKey) return ""; + const payload = `${userAgent}:${sessionID}:${timestamp}`; + const hmac = crypto.createHmac("sha256", apiKey); + hmac.update(payload); + return hmac.digest("hex"); + } + + /** + * Build headers with iFlow-specific signature + * @param {object} credentials - Provider credentials + * @param {boolean} stream - Whether streaming is enabled + * @returns {object} Headers object + */ + buildHeaders(credentials, stream = true) { + // Generate session ID and timestamp + const sessionID = `session-${this.generateUUID()}`; + const timestamp = Date.now(); + + // Get user agent from config + const userAgent = this.config.headers["User-Agent"] || "iFlow-Cli"; + + // Get API key (prefer apiKey, fallback to accessToken) + const apiKey = credentials.apiKey || credentials.accessToken || ""; + + // Create signature + const signature = this.createIFlowSignature(userAgent, sessionID, timestamp, apiKey); + + // Build headers + const headers = { + "Content-Type": "application/json", + ...this.config.headers, + "session-id": sessionID, + "x-iflow-timestamp": timestamp.toString(), + "x-iflow-signature": signature + }; + + // Add authorization + if (credentials.apiKey) { + headers["Authorization"] = `Bearer ${credentials.apiKey}`; + } + + // Add streaming header + if (stream) { + headers["Accept"] = "text/event-stream"; + } + + return headers; + } + + /** + * Build URL for iFlow API + * @param {string} model - Model name + * @param {boolean} stream - Whether streaming is enabled + * @param {number} urlIndex - URL index for fallback + * @param {object} credentials - Provider credentials + * @returns {string} API URL + */ + buildUrl(model, stream, urlIndex = 0, credentials = null) { + return this.config.baseUrl; + } + + /** + * Transform request body - inject stream_options for usage data + * @param {string} model - Model name + * @param {object} body - Request body + * @param {boolean} stream - Whether streaming is enabled + * @param {object} credentials - Provider credentials + * @returns {object} Transformed body + */ + transformRequest(model, body, stream, credentials) { + // Inject stream_options for streaming requests to get usage data + if (stream && body.messages && !body.stream_options) { + body.stream_options = { include_usage: true }; + } + return body; + } +} + +export default IFlowExecutor; diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js new file mode 100644 index 0000000000000000000000000000000000000000..bac26447bbe61a92d68ada0f009098d67273e1d1 --- /dev/null +++ b/open-sse/executors/index.js @@ -0,0 +1,79 @@ +import { AntigravityExecutor } from "./antigravity.js"; +import { AzureExecutor } from "./azure.js"; +import { GeminiCLIExecutor } from "./gemini-cli.js"; +import { GithubExecutor } from "./github.js"; +import { IFlowExecutor } from "./iflow.js"; +import { QoderExecutor } from "./qoder.js"; +import { KiroExecutor } from "./kiro.js"; +import { CodexExecutor } from "./codex.js"; +import { CursorExecutor } from "./cursor.js"; +import { VertexExecutor } from "./vertex.js"; +import { QwenExecutor } from "./qwen.js"; +import { OpenCodeExecutor } from "./opencode.js"; +import { OpenCodeGoExecutor } from "./opencode-go.js"; +import { GrokWebExecutor } from "./grok-web.js"; +import { PerplexityWebExecutor } from "./perplexity-web.js"; +import { OllamaLocalExecutor } from "./ollama-local.js"; +import { CommandCodeExecutor } from "./commandcode.js"; +import { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; +import { MimoFreeExecutor } from "./mimo-free.js"; +import { DefaultExecutor } from "./default.js"; + +const executors = { + antigravity: new AntigravityExecutor(), + azure: new AzureExecutor(), + "gemini-cli": new GeminiCLIExecutor(), + github: new GithubExecutor(), + iflow: new IFlowExecutor(), + qoder: new QoderExecutor(), + kiro: new KiroExecutor(), + codex: new CodexExecutor(), + cursor: new CursorExecutor(), + cu: new CursorExecutor(), // Alias for cursor + vertex: new VertexExecutor("vertex"), + "vertex-partner": new VertexExecutor("vertex-partner"), + qwen: new QwenExecutor(), + opencode: new OpenCodeExecutor(), + "opencode-go": new OpenCodeGoExecutor(), + "grok-web": new GrokWebExecutor(), + "perplexity-web": new PerplexityWebExecutor(), + "ollama-local": new OllamaLocalExecutor(), + commandcode: new CommandCodeExecutor(), + "xiaomi-tokenplan": new XiaomiTokenplanExecutor(), + "mimo-free": new MimoFreeExecutor(), + mmf: new MimoFreeExecutor(), // Alias for mimo-free +}; + +const defaultCache = new Map(); + +export function getExecutor(provider) { + if (executors[provider]) return executors[provider]; + if (!defaultCache.has(provider)) defaultCache.set(provider, new DefaultExecutor(provider)); + return defaultCache.get(provider); +} + +export function hasSpecializedExecutor(provider) { + return !!executors[provider]; +} + +export { BaseExecutor } from "./base.js"; +export { AntigravityExecutor } from "./antigravity.js"; +export { AzureExecutor } from "./azure.js"; +export { GeminiCLIExecutor } from "./gemini-cli.js"; +export { GithubExecutor } from "./github.js"; +export { IFlowExecutor } from "./iflow.js"; +export { QoderExecutor } from "./qoder.js"; +export { KiroExecutor } from "./kiro.js"; +export { CodexExecutor } from "./codex.js"; +export { CursorExecutor } from "./cursor.js"; +export { VertexExecutor } from "./vertex.js"; +export { DefaultExecutor } from "./default.js"; +export { QwenExecutor } from "./qwen.js"; +export { OpenCodeExecutor } from "./opencode.js"; +export { OpenCodeGoExecutor } from "./opencode-go.js"; +export { GrokWebExecutor } from "./grok-web.js"; +export { PerplexityWebExecutor } from "./perplexity-web.js"; +export { OllamaLocalExecutor } from "./ollama-local.js"; +export { CommandCodeExecutor } from "./commandcode.js"; +export { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js"; +export { MimoFreeExecutor } from "./mimo-free.js"; diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js new file mode 100644 index 0000000000000000000000000000000000000000..3034f725f9e9076cde81867f9fbbf499a374729a --- /dev/null +++ b/open-sse/executors/kiro.js @@ -0,0 +1,525 @@ +import { BaseExecutor } from "./base.js"; +import { PROVIDERS } from "../config/providers.js"; +import { v4 as uuidv4 } from "uuid"; +import { refreshKiroToken } from "../services/tokenRefresh.js"; +import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js"; + +/** + * KiroExecutor - Executor for Kiro AI (AWS CodeWhisperer) + * Uses AWS CodeWhisperer streaming API with AWS EventStream binary format + */ +export class KiroExecutor extends BaseExecutor { + constructor() { + super("kiro", PROVIDERS.kiro); + } + + buildHeaders(credentials, stream = true) { + const headers = { + ...this.config.headers, + "Amz-Sdk-Request": "attempt=1; max=3", + "Amz-Sdk-Invocation-Id": uuidv4() + }; + + // 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. + const isApiKey = credentials?.providerSpecificData?.authMethod === "api_key"; + + 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}`; + } + + 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. OAuth keeps the default order + * (kiro.dev first) since its token is what that gateway accepts. + */ + getOrderedBaseUrls(credentials) { + const baseUrls = this.getBaseUrls(); + const isApiKey = credentials?.providerSpecificData?.authMethod === "api_key"; + if (!isApiKey) 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; + } + + /** + * Kiro execute — delegate to BaseExecutor for endpoint fallback + retry, then + * transform the binary AWS EventStream into OpenAI-shaped SSE on success. + * + * 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. + * + * Errors are returned untransformed so the upstream handler can read the body, + * classify the status, and trigger account fallback/cooldown. + */ + async execute(args) { + const result = await super.execute(args); + if (result?.response?.ok) { + result.response = this.transformEventStreamToSSE(result.response, args.model); + } + return result; + } + + /** + * Transform AWS EventStream binary response to SSE text stream + * Using TransformStream instead of ReadableStream.pull() to avoid Workers timeout + */ + transformEventStreamToSSE(response, model) { + let buffer = new Uint8Array(0); + let chunkIndex = 0; + const responseId = `chatcmpl-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + const state = { + endDetected: false, + finishEmitted: false, + hasToolCalls: false, + hasReasoningContent: false, + reasoningChunkCount: 0, + toolCallIndex: 0, + seenToolIds: new Map() + }; + + 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); + newBuffer.set(chunk, buffer.length); + buffer = newBuffer; + + // Parse events from buffer + let iterations = 0; + const maxIterations = 1000; + while (buffer.length >= 16 && iterations < maxIterations) { + iterations++; + const view = new DataView(buffer.buffer, buffer.byteOffset); + const totalLength = view.getUint32(0, false); + + if (totalLength < 16 || totalLength > buffer.length || buffer.length < totalLength) break; + + const eventData = buffer.slice(0, totalLength); + buffer = buffer.slice(totalLength); + + const event = parseEventFrame(eventData); + 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; + + // Handle assistantResponseEvent + if (eventType === "assistantResponseEvent" && event.payload?.content) { + const content = event.payload.content; + state.totalContentLength += content.length; + + const chunk = { + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ + index: 0, + delta: chunkIndex === 0 + ? { role: "assistant", content } + : { content }, + finish_reason: null + }] + }; + chunkIndex++; + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + + // Handle reasoningContentEvent (Kiro thinking / reasoning) + // Kiro returns reasoning as a separate event when the request system + // prompt contains enabled. Surface it + // as OpenAI delta.reasoning_content so downstream translators can map + // it back to Claude thinking blocks / Anthropic reasoning, etc. + if (eventType === "reasoningContentEvent") { + const reasoning = event.payload?.reasoningContentEvent || event.payload || {}; + const reasoningText = (typeof reasoning === "string") + ? reasoning + : (reasoning.text || reasoning.content || ""); + if (reasoningText) { + state.hasReasoningContent = true; + state.totalContentLength += reasoningText.length; + + const reasoningDelta = state.reasoningChunkCount === 0 && chunkIndex === 0 + ? { role: "assistant", reasoning_content: reasoningText } + : { reasoning_content: reasoningText }; + + const chunk = { + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ + index: 0, + delta: reasoningDelta, + finish_reason: null + }] + }; + chunkIndex++; + state.reasoningChunkCount++; + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + } + + // Handle codeEvent + if (eventType === "codeEvent" && event.payload?.content) { + const chunk = { + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ + index: 0, + delta: { content: event.payload.content }, + finish_reason: null + }] + }; + chunkIndex++; + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + + // Handle toolUseEvent + if (eventType === "toolUseEvent" && event.payload) { + state.hasToolCalls = true; + const toolUse = event.payload; + const toolUses = Array.isArray(toolUse) ? toolUse : [toolUse]; + + for (const singleToolUse of toolUses) { + const toolCallId = singleToolUse.toolUseId || `call_${Date.now()}`; + const toolName = singleToolUse.name || ""; + const toolInput = singleToolUse.input; + + let toolIndex; + const isNewTool = !state.seenToolIds.has(toolCallId); + + if (isNewTool) { + toolIndex = state.toolCallIndex++; + state.seenToolIds.set(toolCallId, toolIndex); + + const startChunk = { + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ + index: 0, + delta: { + ...(chunkIndex === 0 ? { role: "assistant" } : {}), + tool_calls: [{ + index: toolIndex, + id: toolCallId, + type: "function", + function: { + name: toolName, + arguments: "" + } + }] + }, + finish_reason: null + }] + }; + chunkIndex++; + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(startChunk)}\n\n`)); + } else { + toolIndex = state.seenToolIds.get(toolCallId); + } + + if (toolInput !== undefined) { + let argumentsStr; + + if (typeof toolInput === 'string') { + argumentsStr = toolInput; + } else if (typeof toolInput === 'object') { + argumentsStr = JSON.stringify(toolInput); + } else { + continue; + } + + const argsChunk = { + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ + index: 0, + delta: { + tool_calls: [{ + index: toolIndex, + function: { + arguments: argumentsStr + } + }] + }, + finish_reason: null + }] + }; + chunkIndex++; + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(argsChunk)}\n\n`)); + } + } + } + + // Handle messageStopEvent + if (eventType === "messageStopEvent") { + const chunk = { + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ + index: 0, + delta: {}, + finish_reason: state.hasToolCalls ? "tool_calls" : "stop" + }] + }; + state.finishEmitted = true; + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + + // Handle contextUsageEvent to extract contextUsagePercentage + if (eventType === "contextUsageEvent" && event.payload?.contextUsagePercentage) { + state.contextUsagePercentage = event.payload.contextUsagePercentage; + // Mark that we received context usage event + state.hasContextUsage = true; + } + + // Handle meteringEvent - mark that we received it + if (eventType === "meteringEvent") { + state.hasMeteringEvent = true; + } + + // Handle metricsEvent for token usage + if (eventType === "metricsEvent") { + // Extract usage data from metricsEvent payload + const metrics = event.payload?.metricsEvent || event.payload; + 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, + completion_tokens: outputTokens, + total_tokens: inputTokens + outputTokens + }; + } + } + } + + // 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 + ? 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) + : 0; + + state.usage = { + prompt_tokens: estimatedInputTokens, + completion_tokens: estimatedOutputTokens, + total_tokens: estimatedInputTokens + estimatedOutputTokens + }; + } + + const finishChunk = { + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ + index: 0, + delta: {}, + 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`)); + } + } + + 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) { + // Emit finish chunk if not already sent + if (!state.finishEmitted) { + state.finishEmitted = true; + const finishChunk = { + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [{ + index: 0, + delta: {}, + finish_reason: state.hasToolCalls ? "tool_calls" : "stop" + }] + }; + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(finishChunk)}\n\n`)); + } + + // Send final done message + controller.enqueue(new TextEncoder().encode(SSE_DONE)); + } + }); + + // Pipe response body through transform stream + if (!response.body) { + 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: { ...SSE_HEADERS } + }); + } + + async refreshCredentials(credentials, log, proxyOptions = null) { + if (!credentials.refreshToken) return null; + + try { + // Use centralized refreshKiroToken function (handles both AWS SSO OIDC and Social Auth) + const result = await refreshKiroToken( + credentials.refreshToken, + credentials.providerSpecificData, + log, + proxyOptions + ); + + return result; + } catch (error) { + log?.error?.("TOKEN", `Kiro refresh error: ${error.message}`); + return null; + } + } +} + +/** + * Parse AWS EventStream frame + */ +function parseEventFrame(data) { + try { + const view = new DataView(data.buffer, data.byteOffset); + const headersLength = view.getUint32(4, false); + + // Parse headers + const headers = {}; + let offset = 12; // After prelude + const headerEnd = 12 + headersLength; + + while (offset < headerEnd && offset < data.length) { + const nameLen = data[offset]; + offset++; + if (offset + nameLen > data.length) break; + + const name = new TextDecoder().decode(data.slice(offset, offset + nameLen)); + offset += nameLen; + + const headerType = data[offset]; + offset++; + + if (headerType === 7) { // String type + const valueLen = (data[offset] << 8) | data[offset + 1]; + offset += 2; + if (offset + valueLen > data.length) break; + + const value = new TextDecoder().decode(data.slice(offset, offset + valueLen)); + offset += valueLen; + headers[name] = value; + } else { + break; + } + } + + // Parse payload + const payloadStart = 12 + headersLength; + const payloadEnd = data.length - 4; // Exclude message CRC + + let payload = null; + if (payloadEnd > payloadStart) { + const payloadStr = new TextDecoder().decode(data.slice(payloadStart, payloadEnd)); + + // Skip empty or whitespace-only payloads + if (!payloadStr || !payloadStr.trim()) { + return { headers, payload: null }; + } + + try { + payload = JSON.parse(payloadStr); + } catch (parseError) { + // Log parse error for debugging + console.warn(`[Kiro] Failed to parse payload: ${parseError.message} | payload: ${payloadStr.substring(0, 100)}`); + payload = { raw: payloadStr }; + } + } + + return { headers, payload }; + } catch { + return null; + } +} + +export default KiroExecutor; diff --git a/open-sse/executors/mimo-free.js b/open-sse/executors/mimo-free.js new file mode 100644 index 0000000000000000000000000000000000000000..81465b548eb986cd052f0093c3c1cc856353753e --- /dev/null +++ b/open-sse/executors/mimo-free.js @@ -0,0 +1,156 @@ +import { BaseExecutor } from "./base.js"; +import { PROVIDERS } from "../config/providers.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { createHash } from "crypto"; +import os from "os"; + +const BOOTSTRAP_URL = "https://api.xiaomimimo.com/api/free-ai/bootstrap"; +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 marker: the free chat endpoint returns 403 "Illegal access" +// unless a system message contains this exact MiMoCode signature substring. +export const MIMO_SYSTEM_MARKER = + "You are MiMoCode, an interactive CLI tool that helps users with software engineering tasks."; + +// In-memory JWT cache (per-process, survives across requests but not restarts) +let cachedJwt = null; +let jwtExpiresAt = 0; + +// Device fingerprint reused as the bootstrap "client" — stable per machine +function generateFingerprint() { + let username = "unknown-user"; + try { + username = os.userInfo().username; + } catch { + // ignore + } + const cpu = (os.cpus()[0]?.model || "unknown-cpu").trim(); + const seed = `${os.hostname()}|${os.platform()}|${os.arch()}|${cpu}|${username}`; + return createHash("sha256").update(seed).digest("hex"); +} + +function generateSessionId() { + let id = SESSION_AFFINITY_PREFIX; + for (let i = 0; i < SESSION_ID_LENGTH; i++) { + id += SESSION_CHARS[Math.floor(Math.random() * SESSION_CHARS.length)]; + } + return id; +} + +// Derive expiry from the JWT exp claim; fall back to a fixed TTL when unparseable +function parseJwtExp(jwt) { + try { + const payload = JSON.parse(Buffer.from(jwt.split(".")[1], "base64").toString()); + if (payload.exp) return payload.exp * 1000; + } catch { + // ignore + } + return Date.now() + JWT_FALLBACK_TTL_SEC * 1000; +} + +// Ensure the body carries the anti-abuse marker in a system message (idempotent) +function injectSystemMarker(body) { + const messages = body?.messages; + if (!Array.isArray(messages)) return body; + const hasMarker = messages.some( + (m) => m?.role === "system" && typeof m.content === "string" && m.content.includes(MIMO_SYSTEM_MARKER) + ); + if (hasMarker) return body; + return { ...body, messages: [{ role: "system", content: MIMO_SYSTEM_MARKER }, ...messages] }; +} + +function resetJwtCache() { + cachedJwt = null; + jwtExpiresAt = 0; +} + +async function bootstrapJwt(proxyOptions = null) { + if (cachedJwt && Date.now() < jwtExpiresAt - JWT_EXPIRY_BUFFER_MS) { + return cachedJwt; + } + + const response = await proxyAwareFetch(BOOTSTRAP_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ client: generateFingerprint() }), + }, proxyOptions); + + if (!response.ok) { + throw new Error(`MiMo bootstrap failed: ${response.status}`); + } + + const data = await response.json(); + if (!data.jwt) { + throw new Error("MiMo bootstrap returned no JWT"); + } + + cachedJwt = data.jwt; + jwtExpiresAt = parseJwtExp(data.jwt); + return cachedJwt; +} + +export class MimoFreeExecutor extends BaseExecutor { + constructor() { + super("mimo-free", PROVIDERS["mimo-free"]); + this.sessionId = generateSessionId(); + } + + buildUrl() { + return CHAT_URL; + } + + buildHeaders(credentials, stream = true) { + return { + "Content-Type": "application/json", + "X-Mimo-Source": "mimocode-cli-free", + "x-session-affinity": this.sessionId, + "Accept": stream ? "text/event-stream" : "application/json", + }; + } + + transformRequest(model, body) { + return injectSystemMarker(body); + } + + async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + let jwt; + try { + jwt = await bootstrapJwt(proxyOptions); + } catch (error) { + log?.error?.("AUTH", `MiMo bootstrap failed: ${error.message}`); + throw error; + } + + const url = this.buildUrl(); + const transformedBody = this.transformRequest(model, body); + const headers = { ...this.buildHeaders(credentials, stream), "Authorization": `Bearer ${jwt}` }; + const bodyStr = JSON.stringify(transformedBody); + log?.debug?.("FETCH", `MIMO-FREE → ${url} | body=${bodyStr.length}B`); + + const response = await proxyAwareFetch(url, { method: "POST", headers, body: bodyStr, signal }, proxyOptions); + + // On auth failure, invalidate cache and retry once with a fresh JWT + if (response.status === 401 || response.status === 403) { + log?.debug?.("AUTH", `MiMo auth failed (${response.status}), re-bootstrapping...`); + resetJwtCache(); + jwt = await bootstrapJwt(proxyOptions); + headers["Authorization"] = `Bearer ${jwt}`; + const retryResponse = await proxyAwareFetch(url, { method: "POST", headers, body: bodyStr, signal }, proxyOptions); + return { response: retryResponse, url, headers, transformedBody }; + } + + return { response, url, headers, transformedBody }; + } +} + +export const __test__ = { + generateFingerprint, generateSessionId, bootstrapJwt, resetJwtCache, parseJwtExp, + injectSystemMarker, MIMO_SYSTEM_MARKER, BOOTSTRAP_URL, CHAT_URL, SESSION_AFFINITY_PREFIX, +}; + +export default MimoFreeExecutor; diff --git a/open-sse/executors/ollama-local.js b/open-sse/executors/ollama-local.js new file mode 100644 index 0000000000000000000000000000000000000000..83de7b8753989a57bde3d746b919c13491940cde --- /dev/null +++ b/open-sse/executors/ollama-local.js @@ -0,0 +1,14 @@ +import { DefaultExecutor } from "./default.js"; +import { resolveOllamaLocalHost } from "../config/providers.js"; + +export class OllamaLocalExecutor extends DefaultExecutor { + constructor() { + super("ollama-local"); + } + + buildUrl(model, stream, urlIndex = 0, credentials = null) { + return `${resolveOllamaLocalHost(credentials)}/api/chat`; + } +} + +export default OllamaLocalExecutor; diff --git a/open-sse/executors/opencode-go.js b/open-sse/executors/opencode-go.js new file mode 100644 index 0000000000000000000000000000000000000000..38b3f2711a92785340fe6ef8b518de9f92dac727 --- /dev/null +++ b/open-sse/executors/opencode-go.js @@ -0,0 +1,42 @@ +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 BASE = "https://opencode.ai/zen/go/v1"; + +export class OpenCodeGoExecutor extends BaseExecutor { + constructor() { + super("opencode-go", PROVIDERS["opencode-go"]); + } + + // buildUrl runs before buildHeaders in BaseExecutor.execute, cache model here + buildUrl(model) { + this._lastModel = model; + return CLAUDE_FORMAT_MODELS.has(model) + ? `${BASE}/messages` + : `${BASE}/chat/completions`; + } + + buildHeaders(credentials, stream = true) { + const key = credentials?.apiKey || credentials?.accessToken; + const headers = { "Content-Type": "application/json" }; + + if (CLAUDE_FORMAT_MODELS.has(this._lastModel)) { + headers["x-api-key"] = key; + headers["anthropic-version"] = ANTHROPIC_API_VERSION; + } else { + headers["Authorization"] = `Bearer ${key}`; + } + + if (stream) headers["Accept"] = "text/event-stream"; + return headers; + } + + transformRequest(model, body) { + return injectReasoningContent({ provider: this.provider, model, body }); + } +} diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js new file mode 100644 index 0000000000000000000000000000000000000000..f7aee211cf42ef6159086fbd2f9c874b5d430831 --- /dev/null +++ b/open-sse/executors/opencode.js @@ -0,0 +1,32 @@ +import { BaseExecutor } from "./base.js"; +import { PROVIDERS } from "../config/providers.js"; +import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; + +// Models that use /zen/v1/messages (claude format) +const MESSAGES_MODELS = new Set(); + +export class OpenCodeExecutor extends BaseExecutor { + constructor() { + super("opencode", PROVIDERS.opencode); + } + + transformRequest(model, body) { + return injectReasoningContent({ provider: this.provider, model, body }); + } + + buildUrl(model) { + const base = this.config.baseUrl; + return MESSAGES_MODELS.has(model) + ? `${base}/zen/v1/messages` + : `${base}/zen/v1/chat/completions`; + } + + buildHeaders() { + return { + "Content-Type": "application/json", + "Authorization": "Bearer public", + "x-opencode-client": "desktop", + "Accept": "text/event-stream" + }; + } +} diff --git a/open-sse/executors/perplexity-web.js b/open-sse/executors/perplexity-web.js new file mode 100644 index 0000000000000000000000000000000000000000..87d64574da8ee2a5e915a0d64df964eb21832ce6 --- /dev/null +++ b/open-sse/executors/perplexity-web.js @@ -0,0 +1,505 @@ +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"; +const PPLX_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"; + +const MODEL_MAP = { + "pplx-auto": ["concise", "pplx_pro"], + "pplx-sonar": ["copilot", "experimental"], + "pplx-gpt": ["copilot", "gpt54"], + "pplx-gemini": ["copilot", "gemini31pro_high"], + "pplx-sonnet": ["copilot", "claude46sonnet"], + "pplx-opus": ["copilot", "claude46opus"], + "pplx-nemotron": ["copilot", "nv_nemotron_3_super"], +}; + +const THINKING_MAP = { + "pplx-gpt": "gpt54_thinking", + "pplx-sonnet": "claude46sonnetthinking", + "pplx-opus": "claude46opusthinking", +}; + +const CITATION_RE = /\[\d+\]/g; +const GROK_TAG_RE = /]*>.*?<\/grok:[^>]*>/gs; +const GROK_SELF_RE = /]*\/>/g; +const XML_DECL_RE = /<[?]xml[^?]*[?]>/g; +const RESPONSE_TAG_RE = /<\/?response\b[^>]*>/gi; +const MULTI_SPACE = / {2,}/g; +const MULTI_NL = /\n{3,}/g; + +const SESSION_MAX_AGE_MS = 3600_000; +const SESSION_MAX_ENTRIES = 200; + +const sessionCache = new Map(); + +// FNV-1a hash for session key lookup +function sessionKey(history) { + const parts = history.map((h) => `${h.role}:${h.content}`).join("\n"); + let hash = 0x811c9dc5; + for (let i = 0; i < parts.length; i++) { + hash ^= parts.charCodeAt(i); + hash = (hash * 0x01000193) >>> 0; + } + return hash.toString(16).padStart(8, "0"); +} + +function sessionLookup(history) { + if (history.length === 0) return null; + const key = sessionKey(history); + const entry = sessionCache.get(key); + if (!entry) return null; + if (Date.now() - entry.ts > SESSION_MAX_AGE_MS) { + sessionCache.delete(key); + return null; + } + return entry.backendUuid; +} + +function sessionStore(history, currentMsg, responseText, backendUuid) { + if (!backendUuid) return; + const full = [...history, { role: "user", content: currentMsg }, { role: "assistant", content: responseText }]; + const key = sessionKey(full); + sessionCache.set(key, { backendUuid, ts: Date.now() }); + if (sessionCache.size > SESSION_MAX_ENTRIES) { + let oldestKey = null; + let oldestTs = Infinity; + for (const [k, v] of sessionCache) { + if (v.ts < oldestTs) { oldestTs = v.ts; oldestKey = k; } + } + if (oldestKey) sessionCache.delete(oldestKey); + } +} + +function cleanResponse(text, strip = true) { + let t = text; + t = t.replace(XML_DECL_RE, ""); + t = t.replace(CITATION_RE, ""); + t = t.replace(GROK_TAG_RE, ""); + t = t.replace(GROK_SELF_RE, ""); + t = t.replace(RESPONSE_TAG_RE, ""); + if (strip) { + t = t.replace(MULTI_SPACE, " "); + t = t.replace(MULTI_NL, "\n\n"); + t = t.trim(); + } + return t; +} + +async function* readPplxSseEvents(body, signal) { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let dataLines = []; + + function flush() { + if (dataLines.length === 0) return null; + const payload = dataLines.join("\n"); + dataLines = []; + const trimmed = payload.trim(); + if (!trimmed || trimmed === "[DONE]") return "done"; + try { return JSON.parse(trimmed); } catch { return null; } + } + + try { + while (true) { + if (signal?.aborted) return; + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + while (true) { + const idx = buffer.indexOf("\n"); + if (idx < 0) break; + const rawLine = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + if (line === "") { + const parsed = flush(); + if (parsed === "done") return; + if (parsed) yield parsed; + continue; + } + if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart()); + if (line === "event: end_of_stream") return; + } + } + buffer += decoder.decode(); + if (buffer.trim().startsWith("data:")) dataLines.push(buffer.trim().slice(5).trimStart()); + const tail = flush(); + if (tail && tail !== "done") yield tail; + } finally { + reader.releaseLock(); + } +} + +function parseOpenAIMessages(messages) { + let systemMsg = ""; + const history = []; + for (const msg of messages) { + let role = String(msg.role || "user"); + if (role === "developer") role = "system"; + let content = ""; + if (typeof msg.content === "string") content = msg.content; + else if (Array.isArray(msg.content)) { + content = msg.content.filter((c) => c.type === "text").map((c) => String(c.text || "")).join(" "); + } + if (!content.trim()) continue; + if (role === "system") systemMsg += content + "\n"; + else if (role === "user" || role === "assistant") history.push({ role, content }); + } + let currentMsg = ""; + if (history.length > 0 && history[history.length - 1].role === "user") { + currentMsg = history.pop().content; + } + return { systemMsg, history, currentMsg }; +} + +function buildPplxRequestBody(query, mode, modelPref, followUpUuid) { + const tz = typeof Intl !== "undefined" ? Intl.DateTimeFormat().resolvedOptions().timeZone : "UTC"; + return { + query_str: query, + params: { + query_str: query, + search_focus: "internet", + mode, + model_preference: modelPref, + sources: ["web"], + attachments: [], + frontend_uuid: crypto.randomUUID(), + frontend_context_uuid: crypto.randomUUID(), + version: PPLX_API_VERSION, + language: "en-US", + timezone: tz, + search_recency_filter: null, + is_incognito: true, + use_schematized_api: true, + last_backend_uuid: followUpUuid, + }, + }; +} + +function formatToolsHint(tools) { + if (!Array.isArray(tools) || tools.length === 0) return ""; + const lines = tools.map((t) => { + const fn = t?.function || t || {}; + const name = fn.name || "unnamed"; + const desc = (fn.description || "").split("\n")[0].slice(0, 200); + return `- ${name}: ${desc}`; + }); + return `Available tools (reference only, cannot invoke):\n${lines.join("\n")}`; +} + +function buildQuery(parsed, followUpUuid, tools) { + if (followUpUuid) return parsed.currentMsg; + const obj = {}; + const instr = []; + if (parsed.systemMsg.trim()) instr.push(parsed.systemMsg.trim()); + const toolsHint = formatToolsHint(tools); + if (toolsHint) instr.push(toolsHint); + instr.push("You have built-in web search. Answer questions directly using search results."); + obj.instructions = instr; + if (parsed.history.length > 0) obj.history = parsed.history; + if (parsed.currentMsg) obj.query = parsed.currentMsg; + else if (parsed.history.length === 0) obj.query = ""; + const json = JSON.stringify(obj); + return json.length > 96000 ? json.slice(-96000) : json; +} + +async function* extractContent(eventStream, signal) { + let fullAnswer = ""; + let backendUuid = null; + let seenLen = 0; + const seenThinking = new Set(); + + for await (const event of readPplxSseEvents(eventStream, signal)) { + if (event.error_code || event.error_message) { + yield { error: event.error_message || `Perplexity error: ${event.error_code}`, done: true }; + return; + } + if (event.backend_uuid) backendUuid = event.backend_uuid; + + const blocks = event.blocks ?? []; + for (const block of blocks) { + const usage = block.intended_usage ?? ""; + + if (usage === "pro_search_steps" && block.plan_block?.steps) { + for (const step of block.plan_block.steps) { + if (step.step_type === "SEARCH_WEB") { + for (const q of step.search_web_content?.queries ?? []) { + const qr = q.query ?? ""; + if (qr && !seenThinking.has(qr)) { + seenThinking.add(qr); + yield { thinking: `Searching: ${qr}`, backendUuid: backendUuid ?? undefined }; + } + } + } else if (step.step_type === "READ_RESULTS") { + for (const u of (step.read_results_content?.urls ?? []).slice(0, 3)) { + if (u && !seenThinking.has(u)) { + seenThinking.add(u); + yield { thinking: `Reading: ${u}`, backendUuid: backendUuid ?? undefined }; + } + } + } + } + } + + if (usage === "plan" && block.plan_block?.goals) { + for (const goal of block.plan_block.goals) { + const desc = goal.description ?? ""; + if (desc && !seenThinking.has(desc)) { + seenThinking.add(desc); + yield { thinking: desc, backendUuid: backendUuid ?? undefined }; + } + } + } + + if (!usage.includes("markdown")) continue; + const mb = block.markdown_block; + if (!mb) continue; + const chunks = mb.chunks ?? []; + if (chunks.length === 0) continue; + + if (mb.progress === "DONE") { + fullAnswer = chunks.join(""); + } else { + const chunkText = chunks.join(""); + const cumulative = fullAnswer + chunkText; + if (cumulative.length > seenLen) { + const delta = cumulative.slice(seenLen); + fullAnswer = cumulative; + seenLen = cumulative.length; + yield { delta, answer: fullAnswer, backendUuid: backendUuid ?? undefined }; + } + } + } + + if (blocks.length === 0 && event.text) { + const t = event.text.trim(); + if (t.length > seenLen) { + const delta = t.slice(seenLen); + fullAnswer = t; + seenLen = t.length; + yield { delta, answer: fullAnswer, backendUuid: backendUuid ?? undefined }; + } + } + + if (event.final || event.status === "COMPLETED") break; + } + yield { delta: "", answer: fullAnswer, backendUuid: backendUuid ?? undefined, done: true }; +} + +function buildStreamingResponse(eventStream, model, cid, created, history, currentMsg, signal) { + const encoder = new TextEncoder(); + return new ReadableStream({ + async start(controller) { + try { + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null, logprobs: null }], + }))); + + let fullAnswer = ""; + let respBackendUuid = null; + + for await (const chunk of extractContent(eventStream, signal)) { + if (chunk.backendUuid) respBackendUuid = chunk.backendUuid; + if (chunk.error) { + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, + choices: [{ index: 0, delta: { content: `[Error: ${chunk.error}]` }, finish_reason: null, logprobs: null }], + }))); + break; + } + if (chunk.thinking) { + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, + choices: [{ index: 0, delta: { reasoning_content: chunk.thinking + "\n" }, finish_reason: null, logprobs: null }], + }))); + continue; + } + if (chunk.done) { fullAnswer = chunk.answer || fullAnswer; break; } + let dt = chunk.delta || ""; + if (dt) { + dt = cleanResponse(dt, false); + if (dt) { + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, + choices: [{ index: 0, delta: { content: dt }, finish_reason: null, logprobs: null }], + }))); + } + } + if (chunk.answer) fullAnswer = chunk.answer; + } + + controller.enqueue(encoder.encode(sseChunk({ + id: cid, object: "chat.completion.chunk", created, model, system_fingerprint: null, + choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }], + }))); + controller.enqueue(encoder.encode(SSE_DONE)); + + sessionStore(history, currentMsg, cleanResponse(fullAnswer), respBackendUuid); + } 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(SSE_DONE)); + } finally { + controller.close(); + } + }, + }); +} + +async function buildNonStreamingResponse(eventStream, model, cid, created, history, currentMsg, signal) { + let fullAnswer = ""; + let respBackendUuid = null; + const thinkingParts = []; + + for await (const chunk of extractContent(eventStream, signal)) { + if (chunk.backendUuid) respBackendUuid = chunk.backendUuid; + if (chunk.error) { + return new Response(JSON.stringify({ + error: { message: chunk.error, type: "upstream_error", code: "PPLX_ERROR" }, + }), { status: 502, headers: { "Content-Type": "application/json" } }); + } + if (chunk.thinking) { thinkingParts.push(chunk.thinking); continue; } + if (chunk.done) { fullAnswer = chunk.answer || fullAnswer; break; } + if (chunk.answer) fullAnswer = chunk.answer; + } + + fullAnswer = cleanResponse(fullAnswer); + sessionStore(history, currentMsg, fullAnswer, respBackendUuid); + + const reasoningContent = thinkingParts.length > 0 ? thinkingParts.join("\n") : undefined; + const msg = { role: "assistant", content: fullAnswer }; + if (reasoningContent) msg.reasoning_content = reasoningContent; + + const promptTokens = Math.ceil(currentMsg.length / 4); + const completionTokens = Math.ceil(fullAnswer.length / 4); + + return new Response(JSON.stringify({ + id: cid, object: "chat.completion", created, model, system_fingerprint: null, + choices: [{ index: 0, message: msg, finish_reason: "stop", logprobs: null }], + usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: promptTokens + completionTokens }, + }), { status: 200, headers: { "Content-Type": "application/json" } }); +} + +export class PerplexityWebExecutor extends BaseExecutor { + constructor() { + super("perplexity-web", PROVIDERS["perplexity-web"]); + } + + async execute({ model, body, stream, credentials, signal, log }) { + const messages = body?.messages; + if (!messages || !Array.isArray(messages) || messages.length === 0) { + const errResp = new Response(JSON.stringify({ + error: { message: "Missing or empty messages array", type: "invalid_request" }, + }), { status: 400, headers: { "Content-Type": "application/json" } }); + return { response: errResp, url: PPLX_SSE_ENDPOINT, headers: {}, transformedBody: body }; + } + + const thinking = body?.thinking === true || (body?.reasoning_effort != null && body.reasoning_effort !== "none"); + + let pplxMode; + let modelPref; + if (thinking && THINKING_MAP[model]) { + pplxMode = "copilot"; + modelPref = THINKING_MAP[model]; + log?.info?.("PPLX-WEB", `Thinking mode → ${model} using ${modelPref}`); + } else if (MODEL_MAP[model]) { + [pplxMode, modelPref] = MODEL_MAP[model]; + } else { + pplxMode = "copilot"; + modelPref = model; + log?.info?.("PPLX-WEB", `Unmapped model ${model}, using as raw preference`); + } + + const parsed = parseOpenAIMessages(messages); + const followUpUuid = sessionLookup(parsed.history); + if (followUpUuid) log?.info?.("PPLX-WEB", `Session continue: ${followUpUuid.slice(0, 12)}...`); + + const query = buildQuery(parsed, followUpUuid, body?.tools); + if (!query.trim()) { + const errResp = new Response(JSON.stringify({ + error: { message: "Empty query after processing", type: "invalid_request" }, + }), { status: 400, headers: { "Content-Type": "application/json" } }); + return { response: errResp, url: PPLX_SSE_ENDPOINT, headers: {}, transformedBody: body }; + } + + const pplxBody = buildPplxRequestBody(query, pplxMode, modelPref, followUpUuid); + + const headers = { + "Content-Type": "application/json", + Accept: "text/event-stream", + Origin: "https://www.perplexity.ai", + Referer: "https://www.perplexity.ai/", + "User-Agent": PPLX_USER_AGENT, + "X-App-ApiClient": "default", + "X-App-ApiVersion": PPLX_API_VERSION, + }; + + if (credentials.accessToken) { + headers["Authorization"] = `Bearer ${credentials.accessToken}`; + } else if (credentials.apiKey) { + headers["Cookie"] = `__Secure-next-auth.session-token=${credentials.apiKey}`; + } + + log?.info?.("PPLX-WEB", `Query to ${model} (pref=${modelPref}, mode=${pplxMode}), len=${query.length}`); + + const fetchOptions = { method: "POST", headers, body: JSON.stringify(pplxBody) }; + if (signal) fetchOptions.signal = signal; + + let response; + try { + response = await fetch(PPLX_SSE_ENDPOINT, fetchOptions); + } catch (err) { + log?.error?.("PPLX-WEB", `Fetch failed: ${err.message || String(err)}`); + const errResp = new Response(JSON.stringify({ + error: { message: `Perplexity connection failed: ${err.message || String(err)}`, type: "upstream_error" }, + }), { status: 502, headers: { "Content-Type": "application/json" } }); + return { response: errResp, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody }; + } + + if (!response.ok) { + const status = response.status; + let errMsg = `Perplexity returned HTTP ${status}`; + if (status === 401 || status === 403) errMsg = "Perplexity auth failed — session cookie may be expired. Re-paste your __Secure-next-auth.session-token."; + else if (status === 429) errMsg = "Perplexity rate limited. Wait a moment and retry."; + log?.warn?.("PPLX-WEB", errMsg); + const errResp = new Response(JSON.stringify({ + error: { message: errMsg, type: "upstream_error", code: `HTTP_${status}` }, + }), { status, headers: { "Content-Type": "application/json" } }); + return { response: errResp, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody }; + } + + if (!response.body) { + const errResp = new Response(JSON.stringify({ + error: { message: "Perplexity returned empty response body", type: "upstream_error" }, + }), { status: 502, headers: { "Content-Type": "application/json" } }); + return { response: errResp, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody }; + } + + const cid = `chatcmpl-pplx-${crypto.randomUUID().slice(0, 12)}`; + const created = Math.floor(Date.now() / 1000); + + let finalResponse; + if (stream) { + const sseStream = buildStreamingResponse(response.body, model, cid, created, parsed.history, parsed.currentMsg, signal); + finalResponse = new Response(sseStream, { + status: 200, + headers: { ...SSE_HEADERS_NO_BUFFER }, + }); + } else { + finalResponse = await buildNonStreamingResponse(response.body, model, cid, created, parsed.history, parsed.currentMsg, signal); + } + return { response: finalResponse, url: PPLX_SSE_ENDPOINT, headers, transformedBody: pplxBody }; + } +} + +export { parseOpenAIMessages, buildQuery, buildPplxRequestBody, formatToolsHint, sessionKey }; + +export default PerplexityWebExecutor; diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.js new file mode 100644 index 0000000000000000000000000000000000000000..2a7714b3c5ac2a6b4905655e48ec036995621535 --- /dev/null +++ b/open-sse/executors/qoder.js @@ -0,0 +1,458 @@ +/** + * QoderExecutor — sends OpenAI-format chat requests to Qoder's COSY-signed + * inference endpoint at api3.qoder.sh, then unwraps Qoder's `{statusCodeValue, + * body}` SSE envelope back into plain OpenAI SSE for the rest of the pipeline. + * + * Differences vs the previous placeholder: + * - URL is api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation + * with `&Encode=1` so we can ship the body through the WAF-bypass + * encoder. + * - Authentication is COSY (RSA + AES + MD5 + ~17 Cosy-* headers), not + * a static HMAC. + * - The request shape Qoder expects is non-trivial (chat_context with + * mirrored modelConfig, business block with stable IDs, system text + * hoisted out of the messages array). All ported from the reference. + * - Model identifier is one of the canonical Qoder keys (auto / ultimate / + * performance / efficient / lite + frontier "*model" ids); the + * translator layer feeds us "qoder/" so we strip the prefix. + * - Per-model `model_config` is fetched live from /algo/api/v2/model/list + * and cached. Sending the wrong block silently downgrades to a + * different model upstream, so a missing entry is a hard error. + */ + +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 "../shared/qoder/constants.js"; +import { getQoderModelConfig, resolveQoderModels } from "../services/qoderModels.js"; + +/** + * Hoist role:"system" messages out of the messages array (Qoder rejects + * system in messages) and flatten any multipart content arrays. + */ +function normalizeMessages(messages) { + if (!Array.isArray(messages) || messages.length === 0) { + return { messages: [], systemText: "" }; + } + const systemParts = []; + const out = []; + for (const msg of messages) { + if (!msg || typeof msg !== "object") continue; + const text = extractText(msg.content); + if (msg.role === "system") { + if (text) systemParts.push(text); + continue; + } + const cloned = { ...msg }; + cloned.content = text; + out.push(cloned); + } + return { messages: out, systemText: systemParts.join("\n\n") }; +} + +function extractText(content) { + if (typeof content === "string") return content; + if (content == null) return ""; + if (Array.isArray(content)) { + const parts = []; + for (const item of content) { + if (item && typeof item === "object") { + if (item.type === "text" && typeof item.text === "string") { + parts.push(item.text); + } else if (typeof item.text === "string") { + parts.push(item.text); + } + } + } + return parts.join("\n"); + } + return String(content); +} + +function lastUserText(messages) { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m?.role === "user" && typeof m.content === "string") { + return m.content; + } + } + return ""; +} + +function stableHash(prefix, ...parts) { + const h = createHash("sha256"); + h.update(prefix); + for (const p of parts) { + h.update("\0"); + h.update(String(p ?? "")); + } + return h.digest("hex").slice(0, 16); +} + +function stableChatRecordId(model, messages, tools, maxTokens) { + const h = createHash("sha256"); + h.update("qoder-record\0"); + h.update(String(model)); + for (const m of messages) { + if (!m || typeof m !== "object") continue; + if (m.role) { h.update("\0"); h.update(m.role); } + if (typeof m.content === "string" && m.content) { + h.update("\0"); h.update(m.content); + } + } + if (tools) { + h.update("\0"); + try { h.update(JSON.stringify(tools)); } catch {} + } + h.update(`\0mt=${maxTokens}`); + return h.digest("hex").slice(0, 16); +} + +function truncate(s, n) { + return s && s.length > n ? `${s.slice(0, n)}...` : s || ""; +} + +/** + * Map the OpenAI-style request body into the exact shape Qoder expects. + */ +async function buildQoderRequestBody({ model, body, credentials, log, proxyOptions, signal }) { + const qoderKey = String(model || "").replace(/^qoder\//, ""); + + // Fetch model config from dynamic API instead of relying on static QODER_MODEL_MAP. + // This allows support for new Qoder models (e.g., qmodel_latest) without code changes. + let modelConfig = await getQoderModelConfig(credentials, qoderKey, { log, proxyOptions, signal }); + if (!modelConfig) { + // Try a forced refresh once before giving up — the cache may simply + // not be populated yet on first ever call for this credential. + const refreshed = await resolveQoderModels(credentials, { forceRefresh: true, log, proxyOptions, signal }); + const retried = refreshed?.rawConfigs.get(qoderKey); + if (!retried) { + throw new Error( + `qoder: model_config for "${qoderKey}" not yet known (run a model list fetch or check upstream connectivity)`, + ); + } + modelConfig = { ...retried, key: qoderKey }; + } + + const { messages, systemText } = normalizeMessages(body.messages || []); + const tools = body.tools; + const isReasoning = !!modelConfig.is_reasoning; + const maxOutputTokens = Number(modelConfig.max_output_tokens) || 0; + + let maxTokens = 32_768; + if (maxOutputTokens > 0) maxTokens = maxOutputTokens; + if (typeof body.max_tokens === "number" && body.max_tokens > 0 && body.max_tokens < maxTokens) { + maxTokens = body.max_tokens; + } + if (typeof body.max_completion_tokens === "number" && body.max_completion_tokens > 0 && body.max_completion_tokens < maxTokens) { + maxTokens = body.max_completion_tokens; + } + + const lastUser = lastUserText(messages); + const psd = credentials.providerSpecificData || {}; + const sessionId = stableHash("qoder-session", psd.userId, qoderKey); + const recordId = stableChatRecordId(qoderKey, messages, tools, maxTokens); + + return { + qoderKey, + payload: { + request_id: uuidv4(), + request_set_id: recordId, + chat_record_id: recordId, + session_id: sessionId, + stream: true, + chat_task: "FREE_INPUT", + is_reply: true, + is_retry: false, + source: 1, + version: "3", + session_type: "qodercli", + agent_id: "agent_common", + task_id: "common", + code_language: "", + chat_prompt: "", + image_urls: null, + aliyun_user_type: "", + system: systemText, + messages, + tools: Array.isArray(tools) ? tools : [], + parameters: { max_tokens: maxTokens }, + chat_context: { + chatPrompt: "", + imageUrls: null, + extra: { + context: [], + modelConfig: { key: qoderKey, is_reasoning: isReasoning }, + originalContent: lastUser, + }, + features: [], + text: lastUser, + }, + model_config: modelConfig, + business: { + product: "cli", + version: "1.0.0", + type: "agent", + stage: "start", + id: uuidv4(), + name: truncate(lastUser, 30), + begin_at: Date.now(), + }, + }, + modelConfig, + }; +} + +/** + * Wrap the upstream's `{statusCodeValue, body}` SSE envelope into plain + * OpenAI SSE chunks the rest of the chatCore pipeline understands. + * + * Each upstream line looks like: + * data: {"statusCodeValue":200,"body":"{\"choices\":[{\"delta\":{...}}]}"} + * The inner body is an OpenAI streaming chunk (or "[DONE]"). We unwrap it + * and re-emit as `data: \n\n`. Errors become `data: [DONE]\n\n` plus + * a synthetic OpenAI error chunk. + */ +function wrapQoderSSE(response, model) { + if (!response.ok || !response.body) return response; + + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let buffer = ""; + let doneEmitted = false; + + // Process one already-extracted SSE line (no trailing newline). Returns + // false when the line indicated end-of-stream so the caller can stop + // forwarding any remaining chunks after [DONE]. + const processLine = (line, controller) => { + const trimmed = line.replace(/\r$/, "").trim(); + if (!trimmed) return; + if (!trimmed.startsWith("data:")) return; + if (doneEmitted) return; // never forward chunks past stream end + + const data = trimmed.slice(5).trimStart(); + if (data === "[DONE]") { + controller.enqueue(encoder.encode(SSE_DONE)); + doneEmitted = true; + return; + } + + let envelope; + try { envelope = JSON.parse(data); } catch { return; } + const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200; + const inner = typeof envelope.body === "string" ? envelope.body : ""; + if (statusVal !== 200) { + const msg = inner || `upstream status ${statusVal}`; + const errChunk = JSON.stringify({ + id: `qoder-error-${Date.now()}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + 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(SSE_DONE)); + doneEmitted = true; + return; + } + if (!inner) return; + if (inner === "[DONE]") { + controller.enqueue(encoder.encode(SSE_DONE)); + doneEmitted = true; + return; + } + // Inner is an OpenAI-shaped chunk. Strip any embedded newlines so the + // SSE frame stays a single event (a literal "\n" inside `inner` would + // otherwise split the frame across multiple data: lines and downstream + // parsers would reassemble them as separate events). + const sanitized = inner.replace(/\r?\n/g, ""); + controller.enqueue(encoder.encode(`data: ${sanitized}\n\n`)); + }; + + const transform = new TransformStream({ + transform(chunk, controller) { + buffer += decoder.decode(chunk, { stream: true }); + let nl; + while ((nl = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + processLine(line, controller); + } + }, + flush(controller) { + // Finalize the decoder so any pending multi-byte sequence is + // released into `buffer` instead of being silently dropped. + buffer += decoder.decode(); + // Drain any trailing line that arrived without a terminating newline + // (e.g. upstream closed the socket immediately after the last write, + // or a CDN stripped the final CRLF). Without this, the chunk that + // carries finish_reason is silently lost. + if (buffer.length > 0) { + processLine(buffer, controller); + buffer = ""; + } + if (!doneEmitted) { + controller.enqueue(encoder.encode(SSE_DONE)); + doneEmitted = true; + } + }, + }); + + const transformed = response.body.pipeThrough(transform); + // Build a Response with passable headers; the streaming handler reads + // `.body` as a ReadableStream regardless of Content-Type. + return new Response(transformed, { + status: response.status, + statusText: response.statusText, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + }, + }); +} + +export class QoderExecutor extends BaseExecutor { + constructor() { + super("qoder", PROVIDERS.qoder); + } + + buildUrl() { + return QODER_CHAT_URL_ENCODED; + } + + // Override execute entirely — Qoder needs: + // - body built from translated chat completion payload + // - body encoded with QoderEncodeBody before signing + // - COSY headers built from the *encoded* body bytes + // - response stream re-wrapped from {statusCodeValue, body} to OpenAI SSE + async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + const url = this.buildUrl(); + + const psd = credentials?.providerSpecificData || {}; + if (!psd.userId) { + // No user id → no way to sign. Surface a 401 so the dashboard nudges + // the user back to OAuth. + const fakeResp = new Response( + JSON.stringify({ error: { message: "qoder credential is missing userId; reconnect the account" } }), + { status: 401, headers: { "Content-Type": "application/json" } }, + ); + return { response: fakeResp, url, headers: {}, transformedBody: body }; + } + if (!credentials?.accessToken) { + // Same shape as the userId guard — clean 401 so chatCore reports + // "reconnect" rather than bubbling cosy.js's synchronous throw as 500. + const fakeResp = new Response( + JSON.stringify({ error: { message: "qoder credential is missing accessToken; reconnect the account" } }), + { status: 401, headers: { "Content-Type": "application/json" } }, + ); + return { response: fakeResp, url, headers: {}, transformedBody: body }; + } + + let qoderKey; + let payload; + try { + ({ qoderKey, payload } = await buildQoderRequestBody({ model, body, credentials, log, proxyOptions, signal })); + } catch (err) { + const fakeResp = new Response( + JSON.stringify({ error: { message: err.message } }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + return { response: fakeResp, url, headers: {}, transformedBody: body }; + } + + const plainBody = Buffer.from(JSON.stringify(payload), "utf8"); + const encodedBodyStr = qoderEncodeBody(plainBody); + const encodedBodyBuf = Buffer.from(encodedBodyStr, "latin1"); + + let cosyHeaders; + try { + cosyHeaders = buildCosyHeaders( + encodedBodyBuf, + url, + { + userId: psd.userId, + authToken: credentials.accessToken, + name: credentials.displayName || "", + email: credentials.email || "", + machineId: psd.machineId || "", + }, + ); + } catch (err) { + // cosy.js throws synchronously on missing userId/authToken — surface + // as 401 so chatCore prompts re-auth instead of returning a 500. + const fakeResp = new Response( + JSON.stringify({ error: { message: `qoder cosy signing failed: ${err.message}` } }), + { status: 401, headers: { "Content-Type": "application/json" } }, + ); + return { response: fakeResp, url, headers: {}, transformedBody: body }; + } + + const modelSource = (payload.model_config && payload.model_config.source) || "system"; + const headers = { + "Content-Type": "application/json", + Accept: "text/event-stream", + "Cache-Control": "no-cache", + "X-Model-Key": qoderKey, + "X-Model-Source": modelSource, + // gzip triggers signature validation on Qoder's CDN; force identity. + "Accept-Encoding": "identity", + ...cosyHeaders, + }; + + // Abort if upstream doesn't return response headers within connect timeout. + const timeoutMs = this.config?.timeoutMs || FETCH_CONNECT_TIMEOUT_MS; + const connectCtrl = new AbortController(); + const connectTimer = setTimeout(() => connectCtrl.abort(new Error("fetch connect timeout")), timeoutMs); + const mergedSignal = signal ? AbortSignal.any([signal, connectCtrl.signal]) : connectCtrl.signal; + + let response; + try { + response = await proxyAwareFetch( + url, + { method: "POST", headers, body: encodedBodyBuf, signal: mergedSignal }, + proxyOptions, + ); + } finally { + clearTimeout(connectTimer); + } + + if (!response.ok) { + // Pass error response through unchanged so chatCore can capture it. + return { response, url, headers, transformedBody: payload }; + } + + const wrapped = wrapQoderSSE(response, `qoder/${qoderKey}`); + return { response: wrapped, url, headers, transformedBody: payload }; + } + + // Qoder device tokens don't refresh through OAuth — the upstream returns + // 403 for our flow. Surfacing failure via 401-on-chat is enough; the + // dashboard tells users to re-login when their token expires (~30 days). + async refreshCredentials() { + return null; + } + + needsRefresh() { + return false; + } +} + +export default QoderExecutor; + +// Internals exposed for unit tests. Not part of the public API — callers +// should import QoderExecutor and use its public methods. +export const __test__ = { + normalizeMessages, + wrapQoderSSE, + buildQoderRequestBody, +}; diff --git a/open-sse/executors/qwen.js b/open-sse/executors/qwen.js new file mode 100644 index 0000000000000000000000000000000000000000..ae828118ce0b859c25aa63b1b912fd4e74082773 --- /dev/null +++ b/open-sse/executors/qwen.js @@ -0,0 +1,129 @@ +import { DefaultExecutor } from "./default.js"; +import { PROVIDERS } from "../config/providers.js"; +import { OAUTH_ENDPOINTS } from "../config/appConstants.js"; + +/** portal.qwen.ai — static fingerprint matching stable Qwen Code release */ +const QWEN_USER_AGENT = "QwenCode/0.12.3 (linux; x64)"; +const QWEN_STAINLESS = { + os: "Linux", + arch: "x64", + lang: "js", + runtime: "node", + runtimeVersion: "v18.19.1", + packageVersion: "5.11.0", + retryCount: "1" +}; +const QWEN_DEFAULT_SYSTEM_MESSAGE = { + role: "system", + content: [{ type: "text", text: "", cache_control: { type: "ephemeral" } }] +}; + +function ensureQwenSystemMessage(body) { + if (!body || typeof body !== "object") return body; + const next = { ...body }; + if (Array.isArray(next.messages)) { + next.messages = [QWEN_DEFAULT_SYSTEM_MESSAGE, ...next.messages]; + } else { + next.messages = [QWEN_DEFAULT_SYSTEM_MESSAGE]; + } + return next; +} + +function isQwenThinkingActive(body) { + const thinking = body?.thinking; + if (thinking === true || body?.enable_thinking === true) return true; + return typeof thinking === "object" && thinking !== null && !Array.isArray(thinking) && thinking.type === "enabled"; +} + +// Qwen rejects tool_choice="required" or object forms when thinking is active; neutralize to "auto". +function sanitizeQwenThinkingToolChoice(body) { + if (!isQwenThinkingActive(body)) return body; + const tc = body.tool_choice; + const incompatible = tc === "required" || (typeof tc === "object" && tc !== null); + if (!incompatible) return body; + return { ...body, tool_choice: "auto" }; +} + +function buildQwenUpstreamHeaders(credentials, stream = true) { + const token = credentials?.apiKey || credentials?.accessToken || ""; + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "User-Agent": QWEN_USER_AGENT, + "X-DashScope-AuthType": "qwen-oauth", + "X-DashScope-CacheControl": "enable", + "X-DashScope-UserAgent": QWEN_USER_AGENT, + "X-Stainless-Arch": QWEN_STAINLESS.arch, + "X-Stainless-Lang": QWEN_STAINLESS.lang, + "X-Stainless-Os": QWEN_STAINLESS.os, + "X-Stainless-Package-Version": QWEN_STAINLESS.packageVersion, + "X-Stainless-Retry-Count": QWEN_STAINLESS.retryCount, + "X-Stainless-Runtime": QWEN_STAINLESS.runtime, + "X-Stainless-Runtime-Version": QWEN_STAINLESS.runtimeVersion, + Connection: "keep-alive", + "Accept-Language": "*", + "Sec-Fetch-Mode": "cors" + }; + headers.Accept = stream ? "text/event-stream" : "application/json"; + return headers; +} + +export class QwenExecutor extends DefaultExecutor { + constructor() { + super("qwen"); + } + + // Qwen tokens are bound to a resource_url returned at OAuth time. + // Using portal.qwen.ai when the token is issued for another shard returns 401/403. + buildUrl(model, stream, urlIndex = 0, credentials = null) { + const resourceUrl = credentials?.providerSpecificData?.resourceUrl; + const host = resourceUrl ? resourceUrl.replace(/^https?:\/\//, "").replace(/\/$/, "") : "portal.qwen.ai"; + return `https://${host}/v1/chat/completions`; + } + + buildHeaders(credentials, stream = true) { + return buildQwenUpstreamHeaders(credentials, stream); + } + + transformRequest(model, body, stream, credentials) { + let next = body && typeof body === "object" ? { ...body } : body; + if (stream && next?.messages && !next.stream_options && !next.thinking && !next.enable_thinking && next.stream !== false) { + next.stream_options = { include_usage: true }; + } + next = sanitizeQwenThinkingToolChoice(next); + return ensureQwenSystemMessage(next); + } + + // Override to capture resource_url from refresh response (required for buildUrl). + async refreshCredentials(credentials, log) { + if (!credentials?.refreshToken) return null; + try { + const response = await fetch(OAUTH_ENDPOINTS.qwen.token, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: credentials.refreshToken, + client_id: PROVIDERS.qwen.clientId + }) + }); + if (!response.ok) return null; + const tokens = await response.json(); + log?.info?.("TOKEN", "qwen refreshed"); + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || credentials.refreshToken, + expiresIn: tokens.expires_in, + providerSpecificData: { + ...(credentials.providerSpecificData || {}), + ...(tokens.resource_url ? { resourceUrl: tokens.resource_url } : {}) + } + }; + } catch (error) { + log?.error?.("TOKEN", `qwen refresh error: ${error.message}`); + return null; + } + } +} + +export default QwenExecutor; diff --git a/open-sse/executors/vertex.js b/open-sse/executors/vertex.js new file mode 100644 index 0000000000000000000000000000000000000000..4cea2ad8452a73aa6a9297c46dc5a0ad32981a39 --- /dev/null +++ b/open-sse/executors/vertex.js @@ -0,0 +1,177 @@ +import { BaseExecutor } from "./base.js"; +import { PROVIDERS } from "../config/providers.js"; +import { parseVertexSaJson, refreshVertexToken, refreshGoogleToken } from "../services/tokenRefresh.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; + +// Cache project IDs resolved from raw API keys { apiKey → projectId } +const projectIdCache = new Map(); + +/** + * Parse Google ADC user credential JSON from apiKey string. + * This is the format produced by `gcloud auth application-default login`. + */ +function parseVertexAdcJson(apiKey) { + if (typeof apiKey !== "string") return null; + try { + const parsed = JSON.parse(apiKey); + if ( + parsed.type === "authorized_user" && + parsed.client_id && + parsed.client_secret && + parsed.refresh_token + ) { + return parsed; + } + return null; + } catch { + return null; + } +} + +/** + * Resolve GCP project ID from a raw Vertex API key. + * Sends a dummy 404 request and parses "projects/{id}" from the error message. + */ +async function resolveProjectId(apiKey) { + if (projectIdCache.has(apiKey)) return projectIdCache.get(apiKey); + + const res = await fetch( + `https://aiplatform.googleapis.com/v1/publishers/google/models/__probe__:generateContent?key=${apiKey}`, + { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" } + ); + const json = await res.json().catch(() => null); + const msg = json?.[0]?.error?.message || json?.error?.message || ""; + const match = msg.match(/projects\/([^/]+)\//); + const projectId = match?.[1] || null; + + if (projectId) projectIdCache.set(apiKey, projectId); + return projectId; +} + +/** + * VertexExecutor - Google Cloud Vertex AI + * + * "vertex" → Gemini models via regional/global Vertex endpoint + * "vertex-partner" → Partner models (Llama, Mistral, GLM, DeepSeek, Qwen) + * via global OpenAI-compatible endpoint + * + * Auth: SA JSON (stored as apiKey) → JWT assertion → Bearer token (via jose) + * Token is minted/cached in tokenRefresh.js, not here. + */ +export class VertexExecutor extends BaseExecutor { + constructor(providerId = "vertex") { + super(providerId, PROVIDERS[providerId] || {}); + } + + buildUrl(model, stream, urlIndex = 0, credentials = null) { + const saJson = parseVertexSaJson(credentials?.apiKey); + const adcJson = parseVertexAdcJson(credentials?.apiKey); + const usesOAuth = !!saJson || !!adcJson || !!credentials?.accessToken; + const rawKey = !usesOAuth ? credentials?.apiKey : null; + const projectId = + saJson?.project_id || + adcJson?.quota_project_id || + credentials?.providerSpecificData?.projectId; + + if (this.provider === "vertex-partner") { + // Partner models require project_id in path regardless of auth method + if (!projectId) throw new Error("Vertex partner models require a project_id. Add it in providerSpecificData or use Service Account JSON."); + const url = `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/global/endpoints/openapi/chat/completions`; + return rawKey ? `${url}?key=${rawKey}` : url; + } + + // Gemini on Vertex + const action = stream ? "streamGenerateContent" : "generateContent"; + + if (usesOAuth) { + // SA JSON / ADC / pre-set accessToken: must use project-scoped path to avoid RESOURCE_PROJECT_INVALID + if (!projectId) { + throw new Error( + "Vertex OAuth/ADC requires a project_id. " + + "Add quota_project_id to your ADC JSON or set providerSpecificData.projectId." + ); + } + const location = credentials?.providerSpecificData?.location || "us-central1"; + let url = `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${location}/publishers/google/models/${model}:${action}`; + if (stream) url += "?alt=sse"; + return url; + } + + // Raw API key: use global publishers endpoint with ?key= param + // ?alt=sse is required for proper SSE streaming (matches every other Gemini executor) + let url = `https://aiplatform.googleapis.com/v1/publishers/google/models/${model}:${action}`; + if (stream) url += "?alt=sse"; + if (rawKey) url += stream ? `&key=${rawKey}` : `?key=${rawKey}`; + return url; + } + + buildHeaders(credentials, stream = true) { + const headers = { "Content-Type": "application/json" }; + + // Only set Bearer token if using SA JSON flow (raw key goes in URL ?key=) + if (credentials.accessToken) { + headers["Authorization"] = `Bearer ${credentials.accessToken}`; + } + + if (stream) headers["Accept"] = "text/event-stream"; + + return headers; + } + + async refreshCredentials(credentials, log) { + const saJson = parseVertexSaJson(credentials?.apiKey); + if (!saJson) return null; + + const result = await refreshVertexToken(saJson, log); + if (!result) return null; + + return { accessToken: result.accessToken, expiresAt: result.expiresAt }; + } + + async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + const saJson = parseVertexSaJson(credentials?.apiKey); + const adcJson = parseVertexAdcJson(credentials?.apiKey); + + // SA JSON flow: mint Bearer token via JWT assertion (cached) + if (saJson) { + const result = await refreshVertexToken(saJson, log); + if (!result?.accessToken) throw new Error("Vertex: failed to mint access token from Service Account JSON"); + credentials.accessToken = result.accessToken; + } + + // ADC user credential flow: refresh Bearer token via Google OAuth2 token endpoint + if (adcJson) { + const result = await refreshGoogleToken( + adcJson.refresh_token, + adcJson.client_id, + adcJson.client_secret, + log + ); + if (!result?.accessToken) throw new Error("Vertex: failed to refresh access token from ADC JSON (authorized_user)"); + credentials.accessToken = result.accessToken; + } + + // vertex-partner with raw key: auto-resolve project_id if not provided + if (this.provider === "vertex-partner" && !saJson && !adcJson && !credentials?.providerSpecificData?.projectId) { + const projectId = await resolveProjectId(credentials.apiKey); + if (!projectId) throw new Error("Vertex: could not resolve project_id from API key. Please add it manually in provider settings."); + log?.debug?.("VERTEX", `Resolved project_id: ${projectId}`); + credentials.providerSpecificData = { ...credentials.providerSpecificData, projectId }; + } + + const url = this.buildUrl(model, stream, 0, credentials); + const headers = this.buildHeaders(credentials, stream); + const transformedBody = this.transformRequest(model, body, stream, credentials); + + const response = await proxyAwareFetch(url, { + method: "POST", + headers, + body: JSON.stringify(transformedBody), + signal, + }, proxyOptions); + + return { response, url, headers, transformedBody }; + } +} + +export default VertexExecutor; diff --git a/open-sse/executors/xiaomi-tokenplan.js b/open-sse/executors/xiaomi-tokenplan.js new file mode 100644 index 0000000000000000000000000000000000000000..a1df9142319584bb0f5bea8af701661d4bf65f03 --- /dev/null +++ b/open-sse/executors/xiaomi-tokenplan.js @@ -0,0 +1,20 @@ +import { DefaultExecutor } from "./default.js"; +import { resolveXiaomiTokenplanBaseUrl } from "../config/providers.js"; +// import { getModelTargetFormat } from "../config/providerModels.js"; +// import { FORMATS } from "../translator/formats.js"; + +export class XiaomiTokenplanExecutor extends DefaultExecutor { + constructor() { + super("xiaomi-tokenplan"); + } + + // Token Plan keys are region-specific — always OpenAI-compatible /chat/completions + buildUrl(model, stream, urlIndex = 0, credentials = null) { + const baseUrl = resolveXiaomiTokenplanBaseUrl(credentials); + // Claude-native aliases route to the Anthropic-compatible messages endpoint + // if (getModelTargetFormat(this.provider, model) === FORMATS.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 new file mode 100644 index 0000000000000000000000000000000000000000..c1a026034adc20fa8a0180d625e0c6d32fc496d3 --- /dev/null +++ b/open-sse/handlers/chatCore.js @@ -0,0 +1,306 @@ +import { detectFormat, getTargetFormat } from "../services/provider.js"; +import { translateRequest } from "../translator/index.js"; +import { FORMATS } from "../translator/formats.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"; +import { trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; +import { getExecutor } from "../executors/index.js"; +import { buildRequestDetail, extractRequestConfig } from "./chatCore/requestDetail.js"; +import { handleForcedSSEToJson } from "./chatCore/sseToJsonHandler.js"; +import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js"; +import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.js"; +import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.js"; +import { dedupeTools } from "../utils/toolDeduper.js"; +import { injectCaveman } from "../rtk/caveman.js"; +import { compressMessages, formatRtkLog } from "../rtk/index.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 + * @param {object} options.body - Request body + * @param {object} options.modelInfo - { provider, model } + * @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 }) { + const { provider, model } = modelInfo; + const requestStartTime = Date.now(); + + const sourceFormat = sourceFormatOverride || detectFormat(body); + + // Check for bypass patterns (warmup, skip, cc naming) + const bypassResponse = handleBypassRequest(body, model, userAgent, ccFilterNaming); + if (bypassResponse) return bypassResponse; + + const alias = PROVIDER_ID_TO_ALIAS[provider] || provider; + const modelTargetFormat = getModelTargetFormat(alias, model); + const targetFormat = modelTargetFormat || getTargetFormat(provider); + const stripList = getModelStrip(alias, model); + const upstreamModel = getModelUpstreamId(alias, model); + + // Inject provider-level thinking config override (only if client hasn't set) + // on/off → extended type (body.thinking), none/low/medium/high → effort type (body.reasoning_effort) + if (providerThinking?.mode && providerThinking.mode !== "auto") { + const mode = providerThinking.mode; + if (mode === "on" && !body.thinking) { + console.log("Injecting provider-level thinking config override: on"); + body = { ...body, thinking: { type: "enabled", budget_tokens: 10000 } }; + } else if (mode === "off" && !body.thinking) { + body = { ...body, thinking: { type: "disabled" } }; + } else if (!body.reasoning_effort) { + body = { ...body, reasoning_effort: mode }; + } + } + + const clientRequestedStreaming = body.stream === true || sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI; + const providerRequiresStreaming = PROVIDERS[provider]?.forceStream === true; + let stream = providerRequiresStreaming ? true : (body.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. + const detectedTool = detectClientTool(clientRawRequest?.headers || {}, body); + if (detectedTool === "deepseek-tui" && body.stream !== true) stream = false; + + // Check client Accept header preference for non-streaming requests + // This fixes AI SDK compatibility where clients send Accept: application/json + const acceptHeader = clientRawRequest?.headers?.accept || ""; + const clientPrefersJson = acceptHeader.includes("application/json"); + const clientPrefersSSE = acceptHeader.includes("text/event-stream"); + if (clientPrefersJson && !clientPrefersSSE && body.stream !== true) { + stream = false; + } + + const reqLogger = await createRequestLogger(sourceFormat, targetFormat, model); + if (clientRawRequest) reqLogger.logClientRawRequest(clientRawRequest.endpoint, clientRawRequest.body, clientRawRequest.headers); + reqLogger.logRawRequest(body); + log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`); + + // Native passthrough: CLI tool and provider are the same ecosystem + // Skip all translation/normalization — only model and Bearer are swapped + 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) { + log?.debug?.("PASSTHROUGH", `${clientTool} → ${provider} | native lossless`); + translatedBody = { ...body, model: upstreamModel }; + // Normalize newer Cowork/CC beta shapes (adaptive thinking, mid-conversation system) the API rejects + if (clientTool === "claude") normalizeClaudePassthrough(translatedBody, upstreamModel); + } else { + translatedBody = translateRequest(sourceFormat, targetFormat, upstreamModel, body, stream, credentials, provider, reqLogger, stripList, connectionId, clientTool); + if (!translatedBody) { + trackPendingRequest(model, provider, connectionId, false, true); + return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Failed to translate request for ${sourceFormat} → ${targetFormat}`); + } + toolNameMap = translatedBody._toolNameMap; + delete translatedBody._toolNameMap; + translatedBody.model = upstreamModel; + } + + // Dedupe duplicate built-in tools when equivalent MCP tools are present (Claude clients only). + if (clientTool === "claude" && Array.isArray(translatedBody.tools)) { + const { tools: deduped, stripped } = dedupeTools(translatedBody.tools); + if (stripped.length > 0) { + translatedBody.tools = deduped; + log?.debug?.("TOOLDEDUP", `stripped ${stripped.length}: ${stripped.slice(0, 3).join(", ")}${stripped.length > 3 ? "..." : ""}`); + } + } + + // Token savers: applied at the final body just before dispatch + // Covers both passthrough (source shape) and translated (target shape) flows + const finalFormat = passthrough ? sourceFormat : targetFormat; + + // TTS models don't support tool messages/function calling + if (getModelType(alias, model) === "tts" && translatedBody.messages) { + translatedBody.messages = translatedBody.messages.filter(msg => msg.role !== "tool"); + delete translatedBody.tools; + } + + // RTK: compress tool_result content + const rtkStats = compressMessages(translatedBody, rtkEnabled); + const rtkLine = formatRtkLog(rtkStats); + if (rtkLine) console.log(rtkLine); + + // Caveman: inject terse-style system prompt + if (cavemanEnabled && cavemanLevel) { + injectCaveman(translatedBody, finalFormat, cavemanLevel); + log?.debug?.("CAVEMAN", `${cavemanLevel} | ${finalFormat}`); + } + + const executor = getExecutor(provider); + trackPendingRequest(model, provider, connectionId, true); + appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => { }); + + const msgCount = translatedBody.messages?.length || translatedBody.input?.length || translatedBody.contents?.length || translatedBody.request?.contents?.length || 0; + log?.debug?.("REQUEST", `${provider.toUpperCase()} | ${model} | ${msgCount} msgs`); + + const streamController = createStreamController({ + onDisconnect: (reason) => { + trackPendingRequest(model, provider, connectionId, false); + if (onDisconnect) onDisconnect(reason); + }, + onError: () => trackPendingRequest(model, provider, connectionId, false), + log, provider, model + }); + + const proxyOptions = { + connectionProxyEnabled: credentials?.providerSpecificData?.connectionProxyEnabled === true, + connectionProxyUrl: credentials?.providerSpecificData?.connectionProxyUrl || "", + connectionNoProxy: credentials?.providerSpecificData?.connectionNoProxy || "", + vercelRelayUrl: credentials?.providerSpecificData?.vercelRelayUrl || "", + }; + + if (proxyOptions.vercelRelayUrl) { + const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown"; + const poolId = credentials?.providerSpecificData?.connectionProxyPoolId || "none"; + log?.info?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | pool=${poolId} | vercel-relay=${proxyOptions.vercelRelayUrl}`); + } else if (proxyOptions.connectionProxyEnabled && proxyOptions.connectionProxyUrl) { + let maskedProxyUrl = proxyOptions.connectionProxyUrl; + try { + const parsed = new URL(proxyOptions.connectionProxyUrl); + const host = parsed.hostname || ""; + const port = parsed.port ? `:${parsed.port}` : ""; + const protocol = parsed.protocol || "http:"; + maskedProxyUrl = `${protocol}//${host}${port}`; + } catch { + // Keep raw if URL parsing fails + } + + const poolId = credentials?.providerSpecificData?.connectionProxyPoolId || "none"; + const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown"; + log?.info?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | pool=${poolId} | url=${maskedProxyUrl}`); + } + + if (proxyOptions.connectionProxyEnabled && proxyOptions.connectionNoProxy) { + const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown"; + log?.debug?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | no_proxy=${proxyOptions.connectionNoProxy}`); + } + + // Execute request + let providerResponse, providerUrl, providerHeaders, finalBody; + try { + const result = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions }); + providerResponse = result.response; + providerUrl = result.url; + providerHeaders = result.headers; + finalBody = result.transformedBody; + reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody); + } catch (error) { + trackPendingRequest(model, provider, connectionId, false, true); + appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}` }).catch(() => { }); + saveRequestDetail(buildRequestDetail({ + provider, model, connectionId, + latency: { ttft: 0, total: Date.now() - requestStartTime }, + tokens: { prompt_tokens: 0, completion_tokens: 0 }, + request: extractRequestConfig(body, stream), + providerRequest: translatedBody || null, + response: { error: error.message || String(error), status: error.name === "AbortError" ? 499 : 502, thinking: null }, + status: "error" + })).catch(() => { }); + + if (error.name === "AbortError") { + streamController.handleError(error); + return createErrorResult(499, "Request aborted"); + } + const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY); + console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`); + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg); + } + + // Handle 401/403 - try token refresh (skip for noAuth providers) + if (!executor.noAuth && (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || providerResponse.status === HTTP_STATUS.FORBIDDEN)) { + try { + const newCredentials = await refreshWithRetry(() => executor.refreshCredentials(credentials, log), 3, log); + if (newCredentials?.accessToken || newCredentials?.copilotToken) { + log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`); + Object.assign(credentials, newCredentials); + if (onCredentialsRefreshed) { + try { await onCredentialsRefreshed(newCredentials); } catch (e) { log?.warn?.("TOKEN", `onCredentialsRefreshed failed: ${e.message}`); } + } + try { + const retryResult = await executor.execute({ model, body: translatedBody, stream, credentials, signal: streamController.signal, log, proxyOptions }); + if (retryResult.response.ok) { providerResponse = retryResult.response; providerUrl = retryResult.url; } + } catch { log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); } + } else { + log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`); + } + } catch (e) { + log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh threw: ${e.message}`); + } + } + + // Provider returned error + if (!providerResponse.ok) { + trackPendingRequest(model, provider, connectionId, false, true); + const { statusCode, message, resetsAtMs } = await parseUpstreamError(providerResponse, executor); + appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => { }); + saveRequestDetail(buildRequestDetail({ + provider, model, connectionId, + latency: { ttft: 0, total: Date.now() - requestStartTime }, + tokens: { prompt_tokens: 0, completion_tokens: 0 }, + request: extractRequestConfig(body, stream), + providerRequest: finalBody || translatedBody || null, + response: { error: message, status: statusCode, thinking: null }, + status: "error" + })).catch(() => { }); + + const errMsg = formatProviderError(new Error(message), provider, model, statusCode); + console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`); + reqLogger.logError(new Error(message), finalBody || translatedBody); + return createErrorResult(statusCode, errMsg, resetsAtMs); + } + + const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess }; + const appendLog = (extra) => appendRequestLog({ model, provider, connectionId, ...extra }).catch(() => { }); + const trackDone = () => trackPendingRequest(model, provider, connectionId, false); + + // Provider forced streaming but client wants JSON + if (!clientRequestedStreaming && providerRequiresStreaming) { + const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, trackDone, appendLog }); + if (result) { streamController.handleComplete(); return result; } + } + + // True non-streaming response + if (!stream) { + const result = await handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, reqLogger, toolNameMap, trackDone, appendLog }); + streamController.handleComplete(); + return result; + } + + // Streaming response + const { onStreamComplete } = buildOnStreamComplete({ ...sharedCtx }); + return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete }); +} + +export function isTokenExpiringSoon(expiresAt, bufferMs = 5 * 60 * 1000) { + if (!expiresAt) return false; + return new Date(expiresAt).getTime() - Date.now() < bufferMs; +} diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js new file mode 100644 index 0000000000000000000000000000000000000000..000406580a87fd570c416cf211217bd5b645b04c --- /dev/null +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -0,0 +1,238 @@ +import { FORMATS } from "../../translator/formats.js"; +import { needsTranslation } from "../../translator/index.js"; +import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js"; +import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js"; +import { createErrorResult } from "../../utils/error.js"; +import { HTTP_STATUS } from "../../config/runtimeConfig.js"; +import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js"; +import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats } from "./requestDetail.js"; +import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; +import { decloakToolNames } from "../../utils/claudeCloaking.js"; + +/** + * Translate non-streaming response body from provider format → OpenAI format. + */ +export function translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) { + if (targetFormat === sourceFormat || targetFormat === FORMATS.OPENAI) return responseBody; + + // Gemini / Antigravity + if (targetFormat === FORMATS.GEMINI || targetFormat === FORMATS.ANTIGRAVITY || targetFormat === FORMATS.GEMINI_CLI || targetFormat === FORMATS.VERTEX) { + const response = responseBody.response || responseBody; + if (!response?.candidates?.[0]) return responseBody; + + const candidate = response.candidates[0]; + const content = candidate.content; + const usage = response.usageMetadata || responseBody.usageMetadata; + let textContent = "", reasoningContent = ""; + const toolCalls = []; + + if (content?.parts) { + for (const part of content.parts) { + if (part.thought === true && part.text) reasoningContent += part.text; + else if (part.text !== undefined) textContent += part.text; + if (part.functionCall) { + toolCalls.push({ + id: `call_${part.functionCall.name}_${Date.now()}_${toolCalls.length}`, + type: "function", + function: { name: part.functionCall.name, arguments: JSON.stringify(part.functionCall.args || {}) } + }); + } + } + } + + const message = { role: "assistant" }; + if (textContent) message.content = textContent; + if (reasoningContent) message.reasoning_content = reasoningContent; + if (toolCalls.length > 0) message.tool_calls = toolCalls; + if (!message.content && !message.tool_calls) message.content = ""; + + let finishReason = (candidate.finishReason || "stop").toLowerCase(); + if (finishReason === "stop" && toolCalls.length > 0) finishReason = "tool_calls"; + + const result = { + id: `chatcmpl-${response.responseId || Date.now()}`, + object: "chat.completion", + created: Math.floor(new Date(response.createTime || Date.now()).getTime() / 1000), + model: response.modelVersion || "gemini", + choices: [{ index: 0, message, finish_reason: finishReason }] + }; + + if (usage) { + result.usage = { + prompt_tokens: (usage.promptTokenCount || 0) + (usage.thoughtsTokenCount || 0), + completion_tokens: usage.candidatesTokenCount || 0, + total_tokens: usage.totalTokenCount || 0 + }; + if (usage.thoughtsTokenCount > 0) { + result.usage.completion_tokens_details = { reasoning_tokens: usage.thoughtsTokenCount }; + } + } + return result; + } + + // Claude + if (targetFormat === FORMATS.CLAUDE) { + // Always translate a Claude-format body to OpenAI, even if `content` is + // 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. + // 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 = []; + + for (const block of (responseBody.content || [])) { + if (block.type === "text") { + // Strip markdown code block markers (e.g. kimi wraps JSON in ```json...```) + const raw = block.text ?? ""; + const text = raw.replace(/^\s*```\s*json\s*\n?/i, "").replace(/\n?\s*```\s*$/i, ""); + textContent += text; + } else if (block.type === "thinking") thinkingContent += block.thinking || ""; + else if (block.type === "tool_use") { + toolCalls.push({ id: block.id, type: "function", function: { name: block.name, arguments: JSON.stringify(block.input || {}) } }); + } + } + + const message = { role: "assistant" }; + if (textContent) message.content = textContent; + if (thinkingContent) message.reasoning_content = thinkingContent; + if (toolCalls.length > 0) message.tool_calls = toolCalls; + if (!message.content && !message.tool_calls) message.content = ""; + + let finishReason = responseBody.stop_reason || "stop"; + if (finishReason === "end_turn") finishReason = "stop"; + if (finishReason === "tool_use") finishReason = "tool_calls"; + + const result = { + id: `chatcmpl-${responseBody.id || Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: responseBody.model || "claude", + choices: [{ index: 0, message, finish_reason: finishReason }] + }; + + if (responseBody.usage) { + result.usage = { + prompt_tokens: responseBody.usage.input_tokens || 0, + completion_tokens: responseBody.usage.output_tokens || 0, + total_tokens: (responseBody.usage.input_tokens || 0) + (responseBody.usage.output_tokens || 0) + }; + } + return result; + } + + // Ollama + if (targetFormat === FORMATS.OLLAMA) { + return ollamaBodyToOpenAI(responseBody); + } + + return responseBody; +} + +/** + * Handle non-streaming response from provider. + */ +export async function handleNonStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, trackDone, appendLog }) { + trackDone(); + const contentType = providerResponse.headers.get("content-type") || ""; + let responseBody; + + if (contentType.includes("text/event-stream")) { + const sseText = await providerResponse.text(); + const parsed = parseSSEToOpenAIResponse(sseText, model); + if (!parsed) { + appendLog({ status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}` }); + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request"); + } + responseBody = parsed; + } else { + try { + responseBody = await providerResponse.json(); + } catch (err) { + appendLog({ status: `FAILED ${HTTP_STATUS.BAD_GATEWAY}` }); + console.error(`[ChatCore] Failed to parse JSON from ${provider}:`, err.message); + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, `Invalid JSON response from ${provider}`); + } + } + + reqLogger.logProviderResponse(providerResponse.status, providerResponse.statusText, providerResponse.headers, responseBody); + if (onRequestSuccess) await onRequestSuccess(); + + // Decloak tool_use names once on raw Claude body, before any translation (INPUT side) + responseBody = decloakToolNames(responseBody, toolNameMap); + + const usage = extractUsageFromResponse(responseBody); + appendLog({ tokens: usage, status: "200 OK" }); + saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint }); + + const translatedResponse = needsTranslation(targetFormat, sourceFormat) + ? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat) + : responseBody; + + // Fix finish_reason for tool_calls: some providers return non-standard values (e.g. "other") + if (translatedResponse?.choices?.[0]) { + const choice = translatedResponse.choices[0]; + const msg = choice.message; + const hasToolCalls = Array.isArray(msg?.tool_calls) && msg.tool_calls.length > 0; + if (hasToolCalls && choice.finish_reason !== "tool_calls") { + choice.finish_reason = "tool_calls"; + } + } + + // Ensure OpenAI-required fields + if (!translatedResponse.object) translatedResponse.object = "chat.completion"; + if (!translatedResponse.created) translatedResponse.created = Math.floor(Date.now() / 1000); + + // Strip Azure-specific fields + delete translatedResponse.prompt_filter_results; + if (translatedResponse?.choices) { + for (const choice of translatedResponse.choices) delete choice.content_filter_results; + } + + if (translatedResponse?.usage) { + translatedResponse.usage = filterUsageForFormat(addBufferToUsage(translatedResponse.usage), sourceFormat); + } + + // 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?.reasoning_content && choice.message.content) { + delete choice.message.reasoning_content; + } + } + } + + reqLogger.logConvertedResponse(translatedResponse); + + const totalLatency = Date.now() - requestStartTime; + saveRequestDetail(buildRequestDetail({ + provider, model, connectionId, + latency: { ttft: totalLatency, total: totalLatency }, + tokens: usage || { prompt_tokens: 0, completion_tokens: 0 }, + request: extractRequestConfig(body, stream), + providerRequest: finalBody || translatedBody || null, + providerResponse: responseBody || null, + response: { + content: translatedResponse?.choices?.[0]?.message?.content || translatedResponse?.content || null, + thinking: translatedResponse?.choices?.[0]?.message?.reasoning_content || translatedResponse?.reasoning_content || null, + finish_reason: translatedResponse?.choices?.[0]?.finish_reason || "unknown" + }, + status: "success" + }, { endpoint: clientRawRequest?.endpoint || null })).catch(err => { + console.error("[RequestDetail] Failed to save:", err.message); + }); + + return { + success: true, + response: new Response(JSON.stringify(translatedResponse), { + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } + }) + }; +} diff --git a/open-sse/handlers/chatCore/requestDetail.js b/open-sse/handlers/chatCore/requestDetail.js new file mode 100644 index 0000000000000000000000000000000000000000..d9dde1a36ef258ebd683035df7b50615faa68fc9 --- /dev/null +++ b/open-sse/handlers/chatCore/requestDetail.js @@ -0,0 +1,102 @@ +import { saveRequestUsage, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; +import { COLORS } from "../../utils/stream.js"; + +const OPTIONAL_PARAMS = [ + "temperature", "top_p", "top_k", + "max_tokens", "max_completion_tokens", + "thinking", "reasoning", "enable_thinking", + "presence_penalty", "frequency_penalty", + "seed", "stop", "tools", "tool_choice", + "response_format", "prediction", "store", "metadata", + "n", "logprobs", "top_logprobs", "logit_bias", + "user", "parallel_tool_calls" +]; + +export function extractRequestConfig(body, stream) { + const config = { messages: body.messages || [], model: body.model, stream }; + for (const param of OPTIONAL_PARAMS) { + if (body[param] !== undefined) config[param] = body[param]; + } + return config; +} + +export function extractUsageFromResponse(responseBody) { + if (!responseBody || typeof responseBody !== "object") return null; + + // Claude format + if (responseBody.usage?.input_tokens !== undefined) { + return { + prompt_tokens: responseBody.usage.input_tokens || 0, + completion_tokens: responseBody.usage.output_tokens || 0, + cache_read_input_tokens: responseBody.usage.cache_read_input_tokens, + cache_creation_input_tokens: responseBody.usage.cache_creation_input_tokens + }; + } + + // OpenAI format + if (responseBody.usage?.prompt_tokens !== undefined) { + return { + prompt_tokens: responseBody.usage.prompt_tokens || 0, + completion_tokens: responseBody.usage.completion_tokens || 0, + cached_tokens: responseBody.usage.prompt_tokens_details?.cached_tokens, + reasoning_tokens: responseBody.usage.completion_tokens_details?.reasoning_tokens + }; + } + + // Gemini format + if (responseBody.usageMetadata) { + return { + prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0, + completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0, + reasoning_tokens: responseBody.usageMetadata.thoughtsTokenCount + }; + } + + return null; +} + +export function buildRequestDetail(base, overrides = {}) { + return { + provider: base.provider || "unknown", + model: base.model || "unknown", + connectionId: base.connectionId || undefined, + timestamp: new Date().toISOString(), + latency: base.latency || { ttft: 0, total: 0 }, + tokens: base.tokens || { prompt_tokens: 0, completion_tokens: 0 }, + request: base.request, + providerRequest: base.providerRequest || null, + providerResponse: base.providerResponse || null, + response: base.response || {}, + status: base.status || "success", + ...overrides + }; +} + +export function saveUsageStats({ provider, model, tokens, connectionId, apiKey, endpoint, label = "USAGE" }) { + if (!tokens || typeof tokens !== "object") return; + + const inTokens = tokens.input_tokens ?? tokens.prompt_tokens ?? 0; + const outTokens = tokens.output_tokens ?? tokens.completion_tokens ?? 0; + + if (inTokens === 0 && outTokens === 0) return; + + const time = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); + const accountSuffix = connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""; + console.log(`${COLORS.green}[${time}] 📊 [${label}] ${provider.toUpperCase()} | in=${inTokens} | out=${outTokens}${accountSuffix}${COLORS.reset}`); + + // Normalize to OpenAI token shape for storage + const normalized = { + prompt_tokens: tokens.prompt_tokens ?? tokens.input_tokens ?? 0, + completion_tokens: tokens.completion_tokens ?? tokens.output_tokens ?? 0 + }; + + saveRequestUsage({ + provider: provider || "unknown", + model: model || "unknown", + tokens: normalized, + timestamp: new Date().toISOString(), + connectionId: connectionId || undefined, + apiKey: apiKey || undefined, + endpoint: endpoint || null + }).catch(() => {}); +} diff --git a/open-sse/handlers/chatCore/sseToJsonHandler.js b/open-sse/handlers/chatCore/sseToJsonHandler.js new file mode 100644 index 0000000000000000000000000000000000000000..1e0edebad851893107ad401361ecfbc587cfe55d --- /dev/null +++ b/open-sse/handlers/chatCore/sseToJsonHandler.js @@ -0,0 +1,235 @@ +import { convertResponsesStreamToJson } from "../../transformer/streamToJsonConverter.js"; +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) { + if (!item?.content || !Array.isArray(item.content)) return ""; + const byType = item.content.find((c) => c.type === "output_text"); + if (typeof byType?.text === "string") return byType.text; + const anyText = item.content.find((c) => typeof c.text === "string"); + if (typeof anyText?.text === "string") return anyText.text; + return ""; +} + +/** + * Codex / Responses API may emit many alternating reasoning + message items. + * Early message blocks often have empty output_text; the user-visible answer is usually in the last non-empty message. + */ +function pickAssistantMessageForChatCompletion(output) { + if (!Array.isArray(output)) return { msgItem: null, textContent: null }; + const messages = output.filter((item) => item?.type === "message"); + if (messages.length === 0) return { msgItem: null, textContent: null }; + for (let i = messages.length - 1; i >= 0; i--) { + const text = textFromResponsesMessageItem(messages[i]); + if (text.length > 0) return { msgItem: messages[i], textContent: text }; + } + const last = messages[messages.length - 1]; + return { msgItem: last, textContent: textFromResponsesMessageItem(last) }; +} + +/** + * Parse OpenAI-style SSE text into a single chat completion JSON. + * Used when provider forces streaming but client wants non-streaming. + */ +export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) { + const chunks = []; + + for (const line of String(rawSSE || "").split("\n")) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) continue; + const payload = trimmed.slice(5).trim(); + if (!payload || payload === "[DONE]") continue; + try { chunks.push(JSON.parse(payload)); } catch { /* ignore malformed lines */ } + } + + if (chunks.length === 0) return null; + + const first = chunks[0]; + const contentParts = []; + const reasoningParts = []; + const toolCallMap = new Map(); // index -> { id, type, function: { name, arguments } } + let finishReason = "stop"; + let usage = null; + + for (const chunk of chunks) { + const choice = chunk?.choices?.[0]; + const delta = choice?.delta || {}; + if (typeof delta.content === "string" && delta.content.length > 0) contentParts.push(delta.content); + if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) reasoningParts.push(delta.reasoning_content); + if (choice?.finish_reason) finishReason = choice.finish_reason; + if (chunk?.usage && typeof chunk.usage === "object") usage = chunk.usage; + + // Accumulate tool_calls from streaming deltas + if (Array.isArray(delta.tool_calls)) { + for (const tc of delta.tool_calls) { + const idx = tc.index ?? 0; + if (!toolCallMap.has(idx)) { + toolCallMap.set(idx, { id: tc.id || "", type: "function", function: { name: "", arguments: "" } }); + } + const existing = toolCallMap.get(idx); + if (tc.id) existing.id = tc.id; + if (tc.function?.name) existing.function.name += tc.function.name; + if (tc.function?.arguments) existing.function.arguments += tc.function.arguments; + } + } + } + + const message = { role: "assistant", content: contentParts.join("") || (toolCallMap.size > 0 ? null : "") }; + if (reasoningParts.length > 0) message.reasoning_content = reasoningParts.join(""); + if (toolCallMap.size > 0) { + message.tool_calls = [...toolCallMap.entries()].sort((a, b) => a[0] - b[0]).map(([, tc]) => tc); + } + + const result = { + id: first.id || `chatcmpl-${Date.now()}`, + object: "chat.completion", + created: first.created || Math.floor(Date.now() / 1000), + model: first.model || fallbackModel || "unknown", + choices: [{ index: 0, message, finish_reason: finishReason }] + }; + if (usage) result.usage = usage; + return result; +} + +/** + * Handle case: provider forced streaming but client wants JSON. + * Supports both Codex/Responses API SSE and standard Chat Completions SSE. + */ +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 === "" && isResponsesProvider(provider)); + if (!isSSE) return null; // not handled here + + trackDone(); + + const ctx = { + provider, model, connectionId, + request: extractRequestConfig(body, stream), + providerRequest: finalBody || translatedBody || null + }; + + // Codex/Responses API SSE path + const isCodexResponsesApi = isResponsesProvider(provider) || sourceFormat === FORMATS.OPENAI_RESPONSES; + if (isCodexResponsesApi) { + try { + const jsonResponse = await convertResponsesStreamToJson(providerResponse.body); + if (onRequestSuccess) await onRequestSuccess(); + + const usage = jsonResponse.usage || {}; + appendLog({ tokens: usage, status: "200 OK" }); + saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint }); + + const { msgItem, textContent } = pickAssistantMessageForChatCompletion(jsonResponse.output); + const totalLatency = Date.now() - requestStartTime; + + saveRequestDetail(buildRequestDetail({ + ...ctx, + latency: { ttft: totalLatency, total: totalLatency }, + tokens: { prompt_tokens: usage.input_tokens || 0, completion_tokens: usage.output_tokens || 0 }, + response: { content: textContent, thinking: null, finish_reason: jsonResponse.status || "unknown" }, + status: "success" + }, { endpoint: clientRawRequest?.endpoint || null })).catch(() => {}); + + // Client is Responses API → return as-is + if (sourceFormat === FORMATS.OPENAI_RESPONSES) { + return { success: true, response: new Response(JSON.stringify(jsonResponse), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) }; + } + + // Build client-format response + const inTokens = usage.input_tokens || 0; + const outTokens = usage.output_tokens || 0; + let finalResp; + + // Extract tool calls from Responses API output (function_call items) + const funcCallItems = (jsonResponse.output || []).filter(item => item.type === "function_call"); + const toolCalls = funcCallItems.map((item, idx) => ({ + id: item.call_id || `call_${item.name}_${Date.now()}_${idx}`, + type: "function", + function: { + name: item.name, + arguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments || {}) + } + })); + const hasToolCalls = toolCalls.length > 0; + + if (sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI) { + finalResp = { + response: { + candidates: [{ content: { role: "model", parts: [{ text: textContent || "" }] }, finishReason: "STOP", index: 0 }], + usageMetadata: { promptTokenCount: inTokens, candidatesTokenCount: outTokens, totalTokenCount: inTokens + outTokens }, + modelVersion: model, + responseId: jsonResponse.id || `resp_${Date.now()}` + } + }; + } else { + const message = { role: "assistant", content: textContent || (hasToolCalls ? null : "") }; + if (hasToolCalls) message.tool_calls = toolCalls; + const responseDone = jsonResponse.status === "completed" || jsonResponse.status === "done"; + const finishReason = hasToolCalls ? "tool_calls" : (responseDone ? "stop" : (jsonResponse.status || "stop")); + finalResp = { + id: jsonResponse.id || `chatcmpl-${Date.now()}`, + object: "chat.completion", + created: jsonResponse.created_at || Math.floor(Date.now() / 1000), + model: jsonResponse.model || model, + choices: [{ index: 0, message, finish_reason: finishReason }], + usage: { prompt_tokens: inTokens, completion_tokens: outTokens, total_tokens: inTokens + outTokens } + }; + } + + return { success: true, response: new Response(JSON.stringify(finalResp), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) }; + } catch (err) { + console.error("[ChatCore] Responses API SSE→JSON failed:", err); + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON"); + } + } + + // Standard Chat Completions SSE path + try { + const sseText = await providerResponse.text(); + const parsed = parseSSEToOpenAIResponse(sseText, model); + if (!parsed) return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Invalid SSE response for non-streaming request"); + + if (onRequestSuccess) await onRequestSuccess(); + + const usage = parsed.usage || {}; + appendLog({ tokens: usage, status: "200 OK" }); + saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint }); + + const totalLatency = Date.now() - requestStartTime; + saveRequestDetail(buildRequestDetail({ + ...ctx, + latency: { ttft: totalLatency, total: totalLatency }, + tokens: usage, + response: { + content: parsed.choices?.[0]?.message?.content || null, + thinking: parsed.choices?.[0]?.message?.reasoning_content || null, + finish_reason: parsed.choices?.[0]?.finish_reason || "unknown" + }, + status: "success" + }, { endpoint: clientRawRequest?.endpoint || null })).catch(() => {}); + + // 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. + // Previously this was unconditional, which broke Qwen3.5, Claude extended thinking, etc. + if (parsed?.choices) { + for (const choice of parsed.choices) { + if (choice?.message?.reasoning_content && choice.message.content) { + delete choice.message.reasoning_content; + } + } + } + + return { success: true, response: new Response(JSON.stringify(parsed), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) }; + } catch (err) { + console.error("[ChatCore] Chat Completions SSE→JSON failed:", err); + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON"); + } +} diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js new file mode 100644 index 0000000000000000000000000000000000000000..aa907cd5c7eb23511ab516789464288825578fb8 --- /dev/null +++ b/open-sse/handlers/chatCore/streamingHandler.js @@ -0,0 +1,108 @@ +import { FORMATS } from "../../translator/formats.js"; +import { needsTranslation } from "../../translator/index.js"; +import { createSSETransformStreamWithLogger, createPassthroughStreamWithLogger } from "../../utils/stream.js"; +import { pipeWithDisconnect } from "../../utils/streamHandler.js"; +import { PROVIDERS } from "../../config/providers.js"; +import { STREAM_STALL_TIMEOUT_MS } from "../../config/runtimeConfig.js"; +import { buildAbortedResponsesTerminalBytes } from "../../utils/responsesStreamHelpers.js"; +import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js"; +import { saveRequestDetail } from "@/lib/usageDb.js"; +import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js"; + +// 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, +}; + +/** + * Determine which SSE transform stream to use based on provider/format. + */ +function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }) { + const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli"); + // 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) { + const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI; + return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey); + } + + if (needsTranslation(targetFormat, sourceFormat)) { + return createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey); + } + + return createPassthroughStreamWithLogger(provider, reqLogger, model, connectionId, body, onStreamComplete, apiKey); +} + +/** + * 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(); + + const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }); + + // Responses passthrough: synthesize response.failed + [DONE] if the stream aborts/stalls before a terminal event + const isResponsesPassthrough = sourceFormat === FORMATS.OPENAI_RESPONSES && targetFormat === FORMATS.OPENAI_RESPONSES; + const onAbortTerminal = isResponsesPassthrough ? buildAbortedResponsesTerminalBytes : null; + const stallTimeoutMs = PROVIDERS[provider]?.stallTimeoutMs || STREAM_STALL_TIMEOUT_MS; + const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal, stallTimeoutMs); + + const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + saveRequestDetail(buildRequestDetail({ + provider, model, connectionId, + latency: { ttft: 0, total: Date.now() - requestStartTime }, + tokens: { prompt_tokens: 0, completion_tokens: 0 }, + request: extractRequestConfig(body, stream), + providerRequest: finalBody || translatedBody || null, + providerResponse: "[Streaming - raw response not captured]", + response: { content: "[Streaming in progress...]", thinking: null, type: "streaming" }, + status: "success" + }, { id: streamDetailId })).catch(err => { + console.error("[RequestDetail] Failed to save streaming request:", err.message); + }); + + return { + success: true, + response: new Response(transformedBody, { headers: SSE_HEADERS }) + }; +} + +/** + * Build onStreamComplete callback for streaming usage tracking. + */ +export function buildOnStreamComplete({ provider, model, connectionId, apiKey, requestStartTime, body, stream, finalBody, translatedBody, clientRawRequest }) { + const streamDetailId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + + const onStreamComplete = (contentObj, usage, ttftAt) => { + const latency = { + ttft: ttftAt ? ttftAt - requestStartTime : Date.now() - requestStartTime, + total: Date.now() - requestStartTime + }; + const safeContent = contentObj?.content || "[Empty streaming response]"; + const safeThinking = contentObj?.thinking || null; + + saveRequestDetail(buildRequestDetail({ + provider, model, connectionId, + latency, + tokens: usage || { prompt_tokens: 0, completion_tokens: 0 }, + request: extractRequestConfig(body, stream), + providerRequest: finalBody || translatedBody || null, + providerResponse: safeContent, + response: { content: safeContent, thinking: safeThinking, type: "streaming" }, + status: "success" + }, { id: streamDetailId })).catch(err => { + console.error("[RequestDetail] Failed to update streaming content:", err.message); + }); + + saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, label: "STREAM USAGE" }); + }; + + return { onStreamComplete, streamDetailId }; +} diff --git a/open-sse/handlers/embeddingProviders/_base.js b/open-sse/handlers/embeddingProviders/_base.js new file mode 100644 index 0000000000000000000000000000000000000000..2a8f3998bf6ca4d02e4521d5cc88d51ad08559ab --- /dev/null +++ b/open-sse/handlers/embeddingProviders/_base.js @@ -0,0 +1,4 @@ +// Shared embedding helpers +export function bearerAuth(creds) { + return { "Authorization": `Bearer ${creds.apiKey || creds.accessToken}` }; +} diff --git a/open-sse/handlers/embeddingProviders/gemini.js b/open-sse/handlers/embeddingProviders/gemini.js new file mode 100644 index 0000000000000000000000000000000000000000..885ee306e8e115d04ded26c1fb2483a22f863928 --- /dev/null +++ b/open-sse/handlers/embeddingProviders/gemini.js @@ -0,0 +1,54 @@ +// Google Gemini embeddings — embedContent / batchEmbedContents +const BASE = "https://generativelanguage.googleapis.com/v1beta"; + +function modelPath(model) { + return model.startsWith("models/") ? model : `models/${model}`; +} + +export default { + buildUrl: (model, creds, { input } = {}) => { + const apiKey = creds.apiKey || creds.accessToken; + const path = modelPath(model); + const op = Array.isArray(input) ? "batchEmbedContents" : "embedContent"; + return `${BASE}/${path}:${op}?key=${encodeURIComponent(apiKey)}`; + }, + buildHeaders: () => ({ "Content-Type": "application/json" }), + buildBody: (model, { input, dimensions }) => { + const m = modelPath(model); + const outputDimensionality = Number(dimensions); + const hasOutputDimensionality = Number.isFinite(outputDimensionality) && outputDimensionality > 0; + if (Array.isArray(input)) { + return { + requests: input.map((text) => ({ + model: m, + content: { parts: [{ text: String(text) }] }, + ...(hasOutputDimensionality ? { outputDimensionality } : {}), + })), + }; + } + return { + model: m, + content: { parts: [{ text: String(input) }] }, + ...(hasOutputDimensionality ? { outputDimensionality } : {}), + }; + }, + normalize: (responseBody, model) => { + if (responseBody.object === "list" && Array.isArray(responseBody.data)) return responseBody; + let items = []; + if (Array.isArray(responseBody.embeddings)) { + items = responseBody.embeddings.map((emb, idx) => ({ + object: "embedding", + index: idx, + embedding: emb.values || [], + })); + } else if (responseBody.embedding?.values) { + items = [{ object: "embedding", index: 0, embedding: responseBody.embedding.values }]; + } + return { + object: "list", + data: items, + model, + usage: { prompt_tokens: 0, total_tokens: 0 }, + }; + }, +}; diff --git a/open-sse/handlers/embeddingProviders/index.js b/open-sse/handlers/embeddingProviders/index.js new file mode 100644 index 0000000000000000000000000000000000000000..62e527787a4929d88d0178ac7f3a7cf1a00a5727 --- /dev/null +++ b/open-sse/handlers/embeddingProviders/index.js @@ -0,0 +1,24 @@ +// Embeddings provider adapter registry +import createOpenAIEmbeddingAdapter from "./openai.js"; +import gemini from "./gemini.js"; +import openaiCompatNode from "./openaiCompatNode.js"; + +const OPENAI_COMPAT_PROVIDERS = [ + "openai", "openrouter", "mistral", "voyage-ai", "fireworks", + "together", "nebius", "github", "nvidia", "jina-ai", + "vercel-ai-gateway", +]; + +const ADAPTERS = { + ...Object.fromEntries(OPENAI_COMPAT_PROVIDERS.map((id) => [id, createOpenAIEmbeddingAdapter(id)])), + gemini, + google_ai_studio: gemini, +}; + +export function getEmbeddingAdapter(provider) { + if (ADAPTERS[provider]) return ADAPTERS[provider]; + if (provider?.startsWith?.("openai-compatible-") || provider?.startsWith?.("custom-embedding-")) { + return openaiCompatNode; + } + return null; +} diff --git a/open-sse/handlers/embeddingProviders/openai.js b/open-sse/handlers/embeddingProviders/openai.js new file mode 100644 index 0000000000000000000000000000000000000000..7d61c7e4955b3c30526feadd7206c7892e0e804f --- /dev/null +++ b/open-sse/handlers/embeddingProviders/openai.js @@ -0,0 +1,31 @@ +// 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 = { + "jina-ai": "https://api.jina.ai/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: () => embedUrl(providerId), + buildHeaders: (creds) => { + return { "Content-Type": "application/json", ...bearerAuth(creds), ...(cfg.headers || {}) }; + }, + buildBody: (model, { input, encoding_format, dimensions }) => { + const body = { model, input }; + if (encoding_format) body.encoding_format = encoding_format; + if (dimensions != null && dimensions !== "") { + const dim = Number(dimensions); + if (Number.isFinite(dim) && dim > 0) body.dimensions = dim; + } + return body; + }, + normalize: (responseBody) => responseBody, + }; +} diff --git a/open-sse/handlers/embeddingProviders/openaiCompatNode.js b/open-sse/handlers/embeddingProviders/openaiCompatNode.js new file mode 100644 index 0000000000000000000000000000000000000000..6581b457f8ee45f73ddf5f83aa7e2852c90045b2 --- /dev/null +++ b/open-sse/handlers/embeddingProviders/openaiCompatNode.js @@ -0,0 +1,13 @@ +// Custom node providers (openai-compatible-* / custom-embedding-*) — baseUrl from credentials +import createOpenAIEmbeddingAdapter from "./openai.js"; + +const baseAdapter = createOpenAIEmbeddingAdapter("openai"); + +export default { + ...baseAdapter, + buildUrl: (_model, creds) => { + const rawBaseUrl = creds?.providerSpecificData?.baseUrl || "https://api.openai.com/v1"; + const baseUrl = rawBaseUrl.replace(/\/$/, "").replace(/\/embeddings$/, ""); + return `${baseUrl}/embeddings`; + }, +}; diff --git a/open-sse/handlers/embeddingsCore.js b/open-sse/handlers/embeddingsCore.js new file mode 100644 index 0000000000000000000000000000000000000000..5a4c92ba1b275f7cbf004284b8a0653783deda1a --- /dev/null +++ b/open-sse/handlers/embeddingsCore.js @@ -0,0 +1,126 @@ +import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js"; +import { HTTP_STATUS } from "../config/runtimeConfig.js"; +import { getExecutor } from "../executors/index.js"; +import { refreshWithRetry } from "../services/tokenRefresh.js"; +import { getEmbeddingAdapter } from "./embeddingProviders/index.js"; + +/** + * Core embeddings handler — orchestrator only. Provider-specific URL/headers/body/normalize + * live in `./embeddingProviders/{id}.js`. + * + * @returns {Promise<{ success: boolean, response: Response, status?: number, error?: string }>} + */ +export async function handleEmbeddingsCore({ + body, + modelInfo, + credentials, + log, + onCredentialsRefreshed, + onRequestSuccess, +}) { + const { provider, model } = modelInfo; + + // Validate input + const input = body.input; + if (!input) { + return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: input"); + } + if (typeof input !== "string" && !Array.isArray(input)) { + return createErrorResult(HTTP_STATUS.BAD_REQUEST, "input must be a string or array of strings"); + } + + const adapter = getEmbeddingAdapter(provider); + if (!adapter) { + return createErrorResult( + HTTP_STATUS.BAD_REQUEST, + `Provider '${provider}' does not support embeddings.` + ); + } + + const ctx = { input }; + const url = adapter.buildUrl(model, credentials, ctx); + const headers = adapter.buildHeaders(credentials, ctx); + const requestBody = adapter.buildBody(model, { + input, + encoding_format: body.encoding_format || "float", + dimensions: body.dimensions, + }); + + log?.debug?.("EMBEDDINGS", `${provider.toUpperCase()} | ${model} | input_type=${Array.isArray(input) ? `array[${input.length}]` : "string"}`); + + let providerResponse; + try { + providerResponse = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(requestBody), + }); + } catch (error) { + const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY); + log?.debug?.("EMBEDDINGS", `Fetch error: ${errMsg}`); + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg); + } + + // Handle 401/403 — try token refresh (skip for noAuth providers) + const executor = getExecutor(provider); + if ( + !executor?.noAuth && + (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || + providerResponse.status === HTTP_STATUS.FORBIDDEN) + ) { + const newCredentials = await refreshWithRetry( + () => executor.refreshCredentials(credentials, log), + 3, + log + ); + + if (newCredentials?.accessToken || newCredentials?.apiKey) { + log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed for embeddings`); + Object.assign(credentials, newCredentials); + if (onCredentialsRefreshed) await onCredentialsRefreshed(newCredentials); + + try { + const retryHeaders = adapter.buildHeaders(credentials, ctx); + const retryUrl = adapter.buildUrl(model, credentials, ctx); + providerResponse = await fetch(retryUrl, { + method: "POST", + headers: retryHeaders, + body: JSON.stringify(requestBody), + }); + } catch { + log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); + } + } else { + log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`); + } + } + + if (!providerResponse.ok) { + const { statusCode, message } = await parseUpstreamError(providerResponse); + const errMsg = formatProviderError(new Error(message), provider, model, statusCode); + log?.debug?.("EMBEDDINGS", `Provider error: ${errMsg}`); + return createErrorResult(statusCode, errMsg); + } + + let responseBody; + try { + responseBody = await providerResponse.json(); + } catch { + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, `Invalid JSON response from ${provider}`); + } + + if (onRequestSuccess) await onRequestSuccess(); + + const normalized = adapter.normalize(responseBody, model); + log?.debug?.("EMBEDDINGS", `Success | usage=${JSON.stringify(normalized.usage || {})}`); + + return { + success: true, + response: new Response(JSON.stringify(normalized), { + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }, + }), + }; +} diff --git a/open-sse/handlers/fetch/index.js b/open-sse/handlers/fetch/index.js new file mode 100644 index 0000000000000000000000000000000000000000..da1c2303ac89c0a503187eaf21bcd1a83f80aebc --- /dev/null +++ b/open-sse/handlers/fetch/index.js @@ -0,0 +1,237 @@ +// Web Fetch handler — dispatches to firecrawl, jina-reader, tavily, exa +// Returns normalized shape across all providers + +const DEFAULT_TIMEOUT_MS = 15000; +const DEFAULT_FORMAT = "markdown"; + +/** + * @typedef {Object} FetchResult + * @property {boolean} success + * @property {number} [status] + * @property {string} [error] + * @property {Object} [data] + */ + +/** + * Fetch with timeout abort. + * @param {string} url + * @param {RequestInit} init + * @param {number} timeoutMs + */ +// Strip non-ASCII chars from header values (HTTP headers must be ByteString). +function sanitizeHeaders(headers) { + if (!headers) return headers; + const out = {}; + for (const [k, v] of Object.entries(headers)) { + out[k] = typeof v === "string" ? v.replace(/[^\x00-\xFF]/g, "").trim() : v; + } + return out; +} + +async function tryFetch(url, init, timeoutMs) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const res = await fetch(url, { ...init, headers: sanitizeHeaders(init.headers), signal: ctrl.signal }); + return { ok: true, res }; + } catch (err) { + const isAbort = err?.name === "AbortError"; + return { ok: false, timeout: isAbort, error: err?.message || String(err) }; + } finally { + clearTimeout(timer); + } +} + +function truncate(text, max) { + if (!text || typeof text !== "string") return text || ""; + if (!max || max <= 0) return text; + return text.length > max ? text.slice(0, max) : text; +} + +function parseJinaTitle(text) { + const m = String(text || "").match(/^\s*#\s+(.+)$/m); + return m ? m[1].trim() : null; +} + +function buildData({ provider, url, title, format, text, costUsd, responseMs, upstreamMs }) { + return { + provider, + url, + title: title || null, + content: { format, text: text || "", length: (text || "").length }, + metadata: { author: null, published_at: null, language: null }, + usage: { fetch_cost_usd: costUsd ?? null }, + metrics: { response_time_ms: responseMs, upstream_latency_ms: upstreamMs } + }; +} + +async function readJsonOrText(res) { + const ct = res.headers.get("content-type") || ""; + if (ct.includes("application/json")) { + try { return { json: await res.json() }; } catch { return { text: "" }; } + } + return { text: await res.text() }; +} + +/** + * Main handler. + * @param {Object} params + * @param {string} params.url + * @param {string} [params.format] + * @param {number} [params.maxCharacters] + * @param {string} params.provider + * @param {Object} [params.providerConfig] + * @param {Object} [params.credentials] + * @param {Function} [params.log] + * @returns {Promise} + */ +export async function handleFetchCore({ url, format, maxCharacters, provider, providerConfig, credentials, log }) { + if (!url || typeof url !== "string") { + return { success: false, status: 400, error: "url is required" }; + } + if (!provider) { + return { success: false, status: 400, error: "provider is required" }; + } + + const fmt = format || DEFAULT_FORMAT; + const timeoutMs = providerConfig?.timeoutMs || DEFAULT_TIMEOUT_MS; + const apiKey = credentials?.apiKey || credentials?.key || credentials?.token || ""; + const costPerQuery = providerConfig?.costPerQuery ?? null; + const startedAt = Date.now(); + + try { + if (provider === "firecrawl") { + return await runFirecrawl({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }); + } + if (provider === "jina-reader") { + return await runJina({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }); + } + if (provider === "tavily") { + return await runTavily({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }); + } + if (provider === "exa") { + return await runExa({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }); + } + return { success: false, status: 400, error: `Unsupported provider: ${provider}` }; + } catch (err) { + log?.("fetch handler error:", err?.message || err); + return { success: false, status: 502, error: err?.message || "Internal fetch error" }; + } +} + +async function runFirecrawl({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }) { + const upstreamStart = Date.now(); + const r = await tryFetch("https://api.firecrawl.dev/v1/scrape", { + method: "POST", + headers: { + "content-type": "application/json", + ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}) + }, + body: JSON.stringify({ url, formats: [fmt] }) + }, timeoutMs); + + if (!r.ok) { + return { success: false, status: r.timeout ? 504 : 502, error: r.error }; + } + const upstreamMs = Date.now() - upstreamStart; + const { json } = await readJsonOrText(r.res); + if (!r.res.ok) { + return { success: false, status: r.res.status, error: json?.error || `Firecrawl error: ${r.res.status}` }; + } + const d = json?.data || {}; + const text = truncate(d.markdown || d.html || d.text || "", maxCharacters); + const title = d.metadata?.title || null; + return { + success: true, + data: buildData({ + provider: "firecrawl", url, title, format: fmt, text, + costUsd: costPerQuery, responseMs: Date.now() - startedAt, upstreamMs + }) + }; +} + +async function runJina({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }) { + const target = `https://r.jina.ai/${encodeURIComponent(url)}`; + const upstreamStart = Date.now(); + const r = await tryFetch(target, { + method: "GET", + headers: apiKey ? { authorization: `Bearer ${apiKey}` } : {} + }, timeoutMs); + + if (!r.ok) { + return { success: false, status: r.timeout ? 504 : 502, error: r.error }; + } + const upstreamMs = Date.now() - upstreamStart; + const body = await r.res.text(); + if (!r.res.ok) { + return { success: false, status: r.res.status, error: body?.slice(0, 500) || `Jina error: ${r.res.status}` }; + } + const text = truncate(body, maxCharacters); + return { + success: true, + data: buildData({ + provider: "jina-reader", url, title: parseJinaTitle(body), format: fmt, text, + costUsd: costPerQuery, responseMs: Date.now() - startedAt, upstreamMs + }) + }; +} + +async function runTavily({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }) { + const upstreamStart = Date.now(); + const r = await tryFetch("https://api.tavily.com/extract", { + method: "POST", + headers: { + "content-type": "application/json", + ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}) + }, + body: JSON.stringify({ urls: [url], extract_depth: "basic" }) + }, timeoutMs); + + if (!r.ok) { + return { success: false, status: r.timeout ? 504 : 502, error: r.error }; + } + const upstreamMs = Date.now() - upstreamStart; + const { json } = await readJsonOrText(r.res); + if (!r.res.ok) { + return { success: false, status: r.res.status, error: json?.error || `Tavily error: ${r.res.status}` }; + } + const first = json?.results?.[0] || {}; + const text = truncate(first.raw_content || "", maxCharacters); + return { + success: true, + data: buildData({ + provider: "tavily", url, title: null, format: fmt, text, + costUsd: costPerQuery, responseMs: Date.now() - startedAt, upstreamMs + }) + }; +} + +async function runExa({ url, fmt, timeoutMs, apiKey, maxCharacters, costPerQuery, startedAt }) { + const upstreamStart = Date.now(); + const r = await tryFetch("https://api.exa.ai/contents", { + method: "POST", + headers: { + "content-type": "application/json", + ...(apiKey ? { "x-api-key": apiKey } : {}) + }, + body: JSON.stringify({ ids: [url], text: true }) + }, timeoutMs); + + if (!r.ok) { + return { success: false, status: r.timeout ? 504 : 502, error: r.error }; + } + const upstreamMs = Date.now() - upstreamStart; + const { json } = await readJsonOrText(r.res); + if (!r.res.ok) { + return { success: false, status: r.res.status, error: json?.error || `Exa error: ${r.res.status}` }; + } + const first = json?.results?.[0] || {}; + const text = truncate(first.text || "", maxCharacters); + return { + success: true, + data: buildData({ + provider: "exa", url, title: first.title || null, format: fmt, text, + costUsd: costPerQuery, responseMs: Date.now() - startedAt, upstreamMs + }) + }; +} diff --git a/open-sse/handlers/imageGenerationCore.js b/open-sse/handlers/imageGenerationCore.js new file mode 100644 index 0000000000000000000000000000000000000000..280b3158371afd27f7eaa826c2eb07149b075587 --- /dev/null +++ b/open-sse/handlers/imageGenerationCore.js @@ -0,0 +1,189 @@ +import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js"; +import { HTTP_STATUS } from "../config/runtimeConfig.js"; +import { refreshWithRetry } from "../services/tokenRefresh.js"; +import { getExecutor } from "../executors/index.js"; +import { getImageAdapter } from "./imageProviders/index.js"; +import { urlToBase64 } from "./imageProviders/_base.js"; + +function serializeRequestBody(requestBody) { + if (typeof FormData !== "undefined" && requestBody instanceof FormData) return requestBody; + if (typeof requestBody === "string") return requestBody; + return JSON.stringify(requestBody); +} + +/** + * Core image generation handler — orchestrator only. + * Provider-specific URL/headers/body/parse/normalize live in `./imageProviders/{id}.js`. + * + * @param {object} options + * @param {object} options.body - Request body { model, prompt, n, size, ... } + * @param {object} options.modelInfo - { provider, model } + * @param {object} options.credentials - Provider credentials + * @param {object} [options.log] - Logger + * @param {boolean} [options.streamToClient] - Pipe SSE to client (codex) + * @param {boolean} [options.binaryOutput] - Return raw image bytes + * @param {function} [options.onCredentialsRefreshed] + * @param {function} [options.onRequestSuccess] + * @returns {Promise<{ success: boolean, response: Response, status?: number, error?: string }>} + */ +export async function handleImageGenerationCore({ + body, + modelInfo, + credentials, + log, + streamToClient = false, + binaryOutput = false, + onCredentialsRefreshed, + onRequestSuccess, +}) { + const { provider, model } = modelInfo; + + if (!body.prompt) { + return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: prompt"); + } + + const adapter = getImageAdapter(provider); + if (!adapter) { + return createErrorResult( + HTTP_STATUS.BAD_REQUEST, + `Provider '${provider}' does not support image generation` + ); + } + + let url; + let headers; + let requestBody; + + try { + url = adapter.buildUrl(model, credentials); + requestBody = await adapter.buildBody(model, body); + headers = adapter.buildHeaders(credentials, requestBody, model, body); + } catch (error) { + return createErrorResult(HTTP_STATUS.BAD_REQUEST, error.message || `Invalid ${provider} image request`); + } + + log?.debug?.("IMAGE", `${provider.toUpperCase()} | ${model} | prompt="${body.prompt.slice(0, 50)}..."`); + + let providerResponse; + try { + providerResponse = await fetch(url, { + method: "POST", + headers, + body: serializeRequestBody(requestBody), + }); + } catch (error) { + const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY); + log?.debug?.("IMAGE", `Fetch error: ${errMsg}`); + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg); + } + + // Handle 401/403 — try token refresh (skipped for noAuth providers) + const executor = getExecutor(provider); + if ( + !executor?.noAuth && + !adapter.noAuth && + (providerResponse.status === HTTP_STATUS.UNAUTHORIZED || + providerResponse.status === HTTP_STATUS.FORBIDDEN) + ) { + const newCredentials = await refreshWithRetry( + () => executor.refreshCredentials(credentials, log), + 3, + log + ); + + if (newCredentials?.accessToken || newCredentials?.apiKey) { + log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed for image generation`); + Object.assign(credentials, newCredentials); + if (onCredentialsRefreshed) await onCredentialsRefreshed(newCredentials); + + try { + const retryBody = await adapter.buildBody(model, body); + const retryHeaders = adapter.buildHeaders(credentials, retryBody, model, body); + const retryUrl = adapter.buildUrl(model, credentials); + providerResponse = await fetch(retryUrl, { + method: "POST", + headers: retryHeaders, + body: serializeRequestBody(retryBody), + }); + } catch { + log?.warn?.("TOKEN", `${provider.toUpperCase()} | retry after refresh failed`); + } + } else { + log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`); + } + } + + if (!providerResponse.ok) { + const { statusCode, message } = await parseUpstreamError(providerResponse); + const errMsg = formatProviderError(new Error(message), provider, model, statusCode); + log?.debug?.("IMAGE", `Provider error: ${errMsg}`); + return createErrorResult(statusCode, errMsg); + } + + // Parse provider response — adapter may override (codex SSE / async polling / binary) + let parsed; + try { + if (adapter.parseResponse) { + parsed = await adapter.parseResponse(providerResponse, { + headers, + log, + streamToClient, + onRequestSuccess, + url, + requestBody, + model, + body, + }); + // Codex streaming case: returns an SSE Response directly + if (parsed?.sseResponse) { + return { success: true, response: parsed.sseResponse }; + } + } else { + parsed = await providerResponse.json(); + } + } catch (parseError) { + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, parseError.message || `Invalid response from ${provider}`); + } + + if (onRequestSuccess) await onRequestSuccess(); + + // Normalize → OpenAI-compatible shape + const normalized = adapter.normalize(parsed, body.prompt); + + // Already in OpenAI shape? skip re-normalize + const finalBody = (normalized.created && Array.isArray(normalized.data)) ? normalized : parsed; + + // Binary output: decode first b64_json (or fetch url) into raw bytes + 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": "*", + }, + }), + }; +} diff --git a/open-sse/handlers/imageProviders/_base.js b/open-sse/handlers/imageProviders/_base.js new file mode 100644 index 0000000000000000000000000000000000000000..f8902de2b1e113d5c89af514f583147e0590ac86 --- /dev/null +++ b/open-sse/handlers/imageProviders/_base.js @@ -0,0 +1,31 @@ +// Shared helpers for image provider adapters + +export const POLL_INTERVAL_MS = 1500; +export const POLL_TIMEOUT_MS = 120000; + +export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Map OpenAI size to provider-specific aspect ratio +export function sizeToAspectRatio(size) { + if (!size || typeof size !== "string") return "1:1"; + const map = { + "1024x1024": "1:1", + "1024x1792": "9:16", + "1792x1024": "16:9", + "1024x1536": "2:3", + "1536x1024": "3:2", + }; + return map[size] || "1:1"; +} + +// Fetch URL → base64 (for providers returning image URLs) +export async function urlToBase64(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to fetch image: ${res.status}`); + const buf = await res.arrayBuffer(); + return Buffer.from(buf).toString("base64"); +} + +export function nowSec() { + return Math.floor(Date.now() / 1000); +} diff --git a/open-sse/handlers/imageProviders/blackForestLabs.js b/open-sse/handlers/imageProviders/blackForestLabs.js new file mode 100644 index 0000000000000000000000000000000000000000..051e35abc6f55af59f98153e5227690865d32574 --- /dev/null +++ b/open-sse/handlers/imageProviders/blackForestLabs.js @@ -0,0 +1,44 @@ +// 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 = PROVIDER_MEDIA["black-forest-labs"]?.imageConfig?.baseUrl; + +export default { + async: true, + buildUrl: (model) => `${BASE_URL}/${model}`, + buildHeaders: (creds) => { + const key = creds?.apiKey || creds?.accessToken; + return { "Content-Type": "application/json", "x-key": key }; + }, + buildBody: (_model, body) => { + const req = { prompt: body.prompt }; + if (body.size) { + const [w, h] = body.size.split("x").map(Number); + if (w) req.width = w; + if (h) req.height = h; + } + if (body.image) req.image_prompt = body.image; + return req; + }, + async parseResponse(response, { headers }) { + const data = await response.json(); + const pollingUrl = data.polling_url; + if (!pollingUrl) throw new Error("BFL: no polling_url returned"); + const deadline = Date.now() + POLL_TIMEOUT_MS; + while (Date.now() < deadline) { + await sleep(POLL_INTERVAL_MS); + const r = await fetch(pollingUrl, { headers: { "x-key": headers["x-key"], "Accept": "application/json" } }); + if (!r.ok) throw new Error(`BFL status ${r.status}`); + const s = await r.json(); + if (s.status === "Ready") return s; + if (s.status === "Error" || s.status === "Failed") throw new Error(s.error || "BFL generation failed"); + } + throw new Error("BFL polling timeout"); + }, + normalize: (responseBody) => { + const sample = responseBody.result?.sample; + if (sample) return { created: nowSec(), data: [{ url: sample }] }; + return { created: nowSec(), data: [] }; + }, +}; diff --git a/open-sse/handlers/imageProviders/cloudflareAi.js b/open-sse/handlers/imageProviders/cloudflareAi.js new file mode 100644 index 0000000000000000000000000000000000000000..d7ecef881849462acb4f8ba666b06e9095c716a2 --- /dev/null +++ b/open-sse/handlers/imageProviders/cloudflareAi.js @@ -0,0 +1,179 @@ +import { nowSec, urlToBase64 } from "./_base.js"; +import { PROVIDER_MEDIA } from "../../providers/index.js"; + +const BASE_URL = PROVIDER_MEDIA["cloudflare-ai"]?.imageConfig?.baseUrl; + +const MULTIPART_MODELS = new Set([ + "@cf/black-forest-labs/flux-2-dev", + "@cf/black-forest-labs/flux-2-klein-4b", + "@cf/black-forest-labs/flux-2-klein-9b", +]); + +const OPTIONAL_FIELDS = [ + "negative_prompt", + "guidance", + "seed", + "num_steps", + "steps", + "strength", +]; + +function sizeToDimensions(size) { + const match = /^(\d+)x(\d+)$/.exec(String(size || "")); + if (!match) return {}; + return { + width: Number(match[1]), + height: Number(match[2]), + }; +} + +function getDimensions(body) { + return { + ...sizeToDimensions(body.size), + ...(Number.isFinite(Number(body.width)) ? { width: Number(body.width) } : {}), + ...(Number.isFinite(Number(body.height)) ? { height: Number(body.height) } : {}), + }; +} + +async function resolveImageInput(value) { + if (Array.isArray(value)) { + return { bytes: value, b64: Buffer.from(value).toString("base64") }; + } + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + if (/^https?:\/\//i.test(trimmed)) { + const b64 = await urlToBase64(trimmed); + return { bytes: base64ToBytes(b64), b64 }; + } + const match = /^data:image\/[^;]+;base64,(.+)$/i.exec(trimmed); + const b64 = match ? match[1] : trimmed; + return { bytes: base64ToBytes(b64), b64 }; +} + +function base64ToBytes(value) { + try { + return Array.from(Buffer.from(value, "base64")); + } catch { + return value; + } +} + +function addOptionalFields(target, body, append) { + for (const key of OPTIONAL_FIELDS) { + const value = body[key]; + if (value === undefined || value === null || value === "") continue; + append(target, key, value); + } +} + +async function buildJsonBody(body) { + const req = { prompt: body.prompt, ...getDimensions(body) }; + + addOptionalFields(req, body, (target, key, value) => { + target[key] = value; + }); + + const imageData = await resolveImageInput(body.image); + if (imageData) { + req.image_b64 = imageData.b64; + req.image = imageData.bytes; + } + + const maskData = await resolveImageInput(body.mask_image || body.maskImage || body.mask); + if (maskData) { + req.mask_b64 = maskData.b64; + req.mask = maskData.bytes; + req.mask_image = maskData.bytes; + } + + return req; +} + +function buildMultipartBody(body) { + const form = new FormData(); + form.append("prompt", body.prompt); + + const dimensions = getDimensions(body); + for (const [key, value] of Object.entries(dimensions)) { + form.append(key, String(value)); + } + + addOptionalFields(form, body, (target, key, value) => { + target.append(key, String(value)); + }); + + return form; +} + +function imageItemFromString(value) { + if (typeof value !== "string" || !value) return null; + if (/^data:image\/[^;]+;base64,/i.test(value)) { + return { b64_json: value.replace(/^data:image\/[^;]+;base64,/i, "") }; + } + if (/^https?:\/\//i.test(value)) return { url: value }; + return { b64_json: value }; +} + +function normalizeCloudflareResponse(responseBody) { + if (responseBody?.created && Array.isArray(responseBody?.data)) return responseBody; + + const result = responseBody?.result ?? responseBody; + const queuedResponse = Array.isArray(result?.responses) + ? result.responses.find((item) => item?.success !== false)?.result + : null; + if (queuedResponse) return normalizeCloudflareResponse(queuedResponse); + + const image = + (typeof result === "string" ? result : null) || + result?.image || + result?.data?.[0]?.b64_json || + result?.data?.[0]?.url; + + const item = imageItemFromString(image); + return { + created: nowSec(), + data: item ? [item] : [], + }; +} + +export default { + buildUrl: (model, creds) => { + const accountId = creds?.providerSpecificData?.accountId; + if (!accountId) throw new Error("cloudflare-ai requires accountId in providerSpecificData"); + return `${BASE_URL}/${accountId}/ai/run/${model}`; + }, + + buildHeaders: (creds, requestBody) => { + const headers = {}; + const isMultipart = typeof FormData !== "undefined" && requestBody instanceof FormData; + if (!isMultipart) { + headers["Content-Type"] = "application/json"; + } + const key = creds?.apiKey || creds?.accessToken; + if (key) headers.Authorization = `Bearer ${key}`; + return headers; + }, + + buildBody: async (model, body) => ( + MULTIPART_MODELS.has(model) + ? buildMultipartBody(body) + : await buildJsonBody(body) + ), + + async parseResponse(response) { + const contentType = (response.headers.get("Content-Type") || "").toLowerCase(); + if (contentType.startsWith("image/")) { + const buf = await response.arrayBuffer(); + return { + created: nowSec(), + data: [{ b64_json: Buffer.from(buf).toString("base64") }], + }; + } + + const json = await response.json(); + return normalizeCloudflareResponse(json); + }, + + normalize: normalizeCloudflareResponse, +}; diff --git a/open-sse/handlers/imageProviders/codex.js b/open-sse/handlers/imageProviders/codex.js new file mode 100644 index 0000000000000000000000000000000000000000..218302abf8ccf9709204d7df805534428e15b50a --- /dev/null +++ b/open-sse/handlers/imageProviders/codex.js @@ -0,0 +1,199 @@ +// 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 = 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"; +const CODEX_MODEL_SUFFIX = "-image"; +const CODEX_REF_DETAIL = "high"; + +function decodeAccountId(idToken) { + try { + const parts = String(idToken || "").split("."); + if (parts.length !== 3) return null; + const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const pad = (4 - (b64.length % 4)) % 4; + const payload = JSON.parse(Buffer.from(b64 + "=".repeat(pad), "base64").toString("utf8")); + return payload?.["https://api.openai.com/auth"]?.chatgpt_account_id || null; + } catch { + return null; + } +} + +function stripImageSuffix(model) { + return model.endsWith(CODEX_MODEL_SUFFIX) ? model.slice(0, -CODEX_MODEL_SUFFIX.length) : model; +} + +function toDataUrl(input) { + if (!input || typeof input !== "string") return null; + if (/^data:image\//i.test(input) || /^https?:\/\//i.test(input)) return input; + return `data:image/png;base64,${input}`; +} + +function buildContent(prompt, refs, detail = CODEX_REF_DETAIL) { + const content = []; + refs.forEach((url, index) => { + content.push({ type: "input_text", text: `` }); + content.push({ type: "input_image", image_url: url, detail }); + content.push({ type: "input_text", text: "" }); + }); + content.push({ type: "input_text", text: prompt }); + return content; +} + +// Parse Codex SSE stream → final base64 image. Optional callbacks for client streaming. +async function parseStream(response, log, callbacks = {}) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let imageB64 = null; + let lastEvent = null; + let bytesReceived = 0; + let lastProgressLogMs = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + bytesReceived += value?.byteLength || 0; + buffer += decoder.decode(value, { stream: true }); + + let sepIdx; + while ((sepIdx = buffer.indexOf("\n\n")) !== -1) { + const block = buffer.slice(0, sepIdx); + buffer = buffer.slice(sepIdx + 2); + + const lines = block.split("\n"); + let eventName = null; + let dataStr = ""; + for (const line of lines) { + if (line.startsWith("event:")) eventName = line.slice(6).trim(); + else if (line.startsWith("data:")) dataStr += line.slice(5).trim(); + } + if (!eventName) continue; + if (eventName !== lastEvent) { + log?.info?.("IMAGE", `codex progress: ${eventName}`); + lastEvent = eventName; + } + + const now = Date.now(); + if (callbacks.onProgress && now - lastProgressLogMs > 200) { + lastProgressLogMs = now; + callbacks.onProgress({ stage: eventName, bytesReceived }); + } + + if (eventName === "response.image_generation_call.partial_image" && dataStr) { + try { + const data = JSON.parse(dataStr); + if (callbacks.onPartialImage && data?.partial_image_b64) { + callbacks.onPartialImage({ b64_json: data.partial_image_b64, index: data.partial_image_index }); + } + } catch {} + } + + if (eventName === "response.output_item.done" && dataStr) { + try { + const data = JSON.parse(dataStr); + const item = data?.item; + if (item?.type === "image_generation_call" && item.result) { + imageB64 = item.result; + } + } catch {} + } + } + } + return imageB64; +} + +// SSE Response that pipes codex progress + partial + done events to client +function buildSseResponse(providerResponse, log, onSuccess) { + const stream = new ReadableStream({ + async start(controller) { + const enc = new TextEncoder(); + const send = (event, data) => { + controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)); + }; + try { + const b64 = await parseStream(providerResponse, log, { + onProgress: (info) => send("progress", info), + onPartialImage: (info) => send("partial_image", info), + }); + if (!b64) { + send("error", { message: "Codex did not return an image. Account may not be entitled (Plus/Pro required)." }); + } else { + if (onSuccess) await onSuccess(); + send("done", { created: nowSec(), data: [{ b64_json: b64 }] }); + } + } catch (err) { + send("error", { message: err?.message || "Stream failed" }); + } finally { + controller.close(); + } + }, + }); + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + "Access-Control-Allow-Origin": "*", + }, + }); +} + +export default { + stream: true, + buildUrl: () => CODEX_RESPONSES_URL, + buildHeaders: (creds) => { + const accountId = creds?.providerSpecificData?.chatgptAccountId || decodeAccountId(creds?.idToken); + return { + "accept": "text/event-stream, application/json", + "authorization": `Bearer ${creds?.accessToken || ""}`, + "chatgpt-account-id": accountId || "", + "content-type": "application/json", + "originator": CODEX_ORIGINATOR, + "session_id": randomUUID(), + "user-agent": CODEX_USER_AGENT, + "version": CODEX_VERSION, + "x-client-request-id": randomUUID(), + }; + }, + buildBody: (model, body) => { + const refs = []; + if (Array.isArray(body.images)) body.images.forEach((i) => { const u = toDataUrl(i); if (u) refs.push(u); }); + const single = toDataUrl(body.image); + if (single) refs.push(single); + const detail = body.image_detail || CODEX_REF_DETAIL; + const imgTool = { type: "image_generation", output_format: (body.output_format || "png").toLowerCase() }; + if (body.size && body.size !== "") imgTool.size = body.size; + if (body.quality && body.quality !== "") imgTool.quality = body.quality; + if (body.background && body.background !== "") imgTool.background = body.background; + return { + model: stripImageSuffix(model), + instructions: "", + input: [{ type: "message", role: "user", content: buildContent(body.prompt, refs, detail) }], + tools: [imgTool], + tool_choice: "auto", + parallel_tool_calls: false, + prompt_cache_key: randomUUID(), + stream: true, + store: false, + reasoning: null, + }; + }, + // Custom: codex parses SSE → either pipe to client or collect b64 + async parseResponse(response, { log, streamToClient, onRequestSuccess }) { + if (streamToClient) { + return { sseResponse: buildSseResponse(response, log, onRequestSuccess) }; + } + const b64 = await parseStream(response, log); + if (!b64) { + throw new Error("Codex did not return an image. Account may not be entitled (Plus/Pro required)."); + } + return { created: nowSec(), data: [{ b64_json: b64 }] }; + }, + normalize: (responseBody) => responseBody, +}; diff --git a/open-sse/handlers/imageProviders/comfyui.js b/open-sse/handlers/imageProviders/comfyui.js new file mode 100644 index 0000000000000000000000000000000000000000..767d7de32062cc673526b9f7205549f62de6417a --- /dev/null +++ b/open-sse/handlers/imageProviders/comfyui.js @@ -0,0 +1,12 @@ +// 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: () => 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 new file mode 100644 index 0000000000000000000000000000000000000000..874c635d608a5497db3e621bfec12997dad9956c --- /dev/null +++ b/open-sse/handlers/imageProviders/falAi.js @@ -0,0 +1,42 @@ +// 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 = PROVIDER_MEDIA["fal-ai"]?.imageConfig?.baseUrl; + +export default { + async: true, + buildUrl: (model) => `${BASE_URL}/${model}`, + buildHeaders: (creds) => { + const key = creds?.apiKey || creds?.accessToken; + return { "Content-Type": "application/json", "Authorization": `Key ${key}` }; + }, + buildBody: (_model, body) => { + const req = { prompt: body.prompt, num_images: body.n || 1 }; + if (body.size) req.image_size = sizeToAspectRatio(body.size); + if (body.image) req.image_url = body.image; + return req; + }, + async parseResponse(response, { headers }) { + const { status_url, response_url } = await response.json(); + const deadline = Date.now() + POLL_TIMEOUT_MS; + while (Date.now() < deadline) { + await sleep(POLL_INTERVAL_MS); + const r = await fetch(status_url, { headers }); + if (!r.ok) throw new Error(`Fal status ${r.status}`); + const s = await r.json(); + if (s.status === "COMPLETED") { + const fr = await fetch(response_url, { headers }); + return await fr.json(); + } + if (s.status === "FAILED") throw new Error(s.error || "Fal generation failed"); + } + throw new Error("Fal polling timeout"); + }, + normalize: (responseBody) => { + const images = Array.isArray(responseBody.images) + ? responseBody.images + : (responseBody.image ? [responseBody.image] : []); + return { created: nowSec(), data: images.map((img) => ({ url: img.url || img })) }; + }, +}; diff --git a/open-sse/handlers/imageProviders/gemini.js b/open-sse/handlers/imageProviders/gemini.js new file mode 100644 index 0000000000000000000000000000000000000000..f1f6a996c3fa89fe3e0d28ff15b3a511275fc214 --- /dev/null +++ b/open-sse/handlers/imageProviders/gemini.js @@ -0,0 +1,26 @@ +// Google Gemini adapter (Nano Banana models) +import { nowSec } from "./_base.js"; +import { PROVIDER_MEDIA } from "../../providers/index.js"; + +const BASE_URL = PROVIDER_MEDIA["gemini"]?.imageConfig?.baseUrl; + +export default { + buildUrl: (model, creds) => { + const apiKey = creds?.apiKey || creds?.accessToken; + const modelId = model.replace(/^models\//, ""); + return `${BASE_URL}/${modelId}:generateContent?key=${encodeURIComponent(apiKey)}`; + }, + buildHeaders: () => ({ "Content-Type": "application/json" }), + buildBody: (_model, body) => ({ + contents: [{ parts: [{ text: body.prompt }] }], + generationConfig: { responseModalities: ["TEXT", "IMAGE"] }, + }), + normalize: (responseBody, prompt) => { + const parts = responseBody.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 }], + }; + }, +}; diff --git a/open-sse/handlers/imageProviders/huggingface.js b/open-sse/handlers/imageProviders/huggingface.js new file mode 100644 index 0000000000000000000000000000000000000000..2093d9c3e0ff794a36e53f95c7cad8512b8af640 --- /dev/null +++ b/open-sse/handlers/imageProviders/huggingface.js @@ -0,0 +1,23 @@ +// HuggingFace Inference API — returns binary image +import { nowSec } from "./_base.js"; +import { PROVIDER_MEDIA } from "../../providers/index.js"; + +const BASE_URL = PROVIDER_MEDIA["huggingface"]?.imageConfig?.baseUrl; + +export default { + buildUrl: (model) => `${BASE_URL}/${model}`, + buildHeaders: (creds) => { + const headers = { "Content-Type": "application/json" }; + const key = creds?.apiKey || creds?.accessToken; + if (key) headers["Authorization"] = `Bearer ${key}`; + return headers; + }, + buildBody: (_model, body) => ({ inputs: body.prompt }), + // HF returns raw image bytes — convert to b64_json + async parseResponse(response) { + const buf = await response.arrayBuffer(); + const base64 = Buffer.from(buf).toString("base64"); + return { created: nowSec(), data: [{ b64_json: base64 }] }; + }, + normalize: (responseBody) => responseBody, +}; diff --git a/open-sse/handlers/imageProviders/index.js b/open-sse/handlers/imageProviders/index.js new file mode 100644 index 0000000000000000000000000000000000000000..95d8e005bf02c373bb8cc0d36602f500f20032eb --- /dev/null +++ b/open-sse/handlers/imageProviders/index.js @@ -0,0 +1,41 @@ +// Image provider adapter registry +import createOpenAIAdapter from "./openai.js"; +import gemini from "./gemini.js"; +import codex from "./codex.js"; +import sdwebui from "./sdwebui.js"; +import comfyui from "./comfyui.js"; +import huggingface from "./huggingface.js"; +import nanobanana from "./nanobanana.js"; +import falAi from "./falAi.js"; +import stabilityAi from "./stabilityAi.js"; +import blackForestLabs from "./blackForestLabs.js"; +import runwayml from "./runwayml.js"; +import cloudflareAi from "./cloudflareAi.js"; + +const ADAPTERS = { + openai: createOpenAIAdapter("openai"), + minimax: createOpenAIAdapter("minimax"), + openrouter: createOpenAIAdapter("openrouter"), + recraft: createOpenAIAdapter("recraft"), + "vercel-ai-gateway": createOpenAIAdapter("vercel-ai-gateway"), + xai: createOpenAIAdapter("xai"), + gemini, + codex, + sdwebui, + comfyui, + huggingface, + nanobanana, + "fal-ai": falAi, + "stability-ai": stabilityAi, + "black-forest-labs": blackForestLabs, + runwayml, + "cloudflare-ai": cloudflareAi, +}; + +export function getImageAdapter(provider) { + return ADAPTERS[provider] || null; +} + +export function isImageProvider(provider) { + return provider in ADAPTERS; +} diff --git a/open-sse/handlers/imageProviders/nanobanana.js b/open-sse/handlers/imageProviders/nanobanana.js new file mode 100644 index 0000000000000000000000000000000000000000..4685fde612fbc9008f3fcb66011eb6ebee3a11c0 --- /dev/null +++ b/open-sse/handlers/imageProviders/nanobanana.js @@ -0,0 +1,60 @@ +// 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 IMG_CFG = PROVIDER_MEDIA["nanobanana"]?.imageConfig || {}; +const SUBMIT_URL = IMG_CFG.baseUrl; +const POLL_BASE = IMG_CFG.pollUrl; + +export default { + async: true, + buildUrl: () => SUBMIT_URL, + buildHeaders: (creds) => { + const headers = { "Content-Type": "application/json" }; + const key = creds?.apiKey || creds?.accessToken; + if (key) headers["Authorization"] = `Bearer ${key}`; + return headers; + }, + buildBody: (_model, body) => { + const ratio = sizeToAspectRatio(body.size); + const isEdit = !!(body.image || (Array.isArray(body.images) && body.images.length)); + const req = { + prompt: body.prompt, + type: isEdit ? "IMAGETOIAMGE" : "TEXTTOIAMGE", + numImages: body.n || 1, + image_size: ratio, + // API requires callBackUrl; we poll instead so a dummy URL is fine. + callBackUrl: "https://localhost/callback", + }; + if (isEdit) { + const urls = Array.isArray(body.images) ? body.images.filter(Boolean) : []; + if (body.image) urls.push(body.image); + req.imageUrls = urls; + } + return req; + }, + // Async: parse submit → poll until SUCCESS, return raw poll data + async parseResponse(response, { headers }) { + const submitData = await response.json(); + if (submitData.code !== 200) throw new Error(submitData.msg || "NanoBanana submit failed"); + const taskId = submitData.data?.taskId; + if (!taskId) throw new Error("NanoBanana: no taskId returned"); + const pollUrl = `${POLL_BASE}?taskId=${encodeURIComponent(taskId)}`; + const deadline = Date.now() + POLL_TIMEOUT_MS; + while (Date.now() < deadline) { + await sleep(POLL_INTERVAL_MS); + const r = await fetch(pollUrl, { headers }); + if (!r.ok) throw new Error(`NanoBanana status ${r.status}`); + const s = await r.json(); + const flag = s.data?.successFlag; + if (flag === 1) return s.data; + if (flag === 2 || flag === 3) throw new Error(s.data?.errorMessage || "NanoBanana generation failed"); + } + throw new Error("NanoBanana polling timeout"); + }, + normalize: (responseBody, prompt) => { + const url = responseBody.response?.resultImageUrl || responseBody.response?.originImageUrl; + if (url) return { created: nowSec(), data: [{ url, revised_prompt: prompt }] }; + return { created: nowSec(), data: [] }; + }, +}; diff --git a/open-sse/handlers/imageProviders/openai.js b/open-sse/handlers/imageProviders/openai.js new file mode 100644 index 0000000000000000000000000000000000000000..5744aa5e318c2a734cbf4ff61125a2f7c2d69224 --- /dev/null +++ b/open-sse/handlers/imageProviders/openai.js @@ -0,0 +1,33 @@ +// OpenAI-compatible adapter (used by openai, minimax, openrouter, recraft) +import { PROVIDER_MEDIA } from "../../providers/index.js"; + +const imageCfg = (id) => PROVIDER_MEDIA[id]?.imageConfig || {}; +const imageUrl = (id) => imageCfg(id).baseUrl; + +export default function createOpenAIAdapter(providerId) { + const cfg = imageCfg(providerId); + return { + buildUrl: () => imageUrl(providerId), + buildHeaders: (creds) => { + const headers = { "Content-Type": "application/json", ...(cfg.headers || {}) }; + const key = creds?.apiKey || creds?.accessToken; + if (key) headers["Authorization"] = `Bearer ${key}`; + return headers; + }, + buildBody: (model, body) => { + const { prompt, n = 1, size = "1024x1024", quality, style, response_format } = body; + 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; + } + return full; + }, + normalize: (responseBody) => responseBody, + }; +} diff --git a/open-sse/handlers/imageProviders/runwayml.js b/open-sse/handlers/imageProviders/runwayml.js new file mode 100644 index 0000000000000000000000000000000000000000..7eabadb5e93d2101ef272578635d408d4a1b131b --- /dev/null +++ b/open-sse/handlers/imageProviders/runwayml.js @@ -0,0 +1,48 @@ +// 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 = PROVIDER_MEDIA["runwayml"]?.imageConfig?.baseUrl; + +export default { + async: true, + buildUrl: (model) => { + // Image models (gen4_image*) → text_to_image; video models → image_to_video + return `${BASE_URL}/${model.includes("image") ? "text_to_image" : "image_to_video"}`; + }, + buildHeaders: (creds) => { + const key = creds?.apiKey || creds?.accessToken; + return { + "Content-Type": "application/json", + "Authorization": `Bearer ${key}`, + "X-Runway-Version": "2024-11-06", + }; + }, + buildBody: (model, body) => { + const isVideo = !model.includes("image"); + const ratio = sizeToAspectRatio(body.size); + if (isVideo) { + return { promptText: body.prompt, model, ratio, duration: 5, ...(body.image ? { promptImage: body.image } : {}) }; + } + return { promptText: body.prompt, model, ratio, ...(body.image ? { referenceImages: [{ uri: body.image }] } : {}) }; + }, + async parseResponse(response, { headers }) { + const { id } = await response.json(); + if (!id) throw new Error("Runway: no task id returned"); + const taskUrl = `${BASE_URL}/tasks/${id}`; + const deadline = Date.now() + POLL_TIMEOUT_MS; + while (Date.now() < deadline) { + await sleep(POLL_INTERVAL_MS); + const r = await fetch(taskUrl, { headers }); + if (!r.ok) throw new Error(`Runway status ${r.status}`); + const s = await r.json(); + if (s.status === "SUCCEEDED") return s; + if (s.status === "FAILED" || s.status === "CANCELLED") throw new Error(s.failure || "Runway task failed"); + } + throw new Error("Runway polling timeout"); + }, + normalize: (responseBody) => { + const outputs = Array.isArray(responseBody.output) ? responseBody.output : []; + return { created: nowSec(), data: outputs.map((url) => ({ url })) }; + }, +}; diff --git a/open-sse/handlers/imageProviders/sdwebui.js b/open-sse/handlers/imageProviders/sdwebui.js new file mode 100644 index 0000000000000000000000000000000000000000..33e3a69690e513628e0e6cbb51a76f1bc131505e --- /dev/null +++ b/open-sse/handlers/imageProviders/sdwebui.js @@ -0,0 +1,20 @@ +// 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: () => BASE_URL, + buildHeaders: () => ({ "Content-Type": "application/json" }), + buildBody: (_model, body) => { + const { prompt, n = 1, size = "1024x1024" } = body; + const [width, height] = size.split("x").map(Number); + return { prompt, width: width || 512, height: height || 512, steps: 20, batch_size: n }; + }, + normalize: (responseBody) => { + const images = Array.isArray(responseBody.images) ? responseBody.images.map((img) => ({ b64_json: img })) : []; + return { created: nowSec(), data: images }; + }, +}; diff --git a/open-sse/handlers/imageProviders/stabilityAi.js b/open-sse/handlers/imageProviders/stabilityAi.js new file mode 100644 index 0000000000000000000000000000000000000000..79f93d55d198debaade8c0d1a4fabb2d678b4b3a --- /dev/null +++ b/open-sse/handlers/imageProviders/stabilityAi.js @@ -0,0 +1,35 @@ +// Stability AI v2 — sync, returns { image: "" } +import { nowSec, sizeToAspectRatio } from "./_base.js"; +import { PROVIDER_MEDIA } from "../../providers/index.js"; + +const BASE_URL = PROVIDER_MEDIA["stability-ai"]?.imageConfig?.baseUrl; + +// Map model id → endpoint segment +function modelToEndpoint(model) { + if (model.includes("ultra")) return "ultra"; + if (model.includes("sd3")) return "sd3"; + return "core"; +} + +export default { + buildUrl: (model) => `${BASE_URL}/${modelToEndpoint(model)}`, + buildHeaders: (creds) => { + const key = creds?.apiKey || creds?.accessToken; + return { + "Content-Type": "application/json", + "Authorization": `Bearer ${key}`, + "Accept": "application/json", + }; + }, + buildBody: (model, body) => { + const req = { prompt: body.prompt, output_format: (body.output_format || "png").toLowerCase() }; + if (body.size) req.aspect_ratio = sizeToAspectRatio(body.size); + if (body.style) req.style_preset = body.style; + if (model.includes("sd3")) req.model = model; + return req; + }, + normalize: (responseBody) => { + if (responseBody.image) return { created: nowSec(), data: [{ b64_json: responseBody.image }] }; + return { created: nowSec(), data: [] }; + }, +}; diff --git a/open-sse/handlers/responsesHandler.js b/open-sse/handlers/responsesHandler.js new file mode 100644 index 0000000000000000000000000000000000000000..8c17f98a4e8f1bfd90fd3b7f794e5851e071981d --- /dev/null +++ b/open-sse/handlers/responsesHandler.js @@ -0,0 +1,99 @@ +/** + * Responses API Handler for Workers + * Converts Chat Completions to Codex Responses API format + */ + +import { handleChatCore } from "./chatCore.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 + * @param {object} options + * @param {object} options.body - Request body (Responses API format) + * @param {object} options.modelInfo - { provider, model } + * @param {object} options.credentials - Provider credentials + * @param {object} options.log - Logger instance (optional) + * @param {function} options.onCredentialsRefreshed - Callback when credentials are refreshed + * @param {function} options.onRequestSuccess - Callback when request succeeds + * @param {function} options.onDisconnect - Callback when client disconnects + * @param {string} options.connectionId - Connection ID for usage tracking + * @returns {Promise<{success: boolean, response?: Response, status?: number, error?: string}>} + */ +export async function handleResponsesCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, connectionId }) { + // Convert Responses API format to Chat Completions format + const convertedBody = convertResponsesApiFormat(body); + + // Preserve client's stream preference (matches OpenClaw behavior) + // Default to false if omitted: Boolean(undefined) = false + const clientRequestedStreaming = convertedBody.stream === true; + if (convertedBody.stream === undefined) { + convertedBody.stream = false; + } + + // Call chat core handler — force sourceFormat so streaming path knows this is a Responses API client + const result = await handleChatCore({ + body: convertedBody, + modelInfo, + credentials, + log, + onCredentialsRefreshed, + onRequestSuccess, + onDisconnect, + connectionId, + sourceFormatOverride: "openai-responses" + }); + + if (!result.success || !result.response) { + return result; + } + + const response = result.response; + const contentType = response.headers.get("Content-Type") || ""; + + // Case 1: Client wants non-streaming, but got SSE (provider forced it, e.g., Codex) + if (!clientRequestedStreaming && contentType.includes("text/event-stream")) { + try { + const jsonResponse = await convertResponsesStreamToJson(response.body); + + return { + success: true, + response: new Response(JSON.stringify(jsonResponse), { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-cache", + "Access-Control-Allow-Origin": "*" + } + }) + }; + } catch (error) { + console.error("[Responses API] Stream-to-JSON conversion failed:", error); + return { + success: false, + status: 500, + error: "Failed to convert streaming response to JSON" + }; + } + } + + // Case 2: Client wants streaming, got SSE - transform it + if (clientRequestedStreaming && contentType.includes("text/event-stream")) { + const transformStream = createResponsesApiTransformStream(null); + const transformedBody = response.body.pipeThrough(transformStream); + + return { + success: true, + response: new Response(transformedBody, { + status: 200, + headers: { ...SSE_HEADERS_CORS } + }) + }; + } + + // Case 3: Non-SSE response (error or non-streaming from provider) - return as-is + return result; +} + diff --git a/open-sse/handlers/search/callers.js b/open-sse/handlers/search/callers.js new file mode 100644 index 0000000000000000000000000000000000000000..64f045c31a28371d30c9b1e0a42d25c7528100c0 --- /dev/null +++ b/open-sse/handlers/search/callers.js @@ -0,0 +1,371 @@ +/** + * Search Provider Request Builders + * + * Ported from OmniRoute open-sse/handlers/search.ts (lines 223-610). + * Builds HTTP request `{ url, init }` for 10 search providers. + * + * @typedef {Object} SearchProviderConfig + * @property {string} id + * @property {string} baseUrl + * @property {string} [method] + * + * @typedef {Object} ContentOptions + * @property {boolean} [snippet] + * @property {boolean} [full_page] + * @property {string} [format] + * @property {number} [max_characters] + * + * @typedef {Object} SearchRequestParams + * @property {string} query + * @property {string} searchType + * @property {number} maxResults + * @property {string} [token] + * @property {string} [country] + * @property {string} [language] + * @property {string} [timeRange] + * @property {number} [offset] + * @property {string[]} [domainFilter] + * @property {ContentOptions} [contentOptions] + * @property {Record} [providerOptions] + * @property {Record} [providerSpecificData] + */ + +// ── Helpers ───────────────────────────────────────────────────────────── + +/** + * Split domain filter into includes / excludes (excludes prefixed with "-"). + * @param {string[]} [domainFilter] + * @returns {{includes: string[], excludes: string[]}} + */ +export function parseDomainFilter(domainFilter) { + if (!domainFilter?.length) return { includes: [], excludes: [] }; + const includes = domainFilter.filter((d) => !d.startsWith("-")); + const excludes = domainFilter.filter((d) => d.startsWith("-")).map((d) => d.slice(1)); + return { includes, excludes }; +} + +/** + * Read string setting from providerOptions first, then providerSpecificData. + * @param {SearchRequestParams} params + * @param {string} key + * @returns {string|undefined} + */ +export function getProviderSetting(params, key) { + const fromOptions = params.providerOptions?.[key]; + if (typeof fromOptions === "string" && fromOptions.trim().length > 0) { + return fromOptions.trim(); + } + const fromProviderData = params.providerSpecificData?.[key]; + if (typeof fromProviderData === "string" && fromProviderData.trim().length > 0) { + return fromProviderData.trim(); + } + return undefined; +} + +/** + * Resolve base URL with optional override from providerOptions.baseUrl. + * @param {SearchProviderConfig} config + * @param {SearchRequestParams} params + * @returns {string} + */ +export function resolveBaseUrl(config, params) { + const override = getProviderSetting(params, "baseUrl"); + return (override || config.baseUrl).replace(/\/+$/, ""); +} + +/** + * Convert offset+maxResults to 1-indexed page number. + * @param {number|undefined} offset + * @param {number} maxResults + * @returns {number|undefined} + */ +export function toPageNumber(offset, maxResults) { + if (typeof offset !== "number" || offset <= 0 || maxResults <= 0) return undefined; + return Math.floor(offset / maxResults) + 1; +} + +// ── Provider Request Builders ─────────────────────────────────────────── + +function buildSerperRequest(config, params) { + const endpoint = params.searchType === "news" ? "/news" : "/search"; + const body = { q: params.query, num: params.maxResults }; + if (params.country) body.gl = params.country.toLowerCase(); + if (params.language) body.hl = params.language; + return { + url: `${resolveBaseUrl(config, params)}${endpoint}`, + init: { + method: "POST", + headers: { "Content-Type": "application/json", "X-API-Key": params.token }, + body: JSON.stringify(body), + }, + }; +} + +function buildBraveRequest(config, params) { + const endpoint = params.searchType === "news" ? "/news/search" : "/web/search"; + const qp = new URLSearchParams({ q: params.query, count: String(params.maxResults) }); + if (params.country) qp.set("country", params.country); + if (params.language) qp.set("search_lang", params.language); + return { + url: `${resolveBaseUrl(config, params)}${endpoint}?${qp}`, + init: { + method: "GET", + headers: { Accept: "application/json", "X-Subscription-Token": params.token }, + }, + }; +} + +function buildPerplexityRequest(config, params) { + const body = { query: params.query, max_results: params.maxResults }; + if (params.country) body.country = params.country; + if (params.language) body.search_language_filter = [params.language]; + if (params.domainFilter?.length) body.search_domain_filter = params.domainFilter; + return { + url: resolveBaseUrl(config, params), + init: { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${params.token}` }, + body: JSON.stringify(body), + }, + }; +} + +function buildExaRequest(config, params) { + const { includes, excludes } = parseDomainFilter(params.domainFilter); + const body = { + query: params.query, + numResults: params.maxResults, + type: "auto", + text: true, + highlights: true, + }; + if (includes.length) body.includeDomains = includes; + if (excludes.length) body.excludeDomains = excludes; + if (params.searchType === "news") body.category = "news"; + return { + url: resolveBaseUrl(config, params), + init: { + method: "POST", + headers: { "Content-Type": "application/json", "x-api-key": params.token }, + body: JSON.stringify(body), + }, + }; +} + +function buildTavilyRequest(config, params) { + const { includes, excludes } = parseDomainFilter(params.domainFilter); + const body = { + query: params.query, + max_results: params.maxResults, + topic: params.searchType === "news" ? "news" : "general", + }; + if (includes.length) body.include_domains = includes; + if (excludes.length) body.exclude_domains = excludes; + if (params.country) body.country = params.country; + return { + url: resolveBaseUrl(config, params), + init: { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${params.token}` }, + body: JSON.stringify(body), + }, + }; +} + +function buildGooglePseRequest(config, params) { + const apiKey = params.token; + const cx = getProviderSetting(params, "cx"); + if (!apiKey || !cx) { + throw new Error("Google Programmable Search requires both apiKey and cx"); + } + const qp = new URLSearchParams({ + key: apiKey, + cx, + q: params.query, + num: String(Math.min(params.maxResults, 10)), + }); + if (params.country) qp.set("gl", params.country.toLowerCase()); + if (params.language) qp.set("hl", params.language); + if (params.timeRange && params.timeRange !== "any") { + const dateRestrictMap = { day: "d1", week: "w1", month: "m1", year: "y1" }; + const dateRestrict = dateRestrictMap[params.timeRange]; + if (dateRestrict) qp.set("dateRestrict", dateRestrict); + } + if (typeof params.offset === "number" && params.offset > 0) { + qp.set("start", String(Math.min(params.offset + 1, 91))); + } + return { + url: `${resolveBaseUrl(config, params)}?${qp}`, + init: { + method: "GET", + headers: { Accept: "application/json" }, + }, + }; +} + +function buildLinkupRequest(config, params) { + const apiKey = params.token; + if (!apiKey) throw new Error("Linkup Search requires an API key"); + + const { includes, excludes } = parseDomainFilter(params.domainFilter); + const requestedDepth = getProviderSetting(params, "depth"); + const depth = + requestedDepth && ["fast", "standard", "deep"].includes(requestedDepth) + ? requestedDepth + : "standard"; + + const body = { + q: params.query, + depth, + outputType: "searchResults", + maxResults: params.maxResults, + }; + if (includes.length) body.includeDomains = includes; + if (excludes.length) body.excludeDomains = excludes; + if (params.timeRange && params.timeRange !== "any") { + const today = new Date(); + const toDate = today.toISOString().slice(0, 10); + const from = new Date(today); + if (params.timeRange === "day") from.setUTCDate(from.getUTCDate() - 1); + if (params.timeRange === "week") from.setUTCDate(from.getUTCDate() - 7); + if (params.timeRange === "month") from.setUTCMonth(from.getUTCMonth() - 1); + if (params.timeRange === "year") from.setUTCFullYear(from.getUTCFullYear() - 1); + body.fromDate = from.toISOString().slice(0, 10); + body.toDate = toDate; + } + + return { + url: resolveBaseUrl(config, params), + init: { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify(body), + }, + }; +} + +function buildSearchApiRequest(config, params) { + const apiKey = params.token; + if (!apiKey) throw new Error("SearchAPI requires an API key"); + + const qp = new URLSearchParams({ + engine: params.searchType === "news" ? "google_news" : "google", + q: params.query, + api_key: apiKey, + }); + if (params.country) qp.set("gl", params.country.toLowerCase()); + if (params.language) qp.set("hl", params.language); + + const page = toPageNumber(params.offset, params.maxResults); + if (page) qp.set("page", String(page)); + + return { + url: `${resolveBaseUrl(config, params)}?${qp}`, + init: { + method: "GET", + headers: { Accept: "application/json" }, + }, + }; +} + +function buildYouComRequest(config, params) { + const apiKey = params.token; + if (!apiKey) throw new Error("You.com Search requires an API key"); + + const { includes, excludes } = parseDomainFilter(params.domainFilter); + const qp = new URLSearchParams({ + query: params.query, + count: String(Math.min(params.maxResults, 100)), + }); + + if (params.timeRange && params.timeRange !== "any") qp.set("freshness", params.timeRange); + if (typeof params.offset === "number" && params.offset > 0 && params.maxResults > 0) { + qp.set("offset", String(Math.min(Math.floor(params.offset / params.maxResults), 9))); + } + if (params.country) qp.set("country", params.country); + if (params.language) qp.set("language", params.language); + if (includes.length) qp.set("include_domains", includes.join(",")); + if (excludes.length) qp.set("exclude_domains", excludes.join(",")); + + if (params.contentOptions?.full_page) { + qp.set("livecrawl", params.searchType === "news" ? "news" : "web"); + qp.append( + "livecrawl_formats", + params.contentOptions.format === "markdown" ? "markdown" : "html" + ); + } + + return { + url: `${resolveBaseUrl(config, params)}?${qp}`, + init: { + method: "GET", + headers: { Accept: "application/json", "X-API-Key": apiKey }, + }, + }; +} + +function buildSearxngRequest(config, params) { + const baseUrl = resolveBaseUrl(config, params); + const url = baseUrl.endsWith("/search") ? baseUrl : `${baseUrl}/search`; + const qp = new URLSearchParams({ + q: params.query, + format: "json", + categories: params.searchType === "news" ? "news" : "general", + }); + if (params.language) qp.set("language", params.language); + if (params.timeRange && params.timeRange !== "any") qp.set("time_range", params.timeRange); + + const page = toPageNumber(params.offset, params.maxResults); + if (page) qp.set("pageno", String(page)); + + return { + url: `${url}?${qp}`, + init: { + method: "GET", + headers: { Accept: "application/json" }, + }, + }; +} + +// ── Dispatcher ────────────────────────────────────────────────────────── + +const BUILDERS = { + "serper": buildSerperRequest, + "brave-search": buildBraveRequest, + "perplexity": buildPerplexityRequest, + "exa": buildExaRequest, + "tavily": buildTavilyRequest, + "google-pse": buildGooglePseRequest, + "linkup": buildLinkupRequest, + "searchapi": buildSearchApiRequest, + "youcom": buildYouComRequest, + "searxng": buildSearxngRequest, +}; + +/** + * Dispatch to the correct provider builder by `provider.id`. + * Falls back to generic POST + bearer auth for unknown providers. + * @param {SearchProviderConfig} provider + * @param {SearchRequestParams} params + * @returns {{url: string, init: RequestInit}} + */ +export function buildSearchRequest(provider, params) { + const builder = BUILDERS[provider.id]; + if (builder) return builder(provider, params); + + return { + url: resolveBaseUrl(provider, params), + init: { + method: provider.method || "POST", + headers: { + "Content-Type": "application/json", + ...(params.token ? { Authorization: `Bearer ${params.token}` } : {}), + }, + body: JSON.stringify({ + query: params.query, + max_results: params.maxResults, + search_type: params.searchType, + }), + }, + }; +} diff --git a/open-sse/handlers/search/chatSearch.js b/open-sse/handlers/search/chatSearch.js new file mode 100644 index 0000000000000000000000000000000000000000..a8a7841eff9d1d0e3eda241a414383598b5741c1 --- /dev/null +++ b/open-sse/handlers/search/chatSearch.js @@ -0,0 +1,408 @@ +/** + * 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; + +/** + * Normalize a citation entry into the unified result shape. + * @param {{url:string, title?:string, snippet?:string}} c + * @param {number} index + * @param {string} provider + * @param {string} retrievedAt + */ +function toResult(c, index, provider, retrievedAt) { + return { + title: c.title || "", + url: c.url, + snippet: c.snippet || "", + position: index + 1, + score: null, + published_at: null, + favicon_url: null, + content: null, + metadata: {}, + citation: { provider, retrieved_at: retrievedAt, rank: index + 1 }, + provider_raw: null + }; +} + +/** Coerce a citation that might be a raw URL string or an object. */ +function normalizeCitation(c) { + if (!c) return null; + if (typeof c === "string") return { url: c }; + if (typeof c === "object" && c.url) return c; + return null; +} + +/** + * Provider-specific configuration map. All providers must implement: + * { endpoint, defaultModel, buildBody, buildHeaders, extractAnswer } + */ +const CHAT_SEARCH_CONFIG = { + gemini: { + endpoint: (model) => searchEndpoint("gemini", model), + buildBody: (query) => ({ + contents: [{ role: "user", parts: [{ text: query }] }], + tools: [{ google_search: {} }] + }), + buildHeaders: (token) => ({ + "Content-Type": "application/json", + "x-goog-api-key": token + }), + extractAnswer: (data) => { + const candidate = data?.candidates?.[0]; + const parts = candidate?.content?.parts || []; + const text = parts.map((p) => p?.text || "").filter(Boolean).join(""); + const chunks = candidate?.groundingMetadata?.groundingChunks || []; + const citations = chunks + .map((ch) => ch?.web) + .filter(Boolean) + .map((w) => ({ url: w.uri || w.url, title: w.title || "" })) + .filter((c) => c.url); + const tokens = data?.usageMetadata?.totalTokenCount || 0; + return { text, citations, tokens }; + } + }, + + openai: { + endpoint: () => searchEndpoint("openai"), + buildBody: (query, model) => { + const body = { + model, + messages: [{ role: "user", content: query }] + }; + // Non-search-preview models need explicit web_search tool + if (!/search/i.test(model)) { + body.tools = [{ type: "web_search" }]; + } + return body; + }, + buildHeaders: (token) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }), + extractAnswer: (data) => { + const msg = data?.choices?.[0]?.message || {}; + const text = msg.content || ""; + const annotations = Array.isArray(msg.annotations) ? msg.annotations : []; + const fromAnn = annotations + .map((a) => a?.url_citation) + .filter(Boolean) + .map((u) => ({ url: u.url, title: u.title || "" })); + const fromTop = Array.isArray(data?.citations) + ? data.citations.map(normalizeCitation).filter(Boolean) + : []; + const citations = fromAnn.length ? fromAnn : fromTop; + const tokens = data?.usage?.total_tokens || 0; + return { text, citations, tokens }; + } + }, + + xai: { + endpoint: () => searchEndpoint("xai"), + buildBody: (query, model) => ({ + model, + input: [{ role: "user", content: query }], + tools: [{ type: "web_search" }] + }), + buildHeaders: (token) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }), + extractAnswer: (data) => { + // /v1/responses returns output[] array of message/tool blocks + const output = Array.isArray(data?.output) ? data.output : []; + let text = ""; + const citations = []; + for (const item of output) { + const parts = Array.isArray(item?.content) ? item.content : []; + for (const p of parts) { + if (typeof p?.text === "string") text += p.text; + const anns = Array.isArray(p?.annotations) ? p.annotations : []; + for (const a of anns) { + const c = normalizeCitation(a?.url ? a : a?.url_citation); + if (c) citations.push(c); + } + } + } + // Fallback: top-level citations array (some response variants) + if (!citations.length && Array.isArray(data?.citations)) { + for (const c of data.citations) { + const n = normalizeCitation(c); + if (n) citations.push(n); + } + } + const tokens = data?.usage?.total_tokens || 0; + return { text, citations, tokens }; + } + }, + + kimi: { + endpoint: () => searchEndpoint("kimi"), + buildBody: (query, model) => ({ + model, + messages: [{ role: "user", content: query }], + tools: [ + { type: "builtin_function", function: { name: "$web_search" } } + ] + }), + buildHeaders: (token) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }), + extractAnswer: (data) => { + const msg = data?.choices?.[0]?.message || {}; + const text = msg.content || ""; + const calls = Array.isArray(msg.tool_calls) ? msg.tool_calls : []; + const citations = []; + for (const call of calls) { + const argStr = call?.function?.arguments; + if (!argStr) continue; + let parsed; + try { + parsed = typeof argStr === "string" ? JSON.parse(argStr) : argStr; + } catch { + continue; + } + const items = + parsed?.search_results || + parsed?.results || + parsed?.references || + []; + if (Array.isArray(items)) { + for (const it of items) { + const url = it?.url || it?.link; + if (!url) continue; + citations.push({ + url, + title: it.title || "", + snippet: it.snippet || it.summary || "" + }); + } + } + } + const tokens = data?.usage?.total_tokens || 0; + return { text, citations, tokens }; + } + }, + + minimax: { + endpoint: () => searchEndpoint("minimax"), + buildBody: (query, model) => ({ + model, + messages: [{ role: "user", content: query }], + tools: [{ type: "web_search" }] + }), + buildHeaders: (token) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }), + extractAnswer: (data) => { + const msg = data?.choices?.[0]?.message || {}; + const text = msg.content || ""; + const citations = []; + const direct = Array.isArray(data?.web_search_results) + ? data.web_search_results + : []; + for (const it of direct) { + const url = it?.url || it?.link; + if (url) { + citations.push({ + url, + title: it.title || "", + snippet: it.snippet || it.summary || "" + }); + } + } + if (!citations.length) { + const calls = Array.isArray(msg.tool_calls) ? msg.tool_calls : []; + for (const call of calls) { + const argStr = call?.function?.arguments; + if (!argStr) continue; + let parsed; + try { + parsed = typeof argStr === "string" ? JSON.parse(argStr) : argStr; + } catch { + continue; + } + const items = parsed?.results || parsed?.search_results || []; + if (Array.isArray(items)) { + for (const it of items) { + const url = it?.url || it?.link; + if (!url) continue; + citations.push({ + url, + title: it.title || "", + snippet: it.snippet || "" + }); + } + } + } + } + const tokens = data?.usage?.total_tokens || 0; + return { text, citations, tokens }; + } + }, + + perplexity: { + endpoint: () => searchEndpoint("perplexity"), + buildBody: (query, model) => ({ + model, + messages: [{ role: "user", content: query }] + }), + buildHeaders: (token) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }), + extractAnswer: (data) => { + const msg = data?.choices?.[0]?.message || {}; + const text = msg.content || ""; + const raw = data?.citations || []; + const citations = Array.isArray(raw) + ? raw.map(normalizeCitation).filter(Boolean) + : []; + const tokens = data?.usage?.total_tokens || 0; + return { text, citations, tokens }; + } + } +}; + +/** + * Execute a chat-search request against the chosen provider. + * @param {object} params + * @param {string} params.provider + * @param {string} params.query + * @param {number} [params.maxResults] + * @param {string} [params.model] + * @param {{apiKey?:string, accessToken?:string}} params.credentials + * @param {{info?:Function, warn?:Function, error?:Function}} [params.log] + * @returns {Promise<{success:boolean, status?:number, error?:string, data?:object}>} + */ +export async function handleChatSearch({ + provider, + query, + maxResults, + model, + credentials, + log +}) { + const startTime = Date.now(); + const cfg = CHAT_SEARCH_CONFIG[provider]; + + if (!cfg) { + return { + success: false, + status: 400, + error: `Unsupported chat-search provider: ${provider}` + }; + } + + if (!query || typeof query !== "string") { + return { success: false, status: 400, error: "Missing query" }; + } + + const token = credentials?.apiKey || credentials?.accessToken; + if (!token) { + return { + success: false, + status: 401, + error: "Missing credentials (apiKey or accessToken)" + }; + } + + const limit = + Number.isFinite(maxResults) && maxResults > 0 + ? Math.floor(maxResults) + : DEFAULT_MAX_RESULTS; + const useModel = model || searchModel(provider); + const url = cfg.endpoint(useModel); + const body = cfg.buildBody(query, useModel); + const headers = cfg.buildHeaders(token); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + let upstreamStart = Date.now(); + let resp; + try { + resp = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: controller.signal + }); + } catch (err) { + clearTimeout(timer); + if (err?.name === "AbortError") { + log?.warn?.(`[chatSearch] timeout provider=${provider}`); + return { success: false, status: 504, error: "Upstream timeout" }; + } + log?.error?.(`[chatSearch] network error provider=${provider}: ${err?.message}`); + return { + success: false, + status: 502, + error: `Network error: ${err?.message || "unknown"}` + }; + } + clearTimeout(timer); + const upstreamLatency = Date.now() - upstreamStart; + + let data; + try { + data = await resp.json(); + } catch { + return { + success: false, + status: 502, + error: `Invalid upstream response (status ${resp.status})` + }; + } + + if (!resp.ok) { + const errMsg = + data?.error?.message || + data?.error || + data?.message || + `Upstream HTTP ${resp.status}`; + log?.warn?.(`[chatSearch] upstream error provider=${provider} status=${resp.status}`); + return { + success: false, + status: resp.status, + error: typeof errMsg === "string" ? errMsg : JSON.stringify(errMsg) + }; + } + + const { text, citations, tokens } = cfg.extractAnswer(data); + const retrievedAt = new Date().toISOString(); + const limited = (citations || []).slice(0, limit); + const results = limited.map((c, i) => toResult(c, i, provider, retrievedAt)); + + return { + success: true, + status: 200, + data: { + provider, + query, + results, + answer: { source: provider, text: text || "", model: useModel }, + usage: { queries_used: 1, search_cost_usd: 0, llm_tokens: tokens || 0 }, + metrics: { + response_time_ms: Date.now() - startTime, + upstream_latency_ms: upstreamLatency, + total_results_available: null + }, + errors: [] + } + }; +} + +export { CHAT_SEARCH_CONFIG }; diff --git a/open-sse/handlers/search/index.js b/open-sse/handlers/search/index.js new file mode 100644 index 0000000000000000000000000000000000000000..f5815471df4fb4cba99b6e89b63f2db0cf667016 --- /dev/null +++ b/open-sse/handlers/search/index.js @@ -0,0 +1,201 @@ +/** + * Search Dispatcher — routes /v1/search requests to dedicated search APIs + * or chat-based LLM search wrappers, with retry-friendly error envelope. + * + * Dependency map: + * provider.searchConfig → dedicated search API (callers + normalizers) + * provider.searchViaChat → wrap chat-completions (chatSearch.js) + */ + +import { buildSearchRequest } from "./callers.js"; +import { normalizeSearchResponse } from "./normalizers.js"; +import { handleChatSearch } from "./chatSearch.js"; + +const GLOBAL_TIMEOUT_MS = 15000; +const NON_RETRIABLE = new Set([400, 401, 403, 404]); + +const CONTROL_CHAR_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/; + +/** Normalize and validate query string. */ +function sanitizeQuery(query) { + if (CONTROL_CHAR_RE.test(query)) return { error: "Query contains invalid control characters" }; + const clean = query.normalize("NFKC").trim().replace(/\s+/g, " "); + if (!clean) return { error: "Query is empty after normalization" }; + return { clean }; +} + +// Strip non-ASCII chars from header values (HTTP headers must be ByteString). +function sanitizeHeaders(headers) { + if (!headers) return headers; + const out = {}; + for (const [k, v] of Object.entries(headers)) { + out[k] = typeof v === "string" ? v.replace(/[^\x00-\xFF]/g, "").trim() : v; + } + return out; +} + +/** Build a JSON Response wrapper used by the auth layer. */ +function jsonResponse(payload, status = 200) { + return new Response(JSON.stringify(payload), { + status, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } + }); +} + +/** Wrap an error result with a Response object so the auth wrapper can return it directly. */ +function errorResult(status, error) { + return { + success: false, + status, + error, + response: jsonResponse({ error: { message: error, code: status } }, status) + }; +} + +/** Wrap a success payload. */ +function successResult(data) { + return { success: true, data, response: jsonResponse(data, 200) }; +} + +/** + * Run a single dedicated search provider attempt. + * @returns {Promise<{success:boolean, status?:number, error?:string, data?:object}>} + */ +async function tryDedicatedProvider({ provider, providerConfig, body, credentials, log, globalStartTime }) { + const startTime = Date.now(); + const token = credentials?.apiKey || credentials?.accessToken || undefined; + + if (providerConfig.authType !== "none" && !token) { + return { success: false, status: 401, error: `No credentials for provider: ${provider.id}` }; + } + + const params = { + query: body.query, + searchType: body.search_type || (providerConfig.searchTypes?.[0] || "web"), + maxResults: Math.min(body.max_results || providerConfig.defaultMaxResults || 5, providerConfig.maxMaxResults || 100), + token, + country: body.country, + language: body.language, + timeRange: body.time_range, + offset: body.offset, + domainFilter: body.domain_filter, + contentOptions: body.content_options, + providerOptions: body.provider_options, + providerSpecificData: credentials?.providerSpecificData + }; + + let url, init; + try { + ({ url, init } = buildSearchRequest({ id: provider.id, ...providerConfig }, params)); + } catch (err) { + return { success: false, status: 400, error: err?.message || `Invalid request for ${provider.id}` }; + } + + // Timeout = min(provider timeout, remaining global) + const remaining = GLOBAL_TIMEOUT_MS - (Date.now() - globalStartTime); + const timeout = Math.min(providerConfig.timeoutMs || 10000, Math.max(remaining, 1000)); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + + log?.info?.("SEARCH", `${provider.id} | "${params.query.slice(0, 80)}" | type=${params.searchType}`); + + try { + const resp = await fetch(url, { ...init, headers: sanitizeHeaders(init.headers), signal: controller.signal }); + clearTimeout(timer); + if (!resp.ok) { + const errText = await resp.text().catch(() => ""); + log?.error?.("SEARCH", `${provider.id} ${resp.status}: ${errText.slice(0, 200)}`); + return { success: false, status: resp.status, error: `${provider.id} returned ${resp.status}: ${errText.slice(0, 200)}` }; + } + const data = await resp.json(); + const normalized = normalizeSearchResponse(provider.id, data, params.query, params.searchType); + const results = normalized.results.slice(0, params.maxResults); + const duration = Date.now() - startTime; + + return { + success: true, + data: { + provider: provider.id, + query: params.query, + results, + answer: null, + usage: { queries_used: 1, search_cost_usd: providerConfig.costPerQuery || 0 }, + metrics: { response_time_ms: duration, upstream_latency_ms: duration, total_results_available: normalized.totalResults }, + errors: [] + } + }; + } catch (err) { + clearTimeout(timer); + const isTimeout = err.name === "AbortError"; + const status = isTimeout ? 504 : 502; + log?.error?.("SEARCH", `${provider.id} ${isTimeout ? "timeout" : "error"}: ${err.message}`); + return { success: false, status, error: `${provider.id} ${isTimeout ? "timeout" : "error"}: ${err.message}` }; + } +} + +/** + * Core search handler. Dispatches to dedicated API or chat-based LLM. + * Same calling convention as handleEmbeddingsCore: returns `{success, response, status?, error?}`. + * + * @param {object} options + * @param {object} options.body Sanitized body from auth wrapper + * @param {object} options.provider Provider entry from AI_PROVIDERS + * @param {object} [options.providerConfig] Provider's searchConfig (if dedicated) + * @param {object|null} options.credentials Provider credentials + * @param {object} [options.log] Logger + */ +export async function handleSearchCore({ body, provider, providerConfig, credentials, log }) { + const globalStartTime = Date.now(); + + // 1. Sanitize query + const { clean, error: sanitizeError } = sanitizeQuery(body.query || ""); + if (sanitizeError) return errorResult(400, sanitizeError); + const normalizedBody = { ...body, query: clean }; + + // 2. Route: dedicated search API takes priority over chat-based + let result; + if (providerConfig) { + result = await tryDedicatedProvider({ + provider, + providerConfig, + body: normalizedBody, + credentials, + log, + globalStartTime + }); + } else if (provider.searchViaChat) { + result = await handleChatSearch({ + provider: provider.id, + query: clean, + maxResults: normalizedBody.max_results, + model: provider.searchViaChat.defaultModel, + credentials, + log + }); + } else { + return errorResult(400, `Provider ${provider.id} does not support web search`); + } + + if (result.success) return successResult(result.data); + + // 3. Failover within global timeout for retriable errors + if ( + !NON_RETRIABLE.has(result.status || 0) && + Date.now() - globalStartTime < GLOBAL_TIMEOUT_MS && + provider.searchViaChat && + providerConfig + ) { + log?.warn?.("SEARCH", `${provider.id} dedicated failed (${result.status}), falling back to chat-based search`); + const fallback = await handleChatSearch({ + provider: provider.id, + query: clean, + maxResults: normalizedBody.max_results, + model: provider.searchViaChat.defaultModel, + credentials, + log + }); + if (fallback.success) return successResult(fallback.data); + } + + return errorResult(result.status || 502, result.error || "Search failed"); +} diff --git a/open-sse/handlers/search/normalizers.js b/open-sse/handlers/search/normalizers.js new file mode 100644 index 0000000000000000000000000000000000000000..da008bf302975496c9258aa0a93a88484c835ed9 --- /dev/null +++ b/open-sse/handlers/search/normalizers.js @@ -0,0 +1,223 @@ +/** + * Search Response Normalizers + * + * Ported from OmniRoute open-sse/handlers/search.ts. + * Each normalizer maps a provider-specific response into the unified SearchResult shape. + */ + +/** Build a unified SearchResult object. */ +function makeResult(providerId, item, idx, now) { + const url = item.url || ""; + return { + title: item.title || "", + url, + display_url: url ? url.replace(/^https?:\/\/(www\.)?/, "").split("?")[0] : undefined, + snippet: item.snippet || "", + position: idx + 1, + score: typeof item.score === "number" ? Math.min(1, Math.max(0, item.score)) : null, + published_at: item.published_at || null, + favicon_url: item.favicon_url || null, + content: item.full_text + ? { format: item.text_format || "text", text: item.full_text, length: item.full_text.length } + : null, + metadata: { + author: item.author || null, + language: null, + source_type: item.source_type || null, + image_url: item.image_url || null, + }, + citation: { provider: providerId, retrieved_at: now, rank: idx + 1 }, + provider_raw: null, + }; +} + +function normalizeSerper(data, _query, searchType) { + const now = new Date().toISOString(); + const items = searchType === "news" ? data.news : data.organic; + if (!Array.isArray(items)) return { results: [], totalResults: null }; + const results = items.map((item, idx) => + makeResult("serper", { title: item.title, url: item.link, snippet: item.snippet || item.description, published_at: item.date }, idx, now) + ); + const total = data.searchParameters?.totalResults; + return { results, totalResults: typeof total === "number" ? total : null }; +} + +function normalizeBrave(data, _query, searchType) { + const now = new Date().toISOString(); + const container = searchType === "news" ? data.news || data : data.web; + const items = container?.results; + if (!Array.isArray(items)) return { results: [], totalResults: null }; + const results = items.map((item, idx) => + makeResult("brave-search", { + title: item.title, + url: item.url, + snippet: item.description, + published_at: item.page_age || item.age, + favicon_url: item.meta_url?.favicon || item.favicon, + }, idx, now) + ); + return { results, totalResults: container?.totalCount ?? null }; +} + +function normalizePerplexity(data, _query, _searchType) { + const now = new Date().toISOString(); + const items = data.results; + if (!Array.isArray(items)) return { results: [], totalResults: null }; + const results = items.map((item, idx) => + makeResult("perplexity", { title: item.title, url: item.url, snippet: item.snippet, published_at: item.date || item.last_updated }, idx, now) + ); + return { results, totalResults: results.length }; +} + +function normalizeExa(data, _query, _searchType) { + const now = new Date().toISOString(); + const items = data.results; + if (!Array.isArray(items)) return { results: [], totalResults: null }; + const results = items.map((item, idx) => + makeResult("exa", { + title: item.title, + url: item.url, + snippet: item.highlights?.[0] || item.text?.slice(0, 300) || "", + score: item.score, + published_at: item.publishedDate, + favicon_url: item.favicon, + author: item.author, + image_url: item.image, + full_text: item.text, + text_format: "text", + }, idx, now) + ); + return { results, totalResults: results.length }; +} + +function normalizeTavily(data, _query, _searchType) { + const now = new Date().toISOString(); + const items = data.results; + if (!Array.isArray(items)) return { results: [], totalResults: null }; + const results = items.map((item, idx) => + makeResult("tavily", { + title: item.title, + url: item.url, + snippet: item.content || "", + score: item.score, + published_at: item.published_date, + full_text: item.raw_content, + text_format: "text", + }, idx, now) + ); + return { results, totalResults: results.length }; +} + +function normalizeGooglePse(data, _query, _searchType) { + const now = new Date().toISOString(); + const items = Array.isArray(data.items) ? data.items : []; + const results = items.map((item, idx) => + makeResult("google-pse", { + title: item.title, + url: item.link, + snippet: item.snippet, + image_url: item.pagemap?.cse_image?.[0]?.src || item.pagemap?.cse_thumbnail?.[0]?.src || item.pagemap?.metatags?.[0]?.["og:image"], + }, idx, now) + ); + const raw = data.searchInformation?.totalResults ?? data.queries?.request?.[0]?.totalResults ?? null; + const total = typeof raw === "string" ? Number(raw) : raw; + return { results, totalResults: Number.isFinite(total) ? total : null }; +} + +function normalizeLinkup(data, _query, _searchType) { + const now = new Date().toISOString(); + const items = Array.isArray(data.results) ? data.results : []; + const results = items.map((item, idx) => + makeResult("linkup", { + title: item.name || item.title, + url: item.url, + snippet: item.content || item.snippet || "", + source_type: item.type || "web", + image_url: item.image_url || item.imageUrl || null, + full_text: item.content, + text_format: "text", + }, idx, now) + ); + return { results, totalResults: results.length }; +} + +function normalizeSearchApi(data, _query, _searchType) { + const now = new Date().toISOString(); + const items = Array.isArray(data.organic_results) ? data.organic_results : Array.isArray(data.top_stories) ? data.top_stories : []; + const results = items.map((item, idx) => + makeResult("searchapi", { + title: item.title, + url: item.link, + snippet: item.snippet || item.description || "", + published_at: item.date || item.published_at, + favicon_url: item.favicon, + author: item.source || null, + image_url: item.thumbnail || null, + }, idx, now) + ); + const raw = data.search_information?.total_results; + const total = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : null; + return { results, totalResults: Number.isFinite(total) ? total : results.length }; +} + +function normalizeYouCom(data, _query, searchType) { + const now = new Date().toISOString(); + const container = data?.results && typeof data.results === "object" ? data.results : undefined; + const section = searchType === "news" ? container?.news || [] : container?.web || []; + const items = Array.isArray(section) ? section : []; + const results = items.map((item, idx) => { + const firstSnippet = Array.isArray(item.snippets) ? item.snippets.find((v) => typeof v === "string") : null; + const livecrawlText = typeof item.markdown === "string" ? item.markdown : typeof item.html === "string" ? item.html : undefined; + const livecrawlFormat = typeof item.markdown === "string" ? "markdown" : "html"; + return makeResult("youcom", { + title: item.title, + url: item.url, + snippet: typeof firstSnippet === "string" ? firstSnippet : typeof item.description === "string" ? item.description : "", + published_at: item.page_age, + favicon_url: item.favicon_url, + image_url: item.thumbnail_url, + source_type: searchType, + full_text: livecrawlText, + text_format: livecrawlText ? livecrawlFormat : undefined, + }, idx, now); + }); + return { results, totalResults: results.length }; +} + +function normalizeSearxng(data, _query, _searchType) { + const now = new Date().toISOString(); + const items = Array.isArray(data.results) ? data.results : []; + const results = items.map((item, idx) => + makeResult("searxng", { + title: item.title, + url: item.url, + snippet: item.content || item.snippet || "", + published_at: item.publishedDate || item.published_date || null, + source_type: Array.isArray(item.engines) ? item.engines.join(", ") : item.engine || item.category || null, + image_url: item.thumbnail || item.img_src || null, + }, idx, now) + ); + return { results, totalResults: results.length }; +} + +const NORMALIZERS = { + "serper": normalizeSerper, + "brave-search": normalizeBrave, + "perplexity": normalizePerplexity, + "exa": normalizeExa, + "tavily": normalizeTavily, + "google-pse": normalizeGooglePse, + "linkup": normalizeLinkup, + "searchapi": normalizeSearchApi, + "youcom": normalizeYouCom, + "searxng": normalizeSearxng, +}; + +/** + * Dispatch to the appropriate normalizer based on providerId. + * @returns {{results: Array, totalResults: number|null}} + */ +export function normalizeSearchResponse(providerId, data, query, searchType) { + const fn = NORMALIZERS[providerId]; + return fn ? fn(data, query, searchType) : { results: [], totalResults: null }; +} diff --git a/open-sse/handlers/sttCore.js b/open-sse/handlers/sttCore.js new file mode 100644 index 0000000000000000000000000000000000000000..acb7d13b034bd7d9d5b11c90b3fc43239131eaf0 --- /dev/null +++ b/open-sse/handlers/sttCore.js @@ -0,0 +1,193 @@ +import { Buffer } from "node:buffer"; +import { createErrorResult } from "../utils/error.js"; +import { HTTP_STATUS } from "../config/runtimeConfig.js"; + +// Build auth headers from sttConfig + token +function buildAuthHeaders(cfg, token) { + if (!token) return {}; + switch (cfg.authHeader) { + case "bearer": return { "Authorization": `Bearer ${token}` }; + case "token": return { "Authorization": `Token ${token}` }; + case "x-api-key": return { "x-api-key": token }; + case "key": return { "Authorization": `Key ${token}` }; + default: return { "Authorization": `Bearer ${token}` }; + } +} + +// Map browser file MIME / ext → audio MIME for binary formats (deepgram/HF) +function resolveAudioContentType(file) { + const t = (file.type || "").toLowerCase(); + if (t.startsWith("audio/")) return t; + const name = typeof file.name === "string" ? file.name.toLowerCase() : ""; + const ext = name.includes(".") ? name.split(".").pop() : ""; + const map = { mp3: "audio/mpeg", mp4: "audio/mp4", m4a: "audio/mp4", wav: "audio/wav", ogg: "audio/ogg", flac: "audio/flac", webm: "audio/webm", aac: "audio/aac", opus: "audio/opus" }; + return map[ext] || "application/octet-stream"; +} + +async function upstreamError(res) { + let txt = ""; + try { txt = await res.text(); } catch {} + let msg = txt || `Upstream error (${res.status})`; + try { const j = JSON.parse(txt); msg = j?.error?.message || j?.error || j?.message || msg; } catch {} + return createErrorResult(res.status, typeof msg === "string" ? msg : JSON.stringify(msg)); +} + +// Deepgram: raw binary POST + model query param +async function transcribeDeepgram(cfg, file, model, token, formData) { + const url = new URL(cfg.baseUrl); + url.searchParams.set("model", model); + url.searchParams.set("smart_format", "true"); + url.searchParams.set("punctuate", "true"); + const lang = formData.get("language"); + if (typeof lang === "string" && lang.trim()) url.searchParams.set("language", lang.trim()); + else url.searchParams.set("detect_language", "true"); + + const buf = await file.arrayBuffer(); + const res = await fetch(url, { + method: "POST", + headers: { ...buildAuthHeaders(cfg, token), "Content-Type": resolveAudioContentType(file) }, + body: buf, + }); + if (!res.ok) return upstreamError(res); + const data = await res.json(); + const text = data.results?.channels?.[0]?.alternatives?.[0]?.transcript ?? ""; + return jsonResponse({ text }); +} + +// AssemblyAI: upload → submit → poll (max 120s) +async function transcribeAssemblyAI(cfg, file, model, token) { + const auth = buildAuthHeaders(cfg, token); + const buf = await file.arrayBuffer(); + const up = await fetch("https://api.assemblyai.com/v2/upload", { + method: "POST", headers: { ...auth, "Content-Type": "application/octet-stream" }, body: buf, + }); + if (!up.ok) return upstreamError(up); + const { upload_url } = await up.json(); + + const sub = await fetch(cfg.baseUrl, { + method: "POST", + headers: { ...auth, "Content-Type": "application/json" }, + body: JSON.stringify({ audio_url: upload_url, speech_models: [model], language_detection: true }), + }); + if (!sub.ok) return upstreamError(sub); + const { id } = await sub.json(); + + const start = Date.now(); + while (Date.now() - start < 120_000) { + await new Promise((r) => setTimeout(r, 2000)); + const poll = await fetch(`${cfg.baseUrl}/${id}`, { headers: auth }); + if (!poll.ok) continue; + const r = await poll.json(); + if (r.status === "completed") return jsonResponse({ text: r.text || "" }); + if (r.status === "error") return createErrorResult(500, r.error || "AssemblyAI failed"); + } + return createErrorResult(504, "AssemblyAI timeout after 120s"); +} + +// Nvidia NIM: multipart, normalize response +async function transcribeNvidia(cfg, file, model, token) { + const fd = new FormData(); + fd.append("file", file, file.name || "audio.wav"); + fd.append("model", model); + const res = await fetch(cfg.baseUrl, { method: "POST", headers: buildAuthHeaders(cfg, token), body: fd }); + if (!res.ok) return upstreamError(res); + const data = await res.json(); + return jsonResponse({ text: data.text || data.transcript || "" }); +} + +// Gemini: generateContent with inline_data audio + transcription prompt +async function transcribeGemini(cfg, file, model, token, formData) { + const buf = await file.arrayBuffer(); + const b64 = Buffer.from(buf).toString("base64"); + const mime = resolveAudioContentType(file); + const lang = formData.get("language"); + const userPrompt = formData.get("prompt"); + let promptText = userPrompt && typeof userPrompt === "string" && userPrompt.trim() + ? userPrompt.trim() + : "Generate a transcript of the speech. Return only the transcribed text, no commentary."; + if (typeof lang === "string" && lang.trim()) promptText += ` Language: ${lang.trim()}.`; + + const url = `${cfg.baseUrl}/${model}:generateContent?key=${token}`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [{ parts: [{ text: promptText }, { inline_data: { mime_type: mime, data: b64 } }] }], + }), + }); + if (!res.ok) return upstreamError(res); + const data = await res.json(); + const text = data?.candidates?.[0]?.content?.parts?.map((p) => p.text).filter(Boolean).join("") || ""; + return jsonResponse({ text }); +} + +// HuggingFace: POST raw binary to {baseUrl}/{model_id} +async function transcribeHuggingFace(cfg, file, model, token) { + if (model.includes("..") || model.includes("//")) return createErrorResult(400, "Invalid model ID"); + const url = `${cfg.baseUrl.replace(/\/+$/, "")}/${model}`; + const buf = await file.arrayBuffer(); + const res = await fetch(url, { + method: "POST", + headers: { ...buildAuthHeaders(cfg, token), "Content-Type": resolveAudioContentType(file) }, + body: buf, + }); + if (!res.ok) return upstreamError(res); + const data = await res.json(); + return jsonResponse({ text: data.text || "" }); +} + +// Default: OpenAI/Groq/Whisper-compatible multipart +async function transcribeOpenAICompatible(cfg, file, model, token, formData) { + const fd = new FormData(); + fd.append("file", file, file.name || "audio.wav"); + fd.append("model", model); + for (const k of ["language", "prompt", "response_format", "temperature"]) { + const v = formData.get(k); + if (v !== null && v !== undefined && v !== "") fd.append(k, v); + } + const res = await fetch(cfg.baseUrl, { method: "POST", headers: buildAuthHeaders(cfg, token), body: fd }); + if (!res.ok) return upstreamError(res); + const ct = res.headers.get("content-type") || "application/json"; + const txt = await res.text(); + return { success: true, response: new Response(txt, { status: 200, headers: { "Content-Type": ct, "Access-Control-Allow-Origin": "*" } }) }; +} + +function jsonResponse(obj) { + return { + success: true, + response: new Response(JSON.stringify(obj), { + status: 200, + headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + }), + }; +} + +/** + * STT core handler — dispatch by sttConfig.format. + * @returns {Promise<{success, response, status?, error?}>} + */ +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 = 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); + if (cfg.authType !== "none" && !token) { + return createErrorResult(HTTP_STATUS.UNAUTHORIZED, `No credentials for STT provider: ${provider}`); + } + + try { + switch (cfg.format) { + case "deepgram": return await transcribeDeepgram(cfg, file, model, token, formData); + case "assemblyai": return await transcribeAssemblyAI(cfg, file, model, token); + case "nvidia-asr": return await transcribeNvidia(cfg, file, model, token); + case "huggingface-asr": return await transcribeHuggingFace(cfg, file, model, token); + case "gemini-stt": return await transcribeGemini(cfg, file, model, token, formData); + default: return await transcribeOpenAICompatible(cfg, file, model, token, formData); + } + } catch (err) { + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, err.message || "STT request failed"); + } +} diff --git a/open-sse/handlers/ttsCore.js b/open-sse/handlers/ttsCore.js new file mode 100644 index 0000000000000000000000000000000000000000..b4b69eebf6bf014a189673ce89c3bd565c76b278 --- /dev/null +++ b/open-sse/handlers/ttsCore.js @@ -0,0 +1,74 @@ +import { Buffer } from "node:buffer"; +import { createErrorResult } from "../utils/error.js"; +import { HTTP_STATUS } from "../config/runtimeConfig.js"; +import { getTtsAdapter, synthesizeViaConfig } from "./ttsProviders/index.js"; + +// Re-export voice fetchers + voices APIs for backward compat with existing routes +export { + VOICE_FETCHERS, + fetchEdgeTtsVoices, + fetchLocalDeviceVoices, + fetchElevenLabsVoices, +} from "./ttsProviders/index.js"; + +// ── Response Formatter (DRY) ─────────────────────────────────── +function createTtsResponse(base64Audio, format, responseFormat) { + const audioBuffer = Buffer.from(base64Audio, "base64"); + + // JSON format: return base64 encoded audio + if (responseFormat === "json") { + return { + success: true, + response: new Response(JSON.stringify({ audio: base64Audio, format }), { + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }, + }), + }; + } + + // Binary format (default): return raw audio + return { + success: true, + response: new Response(audioBuffer, { + headers: { + "Content-Type": `audio/${format}`, + "Content-Length": String(audioBuffer.length), + "Access-Control-Allow-Origin": "*", + }, + }), + }; +} + +// ── Core handler ─────────────────────────────────────────────── +/** + * Synthesize text to audio. Provider logic lives in `./ttsProviders/{id}.js` + * or is dispatched generically via `ttsConfig.format`. + * + * @returns {Promise<{success, response, status?, error?}>} + */ +export async function handleTtsCore({ provider, model, input, credentials, responseFormat = "mp3", language }) { + if (!input?.trim()) { + return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: input"); + } + + try { + // Special-case adapters (google-tts, edge-tts, local-device, elevenlabs, openai, openrouter, gemini) + const adapter = getTtsAdapter(provider); + if (adapter) { + const result = await adapter.synthesize(input.trim(), model, credentials, responseFormat, { language }); + // Adapter may return a full {success, response} (legacy) or {base64, format} + if (result.success !== undefined) return result; + return createTtsResponse(result.base64, result.format, responseFormat); + } + + // Generic config-driven (hyperbolic, deepgram, nvidia, huggingface, inworld, cartesia, playht, coqui, tortoise, qwen, ...) + const result = await synthesizeViaConfig(provider, input.trim(), model, credentials); + if (result) return createTtsResponse(result.base64, result.format, responseFormat); + + return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Provider '${provider}' does not support TTS via this route.`); + } catch (err) { + return createErrorResult(HTTP_STATUS.BAD_GATEWAY, err.message || "TTS synthesis failed"); + } +} diff --git a/open-sse/handlers/ttsProviders/_base.js b/open-sse/handlers/ttsProviders/_base.js new file mode 100644 index 0000000000000000000000000000000000000000..1c9221185599aadb9d6218d0eb4423e398d975d1 --- /dev/null +++ b/open-sse/handlers/ttsProviders/_base.js @@ -0,0 +1,39 @@ +// Shared TTS helpers +import { Buffer } from "node:buffer"; + +export const UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"; + +// Convert upstream Response (binary audio) to { base64, format } +export async function responseToBase64(res, defaultFormat = "mp3") { + const buf = await res.arrayBuffer(); + if (buf.byteLength < 100) throw new Error("Upstream returned empty audio"); + const ctype = res.headers.get("content-type") || ""; + let format = defaultFormat; + if (ctype.includes("wav")) format = "wav"; + else if (ctype.includes("mpeg") || ctype.includes("mp3")) format = "mp3"; + else if (ctype.includes("ogg")) format = "ogg"; + return { base64: Buffer.from(buf).toString("base64"), format }; +} + +export async function throwUpstreamError(res) { + const text = await res.text().catch(() => ""); + let msg = `Upstream error (${res.status})`; + try { + const parsed = JSON.parse(text); + msg = parsed?.error?.message || parsed?.message || parsed?.detail?.message || (typeof parsed?.detail === "string" ? parsed.detail : null) || text || msg; + } catch { msg = text || msg; } + throw new Error(msg); +} + +// Parse `model` string as "modelId/voiceId" — match against known model list (longest prefix wins) +export function parseModelVoice(model, defaultModel = "", defaultVoice = "", knownModels = []) { + if (!model) return { modelId: defaultModel, voiceId: defaultVoice }; + const known = knownModels.map((m) => m.id || m).filter(Boolean).sort((a, b) => b.length - a.length); + for (const id of known) { + if (model === id) return { modelId: id, voiceId: defaultVoice }; + if (model.startsWith(`${id}/`)) return { modelId: id, voiceId: model.slice(id.length + 1) }; + } + const idx = model.lastIndexOf("/"); + if (idx > 0) return { modelId: model.slice(0, idx), voiceId: model.slice(idx + 1) }; + return { modelId: defaultModel || model, voiceId: defaultVoice || model }; +} diff --git a/open-sse/handlers/ttsProviders/edgeTts.js b/open-sse/handlers/ttsProviders/edgeTts.js new file mode 100644 index 0000000000000000000000000000000000000000..66b1dd9eab2d5fa2c6ebae913a73b7cc04c8c615 --- /dev/null +++ b/open-sse/handlers/ttsProviders/edgeTts.js @@ -0,0 +1,89 @@ +// Microsoft Edge / Bing TTS (no auth) — via Bing translator endpoint +import { Buffer } from "node:buffer"; +import { UA } from "./_base.js"; + +const REFRESH_MS = 5 * 60 * 1000; // token TTL ~1h, refresh early +const VOICES_TTL = 24 * 60 * 60 * 1000; + +const cache = { token: null, tokenTime: 0 }; +let _voicesCache = null; +let _voicesCacheTime = 0; + +async function getToken() { + const now = Date.now(); + if (cache.token && now - cache.tokenTime < REFRESH_MS) return cache.token; + const res = await fetch("https://www.bing.com/translator", { + headers: { "User-Agent": UA, "Accept-Language": "vi,en-US;q=0.9,en;q=0.8" }, + }); + if (!res.ok) throw new Error(`Bing translator fetch failed: ${res.status}`); + const rawCookies = res.headers.getSetCookie?.() || []; + const cookie = rawCookies.map((c) => c.split(";")[0]).join("; "); + const html = await res.text(); + const match = html.match(/params_AbusePreventionHelper\s*=\s*\[([^,]+),([^,]+),/); + if (!match) throw new Error("Failed to parse Bing token"); + cache.token = { key: match[1], token: match[2].replace(/"/g, ""), cookie }; + cache.tokenTime = now; + return cache.token; +} + +async function ttsRequest(text, voiceId, token) { + const parts = voiceId.split("-"); + const xmlLang = parts.slice(0, 2).join("-"); + const gender = voiceId.toLowerCase().includes("male") ? "Male" : "Female"; + const ssml = `${text}`; + const body = new URLSearchParams(); + body.append("ssml", ssml); + body.append("token", token.token); + body.append("key", token.key); + return fetch("https://www.bing.com/tfettts?isVertical=1&&IG=1&IID=translator.5023&SFX=1", { + method: "POST", + body: body.toString(), + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "*/*", + "Origin": "https://www.bing.com", + "Referer": "https://www.bing.com/translator", + "User-Agent": UA, + ...(token.cookie ? { "Cookie": token.cookie } : {}), + }, + }); +} + +export async function fetchEdgeTtsVoices() { + const now = Date.now(); + if (_voicesCache && now - _voicesCacheTime < VOICES_TTL) return _voicesCache; + const res = await fetch( + "https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list?trustedclienttoken=6A5AA1D4EAFF4E9FB37E23D68491D6F4", + { headers: { "User-Agent": UA } } + ); + if (!res.ok) throw new Error(`Edge TTS voices fetch failed: ${res.status}`); + const voices = await res.json(); + _voicesCache = voices; + _voicesCacheTime = now; + return voices; +} + +export default { + noAuth: true, + async synthesize(text, model) { + const voiceId = model || "vi-VN-HoaiMyNeural"; + let token = await getToken(); + let res = await ttsRequest(text, voiceId, token); + + // 429/403: invalidate cache and retry once + if (res.status === 429 || res.status === 403) { + cache.token = null; + cache.tokenTime = 0; + token = await getToken(); + res = await ttsRequest(text, voiceId, token); + } + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Bing TTS failed: ${res.status}${body ? " - " + body : ""}`); + } + const buf = await res.arrayBuffer(); + if (buf.byteLength < 1024) throw new Error("Bing TTS returned empty audio"); + return { base64: Buffer.from(buf).toString("base64"), format: "mp3" }; + }, +}; diff --git a/open-sse/handlers/ttsProviders/elevenlabs.js b/open-sse/handlers/ttsProviders/elevenlabs.js new file mode 100644 index 0000000000000000000000000000000000000000..711a5672d1363383ca8df537eb80c638a0764f53 --- /dev/null +++ b/open-sse/handlers/ttsProviders/elevenlabs.js @@ -0,0 +1,48 @@ +// ElevenLabs TTS — voice id with optional model_id prefix +import { Buffer } from "node:buffer"; + +const VOICES_TTL = 24 * 60 * 60 * 1000; +const _voicesCache = new Map(); // by API key + +export async function fetchElevenLabsVoices(apiKey) { + if (!apiKey) throw new Error("ElevenLabs API key required"); + const now = Date.now(); + const cached = _voicesCache.get(apiKey); + if (cached && now - cached.time < VOICES_TTL) return cached.voices; + + const res = await fetch("https://api.elevenlabs.io/v1/voices", { + headers: { "xi-api-key": apiKey, "Content-Type": "application/json" }, + }); + if (!res.ok) throw new Error(`ElevenLabs voices fetch failed: ${res.status}`); + const data = await res.json(); + // Normalize: derive lang from labels for grouping + const voices = (data.voices || []).map((v) => ({ ...v, lang: v.labels?.language || "en" })); + _voicesCache.set(apiKey, { voices, time: now }); + return voices; +} + +export default { + async synthesize(text, model, credentials) { + if (!credentials?.apiKey) throw new Error("ElevenLabs API key required"); + let modelId = "eleven_flash_v2_5"; + let voiceId = model; + if (model && model.includes("/")) [modelId, voiceId] = model.split("/"); + + const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, { + method: "POST", + headers: { "xi-api-key": credentials.apiKey, "Content-Type": "application/json" }, + body: JSON.stringify({ + text, + model_id: modelId, + voice_settings: { stability: 0.5, similarity_boost: 0.75 }, + }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.detail?.message || `ElevenLabs TTS failed: ${res.status}`); + } + const buf = await res.arrayBuffer(); + if (buf.byteLength < 1024) throw new Error("ElevenLabs TTS returned empty audio"); + return { base64: Buffer.from(buf).toString("base64"), format: "mp3" }; + }, +}; diff --git a/open-sse/handlers/ttsProviders/gemini.js b/open-sse/handlers/ttsProviders/gemini.js new file mode 100644 index 0000000000000000000000000000000000000000..1b0cd565c094405bddb4b37e1d98d90364ca0486 --- /dev/null +++ b/open-sse/handlers/ttsProviders/gemini.js @@ -0,0 +1,120 @@ +// Gemini TTS — generateContent with AUDIO modality returns PCM L16, wrap as WAV +import { Buffer } from "node:buffer"; +import { PROVIDER_MEDIA } from "../../providers/index.js"; + +const TTS_CFG = PROVIDER_MEDIA["gemini"]?.ttsConfig || {}; +const TTS_BASE = TTS_CFG.baseUrl; +const KNOWN_MODELS = (TTS_CFG.models || []).map((m) => m.id); +const DEFAULT_MODEL = KNOWN_MODELS[0]; +const DEFAULT_VOICE = "Kore"; + +// Parse "model/voice" — if input doesn't match a known TTS model, treat it as voice with default model +function parseGeminiModelVoice(input) { + if (!input) return { modelId: DEFAULT_MODEL, voiceId: DEFAULT_VOICE }; + for (const id of KNOWN_MODELS) { + if (input === id) return { modelId: id, voiceId: DEFAULT_VOICE }; + if (input.startsWith(`${id}/`)) return { modelId: id, voiceId: input.slice(id.length + 1) }; + } + return { modelId: DEFAULT_MODEL, voiceId: input }; +} +// Gemini returns PCM 16-bit signed mono @ 24kHz +const SAMPLE_RATE = 24000; +const CHANNELS = 1; +const BITS_PER_SAMPLE = 16; + +// Build WAV header for raw PCM payload +function pcmToWav(pcmBuffer) { + const dataSize = pcmBuffer.length; + const byteRate = SAMPLE_RATE * CHANNELS * BITS_PER_SAMPLE / 8; + const blockAlign = CHANNELS * BITS_PER_SAMPLE / 8; + const header = Buffer.alloc(44); + header.write("RIFF", 0); + header.writeUInt32LE(36 + dataSize, 4); + header.write("WAVE", 8); + header.write("fmt ", 12); + header.writeUInt32LE(16, 16); + header.writeUInt16LE(1, 20); + header.writeUInt16LE(CHANNELS, 22); + header.writeUInt32LE(SAMPLE_RATE, 24); + header.writeUInt32LE(byteRate, 28); + header.writeUInt16LE(blockAlign, 32); + header.writeUInt16LE(BITS_PER_SAMPLE, 34); + header.write("data", 36); + header.writeUInt32LE(dataSize, 40); + return Buffer.concat([header, pcmBuffer]); +} + +// Build TTS prompt: add "Say [in {language}]:" prefix to force TTS mode +function buildPrompt(text, language) { + if (/:\s/.test(text)) return text; // user already provided style instruction + return language ? `Say in ${language}: ${text}` : `Say: ${text}`; +} + +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 = `${TTS_BASE}/${modelId}:generateContent?key=${credentials.apiKey}`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [{ parts: [{ text: buildPrompt(text, opts.language) }] }], + generationConfig: { + responseModalities: ["AUDIO"], + speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: voiceId } } }, + }, + }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error?.message || `Gemini TTS failed: ${res.status}`); + } + const data = await res.json(); + const b64 = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData?.data)?.inlineData?.data; + if (!b64) { + const reason = data?.candidates?.[0]?.finishReason || data?.promptFeedback?.blockReason || "unknown"; + throw new Error(`Gemini TTS returned no audio (finishReason: ${reason}, voice: ${voiceId}, model: ${modelId})`); + } + const wav = pcmToWav(Buffer.from(b64, "base64")); + return { base64: wav.toString("base64"), format: "wav" }; + }, +}; + +// Voice fetcher — return prebuilt voices (Gemini has no list API) +const PREBUILT_VOICES = [ + { id: "Zephyr", lang: "en", gender: "Female" }, + { id: "Puck", lang: "en", gender: "Male" }, + { id: "Charon", lang: "en", gender: "Male" }, + { id: "Kore", lang: "en", gender: "Female" }, + { id: "Fenrir", lang: "en", gender: "Male" }, + { id: "Leda", lang: "en", gender: "Female" }, + { id: "Orus", lang: "en", gender: "Male" }, + { id: "Aoede", lang: "en", gender: "Female" }, + { id: "Callirrhoe", lang: "en", gender: "Female" }, + { id: "Autonoe", lang: "en", gender: "Female" }, + { id: "Enceladus", lang: "en", gender: "Male" }, + { id: "Iapetus", lang: "en", gender: "Male" }, + { id: "Umbriel", lang: "en", gender: "Male" }, + { id: "Algieba", lang: "en", gender: "Male" }, + { id: "Despina", lang: "en", gender: "Female" }, + { id: "Erinome", lang: "en", gender: "Female" }, + { id: "Algenib", lang: "en", gender: "Male" }, + { id: "Rasalgethi", lang: "en", gender: "Male" }, + { id: "Laomedeia", lang: "en", gender: "Female" }, + { id: "Achernar", lang: "en", gender: "Female" }, + { id: "Alnilam", lang: "en", gender: "Male" }, + { id: "Schedar", lang: "en", gender: "Male" }, + { id: "Gacrux", lang: "en", gender: "Female" }, + { id: "Pulcherrima", lang: "en", gender: "Female" }, + { id: "Achird", lang: "en", gender: "Male" }, + { id: "Zubenelgenubi", lang: "en", gender: "Male" }, + { id: "Vindemiatrix", lang: "en", gender: "Female" }, + { id: "Sadachbia", lang: "en", gender: "Male" }, + { id: "Sadaltager", lang: "en", gender: "Male" }, + { id: "Sulafat", lang: "en", gender: "Female" }, +]; + +export async function fetchGeminiVoices() { + return PREBUILT_VOICES.map((v) => ({ voice_id: v.id, name: v.id, labels: { language: v.lang, gender: v.gender } })); +} diff --git a/open-sse/handlers/ttsProviders/genericFormats.js b/open-sse/handlers/ttsProviders/genericFormats.js new file mode 100644 index 0000000000000000000000000000000000000000..2f27f9eacd4e1e3397eec0f54e5d7fc94cf7d1ea --- /dev/null +++ b/open-sse/handlers/ttsProviders/genericFormats.js @@ -0,0 +1,169 @@ +// Generic config-driven TTS handlers — dispatched by ttsConfig.format. +// Each handler accepts { baseUrl, apiKey, text, modelId, voiceId } and returns { base64, format }. +import { responseToBase64, throwUpstreamError } from "./_base.js"; +import minimaxTts from "./minimax.js"; + +// Hyperbolic: POST { text } → { audio: base64 } +async function hyperbolic({ baseUrl, apiKey, text }) { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` }, + body: JSON.stringify({ text }), + }); + if (!res.ok) await throwUpstreamError(res); + const data = await res.json(); + return { base64: data.audio, format: "mp3" }; +} + +// Deepgram: model via query, Token auth, returns binary +async function deepgram({ baseUrl, apiKey, text, modelId }) { + const url = new URL(baseUrl); + url.searchParams.set("model", modelId || "aura-asteria-en"); + const res = await fetch(url.toString(), { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": `Token ${apiKey}` }, + body: JSON.stringify({ text }), + }); + if (!res.ok) await throwUpstreamError(res); + return responseToBase64(res, "mp3"); +} + +// Nvidia NIM: POST { input: { text }, voice, model } → binary +async function nvidia({ baseUrl, apiKey, text, modelId, voiceId }) { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` }, + body: JSON.stringify({ input: { text }, voice: voiceId || "default", model: modelId }), + }); + if (!res.ok) await throwUpstreamError(res); + return responseToBase64(res, "wav"); +} + +// HuggingFace: POST {baseUrl}/{modelId} { inputs: text } → binary +async function huggingface({ baseUrl, apiKey, text, modelId }) { + if (!modelId || modelId.includes("..")) throw new Error("Invalid HuggingFace model ID"); + const res = await fetch(`${baseUrl}/${modelId}`, { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` }, + body: JSON.stringify({ inputs: text }), + }); + if (!res.ok) await throwUpstreamError(res); + return responseToBase64(res, "wav"); +} + +// Inworld: Basic auth, JSON { audioContent } +async function inworld({ baseUrl, apiKey, text, modelId, voiceId }) { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": `Basic ${apiKey}` }, + body: JSON.stringify({ + text, + voiceId: voiceId || "Alex", + modelId: modelId || "inworld-tts-1.5-mini", + audioConfig: { audioEncoding: "MP3" }, + }), + }); + if (!res.ok) await throwUpstreamError(res); + const data = await res.json(); + if (!data.audioContent) throw new Error("Inworld TTS returned no audio"); + return { base64: data.audioContent, format: "mp3" }; +} + +// Cartesia: X-API-Key header +async function cartesia({ baseUrl, apiKey, text, modelId, voiceId }) { + const res = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": apiKey, + "Cartesia-Version": "2024-06-10", + }, + body: JSON.stringify({ + model_id: modelId || "sonic-2", + transcript: text, + ...(voiceId ? { voice: { mode: "id", id: voiceId } } : {}), + output_format: { container: "mp3", bit_rate: 128000, sample_rate: 44100 }, + }), + }); + if (!res.ok) await throwUpstreamError(res); + return responseToBase64(res, "mp3"); +} + +// PlayHT: token format "userId:apiKey", voice = s3 URL +async function playht({ baseUrl, apiKey, text, modelId, voiceId }) { + const [userId, key] = (apiKey || ":").split(":"); + const res = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "audio/mpeg", + "X-USER-ID": userId || "", + "Authorization": `Bearer ${key || apiKey}`, + }, + body: JSON.stringify({ + text, + voice: voiceId || "s3://voice-cloning-zero-shot/d9ff78ba-d016-47f6-b0ef-dd630f59414e/female-cs/manifest.json", + voice_engine: modelId || "PlayDialog", + output_format: "mp3", + speed: 1, + }), + }); + if (!res.ok) await throwUpstreamError(res); + return responseToBase64(res, "mp3"); +} + +// Coqui (local, noAuth): POST { text, speaker_id } → WAV +async function coqui({ baseUrl, text, voiceId }) { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text, ...(voiceId ? { speaker_id: voiceId } : {}) }), + }); + if (!res.ok) await throwUpstreamError(res); + return responseToBase64(res, "wav"); +} + +// Tortoise (local, noAuth) +async function tortoise({ baseUrl, text, voiceId }) { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text, voice: voiceId || "random" }), + }); + if (!res.ok) await throwUpstreamError(res); + return responseToBase64(res, "wav"); +} + +// OpenAI-compatible upstream (qwen3-tts, etc.) +async function openaiCompat({ baseUrl, apiKey, text, modelId, voiceId }) { + const headers = { "Content-Type": "application/json" }; + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + const res = await fetch(baseUrl, { + method: "POST", + headers, + body: JSON.stringify({ + model: modelId, + input: text, + voice: voiceId || "alloy", + response_format: "mp3", + speed: 1.0, + }), + }); + if (!res.ok) await throwUpstreamError(res); + return responseToBase64(res, "mp3"); +} + +// format → handler dispatcher +export const FORMAT_HANDLERS = { + hyperbolic, + deepgram, + "nvidia-tts": nvidia, + "huggingface-tts": huggingface, + inworld, + cartesia, + playht, + coqui, + tortoise, + openai: openaiCompat, + "minimax-tts": minimaxTts, +}; diff --git a/open-sse/handlers/ttsProviders/googleTts.js b/open-sse/handlers/ttsProviders/googleTts.js new file mode 100644 index 0000000000000000000000000000000000000000..b77f6d767bb5955f2e654be621070edfc6d96c27 --- /dev/null +++ b/open-sse/handlers/ttsProviders/googleTts.js @@ -0,0 +1,54 @@ +// Google Translate TTS (no auth) — scrape token + batchexecute RPC +import { UA } from "./_base.js"; + +const REFRESH_MS = 11 * 60 * 1000; +const cache = { token: null, tokenTime: 0 }; +let _idx = 0; + +async function getToken() { + const now = Date.now(); + if (cache.token && now - cache.tokenTime < REFRESH_MS) return cache.token; + const res = await fetch("https://translate.google.com/", { headers: { "User-Agent": UA } }); + if (!res.ok) throw new Error(`Google translate fetch failed: ${res.status}`); + const html = await res.text(); + const fSid = html.match(/"FdrFJe":"(.*?)"/)?.[1]; + const bl = html.match(/"cfb2h":"(.*?)"/)?.[1]; + if (!fSid || !bl) throw new Error("Failed to parse Google token"); + cache.token = { "f.sid": fSid, bl }; + cache.tokenTime = now; + return cache.token; +} + +export default { + noAuth: true, + async synthesize(text, model) { + const lang = model || "en"; + const token = await getToken(); + const cleanText = text.replace(/[@^*()\\/\-_+=><"'\u201c\u201d\u3010\u3011]/g, " ").replaceAll(", ", ". "); + const rpcId = "jQ1olc"; + const reqId = (++_idx * 100000) + Math.floor(1000 + Math.random() * 9000); + const query = new URLSearchParams({ + rpcids: rpcId, + "f.sid": token["f.sid"], + bl: token.bl, + hl: lang, + "soc-app": 1, "soc-platform": 1, "soc-device": 1, + _reqid: reqId, + rt: "c", + }); + const payload = [cleanText, lang, null, "undefined", [0]]; + const body = new URLSearchParams(); + body.append("f.req", JSON.stringify([[[rpcId, JSON.stringify(payload), null, "generic"]]])); + const res = await fetch(`https://translate.google.com/_/TranslateWebserverUi/data/batchexecute?${query}`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", "Referer": "https://translate.google.com/" }, + body: body.toString(), + }); + if (!res.ok) throw new Error(`Google TTS failed: ${res.status}`); + const data = await res.text(); + const split = JSON.parse(data.split("\n")[3]); + const base64 = JSON.parse(split[0][2])[0]; + if (!base64 || base64.length < 100) throw new Error("Google TTS returned empty audio"); + return { base64, format: "mp3" }; + }, +}; diff --git a/open-sse/handlers/ttsProviders/index.js b/open-sse/handlers/ttsProviders/index.js new file mode 100644 index 0000000000000000000000000000000000000000..e1bb8b8367f15aa9a7b7026e1a432ae281f84f74 --- /dev/null +++ b/open-sse/handlers/ttsProviders/index.js @@ -0,0 +1,52 @@ +// TTS provider registry +import googleTts from "./googleTts.js"; +import edgeTts, { fetchEdgeTtsVoices } from "./edgeTts.js"; +import localDevice, { fetchLocalDeviceVoices } from "./localDevice.js"; +import elevenlabs, { fetchElevenLabsVoices } from "./elevenlabs.js"; +import openai from "./openai.js"; +import openrouter from "./openrouter.js"; +import gemini, { fetchGeminiVoices } from "./gemini.js"; +import { FORMAT_HANDLERS } from "./genericFormats.js"; +import { parseModelVoice } from "./_base.js"; + +// Special providers with custom synthesize() logic +const SPECIAL_ADAPTERS = { + "google-tts": googleTts, + "edge-tts": edgeTts, + "local-device": localDevice, + elevenlabs, + openai, + openrouter, + gemini, +}; + +export function getTtsAdapter(provider) { + return SPECIAL_ADAPTERS[provider] || null; +} + +// Generic config-driven dispatcher (uses ttsConfig.format) +export async function synthesizeViaConfig(provider, text, model, credentials) { + const { AI_PROVIDERS } = await import("@/shared/constants/providers"); + const cfg = AI_PROVIDERS[provider]?.ttsConfig; + if (!cfg) return null; + const handler = FORMAT_HANDLERS[cfg.format]; + if (!handler) return null; + const apiKey = credentials?.apiKey; + if (cfg.authType !== "none" && !apiKey) throw new Error(`${provider} API key required`); + 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 }); +} + +// Voice fetchers (used by /api/media-providers/tts/voices route) +export const VOICE_FETCHERS = { + "edge-tts": fetchEdgeTtsVoices, + "local-device": fetchLocalDeviceVoices, + elevenlabs: fetchElevenLabsVoices, + gemini: fetchGeminiVoices, +}; + +// Re-export for backward compat +export { fetchEdgeTtsVoices, fetchLocalDeviceVoices, fetchElevenLabsVoices, fetchGeminiVoices }; diff --git a/open-sse/handlers/ttsProviders/localDevice.js b/open-sse/handlers/ttsProviders/localDevice.js new file mode 100644 index 0000000000000000000000000000000000000000..74c5930ff13f93d05d107a8fcfe191d87a184fb1 --- /dev/null +++ b/open-sse/handlers/ttsProviders/localDevice.js @@ -0,0 +1,87 @@ +// Local device TTS — macOS `say` + Windows SAPI + ffmpeg +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const execFileAsync = promisify(execFile); + +let _voicesCache = null; + +async function fetchVoicesMac() { + const { stdout } = await execFileAsync("say", ["-v", "?"]); + const voices = []; + for (const line of stdout.split("\n")) { + const m = line.match(/^([^\s].*?)\s{2,}([a-z]{2}_[A-Z]{2})/); + if (!m) continue; + const name = m[1].trim(); + const locale = m[2].trim(); + const lang = locale.split("_")[0]; + const country = locale.split("_")[1]; + voices.push({ id: name, name, locale, lang, country, gender: "" }); + } + return voices; +} + +async function fetchVoicesWin() { + const script = [ + "Add-Type -AssemblyName System.Speech;", + "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;", + "$s.GetInstalledVoices() | ForEach-Object { $v = $_.VoiceInfo;", + "[PSCustomObject]@{ Name=$v.Name; Culture=$v.Culture.Name; Gender=$v.Gender } }", + "| ConvertTo-Json -Compress", + ].join(" "); + const { stdout } = await execFileAsync( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", script], + { windowsHide: true } + ); + const raw = JSON.parse(stdout.trim() || "[]"); + const list = Array.isArray(raw) ? raw : [raw]; + return list.map((v) => { + const culture = v.Culture || "en-US"; + const [lang, country = ""] = culture.split("-"); + const genderMap = { 1: "Male", 2: "Female", Male: "Male", Female: "Female" }; + return { + id: v.Name, name: v.Name, + locale: culture.replace("-", "_"), + lang, country, + gender: genderMap[v.Gender] || "", + }; + }); +} + +export async function fetchLocalDeviceVoices() { + if (_voicesCache) return _voicesCache; + try { + const voices = process.platform === "win32" ? await fetchVoicesWin() : await fetchVoicesMac(); + _voicesCache = voices; + return voices; + } catch { + return []; + } +} + +async function synthesizeMacOrWin(text, voiceId) { + const dir = await mkdtemp(join(tmpdir(), "tts-")); + const aiffPath = join(dir, "out.aiff"); + const mp3Path = join(dir, "out.mp3"); + try { + const args = voiceId ? ["-v", voiceId, "-o", aiffPath, text] : ["-o", aiffPath, text]; + await execFileAsync("say", args); + await execFileAsync("ffmpeg", ["-y", "-i", aiffPath, "-codec:a", "libmp3lame", "-qscale:a", "4", mp3Path]); + const buf = await readFile(mp3Path); + return buf.toString("base64"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +export default { + noAuth: true, + async synthesize(text, model) { + const base64 = await synthesizeMacOrWin(text, model); + return { base64, format: "mp3" }; + }, +}; diff --git a/open-sse/handlers/ttsProviders/minimax.js b/open-sse/handlers/ttsProviders/minimax.js new file mode 100644 index 0000000000000000000000000000000000000000..b435e2b485ca8d3bfbf14804429f9e8704b854a5 --- /dev/null +++ b/open-sse/handlers/ttsProviders/minimax.js @@ -0,0 +1,59 @@ +import { Buffer } from "node:buffer"; + +function hexToBase64(audioHex) { + const clean = typeof audioHex === "string" ? audioHex.trim() : ""; + if (!clean) throw new Error("MiniMax TTS returned no audio"); + if (clean.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(clean)) { + throw new Error("MiniMax TTS returned invalid audio"); + } + return Buffer.from(clean, "hex").toString("base64"); +} + +// MiniMax T2A HTTP: returns hex-encoded audio in non-streaming mode. +export default async function minimaxTts({ baseUrl, apiKey, text, modelId, voiceId }) { + const res = await fetch(baseUrl, { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}` }, + body: JSON.stringify({ + model: modelId || "speech-2.8-hd", + text, + stream: false, + language_boost: "auto", + output_format: "hex", + voice_setting: { + voice_id: voiceId || "English_expressive_narrator", + speed: 1, + vol: 1, + pitch: 0, + }, + audio_setting: { + sample_rate: 32000, + bitrate: 128000, + format: "mp3", + channel: 1, + }, + }), + }); + + const rawText = await res.text(); + let data = {}; + if (rawText) { + try { data = JSON.parse(rawText); } catch { data = {}; } + } + + const baseResp = data.base_resp || data.baseResp || {}; + const statusCode = Number(baseResp.status_code ?? baseResp.statusCode ?? 0); + const statusMessage = baseResp.status_msg || baseResp.statusMsg || data.message || ""; + + if (!res.ok) { + throw new Error(statusMessage || rawText || `MiniMax TTS error (${res.status})`); + } + if (statusCode !== 0) { + throw new Error(statusMessage || "MiniMax TTS upstream error"); + } + + return { + base64: hexToBase64(data.data?.audio), + format: data.extra_info?.audio_format || data.extraInfo?.audioFormat || "mp3", + }; +} diff --git a/open-sse/handlers/ttsProviders/openai.js b/open-sse/handlers/ttsProviders/openai.js new file mode 100644 index 0000000000000000000000000000000000000000..b1f680a1dcc0067f80b505e1e044947379a7089d --- /dev/null +++ b/open-sse/handlers/ttsProviders/openai.js @@ -0,0 +1,33 @@ +// 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 = DEFAULT_TTS_MODEL; + let voice = "alloy"; + if (model && model.includes("/")) { + const parts = model.split("/"); + if (parts.length === 2) [ttsModel, voice] = parts; + } else if (model) { + voice = model; + } + + const baseUrl = (credentials.baseUrl || "https://api.openai.com").replace(/\/+$/, ""); + const res = await fetch(`${baseUrl}/v1/audio/speech`, { + method: "POST", + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${credentials.apiKey}` }, + body: JSON.stringify({ model: ttsModel, voice, input: text }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error?.message || `OpenAI TTS failed: ${res.status}`); + } + const buf = await res.arrayBuffer(); + return { base64: Buffer.from(buf).toString("base64"), format: "mp3" }; + }, +}; diff --git a/open-sse/handlers/ttsProviders/openrouter.js b/open-sse/handlers/ttsProviders/openrouter.js new file mode 100644 index 0000000000000000000000000000000000000000..84fac0cb0e1167362269320c46e35c912ebb810a --- /dev/null +++ b/open-sse/handlers/ttsProviders/openrouter.js @@ -0,0 +1,73 @@ +// 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 = TTS_CFG.defaultModel; + let voice = "alloy"; + if (model && model.includes("/")) { + const lastSlash = model.lastIndexOf("/"); + const maybVoice = model.slice(lastSlash + 1); + const maybeModel = model.slice(0, lastSlash); + if (maybeModel.includes("/")) { + ttsModel = maybeModel; + voice = maybVoice; + } else { + voice = model; + } + } else if (model) { + voice = model; + } + + const res = await fetch(TTS_CFG.baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${credentials.apiKey}`, + ...(TTS_CFG.headers || {}), + }, + body: JSON.stringify({ + model: ttsModel, + modalities: ["text", "audio"], + audio: { voice, format: "wav" }, + stream: true, + messages: [{ role: "user", content: text }], + }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error?.message || `OpenRouter TTS failed: ${res.status}`); + } + + // Parse SSE stream, accumulate base64 audio chunks + const chunks = []; + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop(); + for (const line of lines) { + if (!line.startsWith("data: ") || line === "data: [DONE]") continue; + try { + const json = JSON.parse(line.slice(6)); + const audioData = json.choices?.[0]?.delta?.audio?.data; + if (audioData) chunks.push(audioData); + } catch {} + } + } + + if (chunks.length === 0) throw new Error("OpenRouter TTS returned no audio data"); + return { base64: chunks.join(""), format: "wav" }; + }, +}; diff --git a/open-sse/index.js b/open-sse/index.js new file mode 100644 index 0000000000000000000000000000000000000000..b8181f0ba40995f6429c1b18b7e6a49893ca969b --- /dev/null +++ b/open-sse/index.js @@ -0,0 +1,79 @@ +// Patch global fetch with proxy support (must be first) +import "./utils/proxyFetch.js"; + +// Config +export { PROVIDERS } from "./config/providers.js"; +export { OAUTH_ENDPOINTS, CLAUDE_SYSTEM_PROMPT } from "./config/appConstants.js"; +export { CACHE_TTL, DEFAULT_MAX_TOKENS, COOLDOWN_MS, BACKOFF_CONFIG } from "./config/runtimeConfig.js"; +export { + PROVIDER_MODELS, + getProviderModels, + getDefaultModel, + isValidModel, + findModelName, + getModelTargetFormat, + PROVIDER_ID_TO_ALIAS, + getModelsByProviderId +} from "./config/providerModels.js"; + +// Translator +export { FORMATS } from "./translator/formats.js"; +export { + register, + translateRequest, + translateResponse, + needsTranslation, + initState, + initTranslators +} from "./translator/index.js"; + +// Services +export { + detectFormat, + getTargetFormat +} from "./services/provider.js"; + +export { parseModel, resolveModelAliasFromMap, getModelInfoCore } from "./services/model.js"; + +export { + checkFallbackError, + isAccountUnavailable, + getUnavailableUntil, + filterAvailableAccounts +} from "./services/accountFallback.js"; + +export { + TOKEN_EXPIRY_BUFFER_MS, + refreshAccessToken, + refreshClaudeOAuthToken, + refreshGoogleToken, + refreshQwenToken, + refreshCodexToken, + refreshIflowToken, + refreshGitHubToken, + refreshCopilotToken, + getAccessToken, + refreshTokenByProvider +} from "./services/tokenRefresh.js"; + +export { + CODEX_MAX_REFRESH_AGE_MS, + shouldRefreshCredentials, + refreshProviderCredentials, + mergeRefreshedCredentials, + mergeProviderSpecificData, +} from "./services/oauthCredentialManager.js"; + +// Handlers +export { handleChatCore, isTokenExpiringSoon } from "./handlers/chatCore.js"; +export { createStreamController, pipeWithDisconnect, createDisconnectAwareStream } from "./utils/streamHandler.js"; + +// Executors +export { getExecutor, hasSpecializedExecutor } from "./executors/index.js"; + +// Utils +export { errorResponse, formatProviderError } from "./utils/error.js"; +export { + createSSETransformStreamWithLogger, + createPassthroughStreamWithLogger +} from "./utils/stream.js"; diff --git a/open-sse/providers/REGISTRY_TEMPLATE.js b/open-sse/providers/REGISTRY_TEMPLATE.js new file mode 100644 index 0000000000000000000000000000000000000000..66875cdf783fe85c29229e2bc7d265fc548221d8 --- /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 0000000000000000000000000000000000000000..77220317957cb44bd2e0ed6cf31c74f744e39b44 --- /dev/null +++ b/open-sse/providers/capabilities.js @@ -0,0 +1,246 @@ +// 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 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-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: 64000 }, + "claude-sonnet-4-6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 64000 }, + + // 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 = {}; + +/** + * 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: { 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 0000000000000000000000000000000000000000..41212d69c2eef67ed1e2eb6303d4b190036c67c2 --- /dev/null +++ b/open-sse/providers/index.js @@ -0,0 +1,48 @@ +// 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.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 0000000000000000000000000000000000000000..a7d273d6017a9c922788a5ddcc1de9a5f1f23fc0 --- /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 0000000000000000000000000000000000000000..5e0548c71d16e4a1e855d864f34d3483766f6bba --- /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 0000000000000000000000000000000000000000..8be351ad94e208c7371eb521714e6fd1f115a452 --- /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/open-sse/providers/pricing.js b/open-sse/providers/pricing.js new file mode 100644 index 0000000000000000000000000000000000000000..9e767a804993458597a99b1db6169fde60460e8a --- /dev/null +++ b/open-sse/providers/pricing.js @@ -0,0 +1,304 @@ +// Pricing rates for AI models — all rates in $/1M tokens +// +// Fallback order (first match wins): +// 1. PROVIDER_PRICING[provider][model] — provider-specific override +// 2. MODEL_PRICING[model] — canonical model price (provider-agnostic) +// 3. PATTERN_PRICING — glob pattern match (e.g. "codex-*") + +/** + * Canonical model pricing — provider-agnostic. + * Cover all known models; deduplicated across providers. + */ +export const MODEL_PRICING = { + // === Anthropic / Claude === + "claude-opus-4-6": { input: 5.00, output: 25.00, cached: 0.50, reasoning: 25.00, cache_creation: 6.25 }, + "claude-opus-4-5-20251101": { input: 5.00, output: 25.00, cached: 0.50, reasoning: 25.00, cache_creation: 6.25 }, + "claude-sonnet-4-6": { input: 3.00, output: 15.00, cached: 0.30, reasoning: 15.00, cache_creation: 3.75 }, + "claude-sonnet-4-5-20250929": { input: 3.00, output: 15.00, cached: 0.30, reasoning: 15.00, cache_creation: 3.75 }, + "claude-haiku-4-5-20251001": { input: 1.00, output: 5.00, cached: 0.10, reasoning: 5.00, cache_creation: 1.25 }, + "claude-sonnet-4-20250514": { input: 3.00, output: 15.00, cached: 1.50, reasoning: 15.00, cache_creation: 3.00 }, + "claude-opus-4-20250514": { input: 15.00, output: 25.00, cached: 7.50, reasoning: 112.50, cache_creation: 15.00 }, + "claude-3-5-sonnet-20241022": { input: 3.00, output: 15.00, cached: 1.50, reasoning: 15.00, cache_creation: 3.00 }, + "claude-haiku-4.5": { input: 0.50, output: 2.50, cached: 0.05, reasoning: 3.75, cache_creation: 0.50 }, + "claude-opus-4.1": { input: 5.00, output: 25.00, cached: 0.50, reasoning: 37.50, cache_creation: 5.00 }, + "claude-opus-4.5": { input: 5.00, output: 25.00, cached: 0.50, reasoning: 37.50, cache_creation: 5.00 }, + "claude-opus-4.6": { input: 5.00, output: 25.00, cached: 0.50, reasoning: 37.50, cache_creation: 5.00 }, + "claude-sonnet-4": { input: 3.00, output: 15.00, cached: 0.30, reasoning: 22.50, cache_creation: 3.00 }, + "claude-sonnet-4.5": { input: 3.00, output: 15.00, cached: 0.30, reasoning: 22.50, cache_creation: 3.00 }, + "claude-sonnet-4.6": { input: 3.00, output: 15.00, cached: 0.30, reasoning: 22.50, cache_creation: 3.00 }, + "claude-opus-4-5-thinking": { input: 5.00, output: 25.00, cached: 0.50, reasoning: 37.50, cache_creation: 5.00 }, + "claude-opus-4-6-thinking": { input: 5.00, output: 25.00, cached: 0.50, reasoning: 37.50, cache_creation: 5.00 }, + + // === OpenAI / GPT === + "gpt-3.5-turbo": { input: 0.50, output: 1.50, cached: 0.25, reasoning: 2.25, cache_creation: 0.50 }, + "gpt-4": { input: 2.50, output: 10.00, cached: 1.25, reasoning: 15.00, cache_creation: 2.50 }, + "gpt-4-turbo": { input: 10.00, output: 30.00, cached: 5.00, reasoning: 45.00, cache_creation: 10.00 }, + "gpt-4o": { input: 2.50, output: 10.00, cached: 1.25, reasoning: 15.00, cache_creation: 2.50 }, + "gpt-4o-mini": { input: 0.15, output: 0.60, cached: 0.075, reasoning: 0.90, cache_creation: 0.15 }, + "gpt-4.1": { input: 2.50, output: 10.00, cached: 1.25, reasoning: 15.00, cache_creation: 2.50 }, + "gpt-5": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 }, + "gpt-5-mini": { input: 0.75, output: 3.00, cached: 0.375, reasoning: 4.50, cache_creation: 0.75 }, + "gpt-5-codex": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 }, + "gpt-5.1": { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 }, + "gpt-5.1-codex": { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 }, + "gpt-5.1-codex-mini": { input: 1.50, output: 6.00, cached: 0.75, reasoning: 9.00, cache_creation: 1.50 }, + "gpt-5.1-codex-mini-high": { input: 2.00, output: 8.00, cached: 1.00, reasoning: 12.00, cache_creation: 2.00 }, + "gpt-5.1-codex-max": { input: 8.00, output: 32.00, cached: 4.00, reasoning: 48.00, cache_creation: 8.00 }, + "gpt-5.2": { input: 5.00, output: 20.00, cached: 2.50, reasoning: 30.00, cache_creation: 5.00 }, + "gpt-5.2-codex": { input: 5.00, output: 20.00, cached: 2.50, reasoning: 30.00, cache_creation: 5.00 }, + "gpt-5.3-codex": { input: 6.00, output: 24.00, cached: 3.00, reasoning: 36.00, cache_creation: 6.00 }, + "gpt-5.3-codex-xhigh": { input: 10.00, output: 40.00, cached: 5.00, reasoning: 60.00, cache_creation: 10.00 }, + "gpt-5.3-codex-high": { input: 8.00, output: 32.00, cached: 4.00, reasoning: 48.00, cache_creation: 8.00 }, + "gpt-5.3-codex-low": { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 }, + "gpt-5.3-codex-none": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 }, + "gpt-5.3-codex-spark": { input: 3.00, output: 12.00, cached: 0.30, reasoning: 12.00, cache_creation: 3.00 }, + "o1": { input: 15.00, output: 60.00, cached: 7.50, reasoning: 90.00, cache_creation: 15.00 }, + "o1-mini": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 }, + + // === Gemini === + "gemini-3-flash-preview": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 }, + "gemini-3-pro-preview": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 }, + "gemini-3.1-pro-low": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 }, + "gemini-3.1-pro-high": { input: 4.00, output: 18.00, cached: 0.50, reasoning: 27.00, cache_creation: 4.00 }, + "gemini-pro-agent": { input: 4.00, output: 18.00, cached: 0.50, reasoning: 27.00, cache_creation: 4.00 }, + "gemini-3-flash-agent": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 }, + "gemini-3.5-flash-low": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 }, + "gemini-3.5-flash-extra-low": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 }, + "gemini-3-flash": { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 }, + "gemini-2.5-pro": { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 }, + "gemini-2.5-flash": { input: 0.30, output: 2.50, cached: 0.03, reasoning: 3.75, cache_creation: 0.30 }, + "gemini-2.5-flash-lite": { input: 0.15, output: 1.25, cached: 0.015, reasoning: 1.875, cache_creation: 0.15 }, + + // === Qwen === + "qwen3-coder-plus": { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 }, + "qwen3-coder-flash": { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 }, + + // === Kimi === + "kimi-k2": { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 }, + "kimi-k2-thinking": { input: 1.50, output: 6.00, cached: 0.75, reasoning: 9.00, cache_creation: 1.50 }, + "kimi-k2.5": { input: 1.20, output: 4.80, cached: 0.60, reasoning: 7.20, cache_creation: 1.20 }, + "kimi-k2.5-thinking": { input: 1.80, output: 7.20, cached: 0.90, reasoning: 10.80, cache_creation: 1.80 }, + "kimi-latest": { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 }, + + // === DeepSeek === + "deepseek-chat": { input: 0.14, output: 0.28, cached: 0.0028, reasoning: 0.28, cache_creation: 0.14 }, + "deepseek-reasoner": { input: 0.14, output: 0.28, cached: 0.0028, reasoning: 0.28, cache_creation: 0.14 }, + "deepseek-r1": { input: 0.14, output: 0.28, cached: 0.0028, reasoning: 0.28, cache_creation: 0.14 }, + "deepseek-v3.2-chat": { input: 0.14, output: 0.28, cached: 0.0028, reasoning: 0.28, cache_creation: 0.14 }, + "deepseek-v3.2-reasoner": { input: 0.14, output: 0.28, cached: 0.0028, reasoning: 0.28, cache_creation: 0.14 }, + "deepseek-v4-flash": { input: 0.14, output: 0.28, cached: 0.0028, reasoning: 0.28, cache_creation: 0.14 }, + "deepseek-v4-pro": { input: 0.435, output: 0.87, cached: 0.003625, reasoning: 0.87, cache_creation: 0.435 }, + + // === GLM === + "glm-4.6": { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 }, + "glm-4.6v": { input: 0.75, output: 3.00, cached: 0.375, reasoning: 4.50, cache_creation: 0.75 }, + "glm-4.7": { input: 0.75, output: 3.00, cached: 0.375, reasoning: 4.50, cache_creation: 0.75 }, + "glm-5": { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 }, + + // === MiniMax === + "MiniMax-M3": { input: 0.30, output: 1.20, cached: 0.06, reasoning: 1.80, cache_creation: 0.30 }, + "MiniMax-M2.1": { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 }, + "MiniMax-M2.5": { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 }, + "MiniMax-M2.7": { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 }, + "minimax-m2.1": { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 }, + "minimax-m2.5": { input: 0.60, output: 2.40, cached: 0.30, reasoning: 3.60, cache_creation: 0.60 }, + + // === Grok === + "grok-code-fast-1": { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 }, + + // === OpenRouter fallback === + "auto": { input: 2.00, output: 8.00, cached: 1.00, reasoning: 12.00, cache_creation: 2.00 }, + + // === Misc === + "oswe-vscode-prime": { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 }, + "gpt-oss-120b-medium": { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 }, + "vision-model": { input: 1.50, output: 6.00, cached: 0.75, reasoning: 9.00, cache_creation: 1.50 }, + "coder-model": { input: 1.50, output: 6.00, cached: 0.75, reasoning: 9.00, cache_creation: 1.50 }, +}; + +/** + * Provider-specific pricing overrides. + * Only include entries where price DIFFERS from MODEL_PRICING. + * Keyed by provider alias (cc, cx, gc, gh, ...) or provider id (openai, anthropic, ...). + */ +export const PROVIDER_PRICING = { + // GitHub Copilot (gh) — gpt-5.3-codex has different rate than canonical + gh: { + "gpt-5.3-codex": { input: 1.75, output: 14.00, cached: 0.175, reasoning: 14.00, cache_creation: 1.75 }, + }, +}; + +/** + * Pattern-based pricing fallback — matched when no exact model entry found. + * Patterns use simple glob: "*" matches any substring. + * First match wins — order matters. + */ +export const PATTERN_PRICING = [ + // --- Codex variants --- + { pattern: "*-codex-xhigh", pricing: { input: 10.00, output: 40.00, cached: 5.00, reasoning: 60.00, cache_creation: 10.00 } }, + { pattern: "*-codex-high", pricing: { input: 8.00, output: 32.00, cached: 4.00, reasoning: 48.00, cache_creation: 8.00 } }, + { pattern: "*-codex-max", pricing: { input: 8.00, output: 32.00, cached: 4.00, reasoning: 48.00, cache_creation: 8.00 } }, + { pattern: "*-codex-mini-*", pricing: { input: 1.50, output: 6.00, cached: 0.75, reasoning: 9.00, cache_creation: 1.50 } }, + { pattern: "*-codex-mini", pricing: { input: 1.50, output: 6.00, cached: 0.75, reasoning: 9.00, cache_creation: 1.50 } }, + { pattern: "*-codex-low", pricing: { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 } }, + { pattern: "*-codex-none", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } }, + { pattern: "*-codex-spark", pricing: { input: 3.00, output: 12.00, cached: 0.30, reasoning: 12.00, cache_creation: 3.00 } }, + { pattern: "codex-*", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } }, + { pattern: "*-codex", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } }, + + // --- Claude --- + { pattern: "claude-opus-*", pricing: { input: 5.00, output: 25.00, cached: 0.50, reasoning: 25.00, cache_creation: 6.25 } }, + { pattern: "claude-sonnet-*", pricing: { input: 3.00, output: 15.00, cached: 0.30, reasoning: 15.00, cache_creation: 3.75 } }, + { pattern: "claude-haiku-*", pricing: { input: 1.00, output: 5.00, cached: 0.10, reasoning: 5.00, cache_creation: 1.25 } }, + { pattern: "claude-*", pricing: { input: 3.00, output: 15.00, cached: 0.30, reasoning: 15.00, cache_creation: 3.75 } }, + + // --- Gemini (specific first, generic last) --- + { pattern: "gemini-*-flash-lite", pricing: { input: 0.15, output: 1.25, cached: 0.015, reasoning: 1.875, cache_creation: 0.15 } }, + { pattern: "gemini-*-flash", pricing: { input: 0.30, output: 2.50, cached: 0.03, reasoning: 3.75, cache_creation: 0.30 } }, + { pattern: "gemini-*-pro", pricing: { input: 2.00, output: 12.00, cached: 0.25, reasoning: 18.00, cache_creation: 2.00 } }, + { pattern: "gemini-3-*", pricing: { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 } }, + { pattern: "gemini-2.5-*", pricing: { input: 0.30, output: 2.50, cached: 0.03, reasoning: 3.75, cache_creation: 0.30 } }, + { pattern: "gemini-*", pricing: { input: 0.50, output: 3.00, cached: 0.03, reasoning: 4.50, cache_creation: 0.50 } }, + + // --- GPT (specific first, generic last) --- + { pattern: "gpt-5.3-*", pricing: { input: 6.00, output: 24.00, cached: 3.00, reasoning: 36.00, cache_creation: 6.00 } }, + { pattern: "gpt-5.2-*", pricing: { input: 5.00, output: 20.00, cached: 2.50, reasoning: 30.00, cache_creation: 5.00 } }, + { pattern: "gpt-5.1-*", pricing: { input: 4.00, output: 16.00, cached: 2.00, reasoning: 24.00, cache_creation: 4.00 } }, + { pattern: "gpt-5-*", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } }, + { pattern: "gpt-5*", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } }, + { pattern: "gpt-4o-*", pricing: { input: 0.15, output: 0.60, cached: 0.075, reasoning: 0.90, cache_creation: 0.15 } }, + { pattern: "gpt-4o", pricing: { input: 2.50, output: 10.00, cached: 1.25, reasoning: 15.00, cache_creation: 2.50 } }, + { pattern: "gpt-4*", pricing: { input: 2.50, output: 10.00, cached: 1.25, reasoning: 15.00, cache_creation: 2.50 } }, + + // --- o1 / o-series --- + { pattern: "o1-*", pricing: { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 } }, + { pattern: "o1", pricing: { input: 15.00, output: 60.00, cached: 7.50, reasoning: 90.00, cache_creation: 15.00 } }, + { pattern: "o3-*", pricing: { input: 10.00, output: 40.00, cached: 5.00, reasoning: 60.00, cache_creation: 10.00 } }, + { pattern: "o4-*", pricing: { input: 2.00, output: 8.00, cached: 1.00, reasoning: 12.00, cache_creation: 2.00 } }, + + // --- Qwen --- + { pattern: "qwen3-coder-*", pricing: { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 } }, + { pattern: "qwen*-coder-*", pricing: { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 } }, + { pattern: "qwen*", pricing: { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 } }, + + // --- Kimi --- + { pattern: "kimi-*-thinking", pricing: { input: 1.80, output: 7.20, cached: 0.90, reasoning: 10.80, cache_creation: 1.80 } }, + { pattern: "kimi-k2*", pricing: { input: 1.20, output: 4.80, cached: 0.60, reasoning: 7.20, cache_creation: 1.20 } }, + { pattern: "kimi-*", pricing: { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 } }, + + // --- DeepSeek --- + { pattern: "deepseek-*reasoner*", pricing: { input: 0.14, output: 0.28, cached: 0.0028, reasoning: 0.28, cache_creation: 0.14 } }, + { pattern: "deepseek-r*", pricing: { input: 0.14, output: 0.28, cached: 0.0028, reasoning: 0.28, cache_creation: 0.14 } }, + { pattern: "deepseek-v*", pricing: { input: 0.14, output: 0.28, cached: 0.0028, reasoning: 0.28, cache_creation: 0.14 } }, + { pattern: "deepseek-*", pricing: { input: 0.14, output: 0.28, cached: 0.0028, reasoning: 0.28, cache_creation: 0.14 } }, + + // --- GLM --- + { pattern: "glm-5*", pricing: { input: 1.00, output: 4.00, cached: 0.50, reasoning: 6.00, cache_creation: 1.00 } }, + { pattern: "glm-4*", pricing: { input: 0.75, output: 3.00, cached: 0.375, reasoning: 4.50, cache_creation: 0.75 } }, + { pattern: "glm-*", pricing: { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 } }, + + // --- MiniMax --- + { pattern: "MiniMax-*", pricing: { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 } }, + { pattern: "minimax-*", pricing: { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 } }, + + // --- Grok --- + { pattern: "grok-code-*", pricing: { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 } }, + { pattern: "grok-*", pricing: { input: 0.50, output: 2.00, cached: 0.25, reasoning: 3.00, cache_creation: 0.50 } }, +]; + +/** + * Match a model ID against a glob pattern (* = wildcard). Case-insensitive: + * registry ids mix casing (e.g. "MiniMax-M2.5" vs "minimax-m2.5"). + */ +export function matchPattern(pattern, model) { + const regex = new RegExp("^" + pattern.split("*").map(s => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$", "i"); + return regex.test(model); +} + +/** + * Resolve pricing for a model using the 3-step fallback chain: + * 1. PROVIDER_PRICING[provider][model] + * 2. MODEL_PRICING[model] + * 3. PATTERN_PRICING (glob match) + * + * @param {string} provider + * @param {string} model + * @returns {object|null} + */ +export function getPricingForModel(provider, model) { + if (!model) return null; + + // 1. Provider-specific override + if (provider && PROVIDER_PRICING[provider]?.[model]) { + return PROVIDER_PRICING[provider][model]; + } + + // 2. Canonical model pricing (strip vendor prefix if needed: "deepseek/deepseek-chat" → "deepseek-chat") + const baseModel = model.includes("/") ? model.split("/").pop() : model; + if (MODEL_PRICING[baseModel]) return MODEL_PRICING[baseModel]; + if (MODEL_PRICING[model]) return MODEL_PRICING[model]; + + // 3. Pattern match + for (const { pattern, pricing } of PATTERN_PRICING) { + if (matchPattern(pattern, baseModel) || matchPattern(pattern, model)) { + return pricing; + } + } + + return null; +} + +/** + * Get all provider pricing (for UI / API). + * Returns PROVIDER_PRICING — consumers should fall back to MODEL_PRICING for unlisted models. + */ +export function getDefaultPricing() { + return PROVIDER_PRICING; +} + +/** + * Format cost for display + * @param {number} cost + * @returns {string} + */ +export function formatCost(cost) { + if (cost === null || cost === undefined || isNaN(cost)) return "$0.00"; + return `$${cost.toFixed(2)}`; +} + +/** + * Calculate cost from tokens and pricing + * @param {object} tokens + * @param {object} pricing + * @returns {number} cost in dollars + */ +export function calculateCostFromTokens(tokens, pricing) { + if (!tokens || !pricing) return 0; + + let cost = 0; + + const inputTokens = tokens.prompt_tokens || tokens.input_tokens || 0; + const cachedTokens = tokens.cached_tokens || tokens.cache_read_input_tokens || 0; + const nonCachedInput = Math.max(0, inputTokens - cachedTokens); + + cost += nonCachedInput * (pricing.input / 1000000); + + if (cachedTokens > 0) { + cost += cachedTokens * ((pricing.cached || pricing.input) / 1000000); + } + + const outputTokens = tokens.completion_tokens || tokens.output_tokens || 0; + cost += outputTokens * (pricing.output / 1000000); + + const reasoningTokens = tokens.reasoning_tokens || 0; + if (reasoningTokens > 0) { + cost += reasoningTokens * ((pricing.reasoning || pricing.output) / 1000000); + } + + const cacheCreationTokens = tokens.cache_creation_input_tokens || 0; + if (cacheCreationTokens > 0) { + cost += cacheCreationTokens * ((pricing.cache_creation || pricing.input) / 1000000); + } + + return cost; +} diff --git a/open-sse/providers/registry/alicode-intl.js b/open-sse/providers/registry/alicode-intl.js new file mode 100644 index 0000000000000000000000000000000000000000..ac98cb2d0051acb8b6bd30bb54e95f2b684fc8da --- /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 0000000000000000000000000000000000000000..5b6a088f9ad779856b1c002033c86f31a9f1c1cb --- /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 0000000000000000000000000000000000000000..1f6a3494df1bf47a31a679fef0d80f719ff47016 --- /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 0000000000000000000000000000000000000000..17abd64dee6b3b1e75c986bd22c8753333c70ad9 --- /dev/null +++ b/open-sse/providers/registry/antigravity.js @@ -0,0 +1,79 @@ +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", + 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, + }, + "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 }, + ], + 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 0000000000000000000000000000000000000000..bb4eb77da87ab65a414315f06b6f9e6f340ed586 --- /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 0000000000000000000000000000000000000000..3e269bef52bb94ef7bddfe0dd188b04b1b1aea38 --- /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 0000000000000000000000000000000000000000..32feca0fe18e5c83e5ecc491373985f26f3d9e50 --- /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 0000000000000000000000000000000000000000..720c5eafc5a09bf980815a9bf510bb12e9bde493 --- /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 0000000000000000000000000000000000000000..bb5a70b7dbb438031553f48cbce81935ad40839c --- /dev/null +++ b/open-sse/providers/registry/blackbox.js @@ -0,0 +1,43 @@ +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", + transport: { + baseUrl: "https://api.blackbox.ai/chat/completions", + thinkingFormat: "openai", + }, + models: [ + { 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" }, + ], +}; diff --git a/open-sse/providers/registry/brave-search.js b/open-sse/providers/registry/brave-search.js new file mode 100644 index 0000000000000000000000000000000000000000..6fcad1191cea04cbc30ab83e01ed4e76f4aae373 --- /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 0000000000000000000000000000000000000000..6440cc488b8546a6ba10757b284fc8416aefac07 --- /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 0000000000000000000000000000000000000000..411925c5c6c85559a6edb55323695eee29139b59 --- /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 0000000000000000000000000000000000000000..964250fd039f948c957045251d67b11c5fdbb379 --- /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 0000000000000000000000000000000000000000..0eed21b13d97035d866eadf808b3bcc89ef0f269 --- /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 0000000000000000000000000000000000000000..9d483d8f3c5c8dc40b96ca86026bcb8c39be7d37 --- /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 0000000000000000000000000000000000000000..cfa788c47a5946e873b7585d27419778b708cae8 --- /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 0000000000000000000000000000000000000000..4d440b7471ee873ac930d48ec2979a13e64efab1 --- /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.js b/open-sse/providers/registry/codebuddy.js new file mode 100644 index 0000000000000000000000000000000000000000..4bf2c811905edfe5178578ba5a021d3775f7d891 --- /dev/null +++ b/open-sse/providers/registry/codebuddy.js @@ -0,0 +1,32 @@ +export default { + id: "codebuddy", + hidden: true, + priority: 90, + display: { + name: "CodeBuddy", + icon: "smart_toy", + color: "#006EFF", + website: "https://copilot.tencent.com", + notice: { + signupUrl: "https://copilot.tencent.com", + }, + }, + category: "oauth", + transport: { + baseUrl: "https://copilot.tencent.com/v1/chat/completions", + auth: { + combined: true, + header: "Authorization", + scheme: "bearer", + }, + }, + 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, + }, +}; diff --git a/open-sse/providers/registry/codex.js b/open-sse/providers/registry/codex.js new file mode 100644 index 0000000000000000000000000000000000000000..4620c4d3a52efd007499abf2e321b8217d952d99 --- /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 0000000000000000000000000000000000000000..68236bb857ee3f7d48813b6019c08187fc812a6d --- /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 0000000000000000000000000000000000000000..74216fa09df67bdd47f3f47b0e9c7d1541716c35 --- /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 0000000000000000000000000000000000000000..3b21fbbcb9b807c4119e759f3d453ecf96056391 --- /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 0000000000000000000000000000000000000000..8f108e21618d9d5f640665b6f659309941409ca6 --- /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 0000000000000000000000000000000000000000..ca0ecdb1624523ac8144d3a74082cff67f86433a --- /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 0000000000000000000000000000000000000000..9d2b41a3df61eca1917d48bbaee218a324543b7e --- /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 0000000000000000000000000000000000000000..f6804ae028023344860473b14a7e15b3f0f7a136 --- /dev/null +++ b/open-sse/providers/registry/deepseek.js @@ -0,0 +1,35 @@ +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", + }, + }, + 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 0000000000000000000000000000000000000000..74781e8a0a572f70e6ecdf3dbb6161c496214d48 --- /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 0000000000000000000000000000000000000000..fad3227dbc591048e3c258dbdc2b6512f1bf3fa6 --- /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 0000000000000000000000000000000000000000..75b8bf0ab96758816b7f8edef5ae799220b650a1 --- /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 0000000000000000000000000000000000000000..a18d7d05c122f12789a36f01bf2acd0b96bf7aba --- /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 0000000000000000000000000000000000000000..fab98fd711c19aa9535c4e4af63be6508c0492e7 --- /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 0000000000000000000000000000000000000000..211fd590d6a38cde74d3236ad1d132aaf4c34bc4 --- /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 0000000000000000000000000000000000000000..3e4a94c2910d5dc4d8404acf10a0356fd19b7df1 --- /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 0000000000000000000000000000000000000000..8101fe5fc4379e6c682b55d0050331a933aad53e --- /dev/null +++ b/open-sse/providers/registry/gemini.js @@ -0,0 +1,80 @@ +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-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 0000000000000000000000000000000000000000..95169eb37bfffea9568ddcf5aab99074ddb795dd --- /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 0000000000000000000000000000000000000000..319379f6752cff88cb184f774216a4db0ba1974a --- /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 0000000000000000000000000000000000000000..90a71b4c40b70a7692577f636807880e3d413110 --- /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 0000000000000000000000000000000000000000..ceeb8796629cfd47aaa28790bd372364e12b7499 --- /dev/null +++ b/open-sse/providers/registry/glm.js @@ -0,0 +1,46 @@ +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: { + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + }, + auth: { + combined: true, + header: "x-api-key", + scheme: "raw", + }, + usage: { + url: "https://api.z.ai/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.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 0000000000000000000000000000000000000000..f2a1c0b471dfc303538a9a0e69e8f00e7b0deda6 --- /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 0000000000000000000000000000000000000000..0b4d748e7c0e0225a60e182e6ab9e147f1fcf871 --- /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 0000000000000000000000000000000000000000..0fbaa4574d06e575192babc2fe3c32a88de74357 --- /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 0000000000000000000000000000000000000000..2ad8a6d8be864a0b5ad07352d9038e0951b382de --- /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 0000000000000000000000000000000000000000..768b0ded95a020fe64fc88b91205042a6c8f5ce1 --- /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 0000000000000000000000000000000000000000..9796cc93267a5f5b786f619addbed5aff52ae65f --- /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 0000000000000000000000000000000000000000..00f550e88ec73422b87824568cb2e684d99ab04f --- /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 0000000000000000000000000000000000000000..a45bb1e3f6789e6c361167af260f7eb4a38fbfe8 --- /dev/null +++ b/open-sse/providers/registry/index.js @@ -0,0 +1,192 @@ +// Auto-generated: static imports of all registry entries +import p0 from "./alicode.js"; +import p1 from "./alicode-intl.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.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.js"; +import p33 from "./gemini-cli.js"; +import p34 from "./github.js"; +import p35 from "./gitlab.js"; +import p36 from "./glm.js"; +import p37 from "./glm-cn.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.js"; +import p50 from "./kimi-coding.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.js"; +import p56 from "./minimax-cn.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.js"; +import p63 from "./ollama-local.js"; +import p64 from "./openai.js"; +import p65 from "./opencode.js"; +import p66 from "./opencode-go.js"; +import p67 from "./openrouter.js"; +import p68 from "./perplexity.js"; +import p69 from "./perplexity-web.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 "./vercel-ai-gateway.js"; +import p86 from "./vertex.js"; +import p87 from "./vertex-partner.js"; +import p88 from "./volcengine-ark.js"; +import p89 from "./voyage-ai.js"; +import p90 from "./xai.js"; +import p91 from "./xiaomi-mimo.js"; +import p92 from "./xiaomi-tokenplan.js"; +import p93 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 +]; diff --git a/open-sse/providers/registry/inworld.js b/open-sse/providers/registry/inworld.js new file mode 100644 index 0000000000000000000000000000000000000000..bc9bad57baf6093750c12f5805ae94f0b2798ae0 --- /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 0000000000000000000000000000000000000000..90c7da2b1dfcb1d442be04ce7aa6a91431a15b04 --- /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 0000000000000000000000000000000000000000..35ffae941c77aa797f60a1664546787a520fc761 --- /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 0000000000000000000000000000000000000000..c259ac7989b176eac1de9bf0240ca9839888f1ea --- /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 0000000000000000000000000000000000000000..77ec4564100477c6e5ab434eb21dec598c1dde3f --- /dev/null +++ b/open-sse/providers/registry/kimi-coding.js @@ -0,0 +1,53 @@ +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: { + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + }, + 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", + ], + }, + }, + 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 0000000000000000000000000000000000000000..ac22357bbc7895fb6fbbad32cddb31240640636f --- /dev/null +++ b/open-sse/providers/registry/kimi.js @@ -0,0 +1,44 @@ +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: { + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + }, + 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 0000000000000000000000000000000000000000..fb78a227fdc0ec3f5402a4e534c11889bdae870e --- /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 0000000000000000000000000000000000000000..19be6bb23e77d04bcb341d239d2a3ac5048bdd3f --- /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 0000000000000000000000000000000000000000..b24fc2de5de8201cbe2a1fe50e761ee4fa83a275 --- /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 0000000000000000000000000000000000000000..4b9074d14eba6c8f52d7c2108c906c21f7bf8155 --- /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 0000000000000000000000000000000000000000..95f130f942008a6f8d6a856b3984628a4b2322db --- /dev/null +++ b/open-sse/providers/registry/minimax-cn.js @@ -0,0 +1,64 @@ +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: { + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + }, + quirks: { + dropOutputConfig: true, + }, + reasoningInject: { + scope: "all", + }, + auth: { + combined: true, + header: "x-api-key", + scheme: "raw", + }, + usage: { + urls: [ + "https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains", + "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains", + ], + }, + }, + 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 0000000000000000000000000000000000000000..99bafae95a1b11a5aeb4d73f688314fa16e6e2ba --- /dev/null +++ b/open-sse/providers/registry/minimax.js @@ -0,0 +1,71 @@ +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: { + "Anthropic-Version": "2023-06-01", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + }, + quirks: { + dropOutputConfig: true, + }, + reasoningInject: { + scope: "all", + }, + auth: { + combined: true, + header: "x-api-key", + scheme: "raw", + }, + usage: { + urls: [ + "https://www.minimax.io/v1/token_plan/remains", + "https://api.minimax.io/v1/api/openplatform/coding_plan/remains", + ], + }, + }, + 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 0000000000000000000000000000000000000000..b5869135b22a16c729c19727d96539e8afcb42ee --- /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 0000000000000000000000000000000000000000..63c776f4b32caa13c89e1dd21019b94b008e93f1 --- /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 0000000000000000000000000000000000000000..f57b49af35eeb55d782e7264b29a53f6a0f90120 --- /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 0000000000000000000000000000000000000000..bcfdd25d4dc025904f4099bcbaf4b093027f18a4 --- /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 0000000000000000000000000000000000000000..35a1f7682b0359bd6871b7148d1891c95100dd48 --- /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 0000000000000000000000000000000000000000..1d83238ad1bf8ba71149abfeb4e32ecb431d5b90 --- /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 0000000000000000000000000000000000000000..0c3978bf8afa7dd0389e75d5e20e67807535aa6b --- /dev/null +++ b/open-sse/providers/registry/ollama.js @@ -0,0 +1,35 @@ +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" }, + ], + 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 0000000000000000000000000000000000000000..9a1ca57b1d74490c7c2f5b791c82ae48d1c4631a --- /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 0000000000000000000000000000000000000000..5242839dc5b499e9319fcef2aedb242a7b37aaa2 --- /dev/null +++ b/open-sse/providers/registry/opencode-go.js @@ -0,0 +1,37 @@ +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: "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" }, + ], +}; diff --git a/open-sse/providers/registry/opencode.js b/open-sse/providers/registry/opencode.js new file mode 100644 index 0000000000000000000000000000000000000000..e83ad3a756a94b51ef14e2ecce83560ddcc2741d --- /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 0000000000000000000000000000000000000000..4ac036411b24d1391e3933a361a4bd50ca109d77 --- /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 0000000000000000000000000000000000000000..fcaa3571a039c7bdd0f1e2516d9bde4475e1b6a0 --- /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 0000000000000000000000000000000000000000..f594b500621bd7bf32007476eb2a43454cc78d09 --- /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 0000000000000000000000000000000000000000..1373e563d6568f34c1152aac18293c04f030b5c5 --- /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 0000000000000000000000000000000000000000..4ee2b52f3f669ef9eb3580b647b2b8abc05b4a6b --- /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 0000000000000000000000000000000000000000..0df381ab5a3a2a5548a9f1992e0f8126e2b46365 --- /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 0000000000000000000000000000000000000000..e64a70a6ae881418656485c5e0d66084449ac2f5 --- /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 0000000000000000000000000000000000000000..4c86e5222ce5996c336b1d9ec8b9ad7d697c2726 --- /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 0000000000000000000000000000000000000000..f253c9252aa22d54aca8f5e9eebf4deaa9acf285 --- /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 0000000000000000000000000000000000000000..c558ba9bdd326ed9c1e08bbf2c09e474a8e3e2da --- /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 0000000000000000000000000000000000000000..308eabbc30c263ca5e5abe12ed18163b78a8455e --- /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 0000000000000000000000000000000000000000..b98d5af08fde7bfcdeb96ed9146714839e5af1d8 --- /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 0000000000000000000000000000000000000000..902cd8147498b9d0a8eff07d3be448cd8851b52f --- /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 0000000000000000000000000000000000000000..7b0368f0d9c236a0715da7f26da6262f5114af7e --- /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 0000000000000000000000000000000000000000..4386c973d670bb315790ae16b14210b0b296ef2d --- /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 0000000000000000000000000000000000000000..85b06f6cdf521eb5515b156f954951cc8e478998 --- /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 0000000000000000000000000000000000000000..1a4bb7a537a74e4d93bdb33b9852b6172954b6d5 --- /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 0000000000000000000000000000000000000000..4d3b87c17b5c6291a2ae9b2f8fcd036d25979d89 --- /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/vercel-ai-gateway.js b/open-sse/providers/registry/vercel-ai-gateway.js new file mode 100644 index 0000000000000000000000000000000000000000..798d94c0fabf6c247104b9e2341b4970c0d98263 --- /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 0000000000000000000000000000000000000000..6e4949461c1ed5759f0743780d09bb30e96ce9bf --- /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 0000000000000000000000000000000000000000..b8765de3a71a635a8bebf5ffb0ad466eb36dfb20 --- /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 0000000000000000000000000000000000000000..16ad8b8371f9b27ee5eb8ef449c426f8d9a58c8c --- /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 0000000000000000000000000000000000000000..b27a6d4e48224ea82876dce2853bce00e65dcdeb --- /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 0000000000000000000000000000000000000000..efe13bddabb873df3493657aaa3c719f3f903215 --- /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 0000000000000000000000000000000000000000..97cec2e84a2716e1a03542bd386b34e72f9e8993 --- /dev/null +++ b/open-sse/providers/registry/xiaomi-mimo.js @@ -0,0 +1,30 @@ +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", + }, + 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 0000000000000000000000000000000000000000..35d714a00a99edf6256d02bb950b31f9606835ad --- /dev/null +++ b/open-sse/providers/registry/xiaomi-tokenplan.js @@ -0,0 +1,43 @@ +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", + }, + 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 0000000000000000000000000000000000000000..d090c638bc0b8a58e9d603c75690e416d1886f46 --- /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 0000000000000000000000000000000000000000..e8b5ddd19b1cf69bf6224be404e3f0df94d6ff04 --- /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 0000000000000000000000000000000000000000..3238858408618f8a52f13a1c7af97b7c3eff2836 --- /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/applyFilter.js b/open-sse/rtk/applyFilter.js new file mode 100644 index 0000000000000000000000000000000000000000..9de34ac8aae08fe1bd584bad3729cd8500a67ab7 --- /dev/null +++ b/open-sse/rtk/applyFilter.js @@ -0,0 +1,15 @@ +// Port of apply_filter (rtk/src/cmds/system/pipe_cmd.rs) — catch_unwind equivalent +// On panic/error: passthrough raw output + warn to stderr +export function safeApply(fn, text) { + if (typeof fn !== "function") return text; + try { + const out = fn(text); + if (typeof out !== "string") return text; + return out; + } catch (err) { + // Rust: eprintln!("[rtk] warning: filter panicked — passing through raw output") + const name = fn.filterName || fn.name || "anonymous"; + console.warn(`[rtk] warning: filter '${name}' panicked — passing through raw output: ${err?.message || err}`); + return text; + } +} diff --git a/open-sse/rtk/autodetect.js b/open-sse/rtk/autodetect.js new file mode 100644 index 0000000000000000000000000000000000000000..99ab6a7775f61bcf43f298be06e1a481b624a2d2 --- /dev/null +++ b/open-sse/rtk/autodetect.js @@ -0,0 +1,111 @@ +// Port of auto_detect_filter (rtk/src/cmds/system/pipe_cmd.rs:132-188) + JS extras +// Order: git-diff → git-status → build-output → grep → find → tree → ls → search-list +// → read-numbered → dedup-log → smart-truncate → null +import { DETECT_WINDOW, READ_NUMBERED_MIN_HIT_RATIO, SMART_TRUNCATE_MIN_LINES } from "./constants.js"; +import { gitDiff } from "./filters/gitDiff.js"; +import { gitStatus } from "./filters/gitStatus.js"; +import { buildOutput } from "./filters/buildOutput.js"; +import { grep } from "./filters/grep.js"; +import { find } from "./filters/find.js"; +import { dedupLog } from "./filters/dedupLog.js"; +import { ls } from "./filters/ls.js"; +import { tree } from "./filters/tree.js"; +import { smartTruncate } from "./filters/smartTruncate.js"; +import { readNumbered, READ_NUMBERED_LINE_RE } from "./filters/readNumbered.js"; +import { searchList, SEARCH_LIST_HEADER_RE } from "./filters/searchList.js"; + +const RE_GIT_DIFF = /^diff --git /m; +const RE_GIT_DIFF_HUNK = /^@@ /m; +const RE_GIT_STATUS = /^On branch |^nothing to commit|^Changes (not |to be )|^Untracked files:/m; +const RE_PORCELAIN = /^[ MADRCU?!][ MADRCU?!] \S/m; +const RE_BUILD_OUTPUT = /^(npm (warn|error|ERR!)|yarn (warn|error)|\s*Compiling\s+\S+|\s*Downloading\s+\S+|added \d+ package|\[ERROR\]|BUILD (SUCCESS|FAILED)|\s*Finished\s+|Successfully (installed|built)|ERROR:)/im; +const RE_TREE_GLYPH = /[├└]──|│ /; +const RE_LS_ROW = /^[-dlbcps][rwx-]{9}/m; +const RE_LS_TOTAL = /^total \d+$/m; + +export function autoDetectFilter(text) { + // Rust: floor_char_boundary to avoid UTF-8 split — JS .slice() by char is safe + const head = text.length > DETECT_WINDOW ? text.slice(0, DETECT_WINDOW) : text; + + if (RE_GIT_DIFF.test(head) || RE_GIT_DIFF_HUNK.test(head)) return gitDiff; + if (RE_GIT_STATUS.test(head)) return gitStatus; + + // Build output BEFORE porcelain check: prevents cargo "Compiling" misdetection as git-status + if (RE_BUILD_OUTPUT.test(head)) return buildOutput; + + if (isMostlyPorcelain(head)) return gitStatus; + + const lines = head.split("\n"); + const nonEmpty = lines.filter(l => l.trim().length > 0); + + // Rust grep rule: first 5 non-empty lines, ANY matches "file:number:content" + const first5 = nonEmpty.slice(0, 5); + if (first5.some(isGrepLine)) return grep; + + // Rust find rule: ALL non-empty lines path-like (no ':'), >=3 lines + if (nonEmpty.length >= 3 && nonEmpty.every(isPathLike)) return find; + + // Tree: contains box-drawing glyphs typical of `tree` command + if (RE_TREE_GLYPH.test(head)) return tree; + + // ls -la: has "total N" header or >=3 rows starting with perms string + if (RE_LS_TOTAL.test(head) || countMatches(head, RE_LS_ROW) >= 3) return ls; + + // Cursor Glob search list header + if (SEARCH_LIST_HEADER_RE.test(head)) return searchList; + + // Line-numbered file dump (" N|content") — fire only if many lines match + if (lines.length >= SMART_TRUNCATE_MIN_LINES && isLineNumbered(lines)) { + return readNumbered; + } + + // Fallback: dedupLog for generic multi-line noise with duplicates + if (nonEmpty.length >= 5) return dedupLog; + + // Last resort: big blob with no structure — smart truncate + if (text.split("\n").length >= SMART_TRUNCATE_MIN_LINES) return smartTruncate; + + return null; +} + +function isGrepLine(line) { + // Rust: splitn(3, ':') → parts.len()==3 && parts[1].parse::().is_ok() + const first = line.indexOf(":"); + if (first === -1) return false; + const second = line.indexOf(":", first + 1); + if (second === -1) return false; + const lineno = line.slice(first + 1, second); + return /^\d+$/.test(lineno); +} + +function isPathLike(line) { + const t = line.trim(); + if (t.length === 0) return false; + if (t.includes(":")) return false; + return t.startsWith(".") || t.startsWith("/") || t.includes("/"); +} + +function isMostlyPorcelain(head) { + const lines = head.split("\n").filter(l => l.trim()); + if (lines.length < 3) return false; + const hits = lines.filter(l => RE_PORCELAIN.test(l)).length; + return hits / lines.length >= 0.6; +} + +function isLineNumbered(lines) { + let hits = 0; + let nonEmpty = 0; + const sample = lines.slice(0, 100); + for (const l of sample) { + if (l.length === 0) continue; + nonEmpty++; + if (READ_NUMBERED_LINE_RE.test(l)) hits++; + } + if (nonEmpty < 5) return false; + return hits / nonEmpty >= READ_NUMBERED_MIN_HIT_RATIO; +} + +function countMatches(text, re) { + const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g"); + return (text.match(g) || []).length; +} diff --git a/open-sse/rtk/caveman.js b/open-sse/rtk/caveman.js new file mode 100644 index 0000000000000000000000000000000000000000..09cc8cfb4d551c439726e75f39654041828f1058 --- /dev/null +++ b/open-sse/rtk/caveman.js @@ -0,0 +1,100 @@ +// 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 { 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 }] }; +} diff --git a/open-sse/rtk/cavemanPrompts.js b/open-sse/rtk/cavemanPrompts.js new file mode 100644 index 0000000000000000000000000000000000000000..0b6f6f57d2f0773b1c0e880555554ddd722d7448 --- /dev/null +++ b/open-sse/rtk/cavemanPrompts.js @@ -0,0 +1,78 @@ +// Caveman intensity-level prompts injected into system message to reduce output tokens. +// Adapted from caveman skill (https://github.com/JuliusBrussee/caveman). + +export const CAVEMAN_LEVELS = { + LITE: "lite", + FULL: "full", + ULTRA: "ultra", + WENYAN_LITE: "wenyan-lite", + WENYAN: "wenyan", + WENYAN_ULTRA: "wenyan-ultra", +}; + +const SHARED_BOUNDARIES = "Code blocks, file paths, commands, errors, URLs: keep exact. Security warnings, irreversible action confirmations, multi-step ordered sequences: write normal. Resume terse style after."; + +const SHARED_EXAMPLES = "Not: \"Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by...\" Yes: \"Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:\""; + +const SHARED_AUTO_CLARITY = "Auto-Clarity: drop caveman for security warnings, irreversible actions, multi-step sequences where fragment ambiguity risks misread, or when user repeats a question. Resume after the clear part."; + +const SHARED_PERSISTENCE = "ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure."; + +export const CAVEMAN_PROMPTS = { + [CAVEMAN_LEVELS.LITE]: [ + "Respond tersely. Keep grammar and full sentences but drop filler, hedging and pleasantries (just/really/basically/sure/of course/I'd be happy to).", + "Pattern: state the thing, the action, the reason. Then next step.", + SHARED_EXAMPLES, + SHARED_BOUNDARIES, + SHARED_AUTO_CLARITY, + SHARED_PERSISTENCE, + ].join(" "), + + [CAVEMAN_LEVELS.FULL]: [ + "Respond like terse caveman. All technical substance stay exact, only fluff die.", + "Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries, hedging. Fragments OK. Short synonyms (big not extensive, fix not implement a solution for).", + "Pattern: [thing] [action] [reason]. [next step].", + SHARED_EXAMPLES, + SHARED_BOUNDARIES, + SHARED_AUTO_CLARITY, + SHARED_PERSISTENCE, + ].join(" "), + + [CAVEMAN_LEVELS.ULTRA]: [ + "Respond ultra-terse. Maximum compression. Telegraphic.", + "Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, use arrows for causality (X → Y). One word when one word enough.", + "Pattern: [thing] → [result]. [fix].", + SHARED_EXAMPLES, + SHARED_BOUNDARIES, + SHARED_AUTO_CLARITY, + SHARED_PERSISTENCE, + ].join(" "), + + [CAVEMAN_LEVELS.WENYAN_LITE]: [ + "Respond semi-classical. Drop filler/hedging but keep grammar structure, classical register.", + "Use classical Chinese sentence patterns where natural. Keep English for technical terms.", + SHARED_EXAMPLES, + SHARED_BOUNDARIES, + SHARED_AUTO_CLARITY, + SHARED_PERSISTENCE, + ].join(" "), + + [CAVEMAN_LEVELS.WENYAN]: [ + "Respond classical Chinese (文言文). Maximum classical terseness. 80-90% character reduction.", + "Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其).", + "Keep English for code, commands, function names, API names, error strings.", + SHARED_EXAMPLES, + SHARED_BOUNDARIES, + SHARED_AUTO_CLARITY, + SHARED_PERSISTENCE, + ].join(" "), + + [CAVEMAN_LEVELS.WENYAN_ULTRA]: [ + "Respond extreme classical compression (文言文 ultra). Maximum compression, ultra terse.", + "Same classical rules as wenyan-full but even more compressed. One classical particle per clause.", + SHARED_EXAMPLES, + SHARED_BOUNDARIES, + SHARED_AUTO_CLARITY, + SHARED_PERSISTENCE, + ].join(" "), +}; diff --git a/open-sse/rtk/constants.js b/open-sse/rtk/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..752c2fee85d6962a52cb1f5c51f74e2375662499 --- /dev/null +++ b/open-sse/rtk/constants.js @@ -0,0 +1,59 @@ +// RTK port constants (mirror Rust defaults) +export const RAW_CAP = 10 * 1024 * 1024; // 10 MiB +export const MIN_COMPRESS_SIZE = 500; // bytes; skip tiny blobs +export const DETECT_WINDOW = 1024; // autodetect peeks first N chars +export const GIT_DIFF_HUNK_MAX_LINES = 100; // per-hunk line cap +export const GIT_DIFF_CONTEXT_KEEP = 3; // context lines around changes +export const DEDUP_LINE_MAX = 2000; // dedupLog truncation cap + +// Rust pipe_cmd.rs parity caps +export const GREP_PER_FILE_MAX = 10; // match rust: matches.iter().take(10) +export const FIND_PER_DIR_MAX = 10; // match rust: files.iter().take(10) +export const FIND_TOTAL_DIR_MAX = 20; // match rust: dirs.iter().take(20) + +// git status caps (rust config::limits()) +export const STATUS_MAX_FILES = 10; // config::limits().status_max_files +export const STATUS_MAX_UNTRACKED = 10; // config::limits().status_max_untracked + +// ls compact_ls (rtk/src/cmds/system/ls.rs) +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", ".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) +export const TREE_MAX_LINES = 200; + +// Cursor Glob "Result of search in '...' (total N files):" list +export const SEARCH_LIST_PER_DIR_MAX = 10; +export const SEARCH_LIST_TOTAL_DIR_MAX = 20; + +// Smart truncate (port of filter.rs smart_truncate fallback) +export const SMART_TRUNCATE_HEAD = 120; // lines kept from top +export const SMART_TRUNCATE_TAIL = 60; // lines kept from bottom +export const SMART_TRUNCATE_MIN_LINES = 250; // only kick in above this + +// readNumbered (files with " N|content" lines, e.g. Cursor read_file) +export const READ_NUMBERED_MIN_HIT_RATIO = 0.7; + +// Filter name strings (Rust parity + JS extras) +export const FILTERS = { + GIT_DIFF: "git-diff", + GIT_STATUS: "git-status", + GIT_LOG: "git-log", + GREP: "grep", + FIND: "find", + LS: "ls", + TREE: "tree", + DEDUP_LOG: "dedup-log", + SMART_TRUNCATE: "smart-truncate", + READ_NUMBERED: "read-numbered", + SEARCH_LIST: "search-list", + BUILD_OUTPUT: "build-output" +}; diff --git a/open-sse/rtk/filters/buildOutput.js b/open-sse/rtk/filters/buildOutput.js new file mode 100644 index 0000000000000000000000000000000000000000..4d3dde91c853e8190624a4ca86c2330c0d3a4bb7 --- /dev/null +++ b/open-sse/rtk/filters/buildOutput.js @@ -0,0 +1,127 @@ +// Compress build tool output (npm, cargo, pip, maven, gradle, etc.) +// Keeps: errors, warnings, final summary +// Strips: progress logs, verbose "Compiling X" lists, download logs + +// Cargo/rustc error continuation: " --> file:line", " |", "N | code", " = note: ..." +const RE_CARGO_ERR_CONT = /^\s*(-->|\||\d+\s*\||=)/; +const DEPRECATION_KEEP = 3; + +export function buildOutput(input) { + const lines = input.split("\n"); + if (lines.length === 0) return input; + + const errors = []; + const warnings = []; + const deprecations = []; + let summary = null; + let compilingCount = 0; + let downloadingCount = 0; + let inCargoError = false; + + for (const line of lines) { + const trimmed = line.trim(); + + // Continuation of cargo error block: keep verbatim while in block + if (inCargoError) { + if (!trimmed) { inCargoError = false; continue; } + if (RE_CARGO_ERR_CONT.test(line)) { errors.push(line); continue; } + inCargoError = false; + } + + if (!trimmed) continue; + + if (/^npm (ERR!|error)/i.test(trimmed) || /^yarn error/i.test(trimmed)) { + errors.push(line); + continue; + } + + if (/^npm warn deprecated/i.test(trimmed)) { + deprecations.push(line); + continue; + } + if (/^npm warn/i.test(trimmed) || /^yarn warn/i.test(trimmed)) { + warnings.push(line); + continue; + } + + if (/^error(\[|:)/i.test(trimmed) || trimmed.startsWith("error -->")) { + errors.push(line); + inCargoError = true; + continue; + } + + if (/^warning(\[|:)/i.test(trimmed) || trimmed.startsWith("warning -->")) { + warnings.push(line); + inCargoError = true; + continue; + } + + if (/^ERROR:/i.test(trimmed)) { + errors.push(line); + continue; + } + + if (/^\[ERROR\]/i.test(trimmed) || /^BUILD FAILED/i.test(trimmed)) { + errors.push(line); + continue; + } + + if (/^\[WARNING\]/i.test(trimmed)) { + warnings.push(line); + continue; + } + + if (/^\s*Compiling\s+\S+/i.test(trimmed)) { + compilingCount++; + continue; + } + if (/^\s*Downloading\s+\S+/i.test(trimmed) || /^Fetching\s+/i.test(trimmed)) { + downloadingCount++; + continue; + } + + if ( + /^(added|removed|changed|audited|installed)\s+\d+\s+package/i.test(trimmed) || + /^\s*Finished\s+/i.test(trimmed) || + /^BUILD SUCCESS/i.test(trimmed) || + /^\d+\s+(vulnerabilities|packages?|warnings?|errors?)/i.test(trimmed) || + /^Successfully (installed|built)/i.test(trimmed) || + /^To address .* issues/i.test(trimmed) || + /^Run `npm (audit|fund)`/i.test(trimmed) || + /packages are looking for funding/i.test(trimmed) + ) { + summary = summary ? `${summary}\n${line}` : line; + continue; + } + } + + let out = ""; + + // Keep first N deprecations verbatim (package name + reason), count the rest + const keepDep = deprecations.slice(0, DEPRECATION_KEEP); + for (const d of keepDep) out += `${d}\n`; + if (deprecations.length > DEPRECATION_KEEP) { + out += `... +${deprecations.length - DEPRECATION_KEEP} more deprecated packages\n`; + } + + if (compilingCount > 0) { + out += `Compiled ${compilingCount} packages\n`; + } + if (downloadingCount > 0) { + out += `Downloaded ${downloadingCount} packages\n`; + } + + for (const e of errors) out += `${e}\n`; + + const keepWarnings = warnings.slice(0, 5); + for (const w of keepWarnings) out += `${w}\n`; + if (warnings.length > 5) { + out += `... +${warnings.length - 5} more warnings\n`; + } + + if (summary) out += `${summary}\n`; + + return out.replace(/\n+$/, "") || input; +} + +buildOutput.filterName = "build-output"; diff --git a/open-sse/rtk/filters/dedupLog.js b/open-sse/rtk/filters/dedupLog.js new file mode 100644 index 0000000000000000000000000000000000000000..f971c160402b11fc5be2497c7c479e3868afdf01 --- /dev/null +++ b/open-sse/rtk/filters/dedupLog.js @@ -0,0 +1,44 @@ +// Generic fallback: collapse consecutive duplicate lines + blank-line dedupe + hard line cap +import { DEDUP_LINE_MAX } from "../constants.js"; + +export function dedupLog(input) { + const lines = input.split("\n"); + const out = []; + let prev = null; + let runCount = 0; + let blankStreak = 0; + + const flushRun = () => { + if (prev !== null && runCount > 1) { + out.push(` ... (${runCount - 1} duplicate lines)`); + } + }; + + for (const line of lines) { + if (line.trim() === "") { + if (blankStreak < 1) out.push(line); + blankStreak += 1; + flushRun(); + prev = null; + runCount = 0; + continue; + } + blankStreak = 0; + if (line === prev) { + runCount += 1; + continue; + } + flushRun(); + out.push(line); + prev = line; + runCount = 1; + if (out.length >= DEDUP_LINE_MAX) { + out.push(`... (truncated at ${DEDUP_LINE_MAX} lines)`); + return out.join("\n"); + } + } + flushRun(); + return out.join("\n"); +} + +dedupLog.filterName = "dedup-log"; diff --git a/open-sse/rtk/filters/find.js b/open-sse/rtk/filters/find.js new file mode 100644 index 0000000000000000000000000000000000000000..5770a99b0507e2a7a63cc69e6df6d8efab90f990 --- /dev/null +++ b/open-sse/rtk/filters/find.js @@ -0,0 +1,48 @@ +// Port of find_wrapper (rtk/src/cmds/system/pipe_cmd.rs:89-128) +// Group by parent dir, show basenames, cap 10/dir and 20 dirs total +import { FIND_PER_DIR_MAX, FIND_TOTAL_DIR_MAX } from "../constants.js"; + +export function find(input) { + const lines = input.split("\n").filter(l => l.trim()); + if (lines.length === 0) return input; + + const byDir = new Map(); + + for (const path of lines) { + const lastSlash = path.lastIndexOf("/"); + let dir; + let basename; + if (lastSlash === -1) { + dir = "."; + basename = path; + } else { + // Rust: PathBuf::from(path).parent().display() + file_name().display() + dir = path.slice(0, lastSlash) || "/"; + basename = path.slice(lastSlash + 1); + } + if (!byDir.has(dir)) byDir.set(dir, []); + byDir.get(dir).push(basename); + } + + // Rust: dirs.sort_by_key(|(d, _)| d.clone()) + const dirs = Array.from(byDir.keys()).sort(); + let out = `${lines.length} files in ${dirs.length} dirs:\n\n`; + + const showDirs = dirs.slice(0, FIND_TOTAL_DIR_MAX); + for (const dir of showDirs) { + const files = byDir.get(dir); + 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`; + } + } + if (dirs.length > FIND_TOTAL_DIR_MAX) { + out += `\n+${dirs.length - FIND_TOTAL_DIR_MAX} more dirs\n`; + } + + return out; +} + +find.filterName = "find"; diff --git a/open-sse/rtk/filters/gitDiff.js b/open-sse/rtk/filters/gitDiff.js new file mode 100644 index 0000000000000000000000000000000000000000..eaf8227bf28ca4a87483d309369c8a42153a1ba8 --- /dev/null +++ b/open-sse/rtk/filters/gitDiff.js @@ -0,0 +1,92 @@ +// Port of Rust git::compact_diff (src/cmds/git/git.rs L325-413) +// Compacts unified diff: file headers, hunk-level truncation at 100 lines, +/-/context counting +import { GIT_DIFF_HUNK_MAX_LINES } from "../constants.js"; + +export function gitDiff(diff, maxLines = 500) { + const result = []; + let currentFile = ""; + let added = 0; + let removed = 0; + let inHunk = false; + let hunkShown = 0; + let hunkSkipped = 0; + let wasTruncated = false; + const maxHunkLines = GIT_DIFF_HUNK_MAX_LINES; + + const lines = diff.split("\n"); + + outer: for (const line of lines) { + if (line.startsWith("diff --git")) { + if (hunkSkipped > 0) { + result.push(` ... (${hunkSkipped} lines truncated)`); + wasTruncated = true; + hunkSkipped = 0; + } + if (currentFile && (added > 0 || removed > 0)) { + result.push(` +${added} -${removed}`); + } + const parts = line.split(" b/"); + currentFile = parts.length > 1 ? parts.slice(1).join(" b/") : "unknown"; + result.push(`\n${currentFile}`); + added = 0; + removed = 0; + inHunk = false; + hunkShown = 0; + } else if (line.startsWith("@@")) { + if (hunkSkipped > 0) { + result.push(` ... (${hunkSkipped} lines truncated)`); + wasTruncated = true; + hunkSkipped = 0; + } + inHunk = true; + hunkShown = 0; + result.push(` ${line}`); + } else if (inHunk) { + if (line.startsWith("+") && !line.startsWith("+++")) { + added += 1; + if (hunkShown < maxHunkLines) { + result.push(` ${line}`); + hunkShown += 1; + } else { + hunkSkipped += 1; + } + } else if (line.startsWith("-") && !line.startsWith("---")) { + removed += 1; + if (hunkShown < maxHunkLines) { + result.push(` ${line}`); + hunkShown += 1; + } else { + hunkSkipped += 1; + } + } else if (hunkShown < maxHunkLines && !line.startsWith("\\")) { + if (hunkShown > 0) { + result.push(` ${line}`); + hunkShown += 1; + } + } + } + + if (result.length >= maxLines) { + result.push("\n... (more changes truncated)"); + wasTruncated = true; + break outer; + } + } + + if (hunkSkipped > 0) { + result.push(` ... (${hunkSkipped} lines truncated)`); + wasTruncated = true; + } + + if (currentFile && (added > 0 || removed > 0)) { + result.push(` +${added} -${removed}`); + } + + if (wasTruncated) { + result.push("[full diff: rtk git diff --no-compact]"); + } + + return result.join("\n"); +} + +gitDiff.filterName = "git-diff"; diff --git a/open-sse/rtk/filters/gitStatus.js b/open-sse/rtk/filters/gitStatus.js new file mode 100644 index 0000000000000000000000000000000000000000..3784d86c089e0a82fbeb3b22101c6152a4bb7049 --- /dev/null +++ b/open-sse/rtk/filters/gitStatus.js @@ -0,0 +1,117 @@ +// Port of git::format_status_output (rtk/src/cmds/git/git.rs:619-730) +// Output format: +// * +// + Staged: N files +// path1 +// ... +K more +// ~ Modified: N files +// ? Untracked: N files +// conflicts: N files +// clean — nothing to commit +import { STATUS_MAX_FILES, STATUS_MAX_UNTRACKED } from "../constants.js"; + +export function gitStatus(input) { + const lines = input.split("\n"); + if (lines.length === 0 || (lines.length === 1 && !lines[0].trim())) { + return "Clean working tree"; + } + + let branch = ""; + const stagedFiles = []; + const modifiedFiles = []; + const untrackedFiles = []; + let staged = 0; + let modified = 0; + let untracked = 0; + let conflicts = 0; + + for (const raw of lines) { + if (!raw.trim()) continue; + + // Long-form branch detection (LLM usually sends this, not porcelain) + const longBranch = raw.match(/^On branch (\S+)/); + if (longBranch) { branch = longBranch[1]; continue; } + + // Porcelain branch header: "## main...origin/main" + if (raw.startsWith("##")) { branch = raw.replace(/^##\s*/, ""); continue; } + + // Porcelain status (2 chars + space + path) + if (raw.length >= 3 && /^[ MADRCU?!][ MADRCU?!] /.test(raw)) { + const x = raw[0]; + const y = raw[1]; + const file = raw.slice(3); + + if (raw.slice(0, 2) === "??") { + untracked++; + untrackedFiles.push(file); + continue; + } + + if ("MADRC".includes(x)) { + staged++; + stagedFiles.push(file); + } else if (x === "U") { + conflicts++; + } + + if (y === "M" || y === "D") { + modified++; + modifiedFiles.push(file); + } + continue; + } + + // Long form fallback ("modified: path", "new file: path", ...) + const longMatch = raw.match(/^\s*(modified|new file|deleted|renamed|both modified):\s+(.+)$/); + if (longMatch) { + const kind = longMatch[1]; + const path = longMatch[2].trim(); + if (kind === "both modified") { conflicts++; } + else if (kind === "modified" || kind === "deleted") { modified++; modifiedFiles.push(path); } + else if (kind === "new file" || kind === "renamed") { staged++; stagedFiles.push(path); } + continue; + } + + // "Untracked files:" section — gather bare paths after this marker + // Handled implicitly: plain paths without markers are skipped (safer). + } + + let out = ""; + if (branch) out += `* ${branch}\n`; + + if (staged > 0) { + out += `+ Staged: ${staged} files\n`; + for (const f of stagedFiles.slice(0, STATUS_MAX_FILES)) out += ` ${f}\n`; + if (stagedFiles.length > STATUS_MAX_FILES) { + out += ` ... +${stagedFiles.length - STATUS_MAX_FILES} more\n`; + } + } + + if (modified > 0) { + out += `~ Modified: ${modified} files\n`; + for (const f of modifiedFiles.slice(0, STATUS_MAX_FILES)) out += ` ${f}\n`; + if (modifiedFiles.length > STATUS_MAX_FILES) { + out += ` ... +${modifiedFiles.length - STATUS_MAX_FILES} more\n`; + } + } + + if (untracked > 0) { + out += `? Untracked: ${untracked} files\n`; + for (const f of untrackedFiles.slice(0, STATUS_MAX_UNTRACKED)) out += ` ${f}\n`; + if (untrackedFiles.length > STATUS_MAX_UNTRACKED) { + out += ` ... +${untrackedFiles.length - STATUS_MAX_UNTRACKED} more\n`; + } + } + + if (conflicts > 0) { + out += `conflicts: ${conflicts} files\n`; + } + + if (staged === 0 && modified === 0 && untracked === 0 && conflicts === 0) { + out += "clean — nothing to commit\n"; + } + + return out.replace(/\n+$/, ""); +} + +gitStatus.filterName = "git-status"; diff --git a/open-sse/rtk/filters/grep.js b/open-sse/rtk/filters/grep.js new file mode 100644 index 0000000000000000000000000000000000000000..2016688771b1c8f4c9f6503a8bcfd9fa4e1118c2 --- /dev/null +++ b/open-sse/rtk/filters/grep.js @@ -0,0 +1,48 @@ +// Port of grep_wrapper (rtk/src/cmds/system/pipe_cmd.rs:50-86) +// Input format: "file:lineno:content" — splitn(3, ':') in Rust +import { GREP_PER_FILE_MAX } from "../constants.js"; + +export function grep(input) { + const byFile = new Map(); + let total = 0; + + for (const line of input.split("\n")) { + // splitn(3, ':') — only split on first 2 colons + const first = line.indexOf(":"); + if (first === -1) continue; + const second = line.indexOf(":", first + 1); + if (second === -1) continue; + const file = line.slice(0, first); + const lineNumStr = line.slice(first + 1, second); + const content = line.slice(second + 1); + // Rust: parts[1].parse::().is_ok() + if (!/^\d+$/.test(lineNumStr)) continue; + total++; + if (!byFile.has(file)) byFile.set(file, []); + byFile.get(file).push([lineNumStr, content]); + } + + if (total === 0) return input; + + // Rust: files.sort_by_key(|(f, _)| *f) + const files = Array.from(byFile.keys()).sort(); + let out = `${total} matches in ${files.length}F:\n\n`; + + for (const file of files) { + const matches = byFile.get(file); + out += `[file] ${file} (${matches.length}):\n`; + const show = matches.slice(0, GREP_PER_FILE_MAX); + for (const [lineNum, content] of show) { + // Rust: format!(" {:>4}: {}", line_num, content.trim()) + out += ` ${lineNum.padStart(4)}: ${content.trim()}\n`; + } + if (matches.length > GREP_PER_FILE_MAX) { + out += ` +${matches.length - GREP_PER_FILE_MAX}\n`; + } + out += "\n"; + } + + return out; +} + +grep.filterName = "grep"; diff --git a/open-sse/rtk/filters/ls.js b/open-sse/rtk/filters/ls.js new file mode 100644 index 0000000000000000000000000000000000000000..d53927da5195bac756d3fea54fbcf2cf37b14aac --- /dev/null +++ b/open-sse/rtk/filters/ls.js @@ -0,0 +1,79 @@ +// Port of compact_ls (rtk/src/cmds/system/ls.rs:154-232) +// Input: `ls -la` style output. Output: compact "name/ (dirs)\nname size" +import { LS_EXT_SUMMARY_TOP, LS_NOISE_DIRS } from "../constants.js"; + +// Rust LS_DATE_RE: month + day + (year|HH:MM) +const LS_DATE_RE = /\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2}\s+(\d{4}|\d{2}:\d{2})\s+/; + +function humanSize(bytes) { + if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)}M`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}K`; + return `${bytes}B`; +} + +function parseLsLine(line) { + const m = LS_DATE_RE.exec(line); + if (!m) return null; + const name = line.slice(m.index + m[0].length); + const beforeDate = line.slice(0, m.index); + const beforeParts = beforeDate.split(/\s+/).filter(Boolean); + if (beforeParts.length < 4) return null; + + const perms = beforeParts[0]; + const fileType = perms.charAt(0); + + // size = rightmost parseable number before the date + let size = 0; + for (let i = beforeParts.length - 1; i >= 0; i--) { + const n = Number(beforeParts[i]); + if (Number.isInteger(n) && String(n) === beforeParts[i]) { size = n; break; } + } + return { fileType, size, name }; +} + +export function ls(input) { + const dirs = []; + const files = []; // [name, sizeStr] + const byExt = new Map(); + + for (const line of input.split("\n")) { + if (line.startsWith("total ") || line.length === 0) continue; + const parsed = parseLsLine(line); + if (!parsed) continue; + if (parsed.name === "." || parsed.name === "..") continue; + + // Rust ls.rs: show_all flag respected — for LLM context always skip noise + if (LS_NOISE_DIRS.includes(parsed.name)) continue; + + if (parsed.fileType === "d") { + dirs.push(parsed.name); + } else if (parsed.fileType === "-" || parsed.fileType === "l") { + const dot = parsed.name.lastIndexOf("."); + const ext = dot > 0 ? parsed.name.slice(dot) : "no ext"; + byExt.set(ext, (byExt.get(ext) || 0) + 1); + files.push([parsed.name, humanSize(parsed.size)]); + } + } + + if (dirs.length === 0 && files.length === 0) return input; + + let out = ""; + for (const d of dirs) out += `${d}/\n`; + for (const [name, size] of files) out += `${name} ${size}\n`; + + // Summary line (Rust port) + let summary = `\nSummary: ${files.length} files, ${dirs.length} dirs`; + if (byExt.size > 0) { + const ext = Array.from(byExt.entries()).sort((a, b) => b[1] - a[1]); + const parts = ext.slice(0, LS_EXT_SUMMARY_TOP).map(([e, c]) => `${c} ${e}`); + summary += ` (${parts.join(", ")}`; + if (ext.length > LS_EXT_SUMMARY_TOP) { + summary += `, +${ext.length - LS_EXT_SUMMARY_TOP} more`; + } + summary += ")"; + } + + return out + summary; +} + +ls.filterName = "ls"; diff --git a/open-sse/rtk/filters/readNumbered.js b/open-sse/rtk/filters/readNumbered.js new file mode 100644 index 0000000000000000000000000000000000000000..8621059b6d79ab4ab502053bcdf417e22a04a23c --- /dev/null +++ b/open-sse/rtk/filters/readNumbered.js @@ -0,0 +1,27 @@ +// Handles Cursor/Codex read_file output: " 1|content\n 2|content". +// Strategy mirrors Rust filter::smart_truncate (filter.rs): keep head+tail, drop middle. +import { SMART_TRUNCATE_HEAD, SMART_TRUNCATE_TAIL, SMART_TRUNCATE_MIN_LINES } from "../constants.js"; + +const LINE_RE = /^\s*\d+\|/; + +export function readNumbered(input) { + const lines = input.split("\n"); + if (lines.length < SMART_TRUNCATE_MIN_LINES) return input; + + // Count how many lines match "N|content" to verify shape (hit ratio check + // already done by autodetect; here we just truncate). + const head = lines.slice(0, SMART_TRUNCATE_HEAD); + const tail = lines.slice(lines.length - SMART_TRUNCATE_TAIL); + const cut = lines.length - head.length - tail.length; + + return [ + ...head, + `... +${cut} lines truncated (file continues)`, + ...tail + ].join("\n"); +} + +readNumbered.filterName = "read-numbered"; + +// Exposed for autodetect +export const READ_NUMBERED_LINE_RE = LINE_RE; diff --git a/open-sse/rtk/filters/searchList.js b/open-sse/rtk/filters/searchList.js new file mode 100644 index 0000000000000000000000000000000000000000..4e18449b763e0ca5ed94c3d2d31093c70d2d9b2c --- /dev/null +++ b/open-sse/rtk/filters/searchList.js @@ -0,0 +1,52 @@ +// Compact "Result of search in '...' (total N files):\n- path\n- path" output +// (Cursor Glob tool). Groups by parent dir like find, shows basenames. +import { SEARCH_LIST_PER_DIR_MAX, SEARCH_LIST_TOTAL_DIR_MAX } from "../constants.js"; + +const HEADER_RE = /^Result of search in '[^']*' \(total (\d+) files?\):/; + +export function searchList(input) { + const lines = input.split("\n"); + if (lines.length === 0) return input; + + // First line must be the header (validated by autodetect too) + const header = lines[0] || ""; + const rest = lines.slice(1); + + const paths = []; + for (const raw of rest) { + const t = raw.trim(); + if (!t.startsWith("- ")) continue; + paths.push(t.slice(2)); + } + if (paths.length === 0) return input; + + const byDir = new Map(); + for (const p of paths) { + const slash = p.lastIndexOf("/"); + const dir = slash === -1 ? "." : (p.slice(0, slash) || "/"); + const name = slash === -1 ? p : p.slice(slash + 1); + if (!byDir.has(dir)) byDir.set(dir, []); + byDir.get(dir).push(name); + } + + const dirs = Array.from(byDir.keys()).sort(); + let out = `${header}\n${paths.length} files in ${dirs.length} dirs:\n\n`; + + for (const dir of dirs.slice(0, SEARCH_LIST_TOTAL_DIR_MAX)) { + const names = byDir.get(dir); + out += `${dir}/ (${names.length}):\n`; + for (const n of names.slice(0, SEARCH_LIST_PER_DIR_MAX)) out += ` ${n}\n`; + if (names.length > SEARCH_LIST_PER_DIR_MAX) { + out += ` +${names.length - SEARCH_LIST_PER_DIR_MAX}\n`; + } + out += "\n"; + } + if (dirs.length > SEARCH_LIST_TOTAL_DIR_MAX) { + out += `+${dirs.length - SEARCH_LIST_TOTAL_DIR_MAX} more dirs\n`; + } + + return out.replace(/\n+$/, ""); +} + +searchList.filterName = "search-list"; +export const SEARCH_LIST_HEADER_RE = HEADER_RE; diff --git a/open-sse/rtk/filters/smartTruncate.js b/open-sse/rtk/filters/smartTruncate.js new file mode 100644 index 0000000000000000000000000000000000000000..f8e30870cb15da81732c9f7262e22db1d03168ba --- /dev/null +++ b/open-sse/rtk/filters/smartTruncate.js @@ -0,0 +1,15 @@ +// Port concept of filter::smart_truncate (rtk/src/core/filter.rs). +// Keep HEAD + TAIL lines, replace middle with "... +N lines truncated". +import { SMART_TRUNCATE_HEAD, SMART_TRUNCATE_TAIL, SMART_TRUNCATE_MIN_LINES } from "../constants.js"; + +export function smartTruncate(input) { + const lines = input.split("\n"); + if (lines.length < SMART_TRUNCATE_MIN_LINES) return input; + + const head = lines.slice(0, SMART_TRUNCATE_HEAD); + const tail = lines.slice(lines.length - SMART_TRUNCATE_TAIL); + const cut = lines.length - head.length - tail.length; + return [...head, `... +${cut} lines truncated`, ...tail].join("\n"); +} + +smartTruncate.filterName = "smart-truncate"; diff --git a/open-sse/rtk/filters/tree.js b/open-sse/rtk/filters/tree.js new file mode 100644 index 0000000000000000000000000000000000000000..f0222d17c785a76370cf8053381306275ae9c411 --- /dev/null +++ b/open-sse/rtk/filters/tree.js @@ -0,0 +1,32 @@ +// Port of filter_tree_output (rtk/src/cmds/system/tree.rs:65-94) +// Removes summary line (e.g. "5 directories, 23 files") and trailing blanks. +import { TREE_MAX_LINES } from "../constants.js"; + +export function tree(input) { + const lines = input.split("\n"); + if (lines.length === 0) return input; + + const filtered = []; + for (const line of lines) { + // Drop "X directories, Y files" summary + if (line.includes("director") && line.includes("file")) continue; + // Drop leading blanks + if (line.trim() === "" && filtered.length === 0) continue; + filtered.push(line); + } + + // Drop trailing blanks + while (filtered.length > 0 && filtered[filtered.length - 1].trim() === "") { + filtered.pop(); + } + + // Cap overly long trees (JS-only safeguard; Rust has no cap) + if (filtered.length > TREE_MAX_LINES) { + const cut = filtered.length - TREE_MAX_LINES; + return filtered.slice(0, TREE_MAX_LINES).join("\n") + `\n... +${cut} more lines`; + } + + return filtered.join("\n"); +} + +tree.filterName = "tree"; diff --git a/open-sse/rtk/index.js b/open-sse/rtk/index.js new file mode 100644 index 0000000000000000000000000000000000000000..dd1e50184bcbd4039dfbcde70a7ede34bfcb6516 --- /dev/null +++ b/open-sse/rtk/index.js @@ -0,0 +1,155 @@ +// RTK port: compress tool_result content in LLM request bodies +// Injected at the top of translateRequest (before any format translation) +import { RAW_CAP, MIN_COMPRESS_SIZE } from "./constants.js"; +import { autoDetectFilter } from "./autodetect.js"; +import { safeApply } from "./applyFilter.js"; + +// Compress tool_result content in-place. Returns stats or null if disabled/failed. +export function compressMessages(body, enabled) { + if (!enabled) return null; + if (!body) return null; + + // Kiro format: conversationState.history + conversationState.currentMessage + if (body.conversationState) { + return compressKiroFormat(body, enabled); + } + + // Support both OpenAI/Claude "messages" and OpenAI Responses "input" + const items = Array.isArray(body.messages) ? body.messages + : Array.isArray(body.input) ? body.input + : null; + if (!items) return null; + + const stats = { bytesBefore: 0, bytesAfter: 0, hits: [] }; + try { + for (let i = 0; i < items.length; i++) { + const msg = items[i]; + if (!msg) continue; + + // Shape 4: OpenAI Responses — top-level { type:"function_call_output", output: string | [{type:"input_text", text}] } + if (msg.type === "function_call_output") { + if (typeof msg.output === "string") { + msg.output = compressText(msg.output, stats, "openai-responses-string"); + } else if (Array.isArray(msg.output)) { + for (let k = 0; k < msg.output.length; k++) { + const part = msg.output[k]; + if (part && part.type === "input_text" && typeof part.text === "string") { + part.text = compressText(part.text, stats, "openai-responses-array"); + } + } + } + continue; + } + + // Shape 1: OpenAI tool message — { role:"tool", content: "string" } + if (msg.role === "tool" && typeof msg.content === "string") { + msg.content = compressText(msg.content, stats, "openai-tool"); + continue; + } + + if (!Array.isArray(msg.content)) continue; + + // Shape 1b: OpenAI tool message — { role:"tool", content:[{type:"text", text:"..."}] } + if (msg.role === "tool") { + for (let k = 0; k < msg.content.length; k++) { + const part = msg.content[k]; + if (part && part.type === "text" && typeof part.text === "string") { + part.text = compressText(part.text, stats, "openai-tool-array"); + } + } + continue; + } + + // Shape 2/3: blocks array with tool_result entries + for (let j = 0; j < msg.content.length; j++) { + const block = msg.content[j]; + if (!block || block.type !== "tool_result") continue; + if (block.is_error === true) continue; // preserve error traces + + if (typeof block.content === "string") { + // Shape 2: claude string form + block.content = compressText(block.content, stats, "claude-string"); + } else if (Array.isArray(block.content)) { + // Shape 3: claude array form — compress each text part + for (let k = 0; k < block.content.length; k++) { + const part = block.content[k]; + if (part && part.type === "text" && typeof part.text === "string") { + part.text = compressText(part.text, stats, "claude-array"); + } + } + } + } + } + } catch (e) { + console.warn("[RTK] compressMessages error:", e.message); + return null; + } + return stats; +} + +// Compress Kiro format: conversationState.history[].userInputMessage.userInputMessageContext.toolResults[].content[].text +function compressKiroFormat(body, enabled) { + const stats = { bytesBefore: 0, bytesAfter: 0, hits: [] }; + try { + const state = body.conversationState; + const allMessages = [...(Array.isArray(state?.history) ? state.history : [])]; + if (state?.currentMessage) allMessages.push(state.currentMessage); + + for (const msg of allMessages) { + const toolResults = msg?.userInputMessage?.userInputMessageContext?.toolResults; + if (!Array.isArray(toolResults)) continue; + + for (const tr of toolResults) { + if (tr.status === "error") continue; // preserve error traces + if (!Array.isArray(tr.content)) continue; + + for (const part of tr.content) { + if (part && typeof part.text === "string") { + part.text = compressText(part.text, stats, "kiro-tool-result"); + } + } + } + } + } catch (e) { + console.warn("[RTK] compressKiroFormat error:", e.message); + return null; + } + return stats; +} + +function compressText(text, stats, shape) { + const bytesIn = text.length; + stats.bytesBefore += bytesIn; + + if (bytesIn < MIN_COMPRESS_SIZE || bytesIn > RAW_CAP) { + stats.bytesAfter += bytesIn; + return text; + } + + const fn = autoDetectFilter(text); + if (!fn) { + stats.bytesAfter += bytesIn; + return text; + } + + const out = safeApply(fn, text); + + // Safety: never return empty, never grow the input + if (!out || out.length === 0 || out.length >= bytesIn) { + stats.bytesAfter += bytesIn; + return text; + } + + stats.bytesAfter += out.length; + stats.hits.push({ shape, filter: fn.filterName || fn.name, saved: bytesIn - out.length }); + return out; +} + +// Convenience: format a log line from stats +export function formatRtkLog(stats) { + if (!stats || !stats.hits || stats.hits.length === 0) return null; + const saved = stats.bytesBefore - stats.bytesAfter; + const pct = stats.bytesBefore > 0 ? ((saved / stats.bytesBefore) * 100).toFixed(1) : "0"; + const filters = Array.from(new Set(stats.hits.map(h => h.filter))).join(","); + return `[RTK] saved ${saved}B / ${stats.bytesBefore}B (${pct}%) via [${filters}] hits=${stats.hits.length}`; +} diff --git a/open-sse/rtk/registry.js b/open-sse/rtk/registry.js new file mode 100644 index 0000000000000000000000000000000000000000..d9d9bf56dd9479eb5af8fe8aaab4a14188c00533 --- /dev/null +++ b/open-sse/rtk/registry.js @@ -0,0 +1,38 @@ +import { FILTERS } from "./constants.js"; +import { gitDiff } from "./filters/gitDiff.js"; +import { gitStatus } from "./filters/gitStatus.js"; +import { grep } from "./filters/grep.js"; +import { find } from "./filters/find.js"; +import { dedupLog } from "./filters/dedupLog.js"; +import { ls } from "./filters/ls.js"; +import { tree } from "./filters/tree.js"; +import { smartTruncate } from "./filters/smartTruncate.js"; +import { readNumbered } from "./filters/readNumbered.js"; +import { searchList } from "./filters/searchList.js"; + +const REGISTRY = { + [FILTERS.GIT_DIFF]: gitDiff, + [FILTERS.GIT_STATUS]: gitStatus, + [FILTERS.GREP]: grep, + [FILTERS.FIND]: find, + [FILTERS.DEDUP_LOG]: dedupLog, + [FILTERS.LS]: ls, + [FILTERS.TREE]: tree, + [FILTERS.SMART_TRUNCATE]: smartTruncate, + [FILTERS.READ_NUMBERED]: readNumbered, + [FILTERS.SEARCH_LIST]: searchList +}; + +// Rust resolve_filter aliases (pipe_cmd.rs): grep|rg, find|fd +const ALIASES = { + rg: grep, + fd: find +}; + +export function resolveFilter(name) { + return REGISTRY[name] || ALIASES[name] || null; +} + +export function allFilters() { + return REGISTRY; +} diff --git a/open-sse/services/accountFallback.js b/open-sse/services/accountFallback.js new file mode 100644 index 0000000000000000000000000000000000000000..8d280da4128505485a0b24e9f4a9902a7fdf8e66 --- /dev/null +++ b/open-sse/services/accountFallback.js @@ -0,0 +1,215 @@ +import { ERROR_RULES, BACKOFF_CONFIG, TRANSIENT_COOLDOWN_MS } from "../config/errorConfig.js"; + +/** + * Calculate exponential backoff cooldown for rate limits (429) + * Level 1: 1s, Level 2: 2s, Level 3: 4s... → max 4 min + * @param {number} backoffLevel - Current backoff level + * @returns {number} Cooldown in milliseconds + */ +export function getQuotaCooldown(backoffLevel = 0) { + const level = Math.max(0, backoffLevel - 1); + const cooldown = BACKOFF_CONFIG.base * Math.pow(2, level); + return Math.min(cooldown, BACKOFF_CONFIG.max); +} + +/** + * Check if error should trigger account fallback (switch to next account) + * Config-driven: matches ERROR_RULES top-to-bottom (text rules first, then status) + * @param {number} status - HTTP status code + * @param {string} errorText - Error message text + * @param {number} backoffLevel - Current backoff level for exponential backoff + * @returns {{ shouldFallback: boolean, cooldownMs: number, newBackoffLevel?: number }} + */ +export function checkFallbackError(status, errorText, backoffLevel = 0) { + const lowerError = errorText + ? (typeof errorText === "string" ? errorText : JSON.stringify(errorText)).toLowerCase() + : ""; + + for (const rule of ERROR_RULES) { + // Text-based rule: match substring in error message + if (rule.text && lowerError && lowerError.includes(rule.text)) { + if (rule.backoff) { + const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel); + return { shouldFallback: true, cooldownMs: getQuotaCooldown(newLevel), newBackoffLevel: newLevel }; + } + return { shouldFallback: true, cooldownMs: rule.cooldownMs }; + } + + // Status-based rule: match HTTP status code + if (rule.status && rule.status === status) { + if (rule.backoff) { + const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel); + return { shouldFallback: true, cooldownMs: getQuotaCooldown(newLevel), newBackoffLevel: newLevel }; + } + return { shouldFallback: true, cooldownMs: rule.cooldownMs }; + } + } + + // Default: transient cooldown for any unmatched error + return { shouldFallback: true, cooldownMs: TRANSIENT_COOLDOWN_MS }; +} + +/** + * Check if account is currently unavailable (cooldown not expired) + */ +export function isAccountUnavailable(unavailableUntil) { + if (!unavailableUntil) return false; + return new Date(unavailableUntil).getTime() > Date.now(); +} + +/** + * Calculate unavailable until timestamp + */ +export function getUnavailableUntil(cooldownMs) { + return new Date(Date.now() + cooldownMs).toISOString(); +} + +/** + * Get the earliest rateLimitedUntil from a list of accounts + * @param {Array} accounts - Array of account objects with rateLimitedUntil + * @returns {string|null} Earliest rateLimitedUntil ISO string, or null + */ +export function getEarliestRateLimitedUntil(accounts) { + let earliest = null; + const now = Date.now(); + for (const acc of accounts) { + if (!acc.rateLimitedUntil) continue; + const until = new Date(acc.rateLimitedUntil).getTime(); + if (until <= now) continue; + if (!earliest || until < earliest) earliest = until; + } + if (!earliest) return null; + return new Date(earliest).toISOString(); +} + +/** + * Format rateLimitedUntil to human-readable "reset after Xm Ys" + * @param {string} rateLimitedUntil - ISO timestamp + * @returns {string} e.g. "reset after 2m 30s" + */ +export function formatRetryAfter(rateLimitedUntil) { + if (!rateLimitedUntil) return ""; + const diffMs = new Date(rateLimitedUntil).getTime() - Date.now(); + if (diffMs <= 0) return "reset after 0s"; + const totalSec = Math.ceil(diffMs / 1000); + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + const parts = []; + if (h > 0) parts.push(`${h}h`); + if (m > 0) parts.push(`${m}m`); + if (s > 0 || parts.length === 0) parts.push(`${s}s`); + return `reset after ${parts.join(" ")}`; +} + +/** Prefix for model lock flat fields on connection record */ +export const MODEL_LOCK_PREFIX = "modelLock_"; + +/** Special key used when no model is known (account-level lock) */ +export const MODEL_LOCK_ALL = `${MODEL_LOCK_PREFIX}__all`; + +/** Build the flat field key for a model lock */ +export function getModelLockKey(model) { + return model ? `${MODEL_LOCK_PREFIX}${model}` : MODEL_LOCK_ALL; +} + +/** + * Check if a model lock on a connection is still active. + * Reads flat field `modelLock_${model}` (or `modelLock___all` when model=null). + */ +export function isModelLockActive(connection, model) { + const key = getModelLockKey(model); + const expiry = connection[key] || connection[MODEL_LOCK_ALL]; + if (!expiry) return false; + return new Date(expiry).getTime() > Date.now(); +} + +/** + * Get earliest active model lock expiry across all modelLock_* fields. + * Used for UI cooldown display. + */ +export function getEarliestModelLockUntil(connection) { + if (!connection) return null; + let earliest = null; + const now = Date.now(); + for (const [key, val] of Object.entries(connection)) { + if (!key.startsWith(MODEL_LOCK_PREFIX) || !val) continue; + const t = new Date(val).getTime(); + if (t <= now) continue; + if (!earliest || t < earliest) earliest = t; + } + return earliest ? new Date(earliest).toISOString() : null; +} + +/** + * Build update object to set a model lock on a connection. + */ +export function buildModelLockUpdate(model, cooldownMs) { + const key = getModelLockKey(model); + return { [key]: new Date(Date.now() + cooldownMs).toISOString() }; +} + +/** + * Build update object to clear all model locks on a connection. + */ +export function buildClearModelLocksUpdate(connection) { + const cleared = {}; + for (const key of Object.keys(connection)) { + if (key.startsWith(MODEL_LOCK_PREFIX)) cleared[key] = null; + } + return cleared; +} + +/** + * Filter available accounts (not in cooldown) + */ +export function filterAvailableAccounts(accounts, excludeId = null) { + const now = Date.now(); + return accounts.filter(acc => { + if (excludeId && acc.id === excludeId) return false; + if (acc.rateLimitedUntil) { + const until = new Date(acc.rateLimitedUntil).getTime(); + if (until > now) return false; + } + return true; + }); +} + +/** + * Reset account state when request succeeds + * Clears cooldown and resets backoff level to 0 + * @param {object} account - Account object + * @returns {object} Updated account with reset state + */ +export function resetAccountState(account) { + if (!account) return account; + return { + ...account, + rateLimitedUntil: null, + backoffLevel: 0, + lastError: null, + status: "active" + }; +} + +/** + * Apply error state to account + * @param {object} account - Account object + * @param {number} status - HTTP status code + * @param {string} errorText - Error message + * @returns {object} Updated account with error state + */ +export function applyErrorState(account, status, errorText) { + if (!account) return account; + + const backoffLevel = account.backoffLevel || 0; + const { cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel); + + return { + ...account, + rateLimitedUntil: cooldownMs > 0 ? getUnavailableUntil(cooldownMs) : null, + backoffLevel: newBackoffLevel ?? backoffLevel, + lastError: { status, message: errorText, timestamp: new Date().toISOString() }, + status: "error" + }; +} diff --git a/open-sse/services/combo.js b/open-sse/services/combo.js new file mode 100644 index 0000000000000000000000000000000000000000..459266ec0466372d47942d30bced4533146ff114 --- /dev/null +++ b/open-sse/services/combo.js @@ -0,0 +1,548 @@ +/** + * Shared combo (model combo) handling with fallback support + */ + +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}]` }; + } + 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) + * @type {Map} + */ +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; +} + +function rotateModelsFromIndex(models, currentIndex) { + const rotatedModels = [...models]; + for (let i = 0; i < currentIndex; i++) { + const moved = rotatedModels.shift(); + rotatedModels.push(moved); + } + return rotatedModels; +} + +/** + * Get rotated model list based on strategy + * @param {string[]} models - Array of model strings + * @param {string} comboName - Name of the combo + * @param {string} strategy - "fallback" or "round-robin" + * @param {number|string} [stickyLimit=1] - Requests per combo model before switching + * @returns {string[]} Rotated models array + */ +export function getRotatedModels(models, comboName, strategy, stickyLimit = 1) { + if (!models || models.length <= 1 || strategy !== "round-robin") { + return models; + } + + const rotationKey = comboName || "__default__"; + const normalizedStickyLimit = normalizeStickyLimit(stickyLimit); + const existingState = comboRotationState.get(rotationKey); + const state = typeof existingState === "number" + ? { index: existingState, consecutiveUseCount: 0 } + : (existingState || { index: 0, consecutiveUseCount: 0 }); + + const currentIndex = state.index % models.length; + const rotatedModels = rotateModelsFromIndex(models, currentIndex); + const nextUseCount = state.consecutiveUseCount + 1; + + if (nextUseCount >= normalizedStickyLimit) { + comboRotationState.set(rotationKey, { + index: (currentIndex + 1) % models.length, + consecutiveUseCount: 0, + }); + } else { + comboRotationState.set(rotationKey, { + index: currentIndex, + consecutiveUseCount: nextUseCount, + }); + } + + return rotatedModels; +} + +/** + * Reset in-memory rotation state when combo/settings change + * @param {string} [comboName] - Combo name to reset; omit to clear all + */ +export function resetComboRotation(comboName) { + if (comboName) comboRotationState.delete(comboName); + else comboRotationState.clear(); +} + +/** + * Get combo models from combos data + * @param {string} modelStr - Model string to check + * @param {Array|Object} combosData - Array of combos or object with combos + * @returns {string[]|null} Array of models or null if not a combo + */ +export function getComboModelsFromData(modelStr, combosData) { + // Don't check if it's in provider/model format + if (modelStr.includes("/")) return null; + + // Handle both array and object formats + const combos = Array.isArray(combosData) ? combosData : (combosData?.combos || []); + + const combo = combos.find(c => c.name === modelStr); + if (combo && combo.models && combo.models.length > 0) { + return combo.models; + } + return null; +} + +/** + * Handle combo chat with fallback + * @param {Object} options + * @param {Object} options.body - Request body + * @param {string[]} options.models - Array of model strings to try + * @param {Function} options.handleSingleModel - Function to handle single model: (body, modelStr) => Promise + * @param {Object} options.log - Logger object + * @param {string} [options.comboName] - Name of the combo (for round-robin tracking) + * @param {string} [options.comboStrategy] - Strategy: "fallback" or "round-robin" + * @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, autoSwitch = true }) { + // Apply rotation strategy if enabled + 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; + let lastStatus = null; + + for (let i = 0; i < rotatedModels.length; i++) { + const modelStr = rotatedModels[i]; + log.info("COMBO", `Trying model ${i + 1}/${rotatedModels.length}: ${modelStr}`); + + try { + const result = await handleSingleModel(body, modelStr); + + // Success (2xx) - return response + if (result.ok) { + log.info("COMBO", `Model ${modelStr} succeeded`); + return result; + } + + // Extract error info from response + let errorText = result.statusText || ""; + let retryAfter = null; + try { + const errorBody = await result.clone().json(); + errorText = errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText; + retryAfter = errorBody?.retryAfter || null; + } catch { + // Ignore JSON parse errors + } + + // Track earliest retryAfter across all combo models + if (retryAfter && (!earliestRetryAfter || new Date(retryAfter) < new Date(earliestRetryAfter))) { + earliestRetryAfter = retryAfter; + } + + // Normalize error text to string (Worker-safe) + if (typeof errorText !== "string") { + try { errorText = JSON.stringify(errorText); } catch { errorText = String(errorText); } + } + + // Check if should fallback to next model + const { shouldFallback, cooldownMs } = checkFallbackError(result.status, errorText); + + if (!shouldFallback) { + log.warn("COMBO", `Model ${modelStr} failed (no fallback)`, { status: result.status }); + return result; + } + + // For transient errors (503/502/504), wait for cooldown before falling through + // so a briefly-overloaded provider gets a chance to recover rather than being + // skipped immediately (fixes: combo falls through on transient 503) + if (cooldownMs && cooldownMs > 0 && cooldownMs <= 5000 && + (result.status === 503 || result.status === 502 || result.status === 504)) { + log.info("COMBO", `Model ${modelStr} transient ${result.status}, waiting ${cooldownMs}ms before next`); + await new Promise(r => setTimeout(r, cooldownMs)); + } + + // Fallback to next model + lastError = errorText || String(result.status); + if (!lastStatus) lastStatus = result.status; + log.warn("COMBO", `Model ${modelStr} failed, trying next`, { status: result.status }); + } catch (error) { + // Catch unexpected exceptions to ensure fallback continues + lastError = error.message || String(error); + if (!lastStatus) lastStatus = 500; + log.warn("COMBO", `Model ${modelStr} threw error, trying next`, { error: lastError }); + } + } + + // All models failed + // Use 503 (Service Unavailable) rather than 406 (Not Acceptable) — 406 implies + // the request itself is invalid, but here the providers are simply unavailable + // or have no active credentials. 503 is more accurate and retryable by clients. + const allDisabled = lastError && lastError.toLowerCase().includes("no credentials"); + const status = allDisabled ? 503 : (lastStatus || 503); + const msg = lastError || "All combo models unavailable"; + + if (earliestRetryAfter) { + const retryHuman = formatRetryAfter(earliestRetryAfter); + log.warn("COMBO", `All models failed | ${msg} (${retryHuman})`); + return unavailableResponse(status, msg, earliestRetryAfter, retryHuman); + } + + log.warn("COMBO", `All models failed | ${msg}`); + return new Response( + JSON.stringify({ error: { message: msg } }), + { 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/compact.js b/open-sse/services/compact.js new file mode 100644 index 0000000000000000000000000000000000000000..812cd27bf161467a5a7ec331e43aa38a4f28f729 --- /dev/null +++ b/open-sse/services/compact.js @@ -0,0 +1,71 @@ +/** + * Shared combo (model combo) handling with fallback support + */ + +/** + * Get combo models from combos data + * @param {string} modelStr - Model string to check + * @param {Array|Object} combosData - Array of combos or object with combos + * @returns {string[]|null} Array of models or null if not a combo + */ +export function getComboModelsFromData(modelStr, combosData) { + // Don't check if it's in provider/model format + if (modelStr.includes("/")) return null; + + // Handle both array and object formats + const combos = Array.isArray(combosData) ? combosData : (combosData?.combos || []); + + const combo = combos.find(c => c.name === modelStr); + if (combo && combo.models && combo.models.length > 0) { + return combo.models; + } + return null; +} + +/** + * Handle combo chat with fallback + * @param {Object} options + * @param {Object} options.body - Request body + * @param {string[]} options.models - Array of model strings to try + * @param {Function} options.handleSingleModel - Function to handle single model: (body, modelStr) => Promise + * @param {Object} options.log - Logger object + * @returns {Promise} + */ +export async function handleComboChat({ body, models, handleSingleModel, log }) { + let lastError = null; + + for (let i = 0; i < models.length; i++) { + const modelStr = models[i]; + log.info("COMBO", `Trying model ${i + 1}/${models.length}: ${modelStr}`); + + let result; + try { + result = await handleSingleModel(body, modelStr); + } catch (e) { + lastError = `${modelStr}: ${e.message}`; + log.warn("COMBO", `Model threw exception, trying next`, { model: modelStr, error: e.message }); + continue; + } + + // Success or client error - return response + if (result.ok || result.status < 500) { + return result; + } + + // 5xx error - try next model + lastError = `${modelStr}: ${result.statusText || result.status}`; + log.warn("COMBO", `Model failed, trying next`, { model: modelStr, status: result.status }); + } + + log.warn("COMBO", "All models failed"); + + // Return 503 with last error + return new Response( + JSON.stringify({ error: lastError || "All combo models unavailable" }), + { + status: 503, + headers: { "Content-Type": "application/json" } + } + ); +} + diff --git a/open-sse/services/kiroModels.js b/open-sse/services/kiroModels.js new file mode 100644 index 0000000000000000000000000000000000000000..6b52413b018b11652ad27989dbf433ca20b7d416 --- /dev/null +++ b/open-sse/services/kiroModels.js @@ -0,0 +1,332 @@ +/** + * Kiro model catalog fetcher. + * + * Calls AWS CodeWhisperer's `ListAvailableModels` endpoint to get the live + * catalog for an authenticated Kiro account, then expands each upstream model + * into 9router-shaped variants: + * + * {upstream} - base model + * {upstream}-thinking - same model, thinking on at request time + * {upstream}-agentic - same model, chunked-write prompt prepended + * {upstream}-thinking-agentic - both + * + * The `-thinking` and `-agentic` suffixes do not exist on the Kiro upstream + * API. They are 9router fictions and the `openai-to-kiro` translator strips + * them before the request leaves this process. + * + * The runtime UA is built to match what Kiro IDE itself sends, because the + * upstream rejects requests with malformed `User-Agent` headers (returns 400 + * "format of value 'os/win/10 lang/js ...' is invalid"). + */ + +import { v4 as uuidv4 } from "uuid"; +import { createHash } from "crypto"; +import { refreshKiroToken } from "./tokenRefresh.js"; + +const KIRO_RUNTIME_SDK_VERSION = "1.0.0"; +const KIRO_AGENT_OS = "windows"; +const KIRO_AGENT_OS_VERSION = "10.0.26200"; +const KIRO_NODE_VERSION = "22.21.1"; +const KIRO_VERSION = "0.10.32"; + +const DEFAULT_REGION = "us-east-1"; +const FETCH_TIMEOUT_MS = 30_000; +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes per credential + +/** @type {Map} */ +const catalogCache = new Map(); + +/** + * Strip the `-agentic` and/or `-thinking` suffixes from a synthetic id, if + * any. Used only for display naming when a Kiro upstream id happens to look + * synthetic (defensive). + */ +function stripSyntheticSuffixes(id) { + let out = id; + if (out.endsWith("-agentic")) out = out.slice(0, -"-agentic".length); + if (out.endsWith("-thinking")) out = out.slice(0, -"-thinking".length); + return out; +} + +/** + * Extract region from a profileArn like + * arn:aws:codewhisperer:us-east-1:123456789012:profile/ABC + */ +function regionFromProfileArn(profileArn) { + if (!profileArn || typeof profileArn !== "string") return DEFAULT_REGION; + const parts = profileArn.split(":"); + if (parts.length >= 4 && parts[3]) return parts[3]; + return DEFAULT_REGION; +} + +/** + * Build the per-account fingerprint headers Kiro upstream validates. + * Keyed off whatever stable identifier we have for this credential, so the + * same account always presents the same machineId. + */ +function buildKiroFingerprintHeaders(credentials) { + const seed = + credentials?.providerSpecificData?.clientId + || credentials?.refreshToken + || credentials?.providerSpecificData?.profileArn + || credentials?.accessToken + || "kiro-anonymous"; + const machineId = createHash("sha256").update(String(seed)).digest("hex"); + + const userAgent = + `aws-sdk-js/${KIRO_RUNTIME_SDK_VERSION} ua/2.1 ` + + `os/${KIRO_AGENT_OS}#${KIRO_AGENT_OS_VERSION} ` + + `lang/js md/nodejs#${KIRO_NODE_VERSION} ` + + `api/codewhispererruntime#${KIRO_RUNTIME_SDK_VERSION} m/N,E ` + + `KiroIDE-${KIRO_VERSION}-${machineId}`; + const amzUserAgent = `aws-sdk-js/${KIRO_RUNTIME_SDK_VERSION} KiroIDE-${KIRO_VERSION}-${machineId}`; + + return { + "User-Agent": userAgent, + "x-amz-user-agent": amzUserAgent, + "x-amzn-kiro-agent-mode": "vibe", + "x-amzn-codewhisperer-optout": "true", + "amz-sdk-request": "attempt=1; max=1", + "amz-sdk-invocation-id": uuidv4(), + "Accept": "application/json" + }; +} + +/** + * Build the synthetic 9router variant set for a single upstream Kiro model. + * + * Returns objects shaped for `PROVIDER_MODELS` (`{ id, name }`) so they can + * be slotted directly into the existing model registry. + * + * The `auto` model is special: Kiro picks the underlying model server-side, + * so the chunked-write `-agentic` prompt is not meaningful (the prompt + * targets coding-agent file writes). Match CLIProxyAPIPlus and skip + * `-agentic` / `-thinking-agentic` for `auto`. + */ +function buildVariants(upstream, displayName) { + const safeUpstream = stripSyntheticSuffixes(upstream); + const display = displayName || `Kiro ${safeUpstream}`; + const isAuto = safeUpstream === "auto"; + + const variants = [ + { + id: safeUpstream, + name: display, + capabilities: { thinking: false, agentic: false } + }, + { + id: `${safeUpstream}-thinking`, + name: `${display} (Thinking)`, + capabilities: { thinking: true, agentic: false } + } + ]; + + if (!isAuto) { + variants.push({ + id: `${safeUpstream}-agentic`, + name: `${display} (Agentic)`, + capabilities: { thinking: false, agentic: true } + }); + variants.push({ + id: `${safeUpstream}-thinking-agentic`, + name: `${display} (Thinking + Agentic)`, + capabilities: { thinking: true, agentic: true } + }); + } + + return variants; +} + +/** + * Format the human-friendly display name for a Kiro model, including the + * rate multiplier when it is something other than 1.0x. + */ +function formatDisplayName(modelName, modelId, rateMultiplier) { + const base = (modelName || modelId || "Kiro").trim(); + const rate = Number(rateMultiplier); + if (!Number.isFinite(rate) || Math.abs(rate - 1.0) < 1e-9 || rate <= 0) { + return `Kiro ${base}`; + } + // Locale-independent decimal formatting. + const rateStr = rate.toFixed(1).replace(",", "."); + return `Kiro ${base} (${rateStr}x credit)`; +} + +/** + * Fetch the raw model catalog from Kiro. Returns the array under `.models` + * from the API response, or throws on network/HTTP error. + */ +async function fetchKiroCatalogRaw(credentials, signal) { + const profileArn = credentials?.providerSpecificData?.profileArn || ""; + const region = regionFromProfileArn(profileArn); + const params = new URLSearchParams(); + params.set("origin", "AI_EDITOR"); + if (profileArn) params.set("profileArn", profileArn); + const url = `https://q.${region}.amazonaws.com/ListAvailableModels?${params.toString()}`; + + const headers = { + ...buildKiroFingerprintHeaders(credentials), + "Authorization": `Bearer ${credentials?.accessToken || ""}` + }; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort("timeout"), FETCH_TIMEOUT_MS); + // Forward outer cancellation if any. + if (signal && typeof signal.addEventListener === "function") { + signal.addEventListener("abort", () => controller.abort(signal.reason)); + } + + let response; + try { + response = await fetch(url, { + method: "GET", + headers, + signal: controller.signal + }); + } finally { + clearTimeout(timer); + } + + if (!response.ok) { + const text = await response.text().catch(() => ""); + const err = new Error(`Kiro ListAvailableModels ${response.status}: ${text || response.statusText}`); + err.status = response.status; + err.body = text; + throw err; + } + + const data = await response.json(); + const models = Array.isArray(data?.models) ? data.models : []; + return models; +} + +/** + * Build a stable cache key for a Kiro credential. Uses the most stable id we + * have available so different login sessions for the same account share a + * cache entry. + */ +function cacheKey(credentials) { + const psd = credentials?.providerSpecificData || {}; + const seed = + psd.profileArn + || psd.clientId + || credentials?.refreshToken + || credentials?.accessToken + || "anonymous"; + return createHash("sha256").update(`kiro:${seed}`).digest("hex"); +} + +/** + * Resolve the live Kiro model catalog for a credential and expand each entry + * into 9router variants (`-thinking`, `-agentic`, `-thinking-agentic`). + * + * On any error (network, 4xx, 5xx), returns `null` so callers can fall back + * to the static catalog without taking down the dashboard or `/v1/models`. + * + * @param {object} credentials Connection record (accessToken, refreshToken, + * providerSpecificData {profileArn, authMethod, clientId, clientSecret, region}) + * @param {object} [options] + * @param {boolean} [options.forceRefresh] Bypass the per-credential cache. + * @param {object} [options.log] Logger. + * @param {function} [options.onCredentialsRefreshed] Persist refreshed token + * back to your credential store. Called with `{ accessToken, refreshToken, + * expiresIn }` whenever a 401 triggers a token refresh. + * @returns {Promise<{ models: object[], rawModels: object[] } | null>} + */ +export async function resolveKiroModels(credentials, options = {}) { + if (!credentials || !credentials.accessToken) { + options.log?.debug?.("KIRO_MODELS", "No 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, rawModels: cached.rawModels }; + } + } + + let raw; + try { + raw = await fetchKiroCatalogRaw(credentials, options.signal); + } catch (err) { + if (err && err.status === 401 && credentials.refreshToken) { + options.log?.info?.("KIRO_MODELS", "Got 401 from Kiro; refreshing token"); + const refreshed = await refreshKiroToken( + credentials.refreshToken, + credentials.providerSpecificData, + options.log + ); + if (refreshed?.accessToken) { + const next = { ...credentials, ...refreshed }; + if (typeof options.onCredentialsRefreshed === "function") { + try { await options.onCredentialsRefreshed(refreshed); } catch (e) { + options.log?.warn?.("KIRO_MODELS", `onCredentialsRefreshed failed: ${e?.message || e}`); + } + } + try { + raw = await fetchKiroCatalogRaw(next, options.signal); + // Update the in-memory credential reference too so retry logic uses + // the fresh token consistently. + credentials.accessToken = next.accessToken; + if (next.refreshToken) credentials.refreshToken = next.refreshToken; + } catch (err2) { + options.log?.warn?.("KIRO_MODELS", `Retry after refresh failed: ${err2?.message || err2}`); + return null; + } + } else { + options.log?.warn?.("KIRO_MODELS", "Token refresh did not return accessToken"); + return null; + } + } else { + options.log?.warn?.("KIRO_MODELS", `ListAvailableModels failed: ${err?.message || err}`); + return null; + } + } + + const expanded = []; + for (const m of raw) { + if (!m || typeof m !== "object") continue; + const upstreamId = m.modelId || m.id; + if (!upstreamId) continue; + const display = formatDisplayName(m.modelName, upstreamId, m.rateMultiplier); + const ctx = Number(m?.tokenLimits?.maxInputTokens) || 200_000; + for (const v of buildVariants(upstreamId, display)) { + expanded.push({ + ...v, + // Carry over context window + raw upstream metadata so the caller + // (e.g. the dashboard models endpoint) can render it. + contextLength: ctx, + rateMultiplier: Number.isFinite(Number(m.rateMultiplier)) ? Number(m.rateMultiplier) : 1.0, + upstreamModelId: upstreamId, + description: m.description || "" + }); + } + } + + catalogCache.set(key, { + expiresAt: now + CACHE_TTL_MS, + models: expanded, + rawModels: raw + }); + + return { models: expanded, rawModels: raw }; +} + +/** + * Drop any cached catalog for this credential. Call this after rotating / + * importing tokens so the next fetch is fresh. + */ +export function invalidateKiroModelCache(credentials) { + if (!credentials) return; + catalogCache.delete(cacheKey(credentials)); +} + +/** + * Drop the entire in-memory cache. Mostly for tests / manual debug. + */ +export function clearKiroModelCache() { + catalogCache.clear(); +} diff --git a/open-sse/services/model.js b/open-sse/services/model.js new file mode 100644 index 0000000000000000000000000000000000000000..6558d707a7227cfd49ebb3a3d983c1b210ebec9a --- /dev/null +++ b/open-sse/services/model.js @@ -0,0 +1,136 @@ +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", + jina: "jina-ai", + "jina-ai": "jina-ai", + polly: "aws-polly", + "aws-polly": "aws-polly", +}; + +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 + */ +export function resolveProviderAlias(aliasOrId) { + return ALIAS_TO_PROVIDER_ID[aliasOrId] || aliasOrId; +} + +/** + * Parse model string: "alias/model" or "provider/model" or just alias + */ +export function parseModel(modelStr) { + if (!modelStr) { + return { provider: null, model: null, isAlias: false, providerAlias: null }; + } + + // Check if standard format: provider/model or alias/model + if (modelStr.includes("/")) { + const firstSlash = modelStr.indexOf("/"); + const providerOrAlias = modelStr.slice(0, firstSlash); + const model = modelStr.slice(firstSlash + 1); + const provider = resolveProviderAlias(providerOrAlias); + return { provider, model, isAlias: false, providerAlias: providerOrAlias }; + } + + // Alias format (model alias, not provider alias) + return { + provider: null, + model: modelStr, + isAlias: true, + providerAlias: null, + }; +} + +/** + * Resolve model alias from aliases object + * Format: { "alias": "provider/model" } + */ +export function resolveModelAliasFromMap(alias, aliases) { + if (!aliases) return null; + + // Check if alias exists + const resolved = aliases[alias]; + if (!resolved) return null; + + // Resolved value is "provider/model" format + if (typeof resolved === "string" && resolved.includes("/")) { + const firstSlash = resolved.indexOf("/"); + const providerOrAlias = resolved.slice(0, firstSlash); + return { + provider: resolveProviderAlias(providerOrAlias), + model: resolved.slice(firstSlash + 1), + }; + } + + // Or object { provider, model } + if (typeof resolved === "object" && resolved.provider && resolved.model) { + return { + provider: resolveProviderAlias(resolved.provider), + model: resolved.model, + }; + } + + return null; +} + +/** + * Get full model info (parse or resolve) + * @param {string} modelStr - Model string + * @param {object|function} aliasesOrGetter - Aliases object or async function to get aliases + */ +export async function getModelInfoCore(modelStr, aliasesOrGetter) { + const parsed = parseModel(modelStr); + + if (!parsed.isAlias) { + return { + provider: parsed.provider, + model: parsed.model, + }; + } + + // Get aliases (from object or function) + const aliases = + typeof aliasesOrGetter === "function" + ? await aliasesOrGetter() + : aliasesOrGetter; + + // Resolve alias + const resolved = resolveModelAliasFromMap(parsed.model, aliases); + if (resolved) { + return resolved; + } + + // Fallback: infer provider from model name prefix + return { + provider: inferProviderFromModelName(parsed.model), + model: parsed.model, + }; +} + +// 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 + */ +function inferProviderFromModelName(modelName) { + if (!modelName) return "openai"; + const m = modelName.toLowerCase(); + 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 new file mode 100644 index 0000000000000000000000000000000000000000..75bebd5008363415aaaac67ee3444da04483b4ee --- /dev/null +++ b/open-sse/services/oauthCredentialManager.js @@ -0,0 +1,156 @@ +import { + getRefreshLeadMs, + isUnrecoverableRefreshError, + refreshTokenByProvider, +} from "./tokenRefresh.js"; +import { PROVIDER_OAUTH } from "../providers/index.js"; + +// 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(); + +function parseTimeMs(value) { + if (value === undefined || value === null || value === "") return null; + if (typeof value === "number") { + return value < 1e12 ? value * 1000 : value; + } + + const parsed = new Date(value).getTime(); + return Number.isFinite(parsed) ? parsed : null; +} + +function toExpiresAt(expiresIn, nowMs = Date.now()) { + if (!expiresIn) return null; + return new Date(nowMs + expiresIn * 1000).toISOString(); +} + +export function getCredentialExpiryMs(credentials) { + return parseTimeMs(credentials?.expiresAt ?? credentials?.tokenExpiresAt); +} + +export function getCredentialLastRefreshMs(credentials) { + return parseTimeMs( + credentials?.lastRefreshAt ?? + credentials?.lastRefresh ?? + credentials?.providerSpecificData?.lastRefreshAt + ); +} + +export function isCodexRefreshStale(credentials, nowMs = Date.now(), maxAgeMs = CODEX_MAX_REFRESH_AGE_MS) { + const lastRefreshMs = getCredentialLastRefreshMs(credentials); + return !lastRefreshMs || nowMs - lastRefreshMs >= maxAgeMs; +} + +export function shouldRefreshCredentials(provider, credentials, nowMs = Date.now()) { + if (!credentials) return false; + + const expiresAtMs = getCredentialExpiryMs(credentials); + if (expiresAtMs !== null && expiresAtMs - nowMs < getRefreshLeadMs(provider)) { + return true; + } + + // 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; + } + + return false; +} + +export function mergeProviderSpecificData(existing, next) { + if (!next || typeof next !== "object") return existing; + return { + ...(existing || {}), + ...next, + }; +} + +export function mergeRefreshedCredentials(provider, currentCredentials, refreshedCredentials, nowMs = Date.now()) { + if (!refreshedCredentials) return null; + if (isUnrecoverableRefreshError(refreshedCredentials)) return refreshedCredentials; + + const next = {}; + const nowIso = new Date(nowMs).toISOString(); + + if (refreshedCredentials.accessToken) next.accessToken = refreshedCredentials.accessToken; + if (refreshedCredentials.apiKey) next.apiKey = refreshedCredentials.apiKey; + if (refreshedCredentials.token) next.token = refreshedCredentials.token; + + const refreshToken = refreshedCredentials.refreshToken ?? currentCredentials?.refreshToken; + if (refreshToken) next.refreshToken = refreshToken; + + const idToken = refreshedCredentials.idToken ?? currentCredentials?.idToken; + if (idToken) next.idToken = idToken; + + if (refreshedCredentials.expiresIn) { + next.expiresIn = refreshedCredentials.expiresIn; + next.expiresAt = toExpiresAt(refreshedCredentials.expiresIn, nowMs); + } else if (refreshedCredentials.expiresAt) { + next.expiresAt = refreshedCredentials.expiresAt; + } + + if (refreshedCredentials.projectId) next.projectId = refreshedCredentials.projectId; + + if (refreshedCredentials.providerSpecificData) { + next.providerSpecificData = mergeProviderSpecificData( + currentCredentials?.providerSpecificData, + refreshedCredentials.providerSpecificData + ); + } + + if (refreshedCredentials.copilotToken) next.copilotToken = refreshedCredentials.copilotToken; + if (refreshedCredentials.copilotTokenExpiresAt) { + next.copilotTokenExpiresAt = refreshedCredentials.copilotTokenExpiresAt; + } + + // trackRefreshAt providers (e.g. codex) always stamp lastRefreshAt for staleness tracking + if ( + PROVIDER_OAUTH[provider]?.trackRefreshAt || + next.accessToken || + next.apiKey || + next.token || + next.refreshToken || + next.copilotToken + ) { + next.lastRefreshAt = refreshedCredentials.lastRefreshAt || nowIso; + } + + return next; +} + +function getRefreshLockKey(provider, credentials) { + const stableId = + credentials?.connectionId || + credentials?.id || + credentials?.email || + credentials?.name || + credentials?.refreshToken?.slice?.(-16) || + "default"; + return `${provider}:${stableId}`; +} + +export async function withCredentialRefreshLock(provider, credentials, refreshFn) { + const key = getRefreshLockKey(provider, credentials); + const existing = refreshLocks.get(key); + if (existing) return existing; + + const pending = Promise.resolve() + .then(refreshFn) + .finally(() => { + refreshLocks.delete(key); + }); + + refreshLocks.set(key, pending); + return pending; +} + +export async function refreshProviderCredentials(provider, credentials, log) { + if (!credentials) return null; + + return withCredentialRefreshLock(provider, credentials, async () => { + const refreshed = await refreshTokenByProvider(provider, credentials, log); + return mergeRefreshedCredentials(provider, credentials, refreshed); + }); +} diff --git a/open-sse/services/projectId.js b/open-sse/services/projectId.js new file mode 100644 index 0000000000000000000000000000000000000000..f9a24e1ab0b3bfb5dc88c79bae6488bd40480d0d --- /dev/null +++ b/open-sse/services/projectId.js @@ -0,0 +1,306 @@ +/** + * Project ID Service - Fetch and cache real Project IDs from Google Cloud Code API + * + * + * Instead of generating random project IDs (e.g. "useful-spark-a1b2c"), + * this service fetches the real Project ID bound to the authenticated user's account. + * This significantly reduces the risk of being flagged by Google's anti-abuse systems. + */ + +import { CLOUD_CODE_API, LOAD_CODE_ASSIST_HEADERS, LOAD_CODE_ASSIST_METADATA } from "../config/appConstants.js"; + +// ─── Cache ──────────────────────────────────────────────────────────────────── +// connectionId -> { projectId: string, fetchedAt: number } +const projectIdCache = new Map(); + +/** How long a cached project ID is considered fresh (1 hour). */ +const CACHE_TTL_MS = 60 * 60 * 1000; + +// ─── Pending-fetch deduplication ───────────────────────────────────────────── +// connectionId -> { promise: Promise, controller: AbortController, startedAt: number } +const pendingFetches = new Map(); + +/** Abort and evict a pending fetch that has been running longer than this (2 min). */ +const PENDING_TTL_MS = 2 * 60 * 1000; + +// ─── Periodic cleanup ──────────────────────────────────────────────────────── +/** How often the background sweep runs (10 min). */ +const CLEANUP_INTERVAL_MS = 10 * 60 * 1000; + +let _cleanupTimer = null; + +/** Run one sweep immediately: evict stale cache entries and abort orphaned pending fetches. */ +export function cleanupNow() { + const now = Date.now(); + + for (const [id, entry] of projectIdCache) { + if (!entry || now - entry.fetchedAt >= CACHE_TTL_MS) { + projectIdCache.delete(id); + } + } + + for (const [id, item] of pendingFetches) { + if (!item || typeof item.startedAt !== "number") { + pendingFetches.delete(id); + continue; + } + if (now - item.startedAt > PENDING_TTL_MS) { + try { item.controller.abort(); } catch (_) { /* ignore */ } + pendingFetches.delete(id); + } + } +} + +/** Start the periodic background cleanup (idempotent). Called automatically on module load. */ +export function startCacheCleanup() { + if (_cleanupTimer) return; + _cleanupTimer = setInterval(() => { + try { cleanupNow(); } catch (e) { + console.warn("[ProjectId] cleanup sweep error:", e?.message ?? e); + } + }, CLEANUP_INTERVAL_MS); + // Unref so the timer doesn't prevent Node from exiting when it is otherwise idle + _cleanupTimer?.unref?.(); +} + +/** Stop the periodic background cleanup (e.g. during graceful shutdown). */ +export function stopCacheCleanup() { + if (!_cleanupTimer) return; + clearInterval(_cleanupTimer); + _cleanupTimer = null; +} + +// Start automatically when the module is first imported +startCacheCleanup(); + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Get the Project ID for a connection, with caching. + * Returns null on failure (callers should fall back to random generation). + * + * @param {string} connectionId - The connection identifier for cache keying + * @param {string} accessToken - Valid OAuth access token + * @returns {Promise} Real project ID or null + */ +export async function getProjectIdForConnection(connectionId, accessToken) { + if (!connectionId || !accessToken) return null; + + // Return cached value if still fresh + const cached = projectIdCache.get(connectionId); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return cached.projectId; + } + + // Deduplicate concurrent fetches for the same connection + if (pendingFetches.has(connectionId)) { + return pendingFetches.get(connectionId).promise; + } + + // Each fetch gets its own AbortController so it can be canceled via removeConnection() + const controller = new AbortController(); + + const promise = (async () => { + try { + const projectId = await fetchProjectId(accessToken, controller.signal); + if (projectId) { + projectIdCache.set(connectionId, {projectId, fetchedAt: Date.now()}); + return projectId; + } + console.warn("[ProjectId] could not fetch projectId for connection", connectionId.slice(0, 8)); + return null; + } catch (error) { + console.warn(`[ProjectId] Error fetching project ID: ${error.message}`); + return null; + } finally { + pendingFetches.delete(connectionId); + } + })(); + + pendingFetches.set(connectionId, {promise, controller, startedAt: Date.now()}); + return promise; +} + +/** + * Invalidate the cached project ID for a connection. + * Call this when a connection's credentials are fully revoked or refreshed. + */ +export function invalidateProjectId(connectionId) { + projectIdCache.delete(connectionId); +} + +/** + * Fully remove a connection: abort any in-flight fetch and delete its cached project ID. + * Wire this into your connection close / disconnect lifecycle events to prevent memory leaks. + * + * @param {string} connectionId + */ +export function removeConnection(connectionId) { + if (!connectionId) return; + projectIdCache.delete(connectionId); + const pending = pendingFetches.get(connectionId); + if (pending) { + try { pending.controller.abort(); } catch (_) { /* ignore */ } + pendingFetches.delete(connectionId); + } +} + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +/** + * Fetch project ID via loadCodeAssist endpoint. + * Falls back to onboardUser when loadCodeAssist returns no project. + * + * @param {string} accessToken + * @param {AbortSignal} signal + * @returns {Promise} + */ +async function fetchProjectId(accessToken, signal) { + const response = await fetch(CLOUD_CODE_API.loadCodeAssist, { + method: "POST", + headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` }, + body: JSON.stringify({ metadata: LOAD_CODE_ASSIST_METADATA }), + signal + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + throw new Error(`loadCodeAssist failed: HTTP ${response.status} ${errorText.slice(0, 200)}`); + } + + const data = await response.json(); + const projectId = extractProjectId(data); + if (projectId) return projectId; + + // Determine the tier to use for onboarding + let tierID = "legacy-tier"; + if (Array.isArray(data.allowedTiers)) { + for (const tier of data.allowedTiers) { + if (tier && typeof tier === "object" && tier.isDefault === true) { + if (tier.id && typeof tier.id === "string" && tier.id.trim()) { + tierID = tier.id.trim(); + break; + } + } + } + } + + return onboardUser(accessToken, tierID, signal); +} + +/** + * Fetch project ID via onboardUser endpoint (polls until done). + * + * @param {string} accessToken + * @param {string} tierID + * @param {AbortSignal} externalSignal – propagated from the connection's AbortController + * @returns {Promise} + */ +async function onboardUser(accessToken, tierID, externalSignal) { + console.log(`[ProjectId] Onboarding user with tier: ${tierID}`); + + const reqBody = { tierId: tierID, metadata: LOAD_CODE_ASSIST_METADATA }; + const MAX_ATTEMPTS = 5; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + // Bail out immediately if the connection was removed + if (externalSignal?.aborted) return null; + + // Per-attempt timeout controller; forwards external abort as well + const localCtrl = new AbortController(); + const timeoutId = setTimeout(() => localCtrl.abort(), 30_000); + const forwardAbort = () => localCtrl.abort(); + externalSignal?.addEventListener("abort", forwardAbort); + + try { + const response = await fetch(CLOUD_CODE_API.onboardUser, { + method: "POST", + headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` }, + body: JSON.stringify(reqBody), + signal: localCtrl.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + throw new Error(`onboardUser HTTP ${response.status}: ${errorText.slice(0, 200)}`); + } + + const data = await response.json(); + + if (data.done === true) { + const projectId = extractProjectIdFromOnboard(data); + if (projectId) { + console.log(`[ProjectId] Successfully onboarded, project ID: ${projectId}`); + return projectId; + } + throw new Error("onboardUser done but no project_id in response"); + } + + // Server not done yet – wait and retry + console.log(`[ProjectId] Onboard attempt ${attempt}/${MAX_ATTEMPTS}: not done yet, waiting...`); + await new Promise(resolve => setTimeout(resolve, 2000)); + + } catch (error) { + clearTimeout(timeoutId); + if (error.name === "AbortError") { + console.warn(`[ProjectId] onboardUser attempt ${attempt} aborted (timeout or connection removed)`); + if (externalSignal?.aborted) return null; // connection gone – stop retrying + continue; + } + if (attempt === MAX_ATTEMPTS) { + console.warn(`[ProjectId] onboardUser failed after ${MAX_ATTEMPTS} attempts: ${error.message}`); + return null; + } + // Continue to next attempt instead of throwing (which would skip remaining retries) + console.warn(`[ProjectId] onboardUser attempt ${attempt} failed: ${error.message}, retrying...`); + await new Promise(resolve => setTimeout(resolve, 2000)); + } finally { + clearTimeout(timeoutId); + externalSignal?.removeEventListener("abort", forwardAbort); + } + } + + return null; +} + +/** + * Extract project ID from loadCodeAssist response. + */ +function extractProjectId(data) { + if (!data) return null; + + if (typeof data.cloudaicompanionProject === "string") { + const id = data.cloudaicompanionProject.trim(); + if (id) return id; + } + + if (data.cloudaicompanionProject && typeof data.cloudaicompanionProject === "object") { + const id = data.cloudaicompanionProject.id; + if (typeof id === "string" && id.trim()) return id.trim(); + } + + return null; +} + +/** + * Extract project ID from onboardUser response. + */ +function extractProjectIdFromOnboard(data) { + if (!data?.response) return null; + + const project = data.response.cloudaicompanionProject; + + if (typeof project === "string") { + const id = project.trim(); + if (id) return id; + } + + if (project && typeof project === "object") { + const id = project.id; + if (typeof id === "string" && id.trim()) return id.trim(); + } + + return null; +} diff --git a/open-sse/services/provider.js b/open-sse/services/provider.js new file mode 100644 index 0000000000000000000000000000000000000000..4b3a6690efef98caeb1208f1b6d82b158719526c --- /dev/null +++ b/open-sse/services/provider.js @@ -0,0 +1,161 @@ +import { PROVIDERS } from "../config/providers.js"; +import { OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.js"; + +const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-"; +const OPENAI_COMPATIBLE_DEFAULTS = { + baseUrl: OPENAI_COMPAT_BASE, +}; + +const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-"; +const ANTHROPIC_COMPATIBLE_DEFAULTS = { + baseUrl: ANTHROPIC_COMPAT_BASE, +}; + +function isOpenAICompatible(provider) { + return typeof provider === "string" && provider.startsWith(OPENAI_COMPATIBLE_PREFIX); +} + +function isAnthropicCompatible(provider) { + return typeof provider === "string" && provider.startsWith(ANTHROPIC_COMPATIBLE_PREFIX); +} + +function getOpenAICompatibleType(provider) { + if (!isOpenAICompatible(provider)) return "chat"; + return provider.includes("responses") ? "responses" : "chat"; +} + +// Detect request format from body structure +export function detectFormat(body) { + // OpenAI Responses API: has input (array or string) instead of messages[] + // The Responses API accepts both input as array and input as a plain string + if (body.input && (Array.isArray(body.input) || typeof body.input === "string") && !body.messages) { + return "openai-responses"; + } + + // Antigravity format: Gemini wrapped in body.request + if (body.request?.contents && body.userAgent === "antigravity") { + return "antigravity"; + } + + // Gemini format: has contents array + if (body.contents && Array.isArray(body.contents)) { + return "gemini"; + } + + // OpenAI-specific indicators (check BEFORE Claude) + // These fields are OpenAI-specific and never appear in Claude format + if ( + body.stream_options || // OpenAI streaming options + body.response_format || // JSON mode, etc. + body.logprobs !== undefined || // Log probabilities + body.top_logprobs !== undefined || + body.n !== undefined || // Number of completions + body.presence_penalty !== undefined || // Penalties + body.frequency_penalty !== undefined || + body.logit_bias || // Token biasing + body.user // User identifier + ) { + return "openai"; + } + + // Claude format: messages with content as array of objects with type + // Claude requires content to be array with specific structure + if (body.messages && Array.isArray(body.messages)) { + const firstMsg = body.messages[0]; + + // If content is array, check if it follows Claude structure + if (firstMsg?.content && Array.isArray(firstMsg.content)) { + const firstContent = firstMsg.content[0]; + + // Claude format has specific types: text, image, tool_use, tool_result + // OpenAI multimodal has: text, image_url (note the difference) + if (firstContent?.type === "text" && !body.model?.includes("/")) { + // Could be Claude or OpenAI multimodal + // Check for Claude-specific fields + if (body.system || body.anthropic_version) { + return "claude"; + } + // Check if image format is Claude (source.type) vs OpenAI (image_url.url) + const hasClaudeImage = firstMsg.content.some(c => + c.type === "image" && c.source?.type === "base64" + ); + const hasOpenAIImage = firstMsg.content.some(c => + c.type === "image_url" && c.image_url?.url + ); + if (hasClaudeImage) return "claude"; + if (hasOpenAIImage) return "openai"; + + // If still unclear, check for tool format + const hasClaudeTool = firstMsg.content.some(c => + c.type === "tool_use" || c.type === "tool_result" + ); + if (hasClaudeTool) return "claude"; + } + } + + // If content is string, it's likely OpenAI (Claude also supports this) + // Check for other Claude-specific indicators + if (body.system !== undefined || body.anthropic_version) { + return "claude"; + } + } + + // Default to OpenAI format + return "openai"; +} + +// Get provider config (internal — no external runtime consumer) +function getProviderConfig(provider) { + if (isOpenAICompatible(provider)) { + const apiType = getOpenAICompatibleType(provider); + return { + ...PROVIDERS.openai, + format: apiType === "responses" ? "openai-responses" : "openai", + baseUrl: OPENAI_COMPATIBLE_DEFAULTS.baseUrl, + }; + } + if (isAnthropicCompatible(provider)) { + return { + ...PROVIDERS.anthropic, // Use Anthropic defaults (header: x-api-key) + format: "claude", + baseUrl: ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl, + }; + } + return PROVIDERS[provider] || PROVIDERS.openai; +} + +// Get target format for provider +export function getTargetFormat(provider) { + if (isOpenAICompatible(provider)) { + return getOpenAICompatibleType(provider) === "responses" ? "openai-responses" : "openai"; + } + if (isAnthropicCompatible(provider)) { + return "claude"; + } + const config = getProviderConfig(provider); + return config.format || "openai"; +} + +// Check if last message is from user +export function isLastMessageFromUser(body) { + const messages = body.messages || body.contents; + if (!messages?.length) return true; + const lastMsg = messages[messages.length - 1]; + return lastMsg?.role === "user"; +} + +// Check if request has thinking config +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) +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 new file mode 100644 index 0000000000000000000000000000000000000000..01e6fb1398ee9fffbbac580b3858dacd073ea259 --- /dev/null +++ b/open-sse/services/qoderModels.js @@ -0,0 +1,214 @@ +/** + * Qoder model catalog fetcher. + * + * Calls /algo/api/v2/model/list (COSY-signed) on the inference host to get + * the live catalog for an authenticated Qoder account, then caches the + * per-model `model_config` blocks by key. Chat requests later look up the + * exact server-published metadata for the model they want — Qoder's chat + * endpoint silently downgrades to a different model when the wrong + * model_config is sent. + * + * On any error the live cache stays empty and chatExecuteCall surfaces the + * problem to the user as "model config not yet fetched, retry shortly". + */ + +import { createHash } from "crypto"; + +import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { buildCosyHeaders } from "../shared/qoder/cosy.js"; +import { + QODER_MODEL_LIST_URL, +} from "../shared/qoder/constants.js"; + +const FETCH_TIMEOUT_MS = 15_000; +const CACHE_TTL_MS = 60 * 60 * 1000; // 1h, same as the Kiro catalog + +/** @type {Map, fetched: boolean }>} */ +const catalogCache = new Map(); + +/** + * In-flight fetch promises keyed by cacheKey. Concurrent first-time + * callers (parallel chat windows) all observe the same Promise so we + * fan-out exactly one upstream request per credential per miss. + * @type {Map, fetched: boolean } | null>>} + */ +const inflight = new Map(); + +/** + * Stable cache key per credential (so different login sessions for the same + * account share an entry). + */ +function cacheKey(credentials) { + const psd = credentials?.providerSpecificData || {}; + const seed = psd.userId || credentials?.refreshToken || credentials?.accessToken || "anonymous"; + return createHash("sha256").update(`qoder:${seed}`).digest("hex"); +} + +/** + * Strip credential -> COSY creds for buildCosyHeaders. + */ +function cosyCredsFromConnection(credentials) { + const psd = credentials?.providerSpecificData || {}; + return { + userId: psd.userId, + authToken: credentials.accessToken, + name: credentials.displayName || "", + email: credentials.email || "", + machineId: psd.machineId || "", + }; +} + +/** + * Fetch the live model list for this credential. Returns: + * { models: [{ id, name, contextLength, isVL, isReasoning, ... }, ...], + * rawConfigs: Map } + * or `null` on any error. + */ +async function fetchQoderCatalogRaw(credentials, signal, proxyOptions = null) { + const creds = cosyCredsFromConnection(credentials); + if (!creds.userId || !creds.authToken) return null; + + const headers = { + Accept: "application/json", + "Accept-Encoding": "identity", + ...buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, creds), + }; + + const controller = new AbortController(); + let timer = null; + let abortListener = null; + let response; + try { + timer = setTimeout(() => controller.abort("timeout"), FETCH_TIMEOUT_MS); + if (signal && typeof signal.addEventListener === "function") { + // If the parent signal already aborted before we got here, the + // 'abort' event has already fired and addEventListener won't + // re-trigger it. Propagate the cancellation immediately. + if (signal.aborted) { + controller.abort(signal.reason); + } else { + abortListener = () => controller.abort(signal.reason); + signal.addEventListener("abort", abortListener); + } + } + response = await proxyAwareFetch( + QODER_MODEL_LIST_URL, + { + method: "GET", + headers, + signal: controller.signal, + }, + proxyOptions, + ); + } finally { + if (timer) clearTimeout(timer); + if (signal && abortListener) signal.removeEventListener("abort", abortListener); + } + + if (!response.ok) return null; + + const body = await response.json().catch(() => null); + if (!body || !Array.isArray(body.chat)) return null; + + const models = []; + const rawConfigs = new Map(); + for (const entry of body.chat) { + if (!entry || typeof entry !== "object") continue; + const key = entry.key; + if (!key) continue; + + // Always cache the config — chat needs model_config even for UI-hidden + // models (enable:false). Upstream still accepts chat for these keys. + rawConfigs.set(key, entry); + if (entry.enable === false) continue; + + const display = entry.display_name || key; + const ctx = Number(entry.max_input_tokens) || 131_072; + models.push({ + id: key, + name: `${display}`, + contextLength: ctx, + isVL: !!entry.is_vl, + isReasoning: !!entry.is_reasoning, + maxOutputTokens: Number(entry.max_output_tokens) || 0, + description: entry.description || "", + }); + } + + return { models, rawConfigs }; +} + +/** + * Get the cached model_config block for a given model key, fetching the + * catalog first if needed. Returns null when the catalog can't be fetched + * (so callers can fall back to the static registry). + */ +export async function getQoderModelConfig(credentials, modelKey, options = {}) { + const cached = await resolveQoderModels(credentials, options); + if (!cached) return null; + const config = cached.rawConfigs.get(modelKey); + if (!config) return null; + // Defensive copy — chat code may mutate `key` to align with the alias path. + return { ...config, key: modelKey }; +} + +/** + * Resolve the live model catalog + raw configs for a credential. Caches + * results for CACHE_TTL_MS so repeated chat requests don't re-fetch, and + * deduplicates concurrent misses so parallel chat windows fan-out exactly + * one upstream request per credential. + */ +export async function resolveQoderModels(credentials, options = {}) { + if (!credentials?.accessToken) return null; + const psd = credentials.providerSpecificData || {}; + if (!psd.userId) return null; + + const key = cacheKey(credentials); + const now = Date.now(); + if (!options.forceRefresh) { + const cached = catalogCache.get(key); + if (cached && cached.expiresAt > now) { + return cached; + } + } + + // Coalesce concurrent misses on the same credential into one upstream call. + // forceRefresh callers still get their own fetch (they wanted fresh data). + const existing = inflight.get(key); + if (existing && !options.forceRefresh) { + return existing; + } + + const fetchPromise = (async () => { + const fetched = await fetchQoderCatalogRaw(credentials, options.signal, options.proxyOptions); + if (!fetched) return null; + const entry = { + expiresAt: Date.now() + CACHE_TTL_MS, + models: fetched.models, + rawConfigs: fetched.rawConfigs, + fetched: true, + }; + catalogCache.set(key, entry); + return entry; + })(); + + inflight.set(key, fetchPromise); + try { + return await fetchPromise; + } finally { + // Clear only if this is still the in-flight entry — a forceRefresh + // call that started later may have replaced it. + if (inflight.get(key) === fetchPromise) { + inflight.delete(key); + } + } +} + +export function invalidateQoderCatalog(credentials) { + if (!credentials) return; + catalogCache.delete(cacheKey(credentials)); +} + +export function clearQoderCatalog() { + catalogCache.clear(); +} diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js new file mode 100644 index 0000000000000000000000000000000000000000..10d8efea2a0ce0dbabd1f5789500ed21b1416170 --- /dev/null +++ b/open-sse/services/tokenRefresh.js @@ -0,0 +1,247 @@ +import { PROVIDERS } from "../config/providers.js"; +import { OAUTH_ENDPOINTS, REFRESH_LEAD_MS } from "../config/appConstants.js"; +import { + refreshXaiToken, + refreshAccessToken, + refreshClaudeOAuthToken, + refreshGoogleToken, + refreshQwenToken, + refreshCodexToken, + refreshKiroToken, + refreshIflowToken, + refreshGitHubToken, + refreshCopilotToken, + classifyOAuthRefreshError, +} from "./tokenRefresh/providers.js"; + +// Re-export all provider refresh functions (preserves public API for all consumers) +export { + refreshAccessToken, + refreshClaudeOAuthToken, + refreshGoogleToken, + refreshQwenToken, + refreshCodexToken, + refreshKiroToken, + refreshIflowToken, + refreshGitHubToken, + refreshCopilotToken, + classifyOAuthRefreshError, +}; + +export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; + +export function isUnrecoverableRefreshError(result) { + return ( + result && + typeof result === "object" && + (result.error === "unrecoverable_refresh_error" || + result.error === "refresh_token_reused" || + result.error === "invalid_request" || + result.error === "invalid_grant") + ); +} + +export function getRefreshLeadMs(provider) { + return REFRESH_LEAD_MS[provider] || TOKEN_EXPIRY_BUFFER_MS; +} + +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(); + +export async function refreshVertexToken(saJson, log) { + const cacheKey = saJson.client_email; + const cached = vertexTokenCache.get(cacheKey); + + 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(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 (!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; + } +} + +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), + 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; + } + return _getAccessTokenInternal(provider, credentials, log); +} + +async function _getAccessTokenInternal(provider, credentials, log) { + 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); +} + +export async function refreshTokenByProvider(provider, credentials, log) { + if (!credentials.refreshToken) return null; + const handler = REFRESH_HANDLERS[provider]; + return handler ? handler(credentials, log) : refreshAccessToken(provider, credentials.refreshToken, credentials, log); +} + +export function formatProviderCredentials(provider, credentials, log) { + const config = PROVIDERS[provider]; + if (!config) { + log?.warn?.("TOKEN_REFRESH", `No configuration found for provider: ${provider}`); + return null; + } + + switch (provider) { + case "gemini": + return { + apiKey: credentials.apiKey, + accessToken: credentials.accessToken, + projectId: credentials.projectId + }; + + case "claude": + return { + apiKey: credentials.apiKey, + accessToken: credentials.accessToken + }; + + case "codex": + case "qwen": + case "iflow": + case "openai": + case "openrouter": + case "xai": + return { + apiKey: credentials.apiKey, + accessToken: credentials.accessToken + }; + + case "antigravity": + case "gemini-cli": + return { + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken, + projectId: credentials.projectId + }; + + default: + return { + apiKey: credentials.apiKey, + accessToken: credentials.accessToken, + refreshToken: credentials.refreshToken + }; + } +} + +export async function getAllAccessTokens(userInfo, log) { + const results = {}; + + if (userInfo.connections && Array.isArray(userInfo.connections)) { + for (const connection of userInfo.connections) { + if (connection.isActive && connection.provider) { + const token = await getAccessToken(connection.provider, { + refreshToken: connection.refreshToken + }, log); + + if (token) { + results[connection.provider] = token; + } + } + } + } + + return results; +} + +export async function refreshWithRetry(refreshFn, maxRetries = 3, log = null) { + for (let attempt = 0; attempt < maxRetries; attempt++) { + if (attempt > 0) { + const delay = attempt * 1000; + log?.debug?.("TOKEN_REFRESH", `Retry ${attempt}/${maxRetries} after ${delay}ms`); + await new Promise(r => setTimeout(r, delay)); + } + + try { + const result = await refreshFn(); + if (result) return result; + } catch (error) { + log?.warn?.("TOKEN_REFRESH", `Attempt ${attempt + 1}/${maxRetries} failed: ${error.message}`); + } + } + + log?.error?.("TOKEN_REFRESH", `All ${maxRetries} retry attempts failed`); + return null; +} diff --git a/open-sse/services/tokenRefresh/dedup.js b/open-sse/services/tokenRefresh/dedup.js new file mode 100644 index 0000000000000000000000000000000000000000..ea4666c420a5200c2414d8a686e2440e0d575439 --- /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 0000000000000000000000000000000000000000..33d6baf6508bd5e47121fe87785534ab7e0cadd4 --- /dev/null +++ b/open-sse/services/tokenRefresh/providers.js @@ -0,0 +1,526 @@ +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"; + +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 (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); +} diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js new file mode 100644 index 0000000000000000000000000000000000000000..d88167090fd252630c91c7c6be024172cdd2c311 --- /dev/null +++ b/open-sse/services/usage.js @@ -0,0 +1,56 @@ +/** + * Usage Fetcher - Get usage data from provider APIs + */ + +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"; + +export { consumeCodexRateLimitResetCredit }; +import { getKiroUsage } from "./usage/kiro.js"; +import { getMiniMaxUsage } from "./usage/minimax.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), +}; + +export async function getUsageForProvider(connection, proxyOptions = null) { + const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection; + const providerDataWithProjectId = { + ...(providerSpecificData || {}), + ...(projectId ? { projectId } : {}), + }; + + 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 0000000000000000000000000000000000000000..85ab8e6f5bd2fd8a7ce91c5947f07a22ff95276a --- /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/codex.js b/open-sse/services/usage/codex.js new file mode 100644 index 0000000000000000000000000000000000000000..cfcd5931c385900788450a93e818d5bea8720fad --- /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 0000000000000000000000000000000000000000..8eec3b60b4e2a92d7a6f46d4fe2f21484fcaa10d --- /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 0000000000000000000000000000000000000000..e32267254c63e8efccd801a341fab0d119e35507 --- /dev/null +++ b/open-sse/services/usage/google.js @@ -0,0 +1,240 @@ +/** + * 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', + ]; + + 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 0000000000000000000000000000000000000000..fb221565673dea907ac74a99d2e2966ae73ec67b --- /dev/null +++ b/open-sse/services/usage/kiro.js @@ -0,0 +1,183 @@ +/** + * 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 apiKeyHeaders = isApiKey ? { tokentype: "API_KEY" } : {}; + + // 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, + }, + }, + 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, + }, + 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, + }, + }, 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 0000000000000000000000000000000000000000..81a6087f9cdd13a539f2fde5010424503acb4d48 --- /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 0000000000000000000000000000000000000000..6ce012faa14c1dbc3d3132ac419627e1a0a4a21f --- /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 0000000000000000000000000000000000000000..7ca59f7729022aac3e8f5809ece9a203d56f0059 --- /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 0000000000000000000000000000000000000000..1b2b7df61620c5161b69f36112606cf98c60d600 --- /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 0000000000000000000000000000000000000000..76c2a8d396ed86d785488d93fd1bbd525d2268ca --- /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 0000000000000000000000000000000000000000..1d9ce303a28e8505daf0e40efcc3dbebacd34aca --- /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 0000000000000000000000000000000000000000..d5d59af7ff9047dac834ad87a4f1d86d6e8d9ed1 --- /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 0000000000000000000000000000000000000000..31449e8597adb3291202d5d87b8572745c4d1dd8 --- /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/transformer/responsesTransformer.js b/open-sse/transformer/responsesTransformer.js new file mode 100644 index 0000000000000000000000000000000000000000..ac84db2092b37eb247872315054200d3d6cd9519 --- /dev/null +++ b/open-sse/transformer/responsesTransformer.js @@ -0,0 +1,439 @@ +/** + * Responses API Transformer + * Converts OpenAI Chat Completions SSE to Codex Responses API SSE format + * Can be used in both Next.js and Cloudflare Workers + */ + +import fs from "fs"; +import path from "path"; + +// Create log directory for responses (Node.js only) +export function createResponsesLogger(model, logsDir = null) { + // Skip logging in worker environment (no fs) + if (typeof fs.mkdirSync !== "function") { + return null; + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15); + const uniqueId = Math.random().toString(36).slice(2, 8); + const baseDir = logsDir || (typeof process !== "undefined" ? process.cwd() : "."); + const logDir = path.join(baseDir, "logs", `responses_${model}_${timestamp}_${uniqueId}`); + + try { + fs.mkdirSync(logDir, { recursive: true }); + } catch { + return null; + } + + let inputEvents = []; + let outputEvents = []; + + return { + logInput: (event) => { + inputEvents.push(event); + }, + logOutput: (event) => { + outputEvents.push(event); + }, + flush: () => { + try { + fs.writeFileSync(path.join(logDir, "1_input_stream.txt"), inputEvents.join("\n")); + fs.writeFileSync(path.join(logDir, "2_output_stream.txt"), outputEvents.join("\n")); + } catch (e) { + console.log("[RESPONSES] Failed to write logs:", e.message); + } + } + }; +} + +/** + * Create TransformStream that converts Chat Completions SSE to Responses API SSE + * @param {Object} logger - Optional logger instance + * @returns {TransformStream} + */ +export function createResponsesApiTransformStream(logger = null) { + const state = { + seq: 0, + responseId: `resp_${Date.now()}`, + created: Math.floor(Date.now() / 1000), + started: false, + msgTextBuf: {}, + msgItemAdded: {}, + msgContentAdded: {}, + msgItemDone: {}, + reasoningId: "", + reasoningIndex: -1, + reasoningBuf: "", + reasoningPartAdded: false, + reasoningDone: false, + inThinking: false, + funcArgsBuf: {}, + funcNames: {}, + funcCallIds: {}, + funcArgsDone: {}, + funcItemDone: {}, + buffer: "", + completedSent: false + }; + + const encoder = new TextEncoder(); + const nextSeq = () => ++state.seq; + + const emit = (controller, eventType, data) => { + data.sequence_number = nextSeq(); + const output = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`; + logger?.logOutput(output.trim()); + controller.enqueue(encoder.encode(output)); + }; + + // Helper to start reasoning + const startReasoning = (controller, idx) => { + if (!state.reasoningId) { + state.reasoningId = `rs_${state.responseId}_${idx}`; + state.reasoningIndex = idx; + + emit(controller, "response.output_item.added", { + type: "response.output_item.added", + output_index: idx, + item: { + id: state.reasoningId, + type: "reasoning", + summary: [] + } + }); + + emit(controller, "response.reasoning_summary_part.added", { + type: "response.reasoning_summary_part.added", + item_id: state.reasoningId, + output_index: idx, + summary_index: 0, + part: { type: "summary_text", text: "" } + }); + state.reasoningPartAdded = true; + } + }; + + const emitReasoningDelta = (controller, text) => { + if (!text) return; + state.reasoningBuf += text; + emit(controller, "response.reasoning_summary_text.delta", { + type: "response.reasoning_summary_text.delta", + item_id: state.reasoningId, + output_index: state.reasoningIndex, + summary_index: 0, + delta: text + }); + }; + + const closeReasoning = (controller) => { + if (state.reasoningId && !state.reasoningDone) { + state.reasoningDone = true; + + emit(controller, "response.reasoning_summary_text.done", { + type: "response.reasoning_summary_text.done", + item_id: state.reasoningId, + output_index: state.reasoningIndex, + summary_index: 0, + text: state.reasoningBuf + }); + + emit(controller, "response.reasoning_summary_part.done", { + type: "response.reasoning_summary_part.done", + item_id: state.reasoningId, + output_index: state.reasoningIndex, + summary_index: 0, + part: { type: "summary_text", text: state.reasoningBuf } + }); + + emit(controller, "response.output_item.done", { + type: "response.output_item.done", + output_index: state.reasoningIndex, + item: { + id: state.reasoningId, + type: "reasoning", + summary: [{ type: "summary_text", text: state.reasoningBuf }] + } + }); + } + }; + + const closeMessage = (controller, idx) => { + if (state.msgItemAdded[idx] && !state.msgItemDone[idx]) { + state.msgItemDone[idx] = true; + const fullText = state.msgTextBuf[idx] || ""; + const msgId = `msg_${state.responseId}_${idx}`; + + emit(controller, "response.output_text.done", { + type: "response.output_text.done", + item_id: msgId, + output_index: parseInt(idx), + content_index: 0, + text: fullText, + logprobs: [] + }); + + emit(controller, "response.content_part.done", { + type: "response.content_part.done", + item_id: msgId, + output_index: parseInt(idx), + content_index: 0, + part: { type: "output_text", annotations: [], logprobs: [], text: fullText } + }); + + emit(controller, "response.output_item.done", { + type: "response.output_item.done", + output_index: parseInt(idx), + item: { + id: msgId, + type: "message", + content: [{ type: "output_text", annotations: [], logprobs: [], text: fullText }], + role: "assistant" + } + }); + } + }; + + const closeToolCall = (controller, idx) => { + const callId = state.funcCallIds[idx]; + if (callId && !state.funcItemDone[idx]) { + const args = state.funcArgsBuf[idx] || "{}"; + + emit(controller, "response.function_call_arguments.done", { + type: "response.function_call_arguments.done", + item_id: `fc_${callId}`, + output_index: parseInt(idx), + arguments: args + }); + + emit(controller, "response.output_item.done", { + type: "response.output_item.done", + output_index: parseInt(idx), + item: { + id: `fc_${callId}`, + type: "function_call", + arguments: args, + call_id: callId, + name: state.funcNames[idx] || "" + } + }); + + state.funcItemDone[idx] = true; + state.funcArgsDone[idx] = true; + } + }; + + const sendCompleted = (controller) => { + if (!state.completedSent) { + state.completedSent = true; + emit(controller, "response.completed", { + type: "response.completed", + response: { + id: state.responseId, + object: "response", + created_at: state.created, + status: "completed", + background: false, + error: null + } + }); + } + }; + + return new TransformStream({ + transform(chunk, controller) { + const text = new TextDecoder().decode(chunk); + logger?.logInput(text.trim()); + state.buffer += text; + + const messages = state.buffer.split("\n\n"); + state.buffer = messages.pop() || ""; + + for (const msg of messages) { + if (!msg.trim()) continue; + + const dataMatch = msg.match(/^data:\s*(.+)$/m); + if (!dataMatch) continue; + + const dataStr = dataMatch[1].trim(); + if (dataStr === "[DONE]") continue; + + let parsed; + try { + parsed = JSON.parse(dataStr); + } catch { + continue; + } + + if (!parsed.choices?.length) continue; + + const choice = parsed.choices[0]; + const idx = choice.index || 0; + const delta = choice.delta || {}; + + // Emit initial events + if (!state.started) { + state.started = true; + state.responseId = parsed.id ? `resp_${parsed.id}` : state.responseId; + + emit(controller, "response.created", { + type: "response.created", + response: { + id: state.responseId, + object: "response", + created_at: state.created, + status: "in_progress", + background: false, + error: null, + output: [] + } + }); + + emit(controller, "response.in_progress", { + type: "response.in_progress", + response: { + id: state.responseId, + object: "response", + created_at: state.created, + status: "in_progress" + } + }); + } + + // Handle reasoning_content (OpenAI native format) + if (delta.reasoning_content) { + startReasoning(controller, idx); + emitReasoningDelta(controller, delta.reasoning_content); + } + + // Handle text content (may contain tags) + if (delta.content) { + let content = delta.content; + + if (content.includes("")) { + state.inThinking = true; + content = content.replace("", ""); + startReasoning(controller, idx); + } + + if (content.includes("")) { + const parts = content.split(""); + const thinkPart = parts[0]; + const textPart = parts.slice(1).join(""); + + if (thinkPart) emitReasoningDelta(controller, thinkPart); + closeReasoning(controller); + state.inThinking = false; + content = textPart; + } + + if (state.inThinking && content) { + emitReasoningDelta(controller, content); + continue; + } + + // Regular text content + if (content) { + if (!state.msgItemAdded[idx]) { + state.msgItemAdded[idx] = true; + const msgId = `msg_${state.responseId}_${idx}`; + + emit(controller, "response.output_item.added", { + type: "response.output_item.added", + output_index: idx, + item: { id: msgId, type: "message", content: [], role: "assistant" } + }); + } + + if (!state.msgContentAdded[idx]) { + state.msgContentAdded[idx] = true; + + emit(controller, "response.content_part.added", { + type: "response.content_part.added", + item_id: `msg_${state.responseId}_${idx}`, + output_index: idx, + content_index: 0, + part: { type: "output_text", annotations: [], logprobs: [], text: "" } + }); + } + + emit(controller, "response.output_text.delta", { + type: "response.output_text.delta", + item_id: `msg_${state.responseId}_${idx}`, + output_index: idx, + content_index: 0, + delta: content, + logprobs: [] + }); + + if (!state.msgTextBuf[idx]) state.msgTextBuf[idx] = ""; + state.msgTextBuf[idx] += content; + } + } + + // Handle tool_calls + if (delta.tool_calls) { + closeMessage(controller, idx); + + for (const tc of delta.tool_calls) { + const tcIdx = tc.index ?? 0; + const newCallId = tc.id; + const funcName = tc.function?.name; + + if (funcName) state.funcNames[tcIdx] = funcName; + + if (!state.funcCallIds[tcIdx] && newCallId) { + state.funcCallIds[tcIdx] = newCallId; + + emit(controller, "response.output_item.added", { + type: "response.output_item.added", + output_index: tcIdx, + item: { + id: `fc_${newCallId}`, + type: "function_call", + arguments: "", + call_id: newCallId, + name: state.funcNames[tcIdx] || "" + } + }); + } + + if (!state.funcArgsBuf[tcIdx]) state.funcArgsBuf[tcIdx] = ""; + + if (tc.function?.arguments) { + const refCallId = state.funcCallIds[tcIdx] || newCallId; + if (refCallId) { + emit(controller, "response.function_call_arguments.delta", { + type: "response.function_call_arguments.delta", + item_id: `fc_${refCallId}`, + output_index: tcIdx, + delta: tc.function.arguments + }); + } + state.funcArgsBuf[tcIdx] += tc.function.arguments; + } + } + } + + // Handle finish_reason + if (choice.finish_reason) { + for (const i in state.msgItemAdded) closeMessage(controller, i); + closeReasoning(controller); + for (const i in state.funcCallIds) closeToolCall(controller, i); + sendCompleted(controller); + } + } + }, + + flush(controller) { + for (const i in state.msgItemAdded) closeMessage(controller, i); + closeReasoning(controller); + for (const i in state.funcCallIds) closeToolCall(controller, i); + sendCompleted(controller); + + logger?.logOutput("data: [DONE]"); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + logger?.flush(); + } + }); +} + diff --git a/open-sse/transformer/streamToJsonConverter.js b/open-sse/transformer/streamToJsonConverter.js new file mode 100644 index 0000000000000000000000000000000000000000..c08acb2553b18b57fc5f7f5c1d1c1c444c0ef96d --- /dev/null +++ b/open-sse/transformer/streamToJsonConverter.js @@ -0,0 +1,103 @@ +/** + * Stream-to-JSON Converter + * Converts Responses API SSE stream to single JSON response + * Used when client requests non-streaming but provider forces streaming (e.g., Codex) + */ + +/** + * Process a single SSE message and update state accordingly. + */ +function processSSEMessage(msg, state) { + if (!msg.trim()) return; + + const eventMatch = msg.match(/^event:\s*(.+)$/m); + const dataMatch = msg.match(/^data:\s*(.+)$/m); + if (!eventMatch || !dataMatch) return; + + const eventType = eventMatch[1].trim(); + const dataStr = dataMatch[1].trim(); + if (dataStr === "[DONE]") return; + + let parsed; + try { parsed = JSON.parse(dataStr); } + catch { return; } + + if (eventType === "response.created") { + state.responseId = parsed.response?.id || state.responseId; + state.created = parsed.response?.created_at || state.created; + } else if (eventType === "response.output_item.done") { + state.items.set(parsed.output_index ?? 0, parsed.item); + } else if (eventType === "response.completed" || eventType === "response.done") { + state.status = "completed"; + if (parsed.response?.usage) { + state.usage.input_tokens = parsed.response.usage.input_tokens || 0; + state.usage.output_tokens = parsed.response.usage.output_tokens || 0; + state.usage.total_tokens = parsed.response.usage.total_tokens || 0; + } + } else if (eventType === "response.failed") { + state.status = "failed"; + } +} + +const EMPTY_RESPONSE = { input_tokens: 0, output_tokens: 0, total_tokens: 0 }; + +/** + * Convert Responses API SSE stream to single JSON response + * @param {ReadableStream} stream - SSE stream from provider + * @returns {Promise} Final JSON response in Responses API format + */ +export async function convertResponsesStreamToJson(stream) { + if (!stream || typeof stream.getReader !== "function") { + return { id: `resp_${Date.now()}`, object: "response", created_at: Math.floor(Date.now() / 1000), status: "failed", output: [], usage: { ...EMPTY_RESPONSE } }; + } + + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + const state = { + responseId: "", + created: Math.floor(Date.now() / 1000), + status: "in_progress", + usage: { ...EMPTY_RESPONSE }, + items: new Map() + }; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const messages = buffer.split("\n\n"); + buffer = messages.pop() || ""; + + for (const msg of messages) { + processSSEMessage(msg, state); + } + } + + // Flush remaining buffer (last event may not end with \n\n) + if (buffer.trim()) { + processSSEMessage(buffer, state); + } + } finally { + reader.releaseLock(); + } + + // Build output array from accumulated items (ordered by index) + const output = []; + const maxIndex = state.items.size > 0 ? Math.max(...state.items.keys()) : -1; + for (let i = 0; i <= maxIndex; i++) { + output.push(state.items.get(i) || { type: "message", content: [], role: "assistant" }); + } + + return { + id: state.responseId || `resp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + object: "response", + created_at: state.created, + status: state.status || "completed", + output, + usage: state.usage + }; +} diff --git a/open-sse/translator/concerns/chunk.js b/open-sse/translator/concerns/chunk.js new file mode 100644 index 0000000000000000000000000000000000000000..35d975313abfdf940d32a1bb11f8f9ad52684ba9 --- /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 0000000000000000000000000000000000000000..684a7001ca6833c478874691f2a50bbe2c43eaa4 --- /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 0000000000000000000000000000000000000000..73864b5b9a3b58e923d5101451758e8e355c005c --- /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 0000000000000000000000000000000000000000..36c1b71b28e28ecac66797d7229902db9fbc2ad8 --- /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 0000000000000000000000000000000000000000..169aeaf043557d3b34e121ba5e23d4bc4c389278 --- /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 0000000000000000000000000000000000000000..b3bba2de3484cb56230c1db5bf2eb7b63c042611 --- /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 0000000000000000000000000000000000000000..3b8fc9a9e0adf57822206ce9ea8aa10e3ef8e93c --- /dev/null +++ b/open-sse/translator/concerns/paramSupport.js @@ -0,0 +1,31 @@ +// 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"] }, +]; + +// Test a rule's match (regex or predicate) against the model id. +function matches(rule, model) { + 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]; + } + } + return body; +} diff --git a/open-sse/translator/concerns/prefetch.js b/open-sse/translator/concerns/prefetch.js new file mode 100644 index 0000000000000000000000000000000000000000..5055be3c20cdf10011e1c3915fcb7371514c0ef0 --- /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 0000000000000000000000000000000000000000..f4855eb78e85231b15509e5cd008b49a8b1905e5 --- /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 0000000000000000000000000000000000000000..3db892c462331cab0a3676fdd4838628263dd313 --- /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 0000000000000000000000000000000000000000..0ed27608d035ef35628f76fb5f10b04c8f77156f --- /dev/null +++ b/open-sse/translator/concerns/thinkingUnified.js @@ -0,0 +1,266 @@ +// 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 } 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; +} + +// 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 === "xhigh" || level === "max" ? "high" : 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" : (toLevel(eff) || "high"); + 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/concerns/toolCall.js b/open-sse/translator/concerns/toolCall.js new file mode 100644 index 0000000000000000000000000000000000000000..8de82f715376059f63f0935d68a4996123b4fc20 --- /dev/null +++ b/open-sse/translator/concerns/toolCall.js @@ -0,0 +1,153 @@ +// Tool call helper functions for translator + +// 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, "")}` : ""; + return `call_msg${msgIndex}_tc${tcIndex}${name}`; +} + +// Sanitize ID to match Anthropic pattern: keep only alphanumeric, underscore, hyphen +function sanitizeToolId(id) { + if (!id || typeof id !== "string") return null; + const sanitized = id.replace(/[^a-zA-Z0-9_-]/g, ""); + return sanitized.length > 0 ? sanitized : null; +} + +// Ensure all tool_calls have valid id field and arguments is string (some providers require it) +export function ensureToolCallIds(body) { + if (!body.messages || !Array.isArray(body.messages)) return body; + + for (let i = 0; i < body.messages.length; i++) { + const msg = body.messages[i]; + if (msg.role === "assistant" && msg.tool_calls && Array.isArray(msg.tool_calls)) { + for (let j = 0; j < msg.tool_calls.length; j++) { + const tc = msg.tool_calls[j]; + // Validate or regenerate ID for Anthropic compatibility + if (!tc.id || !TOOL_ID_PATTERN.test(tc.id)) { + const sanitized = sanitizeToolId(tc.id); + tc.id = sanitized || generateToolCallId(i, j, tc.function?.name); + } + if (!tc.type) { + tc.type = "function"; + } + // Ensure arguments is JSON string, not object + if (tc.function?.arguments && typeof tc.function.arguments !== "string") { + tc.function.arguments = JSON.stringify(tc.function.arguments); + } + } + } + + // Validate tool_call_id in tool messages (role: "tool") + if (msg.role === "tool" && msg.tool_call_id && !TOOL_ID_PATTERN.test(msg.tool_call_id)) { + const sanitized = sanitizeToolId(msg.tool_call_id); + msg.tool_call_id = sanitized || generateToolCallId(i, 0); + } + + // Also validate tool_use blocks in content (Claude format) + if (Array.isArray(msg.content)) { + for (let k = 0; k < msg.content.length; k++) { + const block = msg.content[k]; + if (block.type === "tool_use" && block.id && !TOOL_ID_PATTERN.test(block.id)) { + const sanitized = sanitizeToolId(block.id); + block.id = sanitized || generateToolCallId(i, k, block.name); + } + // Validate tool_use_id in tool_result blocks + if (block.type === "tool_result" && block.tool_use_id && !TOOL_ID_PATTERN.test(block.tool_use_id)) { + const sanitized = sanitizeToolId(block.tool_use_id); + block.tool_use_id = sanitized || generateToolCallId(i, k); + } + } + } + } + + return body; +} + +// Get tool_call ids from assistant message (OpenAI format: tool_calls, Claude format: tool_use in content) +export function getToolCallIds(msg) { + if (msg.role !== "assistant") return []; + + const ids = []; + + // OpenAI format: tool_calls array + if (msg.tool_calls && Array.isArray(msg.tool_calls)) { + for (const tc of msg.tool_calls) { + if (tc.id) ids.push(tc.id); + } + } + + // Claude format: tool_use blocks in content + if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_use" && block.id) { + ids.push(block.id); + } + } + } + + return ids; +} + +// Check if user message has tool_result for given ids (OpenAI format: role=tool, Claude format: tool_result in content) +export function hasToolResults(msg, toolCallIds) { + if (!msg || !toolCallIds.length) return false; + + // OpenAI format: role = "tool" with tool_call_id + if (msg.role === "tool" && msg.tool_call_id) { + return toolCallIds.includes(msg.tool_call_id); + } + + // Claude format: tool_result blocks in user message content + if (msg.role === "user" && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "tool_result" && toolCallIds.includes(block.tool_use_id)) { + return true; + } + } + } + + return false; +} + +// Fix missing tool responses - insert empty tool_result if assistant has tool_use but next message has no tool_result +export function fixMissingToolResponses(body) { + if (!body.messages || !Array.isArray(body.messages)) return body; + + const newMessages = []; + + for (let i = 0; i < body.messages.length; i++) { + const msg = body.messages[i]; + const nextMsg = body.messages[i + 1]; + + newMessages.push(msg); + + // Check if this is assistant with tool_calls/tool_use + const toolCallIds = getToolCallIds(msg); + if (toolCallIds.length === 0) continue; + + // Check if next message has tool_result + if (nextMsg && !hasToolResults(nextMsg, toolCallIds)) { + // Insert tool responses for each tool_call + for (const id of toolCallIds) { + // OpenAI format: role = "tool" + newMessages.push({ + role: "tool", + tool_call_id: id, + content: "" + }); + } + } + } + + body.messages = newMessages; + return body; +} + diff --git a/open-sse/translator/concerns/usage.js b/open-sse/translator/concerns/usage.js new file mode 100644 index 0000000000000000000000000000000000000000..44622901980c2a0fe33a92e21105f343f9ded457 --- /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/formats.js b/open-sse/translator/formats.js new file mode 100644 index 0000000000000000000000000000000000000000..89367d0071ab7574e93307dfabb272fec226ab6a --- /dev/null +++ b/open-sse/translator/formats.js @@ -0,0 +1,36 @@ +// Format identifiers +export const FORMATS = { + OPENAI: "openai", + OPENAI_RESPONSES: "openai-responses", + OPENAI_RESPONSE: "openai-response", + CLAUDE: "claude", + GEMINI: "gemini", + GEMINI_CLI: "gemini-cli", + VERTEX: "vertex", + CODEX: "codex", + ANTIGRAVITY: "antigravity", + KIRO: "kiro", + CURSOR: "cursor", + OLLAMA: "ollama", + COMMANDCODE: "commandcode" +}; + +/** + * Detect source format from request URL pathname + body. + * Returns null to fall back to body-based detection. + */ +export function detectFormatByEndpoint(pathname, body) { + // /v1/responses is always openai-responses + if (pathname.includes("/v1/responses")) return FORMATS.OPENAI_RESPONSES; + + // /v1/messages is always Claude + if (pathname.includes("/v1/messages")) return FORMATS.CLAUDE; + + // /v1/chat/completions + input[] → treat as openai (Cursor CLI sends Responses body via chat endpoint) + if (pathname.includes("/v1/chat/completions") && Array.isArray(body?.input)) { + return FORMATS.OPENAI; + } + + return null; +} + diff --git a/open-sse/translator/formats/claude.js b/open-sse/translator/formats/claude.js new file mode 100644 index 0000000000000000000000000000000000000000..1dac5c331b60799123dd38fad7a366c73e8f2b82 --- /dev/null +++ b/open-sse/translator/formats/claude.js @@ -0,0 +1,269 @@ +// Claude helper functions for translator +import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.js"; +import { ROLE, CLAUDE_BLOCK } from "../schema/index.js"; +import { adjustMaxTokens } from "./maxTokens.js"; +import { applyCloaking } from "../../utils/claudeCloaking.js"; +import { resolveSessionId } from "../../utils/sessionManager.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 === CLAUDE_BLOCK.TEXT && block.text?.trim()) || + block.type === CLAUDE_BLOCK.TOOL_USE || + block.type === CLAUDE_BLOCK.TOOL_RESULT + ); + } + return false; +} + +// Fix tool_use/tool_result ordering for Claude API +// 1. Assistant message with tool_use: remove text AFTER tool_use (Claude doesn't allow) +// 2. Merge consecutive same-role messages +export function fixToolUseOrdering(messages) { + if (messages.length <= 1) return messages; + + // Pass 1: Fix assistant messages with tool_use - remove text after tool_use + for (const msg of messages) { + 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 === CLAUDE_BLOCK.TOOL_USE) { + foundToolUse = true; + newContent.push(block); + } 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 + newContent.push(block); + } + // Skip text blocks AFTER tool_use + } + + msg.content = newContent; + } + } + } + + // Pass 2: Merge consecutive same-role messages + const merged = []; + + for (const msg of messages) { + const last = merged[merged.length - 1]; + + if (last && last.role === msg.role) { + // Merge content arrays + 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 === 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: CLAUDE_BLOCK.TEXT, text: msg.content }]; + merged.push({ role: msg.role, content: [...content] }); + } + } + + return merged; +} + +// Models that reject thinking.type "adaptive" (only Sonnet/Opus support it) +const ADAPTIVE_THINKING_UNSUPPORTED = /haiku/i; + +// 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 +export function normalizeClaudePassthrough(body, model = "") { + if (!body || typeof body !== "object") return body; + + // 1. Downgrade adaptive thinking for models that don't support it + if (body.thinking?.type === "adaptive" && ADAPTIVE_THINKING_UNSUPPORTED.test(model)) { + body.thinking = { type: "enabled", budget_tokens: 10000 }; + } + + // 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 === 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: CLAUDE_BLOCK.TEXT, text }); + continue; + } + messages.push(msg); + } + + if (systemBlocks.length > 0) { + const existing = Array.isArray(body.system) + ? body.system + : typeof body.system === "string" && body.system.trim() + ? [{ type: "text", text: body.system }] + : []; + body.system = [...existing, ...systemBlocks]; + body.messages = messages; + } + } + + return body; +} + +// 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, 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) => { + const { cache_control, ...rest } = block; + if (i === body.system.length - 1) { + return { ...rest, cache_control: { type: "ephemeral", ttl: "1h" } }; + } + return rest; + }); + } + + // 2. Messages: process in optimized passes + if (body.messages && Array.isArray(body.messages)) { + const len = body.messages.length; + let filtered = []; + + // Pass 1: remove cache_control + filter empty messages + for (let i = 0; i < len; i++) { + const msg = body.messages[i]; + + // Remove cache_control from content blocks + if (Array.isArray(msg.content)) { + for (const block of msg.content) { + delete block.cache_control; + } + } + + // Keep final assistant even if empty, otherwise check valid content + const isFinalAssistant = i === len - 1 && msg.role === "assistant"; + if (isFinalAssistant || hasValidContent(msg)) { + filtered.push(msg); + } + } + + // Pass 1.5: Fix tool_use/tool_result ordering + // Each tool_use must have tool_result in the NEXT message (not same message with other content) + filtered = fixToolUseOrdering(filtered); + + body.messages = filtered; + + // Check if thinking is enabled AND last message is from user + const lastMessage = filtered[filtered.length - 1]; + const lastMessageIsUser = lastMessage?.role === "user"; + const thinkingEnabled = body.thinking?.type === "enabled" && lastMessageIsUser; + + // Pass 2 (reverse): add cache_control to last assistant + handle thinking for Anthropic + let lastAssistantProcessed = false; + for (let i = filtered.length - 1; i >= 0; i--) { + const msg = filtered[i]; + + if (msg.role === "assistant" && Array.isArray(msg.content)) { + // Add cache_control to last non-thinking block of first (from end) assistant with content + // thinking/redacted_thinking blocks do not support cache_control + if (!lastAssistantProcessed && msg.content.length > 0) { + for (let j = msg.content.length - 1; j >= 0; j--) { + const block = msg.content[j]; + if (block.type !== CLAUDE_BLOCK.THINKING && block.type !== CLAUDE_BLOCK.REDACTED_THINKING) { + block.cache_control = { type: "ephemeral" }; + break; + } + } + lastAssistantProcessed = true; + } + + // Handle thinking blocks for Anthropic endpoint only + if (provider === "claude" || provider?.startsWith("anthropic-compatible")) { + let hasToolUse = false; + let hasThinking = false; + + // Always replace signature for all thinking blocks + for (const block of msg.content) { + if (block.type === CLAUDE_BLOCK.THINKING || block.type === CLAUDE_BLOCK.REDACTED_THINKING) { + block.signature = DEFAULT_THINKING_CLAUDE_SIGNATURE; + hasThinking = true; + } + if (block.type === CLAUDE_BLOCK.TOOL_USE) hasToolUse = true; + } + + // Add thinking block if thinking enabled + has tool_use but no thinking + if (thinkingEnabled && !hasThinking && hasToolUse) { + msg.content.unshift({ + type: CLAUDE_BLOCK.THINKING, + thinking: ".", + signature: DEFAULT_THINKING_CLAUDE_SIGNATURE + }); + } + } + } + } + } + + // 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 + if (provider !== "claude") { + body.tools = body.tools.filter(tool => !tool.type || tool.type === "function"); + } + + body.tools = body.tools.map((tool, i) => { + const { cache_control, ...rest } = tool; + if (i === body.tools.length - 1) { + return { ...rest, cache_control: { type: "ephemeral", ttl: "1h" } }; + } + return rest; + }); + + // Remove tools array and tool_choice if empty after filtering + if (body.tools.length === 0) { + delete body.tools; + delete body.tool_choice; + } + } + + // 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 sid = sessionId || resolveSessionId({ headers: rawHeaders, body, connectionId, scope: "claude" }); + body = applyCloaking(body, apiKey, sid); + } + + return body; +} + diff --git a/open-sse/translator/formats/gemini.js b/open-sse/translator/formats/gemini.js new file mode 100644 index 0000000000000000000000000000000000000000..73b7561c0713d1b77e46cbb223f2ea3b6030bca6 --- /dev/null +++ b/open-sse/translator/formats/gemini.js @@ -0,0 +1,378 @@ +// 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", + // Claude rejects these in VALIDATED mode + "default", "examples", + // JSON Schema meta keywords + "$schema", "$defs", "definitions", "const", "$ref", "$comment", + // Object validation keywords (not supported) + "additionalProperties", "propertyNames", "patternProperties", "enumDescriptions", + // Complex schema keywords (handled by flattenAnyOfOneOf/mergeAllOf) + "anyOf", "oneOf", "allOf", "not", + // Dependency keywords (not supported) + "dependencies", "dependentSchemas", "dependentRequired", + // Other unsupported keywords + "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" +]; + +// Default safety settings +export const DEFAULT_SAFETY_SETTINGS = [ + { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "OFF" }, + { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "OFF" }, + { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "OFF" }, + { category: "HARM_CATEGORY_HARASSMENT", threshold: "OFF" }, + { category: "HARM_CATEGORY_CIVIC_INTEGRITY", threshold: "OFF" } +]; + +// Convert OpenAI content to Gemini parts +export function convertOpenAIContentToParts(content) { + const parts = []; + + if (typeof content === "string") { + parts.push({ text: content }); + } else if (Array.isArray(content)) { + for (const item of content) { + if (item.type === OPENAI_BLOCK.TEXT) { + parts.push({ text: item.text }); + } 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) { + const mimePart = url.substring(5, commaIndex); // skip "data:" + const data = url.substring(commaIndex + 1); + const mimeType = mimePart.split(";")[0]; + + parts.push({ + inlineData: { mime_type: mimeType, data: data } + }); + } + } 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 === 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 === OPENAI_BLOCK.AUDIO_URL && item.audio_url?.url?.startsWith("data:")) { + const url = item.audio_url.url; + const commaIndex = url.indexOf(","); + if (commaIndex !== -1) { + const mimePart = url.substring(5, commaIndex); + const data = url.substring(commaIndex + 1); + const mimeType = mimePart.split(";")[0]; + parts.push({ + 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 } }); + } + } + } + } + + return parts; +} + +// Extract text content from OpenAI content +export function extractTextContent(content, separator = "") { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content.filter(c => c.type === OPENAI_BLOCK.TEXT).map(c => c.text).join(separator); + } + return ""; +} + +// Try parse JSON safely (null fallback on parse error; re-export keeps legacy API) +export function tryParseJSON(str) { + return safeParseJSON(str, null); +} + +// Generate request ID +export function generateRequestId() { + return `agent-${crypto.randomUUID()}`; +} + +// Generate session ID (binary-compatible format: UUID + timestamp) +export function generateSessionId() { + return crypto.randomUUID() + Date.now().toString(); +} + +// Generate project ID +export function generateProjectId() { + const adjectives = ["useful", "bright", "swift", "calm", "bold"]; + const nouns = ["fuze", "wave", "spark", "flow", "core"]; + const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; + const noun = nouns[Math.floor(Math.random() * nouns.length)]; + return `${adj}-${noun}-${crypto.randomUUID().slice(0, 5)}`; +} + +// Helper: Remove unsupported keywords recursively from object/array +// Also strips all vendor extension fields (x- prefixed) not supported by Gemini +function removeUnsupportedKeywords(obj, keywords) { + if (!obj || typeof obj !== "object") return; + + if (Array.isArray(obj)) { + for (const item of obj) { + removeUnsupportedKeywords(item, keywords); + } + return; + } + + for (const key of Object.keys(obj)) { + if (keywords.includes(key) || key.startsWith("x-")) { + delete obj[key]; + continue; + } + + const value = obj[key]; + if (value && typeof value === "object") { + removeUnsupportedKeywords(value, keywords); + } + } +} + +// Convert const to enum +function convertConstToEnum(obj) { + if (!obj || typeof obj !== "object") return; + + if (obj.const !== undefined && !obj.enum) { + obj.enum = [obj.const]; + delete obj.const; + } + + for (const value of Object.values(obj)) { + if (value && typeof value === "object") { + convertConstToEnum(value); + } + } +} + +// Convert enum values to strings (Gemini requires string enum values + explicit type:"string") +function convertEnumValuesToStrings(obj) { + if (!obj || typeof obj !== "object") return; + + if (obj.enum && Array.isArray(obj.enum)) { + obj.enum = obj.enum.map(v => String(v)); + // Gemini API requires type:"string" when enum is present — without it returns 400 + if (!obj.type) { + obj.type = "string"; + } + } + + for (const value of Object.values(obj)) { + if (value && typeof value === "object") { + convertEnumValuesToStrings(value); + } + } +} + +// Merge allOf schemas +function mergeAllOf(obj) { + if (!obj || typeof obj !== "object") return; + + if (obj.allOf && Array.isArray(obj.allOf)) { + const merged = {}; + + for (const item of obj.allOf) { + if (item.properties) { + if (!merged.properties) merged.properties = {}; + Object.assign(merged.properties, item.properties); + } + if (item.required && Array.isArray(item.required)) { + if (!merged.required) merged.required = []; + for (const req of item.required) { + if (!merged.required.includes(req)) { + merged.required.push(req); + } + } + } + } + + delete obj.allOf; + if (merged.properties) obj.properties = { ...obj.properties, ...merged.properties }; + if (merged.required) obj.required = [...(obj.required || []), ...merged.required]; + } + + for (const value of Object.values(obj)) { + if (value && typeof value === "object") { + mergeAllOf(value); + } + } +} + +// Select best schema from anyOf/oneOf +function selectBest(items) { + let bestIdx = 0; + let bestScore = -1; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + let score = 0; + const type = item.type; + + if (type === "object" || item.properties) { + score = 3; + } else if (type === "array" || item.items) { + score = 2; + } else if (type && type !== "null") { + score = 1; + } + + if (score > bestScore) { + bestScore = score; + bestIdx = i; + } + } + + return bestIdx; +} + +// Flatten anyOf/oneOf +function flattenAnyOfOneOf(obj) { + if (!obj || typeof obj !== "object") return; + + if (obj.anyOf && Array.isArray(obj.anyOf) && obj.anyOf.length > 0) { + const nonNullSchemas = obj.anyOf.filter(s => s && s.type !== "null"); + if (nonNullSchemas.length > 0) { + const bestIdx = selectBest(nonNullSchemas); + const selected = nonNullSchemas[bestIdx]; + delete obj.anyOf; + Object.assign(obj, selected); + } + } + + if (obj.oneOf && Array.isArray(obj.oneOf) && obj.oneOf.length > 0) { + const nonNullSchemas = obj.oneOf.filter(s => s && s.type !== "null"); + if (nonNullSchemas.length > 0) { + const bestIdx = selectBest(nonNullSchemas); + const selected = nonNullSchemas[bestIdx]; + delete obj.oneOf; + Object.assign(obj, selected); + } + } + + for (const value of Object.values(obj)) { + if (value && typeof value === "object") { + flattenAnyOfOneOf(value); + } + } +} + +// Flatten type arrays +function flattenTypeArrays(obj) { + if (!obj || typeof obj !== "object") return; + + if (obj.type && Array.isArray(obj.type)) { + const nonNullTypes = obj.type.filter(t => t !== "null"); + obj.type = nonNullTypes.length > 0 ? nonNullTypes[0] : "string"; + } + + for (const value of Object.values(obj)) { + if (value && typeof value === "object") { + flattenTypeArrays(value); + } + } +} + +// Infer missing type=object when properties exist (Gemini requires explicit type) +function ensureObjectType(obj) { + if (!obj || typeof obj !== "object") return; + if (obj.properties && !obj.type) obj.type = "object"; + for (const v of Object.values(obj)) if (v && typeof v === "object") ensureObjectType(v); +} + +// Clean JSON Schema for Antigravity API compatibility - removes unsupported keywords recursively +export function cleanJSONSchemaForAntigravity(schema) { + if (!schema || typeof schema !== "object") return schema; + + // Mutate directly (schema is only used once per request) + let cleaned = schema; + + // Phase 1: Convert and prepare + convertConstToEnum(cleaned); + convertEnumValuesToStrings(cleaned); + + // Phase 2: Flatten complex structures + mergeAllOf(cleaned); + flattenAnyOfOneOf(cleaned); + flattenTypeArrays(cleaned); + + // Phase 2.5: Infer missing type=object when properties exist (Gemini requirement) + ensureObjectType(cleaned); + + // Phase 3: Remove all unsupported keywords at ALL levels (including inside arrays) + removeUnsupportedKeywords(cleaned, UNSUPPORTED_SCHEMA_CONSTRAINTS); + + // Phase 4: Cleanup required fields recursively + function cleanupRequired(obj) { + if (!obj || typeof obj !== "object") return; + + if (obj.required && Array.isArray(obj.required) && obj.properties) { + const validRequired = obj.required.filter(field => + Object.prototype.hasOwnProperty.call(obj.properties, field) + ); + if (validRequired.length === 0) { + delete obj.required; + } else { + obj.required = validRequired; + } + } + + // Recurse into nested objects + for (const value of Object.values(obj)) { + if (value && typeof value === "object") { + cleanupRequired(value); + } + } + } + + cleanupRequired(cleaned); + + // Phase 5: Add placeholder for empty object schemas (Antigravity requirement) + function addPlaceholders(obj) { + if (!obj || typeof obj !== "object") return; + + if (obj.type === "object") { + if (!obj.properties || Object.keys(obj.properties).length === 0) { + obj.properties = { + reason: { + type: "string", + description: "Brief explanation of why you are calling this tool" + } + }; + obj.required = ["reason"]; + } + } + + // Recurse into nested objects + for (const value of Object.values(obj)) { + if (value && typeof value === "object") { + addPlaceholders(value); + } + } + } + + addPlaceholders(cleaned); + + return cleaned; +} + diff --git a/open-sse/translator/formats/maxTokens.js b/open-sse/translator/formats/maxTokens.js new file mode 100644 index 0000000000000000000000000000000000000000..0e5b36f26ca12df2714e724bb41bfa6dd0785a21 --- /dev/null +++ b/open-sse/translator/formats/maxTokens.js @@ -0,0 +1,30 @@ +import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../config/runtimeConfig.js"; + +/** + * Adjust max_tokens based on request context + * @param {object} body - Request body + * @returns {number} Adjusted max_tokens + */ +export function adjustMaxTokens(body) { + let maxTokens = body.max_tokens || DEFAULT_MAX_TOKENS; + + // 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; + } + } + + // Ensure max_tokens > thinking.budget_tokens (Claude API requirement) + // Claude API requires strictly greater, so add buffer instead of using DEFAULT_MAX_TOKENS + // which could equal budget_tokens when budget_tokens >= 64000 + if (body.thinking?.budget_tokens && maxTokens <= body.thinking.budget_tokens) { + 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/formats/openai.js b/open-sse/translator/formats/openai.js new file mode 100644 index 0000000000000000000000000000000000000000..d6c850c4628235e24fdc3028192fe01cce8ddb3d --- /dev/null +++ b/open-sse/translator/formats/openai.js @@ -0,0 +1,130 @@ +// OpenAI helper functions for translator +import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK, VALID_OPENAI_CONTENT_TYPES, VALID_OPENAI_MESSAGE_TYPES } from "../schema/index.js"; + +// 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 +export function filterToOpenAIFormat(body) { + if (!body.messages || !Array.isArray(body.messages)) return body; + + body.messages = body.messages.map(msg => { + // Normalize developer role to system (many providers don't support developer) + if (msg.role === ROLE.DEVELOPER) msg = { ...msg, role: ROLE.SYSTEM }; + + // Keep tool messages as-is (OpenAI format) + if (msg.role === ROLE.TOOL) return msg; + + // Keep assistant messages with tool_calls as-is + if (msg.role === ROLE.ASSISTANT && msg.tool_calls) return msg; + + // Handle string content + if (typeof msg.content === "string") return msg; + + // Handle array content + if (Array.isArray(msg.content)) { + const filteredContent = []; + + for (const block of msg.content) { + // Skip thinking blocks + 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 === CLAUDE_BLOCK.TOOL_USE) { + // Convert tool_use to tool_calls format (handled separately) + continue; + } else if (block.type === CLAUDE_BLOCK.TOOL_RESULT) { + // Keep tool_result but clean it + const { signature, cache_control, ...cleanBlock } = block; + filteredContent.push(cleanBlock); + } + } + + // If all content was filtered, add empty text + if (filteredContent.length === 0) { + filteredContent.push({ type: OPENAI_BLOCK.TEXT, text: "" }); + } + + return { ...msg, content: filteredContent }; + } + + return msg; + }); + + // 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 === ROLE.TOOL) return true; + // Always keep assistant messages with tool_calls + 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 === OPENAI_BLOCK.TEXT && b.text?.trim()) || + b.type !== OPENAI_BLOCK.TEXT + ); + } + return true; + }); + + // Remove empty tools array (some providers like QWEN reject it) + if (body.tools && Array.isArray(body.tools) && body.tools.length === 0) { + delete body.tools; + } + + // Normalize tools to OpenAI format (from Claude, Gemini, etc.) + if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) { + body.tools = body.tools.map(tool => { + // Already OpenAI format + 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: OPENAI_BLOCK.FUNCTION, + function: { + name: tool.name, + description: String(tool.description || ""), + parameters: tool.input_schema || { type: "object", properties: {} } + } + }; + } + + // Gemini format: {functionDeclarations: [{name, description, parameters}]} + if (tool.functionDeclarations && Array.isArray(tool.functionDeclarations)) { + return tool.functionDeclarations.map(fn => ({ + type: OPENAI_BLOCK.FUNCTION, + function: { + name: fn.name, + description: String(fn.description || ""), + parameters: fn.parameters || { type: "object", properties: {} } + } + })); + } + + return tool; + }).flat(); + } + + // Normalize tool_choice to OpenAI format + if (body.tool_choice && typeof body.tool_choice === "object") { + const choice = body.tool_choice; + // Claude format: {type: "auto|any|tool", name?: "..."} + if (choice.type === "auto") { + body.tool_choice = "auto"; + } else if (choice.type === "any") { + body.tool_choice = "required"; + } else if (choice.type === "tool" && choice.name) { + body.tool_choice = { type: OPENAI_BLOCK.FUNCTION, function: { name: choice.name } }; + } + } + + return body; +} + diff --git a/open-sse/translator/formats/responsesApi.js b/open-sse/translator/formats/responsesApi.js new file mode 100644 index 0000000000000000000000000000000000000000..c41ee470dbda45892f4f64ab097563a428e86998 --- /dev/null +++ b/open-sse/translator/formats/responsesApi.js @@ -0,0 +1,141 @@ +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. + * An empty array is treated like an empty string — providers require at least one user + * message, so we inject a placeholder rather than forwarding an empty messages[]. + * @param {string|Array} input - raw input from Responses API body + * @returns {Array|null} normalized array or null if invalid + */ +export function normalizeResponsesInput(input) { + if (typeof input === "string") { + const text = input.trim() === "" ? "..." : input; + 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: RESPONSES_ITEM.MESSAGE, role: ROLE.USER, content: [{ type: RESPONSES_ITEM.INPUT_TEXT, text: "..." }] }]; + } + return input; + } + return null; +} + +/** + * Convert OpenAI Responses API format to standard chat completions format + * Responses API uses: { input: [...], instructions: "..." } + * Chat API uses: { messages: [...] } + */ +export function convertResponsesApiFormat(body) { + if (!body.input) return body; + + const result = { ...body }; + result.messages = []; + + // Convert instructions to system message + if (body.instructions) { + result.messages.push({ role: ROLE.SYSTEM, content: body.instructions }); + } + + // Group items by conversation turn + let currentAssistantMsg = null; + let pendingToolCalls = []; + let pendingToolResults = []; + + const inputItems = normalizeResponsesInput(body.input); + if (!inputItems) return 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 ? RESPONSES_ITEM.MESSAGE : null); + + if (itemType === RESPONSES_ITEM.MESSAGE) { + // Flush any pending assistant message with tool calls + if (currentAssistantMsg) { + result.messages.push(currentAssistantMsg); + currentAssistantMsg = null; + } + // Flush pending tool results + if (pendingToolResults.length > 0) { + for (const tr of pendingToolResults) { + result.messages.push(tr); + } + pendingToolResults = []; + } + + // 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 === 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: 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 === RESPONSES_ITEM.FUNCTION_CALL) { + // Start or append to assistant message with tool_calls + if (!currentAssistantMsg) { + currentAssistantMsg = { + role: ROLE.ASSISTANT, + content: null, + tool_calls: [] + }; + } + // Skip items with empty/missing name — upstream APIs reject nameless tool calls (#444) + if (!item.name || typeof item.name !== "string" || item.name.trim() === "") continue; + currentAssistantMsg.tool_calls.push({ + id: item.call_id, + type: OPENAI_BLOCK.FUNCTION, + function: { + name: item.name, + arguments: item.arguments + } + }); + } + else if (itemType === RESPONSES_ITEM.FUNCTION_CALL_OUTPUT) { + // Flush assistant message first if exists + if (currentAssistantMsg) { + result.messages.push(currentAssistantMsg); + currentAssistantMsg = null; + } + // Add tool result + pendingToolResults.push({ + role: ROLE.TOOL, + tool_call_id: item.call_id, + content: typeof item.output === "string" ? item.output : JSON.stringify(item.output) + }); + } + else if (itemType === RESPONSES_ITEM.REASONING) { + // Skip reasoning items - they are for display only + continue; + } + } + + // Flush remaining + if (currentAssistantMsg) { + result.messages.push(currentAssistantMsg); + } + if (pendingToolResults.length > 0) { + for (const tr of pendingToolResults) { + result.messages.push(tr); + } + } + + // Cleanup Responses API specific fields + delete result.input; + delete result.instructions; + delete result.include; + delete result.prompt_cache_key; + delete result.store; + delete result.reasoning; + + return result; +} diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js new file mode 100644 index 0000000000000000000000000000000000000000..8c25d486d6fc531b4967954d9058ea9a75e8dbae --- /dev/null +++ b/open-sse/translator/index.js @@ -0,0 +1,282 @@ +import { FORMATS } from "./formats.js"; +import { ensureToolCallIds, fixMissingToolResponses } from "./concerns/toolCall.js"; +import { prepareClaudeRequest } from "./formats/claude.js"; +import { cloakClaudeTools } from "../utils/claudeCloaking.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. 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); + } + if (responseFn) { + responseRegistry.set(key, responseFn); + } +} + +// 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 = []) { + if (!stripList.length || !body.messages || !Array.isArray(body.messages)) return; + const imageTypes = new Set(["image_url", "image"]); + const audioTypes = new Set(["audio_url", "input_audio"]); + const shouldStrip = (type) => { + if (imageTypes.has(type)) return stripList.includes("image"); + if (audioTypes.has(type)) return stripList.includes("audio"); + return false; + }; + for (const msg of body.messages) { + if (!Array.isArray(msg.content)) continue; + msg.content = msg.content.filter(part => !shouldStrip(part.type)); + if (msg.content.length === 0) msg.content = ""; + } +} + +// Translate request: source -> openai -> target +export function translateRequest(sourceFormat, targetFormat, model, body, stream = true, credentials = null, provider = null, reqLogger = null, stripList = [], connectionId = null, clientTool = null) { + ensureInitialized(); + let result = body; + + // Strip explicit content types (opt-in via strip[] in PROVIDER_MODELS entry) + stripContentTypes(result, stripList); + + // Normalize thinking config: remove if lastMessage is not user + normalizeThinkingConfig(result); + + // Always ensure tool_calls have id (some providers require it) + ensureToolCallIds(result); + + // 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) { + // 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); + } + } + } + } + + // 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) { + result = filterToOpenAIFormat(result); + } + + // 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, credentials?.rawHeaders, clientSessionId); + } + + // Claude cloaking: rename client tools with _cc suffix (anti-ban) + // 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); + result = cloakedBody; + if (toolNameMap?.size > 0) { + result._toolNameMap = toolNameMap; + } + } + } + + // Antigravity cloaking disabled + // if (provider === FORMATS.ANTIGRAVITY && body.userAgent !== FORMATS.ANTIGRAVITY) { + // const { cloakedBody, toolNameMap } = AntigravityExecutor.cloakTools(result); + // result = cloakedBody; + // if (toolNameMap?.size > 0) { + // result._toolNameMap = toolNameMap; + // } + // } + + return result; +} + +// Translate response chunk: target -> openai -> source +export function translateResponse(targetFormat, sourceFormat, chunk, state) { + ensureInitialized(); + // If same format, return as-is + if (sourceFormat === targetFormat) { + return [chunk]; + } + + 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}`); + if (toOpenAI) { + results = []; + const converted = toOpenAI(chunk, state); + if (converted) { + results = Array.isArray(converted) ? converted : [converted]; + openaiResults = results; // Store OpenAI intermediate + } + } + } + + // Step 2: openai -> source (if source is not openai) + if (sourceFormat !== FORMATS.OPENAI) { + const fromOpenAI = responseRegistry.get(`${FORMATS.OPENAI}:${sourceFormat}`); + if (fromOpenAI) { + const finalResults = []; + for (const r of results) { + const converted = fromOpenAI(r, state); + if (converted) { + finalResults.push(...(Array.isArray(converted) ? converted : [converted])); + } + } + results = finalResults; + } + } + + // Attach OpenAI intermediate results for logging + if (openaiResults && sourceFormat !== FORMATS.OPENAI && targetFormat !== FORMATS.OPENAI) { + results._openaiIntermediate = openaiResults; + } + + return results; +} + +// Check if translation needed +export function needsTranslation(sourceFormat, targetFormat) { + return sourceFormat !== targetFormat; +} + +// Initialize state for streaming response based on format +export function initState(sourceFormat) { + // Base state for all formats + const base = { + messageId: null, + model: null, + textBlockStarted: false, + thinkingBlockStarted: false, + inThinkingBlock: false, + currentBlockIndex: null, + toolCalls: new Map(), + finishReason: null, + finishReasonSent: false, + usage: null, + contentBlockIndex: -1 + }; + + // Add openai-responses specific fields + if (sourceFormat === FORMATS.OPENAI_RESPONSES) { + return { + ...base, + seq: 0, + responseId: `resp_${Date.now()}`, + created: Math.floor(Date.now() / 1000), + started: false, + msgTextBuf: {}, + msgItemAdded: {}, + msgContentAdded: {}, + msgItemDone: {}, + reasoningId: "", + reasoningIndex: -1, + reasoningBuf: "", + reasoningPartAdded: false, + reasoningDone: false, + inThinking: false, + funcArgsBuf: {}, + funcNames: {}, + funcCallIds: {}, + funcArgsDone: {}, + funcItemDone: {}, + completedSent: false + }; + } + + return base; +} + +// 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 new file mode 100644 index 0000000000000000000000000000000000000000..b1dbd7bf61227db82ad63ce32febada81010e370 --- /dev/null +++ b/open-sse/translator/request/antigravity-to-openai.js @@ -0,0 +1,226 @@ +import { register } from "../index.js"; +import { FORMATS } from "../formats.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 } } +export function antigravityToOpenAIRequest(model, body, stream) { + const req = body.request || body; + const result = { + model: model, + messages: [], + stream: stream + }; + + // Generation config + if (req.generationConfig) { + const config = req.generationConfig; + if (config.maxOutputTokens) { + const tempBody = { max_tokens: config.maxOutputTokens, tools: req.tools }; + result.max_tokens = adjustMaxTokens(tempBody); + } + if (config.temperature !== undefined) { + result.temperature = config.temperature; + } + if (config.topP !== undefined) { + result.top_p = config.topP; + } + if (config.topK !== undefined) { + result.top_k = config.topK; + } + + // Thinking config → reasoning_effort + if (config.thinkingConfig) { + const effort = budgetToEffort(config.thinkingConfig.thinkingBudget || 0); + if (effort) result.reasoning_effort = effort; + } + } + + // System instruction + if (req.systemInstruction) { + const systemText = extractText(req.systemInstruction); + if (systemText) { + result.messages.push({ role: ROLE.SYSTEM, content: systemText }); + } + } + + // Convert contents to messages + if (req.contents && Array.isArray(req.contents)) { + for (const content of req.contents) { + const converted = convertContent(content); + if (converted) { + if (Array.isArray(converted)) { + result.messages.push(...converted); + } else { + result.messages.push(converted); + } + } + } + } + + // Tools + if (req.tools && Array.isArray(req.tools)) { + result.tools = []; + for (const tool of req.tools) { + if (tool.functionDeclarations) { + for (const func of tool.functionDeclarations) { + result.tools.push({ + type: OPENAI_BLOCK.FUNCTION, + function: { + name: func.name, + description: func.description || "", + parameters: normalizeSchemaTypes(func.parameters) || { type: "object", properties: {} } + } + }); + } + } + } + } + + return result; +} + +// Recursively convert Antigravity schema types (OBJECT, STRING, etc.) to lowercase +// and strip unsupported fields like enumDescriptions +function normalizeSchemaTypes(schema) { + if (!schema || typeof schema !== "object") return schema; + + const result = Array.isArray(schema) ? [...schema] : { ...schema }; + + + if (typeof result.type === "string") { + result.type = result.type.toLowerCase(); + } + + // Strip enumDescriptions — not supported by upstream APIs + delete result.enumDescriptions; + + + if (result.properties) { + const normalized = {}; + for (const [key, val] of Object.entries(result.properties)) { + normalized[key] = normalizeSchemaTypes(val); + } + result.properties = normalized; + } + + if (result.items) { + result.items = normalizeSchemaTypes(result.items); + } + + return result; +} + +// Convert Antigravity content to OpenAI message +// Handles: text, thought, thoughtSignature, functionCall, functionResponse, inlineData +function convertContent(content) { + 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; + } + + const textParts = []; + const toolCalls = []; + const toolResults = []; + let reasoningContent = ""; + + for (const part of content.parts) { + // Thinking content (thought: true) + if (part.thought === true && part.text) { + reasoningContent += part.text; + continue; + } + + // Text with thoughtSignature = regular text after thinking + if (part.thoughtSignature && part.text !== undefined) { + textParts.push({ type: OPENAI_BLOCK.TEXT, text: part.text }); + continue; + } + + // Regular text + if (part.text !== undefined) { + textParts.push({ type: OPENAI_BLOCK.TEXT, text: part.text }); + } + + // Inline data (images) + if (part.inlineData) { + textParts.push({ + type: OPENAI_BLOCK.IMAGE_URL, + image_url: { + url: encodeDataUri(part.inlineData.mimeType, part.inlineData.data) + } + }); + } + + // Function call + if (part.functionCall) { + toolCalls.push({ + // 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 || {}) + } + }); + } + + // Function response → collect all, each becomes a separate tool message + if (part.functionResponse) { + toolResults.push({ + role: ROLE.TOOL, + tool_call_id: part.functionResponse.id || `call_${part.functionResponse.name}`, + content: JSON.stringify(part.functionResponse.response?.result || part.functionResponse.response || {}) + }); + } + } + + // Content with only functionResponses → return array of tool messages + if (toolResults.length > 0) { + return toolResults; + } + + // Assistant with tool calls + if (toolCalls.length > 0) { + const msg = { role: ROLE.ASSISTANT }; + if (textParts.length > 0) { + msg.content = collapseTextParts(textParts); + } + if (reasoningContent) { + msg.reasoning_content = reasoningContent; + } + msg.tool_calls = toolCalls; + return msg; + } + + // Regular message + if (textParts.length > 0 || reasoningContent) { + const msg = { role }; + if (textParts.length > 0) { + msg.content = collapseTextParts(textParts); + } + if (reasoningContent) { + msg.reasoning_content = reasoningContent; + } + return msg; + } + + return null; +} + +// Extract text from systemInstruction +function extractText(instruction) { + if (typeof instruction === "string") return instruction; + if (instruction.parts && Array.isArray(instruction.parts)) { + return instruction.parts.map(p => p.text || "").join(""); + } + return ""; +} + +// Register +register(FORMATS.ANTIGRAVITY, FORMATS.OPENAI, antigravityToOpenAIRequest, null); 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 0000000000000000000000000000000000000000..8a38e4a9dc94f5a87291d7b262bb8d5b2ae8e2ea --- /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 new file mode 100644 index 0000000000000000000000000000000000000000..f5a5602cd996de23b994105babca1478c4c8f12d --- /dev/null +++ b/open-sse/translator/request/claude-to-openai.js @@ -0,0 +1,236 @@ +import { register } from "../index.js"; +import { FORMATS } from "../formats.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 ""; + return text.replace(/^x-anthropic-billing-header:[^\n]*(?:\r?\n)?/i, ""); +} + +// Convert Claude request to OpenAI format +export function claudeToOpenAIRequest(model, body, stream) { + const result = { + model: model, + messages: [], + stream: stream + }; + + // Max tokens + if (body.max_tokens) { + result.max_tokens = adjustMaxTokens(body); + } + + // Temperature + if (body.temperature !== undefined) { + result.temperature = body.temperature; + } + + // System message + if (body.system) { + const systemContent = Array.isArray(body.system) + ? body.system.map(s => stripAnthropicBillingHeader(s.text || "")).filter(Boolean).join("\n") + : stripAnthropicBillingHeader(body.system); + + if (systemContent) { + result.messages.push({ + role: ROLE.SYSTEM, + content: systemContent + }); + } + } + + // Convert messages + if (body.messages && Array.isArray(body.messages)) { + for (let i = 0; i < body.messages.length; i++) { + const msg = body.messages[i]; + const converted = convertClaudeMessage(msg); + if (converted) { + // Handle array of messages (multiple tool results) + if (Array.isArray(converted)) { + result.messages.push(...converted); + } else { + result.messages.push(converted); + } + } + } + } + + // 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: OPENAI_BLOCK.FUNCTION, + function: { + name: tool.name, + description: String(tool.description || ""), + parameters: tool.input_schema || { type: "object", properties: {} } + } + })); + } + + // Tool choice + if (body.tool_choice) { + result.tool_choice = convertToolChoice(body.tool_choice); + } + + return result; +} + +// Fix missing tool responses - add empty responses for tool_calls without responses +function fixMissingToolResponsesOpenAI(messages) { + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + 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 + const respondedIds = new Set(); + let insertPosition = i + 1; + for (let j = i + 1; j < messages.length; j++) { + const nextMsg = messages[j]; + if (nextMsg.role === ROLE.TOOL && nextMsg.tool_call_id) { + respondedIds.add(nextMsg.tool_call_id); + insertPosition = j + 1; + } else { + break; + } + } + + // Find missing responses and insert them + const missingIds = toolCallIds.filter(id => !respondedIds.has(id)); + + if (missingIds.length > 0) { + const missingResponses = missingIds.map(id => ({ + role: ROLE.TOOL, + tool_call_id: id, + content: "[No response received]" + })); + messages.splice(insertPosition, 0, ...missingResponses); + i = insertPosition + missingResponses.length - 1; + } + } + } +} + +// Convert single Claude message - returns single message or array of messages +function convertClaudeMessage(msg) { + const role = msg.role === ROLE.USER || msg.role === ROLE.TOOL ? ROLE.USER : ROLE.ASSISTANT; + + // Simple string content + if (typeof msg.content === "string") { + return { role, content: msg.content }; + } + + // Array content + if (Array.isArray(msg.content)) { + const parts = []; + const toolCalls = []; + const toolResults = []; + + for (const block of msg.content) { + switch (block.type) { + case CLAUDE_BLOCK.TEXT: + parts.push({ type: OPENAI_BLOCK.TEXT, text: block.text }); + break; + + case CLAUDE_BLOCK.IMAGE: + if (block.source?.type === "base64") { + parts.push({ + type: OPENAI_BLOCK.IMAGE_URL, + image_url: { + url: encodeDataUri(block.source.media_type, block.source.data) + } + }); + } + break; + + case CLAUDE_BLOCK.TOOL_USE: + toolCalls.push({ + id: block.id, + type: OPENAI_BLOCK.FUNCTION, + function: { + name: block.name, + arguments: JSON.stringify(block.input || {}) + } + }); + break; + + 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 === CLAUDE_BLOCK.TEXT) + .map(c => c.text) + .join("\n") || JSON.stringify(block.content); + } else if (block.content) { + resultContent = JSON.stringify(block.content); + } + + toolResults.push({ + role: ROLE.TOOL, + tool_call_id: block.tool_use_id, + content: resultContent + }); + break; + } + } + + // If has tool results, return array of tool messages + if (toolResults.length > 0) { + if (parts.length > 0) { + 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: ROLE.ASSISTANT }; + if (parts.length > 0) { + result.content = collapseTextParts(parts); + } + result.tool_calls = toolCalls; + return result; + } + + // Return content + if (parts.length > 0) { + return { + role, + content: collapseTextParts(parts) + }; + } + + // Empty content array + if (msg.content.length === 0) { + return { role, content: "" }; + } + } + + return null; +} + +// Convert tool choice +function convertToolChoice(choice) { + if (!choice) return "auto"; + if (typeof choice === "string") return choice; + + switch (choice.type) { + case "auto": return "auto"; + case "any": return "required"; + case "tool": return { type: OPENAI_BLOCK.FUNCTION, function: { name: choice.name } }; + default: return "auto"; + } +} + +// Register +register(FORMATS.CLAUDE, FORMATS.OPENAI, claudeToOpenAIRequest, null); diff --git a/open-sse/translator/request/gemini-to-openai.js b/open-sse/translator/request/gemini-to-openai.js new file mode 100644 index 0000000000000000000000000000000000000000..161ea9be0d7a1ce7b189a85d9930552fcb4fe42f --- /dev/null +++ b/open-sse/translator/request/gemini-to-openai.js @@ -0,0 +1,152 @@ +import { register } from "../index.js"; +import { FORMATS } from "../formats.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) { + const result = { + model: model, + messages: [], + stream: stream + }; + + // Generation config + if (body.generationConfig) { + const config = body.generationConfig; + if (config.maxOutputTokens) { + const tempBody = { max_tokens: config.maxOutputTokens, tools: body.tools }; + result.max_tokens = adjustMaxTokens(tempBody); + } + if (config.temperature !== undefined) { + result.temperature = config.temperature; + } + if (config.topP !== undefined) { + result.top_p = config.topP; + } + } + + // System instruction + if (body.systemInstruction) { + const systemText = extractGeminiText(body.systemInstruction); + if (systemText) { + result.messages.push({ + role: ROLE.SYSTEM, + content: systemText + }); + } + } + + // Convert contents to messages + if (body.contents && Array.isArray(body.contents)) { + for (const content of body.contents) { + const converted = convertGeminiContent(content); + if (converted) { + result.messages.push(converted); + } + } + } + + // Tools + if (body.tools && Array.isArray(body.tools)) { + result.tools = []; + for (const tool of body.tools) { + if (tool.functionDeclarations) { + for (const func of tool.functionDeclarations) { + result.tools.push({ + type: OPENAI_BLOCK.FUNCTION, + function: { + name: func.name, + description: func.description || "", + parameters: func.parameters || { type: "object", properties: {} } + } + }); + } + } + } + } + + return result; +} + +// Convert Gemini content to OpenAI message +function convertGeminiContent(content) { + const role = content.role === GEMINI_ROLE.USER ? ROLE.USER : ROLE.ASSISTANT; + + if (!content.parts || !Array.isArray(content.parts)) { + return null; + } + + const parts = []; + const toolCalls = []; + + for (const part of content.parts) { + if (part.text !== undefined) { + parts.push({ type: OPENAI_BLOCK.TEXT, text: part.text }); + } + + if (part.inlineData) { + parts.push({ + type: OPENAI_BLOCK.IMAGE_URL, + image_url: { + 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: part.functionCall.id || `call_${part.functionCall.name}`, + type: OPENAI_BLOCK.FUNCTION, + function: { + name: part.functionCall.name, + arguments: JSON.stringify(part.functionCall.args || {}) + } + }); + } + + if (part.functionResponse) { + return { + 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: ROLE.ASSISTANT }; + if (parts.length > 0) { + result.content = parts.length === 1 ? parts[0].text : parts; + } + result.tool_calls = toolCalls; + return result; + } + + if (parts.length > 0) { + return { + role, + content: collapseTextParts(parts) + }; + } + + return null; +} + +// Extract text from Gemini content +function extractGeminiText(content) { + if (typeof content === "string") return content; + if (content.parts && Array.isArray(content.parts)) { + return content.parts.map(p => p.text || "").join(""); + } + return ""; +} + +// Register +register(FORMATS.GEMINI, FORMATS.OPENAI, geminiToOpenAIRequest, null); +register(FORMATS.GEMINI_CLI, FORMATS.OPENAI, geminiToOpenAIRequest, null); + diff --git a/open-sse/translator/request/openai-responses.js b/open-sse/translator/request/openai-responses.js new file mode 100644 index 0000000000000000000000000000000000000000..0f49c059e5595effe9a83d7362890454cbd729a2 --- /dev/null +++ b/open-sse/translator/request/openai-responses.js @@ -0,0 +1,325 @@ +/** + * Translator: OpenAI Responses API → OpenAI Chat Completions + * + * Responses API uses: { input: [...], instructions: "..." } + * Chat API uses: { messages: [...] } + */ +import { register } from "../index.js"; +import { FORMATS } from "../formats.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; +const clampCallId = (id) => (typeof id === "string" && id.length > MAX_CALL_ID_LEN ? id.substring(0, MAX_CALL_ID_LEN) : id); + +/** + * Convert OpenAI Responses API request to OpenAI Chat Completions format + */ +export function openaiResponsesToOpenAIRequest(model, body, stream, credentials) { + if (!body.input) return body; + + const result = { ...body }; + result.messages = []; + + // Convert instructions to system message + if (body.instructions) { + result.messages.push({ role: ROLE.SYSTEM, content: body.instructions }); + } + + // Group items by conversation turn + let currentAssistantMsg = null; + let pendingToolResults = []; + let pendingReasoning = ""; + + const inputItems = normalizeResponsesInput(body.input); + if (!inputItems) return body; + + // Extract reasoning text from summary[].text or encrypted_content fallback + const extractReasoningText = (item) => { + if (Array.isArray(item.summary)) { + const txt = item.summary.map(s => s?.text || "").filter(Boolean).join("\n"); + if (txt) return txt; + } + if (Array.isArray(item.content)) { + const txt = item.content.map(c => c?.text || "").filter(Boolean).join("\n"); + if (txt) return txt; + } + return ""; + }; + + 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 ? RESPONSES_ITEM.MESSAGE : null); + + if (itemType === RESPONSES_ITEM.MESSAGE) { + // Flush any pending assistant message with tool calls + if (currentAssistantMsg) { + result.messages.push(currentAssistantMsg); + currentAssistantMsg = null; + } + // Flush pending tool results + if (pendingToolResults.length > 0) { + for (const tr of pendingToolResults) { + result.messages.push(tr); + } + pendingToolResults = []; + } + + // 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 === 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: 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 === ROLE.ASSISTANT && pendingReasoning) { + msg.reasoning_content = pendingReasoning; + } + pendingReasoning = ""; + result.messages.push(msg); + } + else if (itemType === RESPONSES_ITEM.FUNCTION_CALL) { + // Start or append to assistant message with tool_calls + if (!currentAssistantMsg) { + currentAssistantMsg = { + role: ROLE.ASSISTANT, + content: null, + tool_calls: [] + }; + if (pendingReasoning) { + currentAssistantMsg.reasoning_content = pendingReasoning; + pendingReasoning = ""; + } + } + // Skip items with empty/missing name — Codex/OpenAI reject nameless tool calls (#444) + if (!item.name || typeof item.name !== "string" || item.name.trim() === "") continue; + currentAssistantMsg.tool_calls.push({ + id: item.call_id, + type: OPENAI_BLOCK.FUNCTION, + function: { + name: item.name, + arguments: item.arguments + } + }); + } + else if (itemType === RESPONSES_ITEM.FUNCTION_CALL_OUTPUT) { + // Flush assistant message first if exists + if (currentAssistantMsg) { + result.messages.push(currentAssistantMsg); + currentAssistantMsg = null; + } + // Flush any pending tool results first + if (pendingToolResults.length > 0) { + for (const tr of pendingToolResults) { + result.messages.push(tr); + } + pendingToolResults = []; + } + // Add tool result immediately + result.messages.push({ + role: ROLE.TOOL, + tool_call_id: item.call_id, + content: typeof item.output === "string" ? item.output : JSON.stringify(item.output) + }); + } + 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; + continue; + } + } + + // Flush remaining + if (currentAssistantMsg) { + result.messages.push(currentAssistantMsg); + } + if (pendingToolResults.length > 0) { + for (const tr of pendingToolResults) { + result.messages.push(tr); + } + } + + // Convert tools format. + // Responses API supports "hosted" tools (e.g. { type: "request_user_input" }) that carry no + // explicit `name` field and cannot be represented as Chat Completions function declarations. + // Filter them out to avoid sending nameless functionDeclarations to downstream providers + // such as Gemini, which strictly validates function names. + if (body.tools && Array.isArray(body.tools)) { + result.tools = body.tools + .map(tool => { + // Already in Chat Completions format: { type: "function", function: { name, ... } } + if (tool.function) return tool; + // Responses API function tool: { type: "function", name, description, parameters } + // Only convert when a non-empty name is present; skip hosted tools without one. + const name = tool.name; + if (!name || typeof name !== "string" || name.trim() === "") return null; + return { + type: OPENAI_BLOCK.FUNCTION, + function: { + name, + description: String(tool.description || ""), + parameters: normalizeToolParameters(tool.parameters), + strict: tool.strict + } + }; + }) + .filter(Boolean); + } + + // 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; + delete result.prompt_cache_key; + delete result.store; + delete result.reasoning; + + return result; +} + +/** + * Ensure object schema always has properties field (required by Codex Responses API) + */ +function normalizeToolParameters(params) { + if (!params) return { type: "object", properties: {} }; + if (params.type === "object" && !params.properties) return { ...params, properties: {} }; + return params; +} + +/** + * Convert OpenAI Chat Completions to OpenAI Responses API format + */ +export function openaiToOpenAIResponsesRequest(model, body, stream, credentials) { + // Body already in Responses API format (e.g. Cursor CLI calling /chat/completions with input[]) + if (body.input) return { ...body, model, stream: true }; + + const result = { + model, + input: [], + stream: true, + store: false + }; + + // Extract system message as instructions + let hasSystemMessage = false; + const messages = body.messages || []; + + for (const msg of messages) { + if (msg.role === ROLE.SYSTEM) { + // Use first system message as instructions + if (!hasSystemMessage) { + result.instructions = typeof msg.content === "string" ? msg.content : ""; + hasSystemMessage = true; + } + continue; // Skip system messages in input + } + + // Convert user/assistant messages to input items + 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 === 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 === OPENAI_BLOCK.IMAGE_URL) { + const url = typeof c.image_url === "string" ? c.image_url : c.image_url?.url; + return { type: RESPONSES_ITEM.INPUT_IMAGE, image_url: url, detail: c.image_url?.detail || "auto" }; + } + 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) }; + }) + : []; + + // Only push a message block if content is non-empty. + // Assistant messages with only tool_calls have content: null — skip the + // message block in that case; the tool_calls are pushed separately below. + if (content.length > 0) { + result.input.push({ + type: RESPONSES_ITEM.MESSAGE, + role: msg.role, + content + }); + } + } + + // Convert tool calls + if (msg.role === ROLE.ASSISTANT && msg.tool_calls) { + for (const tc of msg.tool_calls) { + result.input.push({ + type: RESPONSES_ITEM.FUNCTION_CALL, + call_id: clampCallId(tc.id), + name: tc.function?.name || "_unknown", + arguments: tc.function?.arguments || "{}" + }); + } + } + + // Convert tool results - output must be a string for Responses API + 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: RESPONSES_ITEM.FUNCTION_CALL_OUTPUT, + call_id: clampCallId(msg.tool_call_id), + output + }); + } + } + + // If no system message, leave instructions empty (will be filled by executor) + if (!hasSystemMessage) { + result.instructions = ""; + } + + // Convert tools format + if (body.tools && Array.isArray(body.tools)) { + result.tools = body.tools.map(tool => { + if (tool.type === OPENAI_BLOCK.FUNCTION) { + return { + type: OPENAI_BLOCK.FUNCTION, + name: tool.function.name, + description: String(tool.function.description || ""), + parameters: normalizeToolParameters(tool.function.parameters), + strict: tool.function.strict + }; + } + return tool; + }); + } + + // Pass through other relevant fields + 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; + + return result; +} + +// Register both directions +register(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, openaiResponsesToOpenAIRequest, null); +register(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, openaiToOpenAIResponsesRequest, null); diff --git a/open-sse/translator/request/openai-to-claude.js b/open-sse/translator/request/openai-to-claude.js new file mode 100644 index 0000000000000000000000000000000000000000..bc73149b327058e2840af9bcf0522b9f00ca4299 --- /dev/null +++ b/open-sse/translator/request/openai-to-claude.js @@ -0,0 +1,370 @@ +import { register } from "../index.js"; +import { FORMATS } from "../formats.js"; +import { CLAUDE_SYSTEM_PROMPT } from "../../config/appConstants.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. +const CLAUDE_OAUTH_TOOL_PREFIX = ""; + +// Convert OpenAI request to Claude format +export function openaiToClaudeRequest(model, body, stream) { + // Tool name mapping for Claude OAuth (capitalizedName → originalName) + const toolNameMap = new Map(); + const result = { + model: model, + max_tokens: adjustMaxTokens(body), + stream: stream + }; + + // Temperature + if (body.temperature !== undefined) { + result.temperature = body.temperature; + } + + // Messages + result.messages = []; + const systemParts = []; + + if (body.messages && Array.isArray(body.messages)) { + // Extract system messages + for (const msg of body.messages) { + 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 !== ROLE.SYSTEM); + + // Process messages with merging logic + // CRITICAL: tool_result must be in separate message immediately after tool_use + let currentRole = undefined; + let currentParts = []; + + const flushCurrentMessage = () => { + if (currentRole && currentParts.length > 0) { + result.messages.push({ role: currentRole, content: currentParts }); + currentParts = []; + } + }; + + for (const msg of nonSystemMessages) { + 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 === 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 === CLAUDE_BLOCK.TOOL_RESULT); + const otherBlocks = blocks.filter(b => b.type !== CLAUDE_BLOCK.TOOL_RESULT); + + flushCurrentMessage(); + + if (toolResultBlocks.length > 0) { + result.messages.push({ role: ROLE.USER, content: toolResultBlocks }); + } + + if (otherBlocks.length > 0) { + currentRole = newRole; + currentParts.push(...otherBlocks); + } + continue; + } + + if (currentRole !== newRole) { + flushCurrentMessage(); + currentRole = newRole; + } + + currentParts.push(...blocks); + + if (hasToolUse) { + flushCurrentMessage(); + } + } + + flushCurrentMessage(); + + // 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 === ROLE.ASSISTANT && Array.isArray(message.content) && message.content.length > 0) { + // Find the last block that can have cache_control (not thinking blocks) + 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)) { + block.cache_control = { type: "ephemeral" }; + break; + } + } + break; + } + } + } + + // Handle response_format for JSON mode + if (body.response_format) { + const responseFormat = body.response_format; + if (responseFormat.type === "json_schema" && responseFormat.json_schema?.schema) { + const schemaJson = JSON.stringify(responseFormat.json_schema.schema, null, 2); + systemParts.push(`You must respond with valid JSON that strictly follows this JSON schema: +\`\`\`json +${schemaJson} +\`\`\` +Respond ONLY with the JSON object, no other text.`); + } else if (responseFormat.type === "json_object") { + systemParts.push("You must respond with valid JSON. Respond ONLY with a JSON object, no other text."); + } + } + + // System with Claude Code prompt and cache_control + const claudeCodePrompt = { type: CLAUDE_BLOCK.TEXT, text: CLAUDE_SYSTEM_PROMPT }; + + if (systemParts.length > 0) { + const systemText = systemParts.join("\n"); + result.system = [ + claudeCodePrompt, + { type: CLAUDE_BLOCK.TEXT, text: systemText, cache_control: { type: "ephemeral", ttl: "1h" } } + ]; + } else { + result.system = [claudeCodePrompt]; + } + + // Tools - convert from OpenAI format to Claude format with prefix for OAuth + if (body.tools && Array.isArray(body.tools)) { + result.tools = []; + 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 !== OPENAI_BLOCK.FUNCTION) { + result.tools.push(tool); + continue; + } + + const toolData = toolType === OPENAI_BLOCK.FUNCTION && tool.function ? tool.function : tool; + const originalName = toolData.name; + + // Claude OAuth requires prefixed tool names to avoid conflicts + const toolName = CLAUDE_OAUTH_TOOL_PREFIX + originalName; + + // Store mapping for response translation (prefixed → original) + toolNameMap.set(toolName, originalName); + + result.tools.push({ + name: toolName, + description: toolData.description || "", + input_schema: toolData.parameters || toolData.input_schema || { type: "object", properties: {}, required: [] } + }); + } + + if (result.tools.length > 0) { + result.tools[result.tools.length - 1].cache_control = { type: "ephemeral", ttl: "1h" }; + } + } + + // Tool choice + if (body.tool_choice) { + result.tool_choice = convertOpenAIToolChoice(body.tool_choice); + } + + // Thinking is normalized centrally by applyThinking (thinkingUnified.js) after translation. + + // Attach toolNameMap to result for response translation + if (toolNameMap.size > 0) { + result._toolNameMap = toolNameMap; + } + + return result; +} + +// Get content blocks from single message +function getContentBlocksFromMessage(msg, toolNameMap = new Map()) { + const blocks = []; + + if (msg.role === ROLE.TOOL) { + blocks.push({ + type: CLAUDE_BLOCK.TOOL_RESULT, + tool_use_id: msg.tool_call_id, + content: msg.content + }); + } else if (msg.role === ROLE.USER) { + if (typeof msg.content === "string") { + if (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 === 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: 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 === OPENAI_BLOCK.IMAGE_URL) { + const url = part.image_url.url; + const parsed = parseDataUri(url); + if (parsed) { + blocks.push({ + 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: CLAUDE_BLOCK.IMAGE, + source: { type: "url", url } + }); + } + } 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 === ROLE.ASSISTANT) { + if (Array.isArray(msg.content)) { + for (const part of msg.content) { + 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: 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, "\n"); + if (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 === OPENAI_BLOCK.FUNCTION) { + // Apply prefix to tool name + const toolName = CLAUDE_OAUTH_TOOL_PREFIX + tc.function.name; + blocks.push({ + type: CLAUDE_BLOCK.TOOL_USE, + id: tc.id, + name: toolName, + input: safeParseJSON(tc.function.arguments, tc.function.arguments) + }); + } + } + } + } + + return blocks; +} + +// Convert OpenAI tool choice to Claude format. +// Claude only accepts tool_choice.type of "auto" | "any" | "tool" | "none"; +// anything else (e.g. OpenAI's "function") triggers a 400, so we never pass an +// unrecognized type through. +const CLAUDE_TOOL_CHOICE_TYPES = new Set(["auto", "any", "tool", "none"]); + +function convertOpenAIToolChoice(choice) { + if (!choice) return { type: "auto" }; + + // OpenAI string forms: "auto" | "none" | "required" + if (typeof choice === "string") { + if (choice === "required") return { type: "any" }; + return { type: "auto" }; // "auto", "none", or anything unexpected + } + + if (typeof choice === "object") { + // OpenAI forced tool: { type: "function", function: { name } }. + // Checked before the native pass-through below, because the OpenAI shape + // also carries a `.type` ("function") that Claude rejects. + if (choice.function?.name) { + return { type: "tool", name: choice.function.name }; + } + // Already Claude-native — only pass through types Claude actually accepts, + // so a malformed or unknown type can never leak into the upstream request. + if (CLAUDE_TOOL_CHOICE_TYPES.has(choice.type)) { + return choice; + } + } + + return { type: "auto" }; +} + +// OpenAI -> Claude format for Antigravity (without system prompt modifications) +function openaiToClaudeRequestForAntigravity(model, body, stream) { + const result = openaiToClaudeRequest(model, body, stream); + + // Remove Claude Code system prompt, keep only user's system messages + if (result.system && Array.isArray(result.system)) { + result.system = result.system.filter(block => + !block.text || !block.text.includes("You are Claude Code") + ); + if (result.system.length === 0) { + delete result.system; + } + } + + // Strip prefix from tool names for Antigravity (doesn't use Claude OAuth) + if (result.tools && Array.isArray(result.tools)) { + result.tools = result.tools.map(tool => { + if (tool.name && tool.name.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) { + return { + ...tool, + name: tool.name.slice(CLAUDE_OAUTH_TOOL_PREFIX.length) + }; + } + return tool; + }); + } + + // Strip prefix from tool_use in messages + if (result.messages && Array.isArray(result.messages)) { + result.messages = result.messages.map(msg => { + if (!msg.content || !Array.isArray(msg.content)) { + return msg; + } + + const updatedContent = msg.content.map(block => { + 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) + }; + } + return block; + }); + + return { ...msg, content: updatedContent }; + }); + } + + return result; +} + +// Export for use in other translators +export { openaiToClaudeRequestForAntigravity }; + +// Register +register(FORMATS.OPENAI, FORMATS.CLAUDE, openaiToClaudeRequest, null); + diff --git a/open-sse/translator/request/openai-to-commandcode.js b/open-sse/translator/request/openai-to-commandcode.js new file mode 100644 index 0000000000000000000000000000000000000000..9825048bd48a39c9d756ecd27800581b10899af2 --- /dev/null +++ b/open-sse/translator/request/openai-to-commandcode.js @@ -0,0 +1,172 @@ +/** + * OpenAI → CommandCode request translator + * + * Upstream `/alpha/generate` schema (verified live with curl 2026-05-07): + * - params.system: STRING at top level (Anthropic-style; system messages NOT allowed in messages[]) + * - params.messages[*].role ∈ {"user","assistant","tool"} + * - params.messages[*].content: Array of content blocks (NEVER a string) + * - tool_use blocks (assistant): {type:"tool-call", toolCallId, toolName, input} + * - tool_result blocks (role=user): {type:"tool-result", toolCallId, toolName, output} + * - tools[*]: Anthropic plain {name, description, input_schema} + */ +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 ""; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const parts = []; + for (const p of content) { + if (typeof p === "string") parts.push(p); + else if (p && typeof p === "object" && typeof p.text === "string") parts.push(p.text); + } + return parts.join("\n"); + } + return String(content); +} + +function toContentBlocks(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: OPENAI_BLOCK.TEXT, text: part }); + } else if (part && typeof part === "object") { + 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: OPENAI_BLOCK.TEXT, text: part.text }); + } + } + } + return blocks.length ? blocks : [{ type: OPENAI_BLOCK.TEXT, text: "" }]; + } + return [{ type: OPENAI_BLOCK.TEXT, text: String(content) }]; +} + +function safeParseJson(s) { + if (s == null) return {}; + if (typeof s !== "string") return s; + try { return JSON.parse(s); } catch { return {}; } +} + +function convertMessages(messages = []) { + const out = []; + const systemTexts = []; + + for (const m of messages) { + if (!m) continue; + const role = m.role; + + if (role === ROLE.SYSTEM) { + const t = flattenText(m.content); + if (t) systemTexts.push(t); + continue; + } + + if (role === ROLE.TOOL) { + const value = typeof m.content === "string" ? m.content : flattenText(m.content); + out.push({ + role: ROLE.TOOL, + content: [{ + type: "tool-result", + toolCallId: m.tool_call_id || "", + toolName: m.name || "", + output: { type: "text", value }, + }], + }); + continue; + } + + if (role === ROLE.ASSISTANT) { + const blocks = []; + const text = flattenText(m.content); + 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 || {}; + blocks.push({ + type: "tool-call", + toolCallId: tc.id || "", + toolName: fn.name || "", + input: safeParseJson(fn.arguments), + }); + } + } + out.push({ role: ROLE.ASSISTANT, content: blocks.length ? blocks : [{ type: OPENAI_BLOCK.TEXT, text: "" }] }); + continue; + } + + out.push({ role: ROLE.USER, content: toContentBlocks(m.content) }); + } + + return { messages: out, system: systemTexts.join("\n\n") }; +} + +function convertTools(tools) { + if (!Array.isArray(tools) || tools.length === 0) return undefined; + const result = []; + for (const t of tools) { + if (!t) continue; + if (t.type === OPENAI_BLOCK.FUNCTION && t.function) { + result.push({ + name: t.function.name, + description: t.function.description, + input_schema: t.function.parameters || { type: "object" }, + }); + } else if (t.name && (t.input_schema || t.parameters)) { + result.push({ + name: t.name, + description: t.description, + input_schema: t.input_schema || t.parameters, + }); + } + } + return result.length ? result : undefined; +} + +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 ?? DEFAULT_MAX_TOKENS, + temperature: body.temperature ?? 0.3, + }; + + if (system) params.system = system; + + const tools = convertTools(body.tools); + if (tools) params.tools = tools; + if (body.top_p != null) params.top_p = body.top_p; + + const today = new Date().toISOString().slice(0, 10); + + return { + threadId: randomUUID(), + memory: "", + config: { + workingDir: process.cwd(), + date: today, + environment: process.platform, + structure: [], + isGitRepo: false, + currentBranch: "", + mainBranch: "", + gitStatus: "", + recentCommits: [], + }, + params, + }; +} + +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 new file mode 100644 index 0000000000000000000000000000000000000000..02c55b3b2eebbc2cf17c0c11c0fac2ea77ec1807 --- /dev/null +++ b/open-sse/translator/request/openai-to-cursor.js @@ -0,0 +1,185 @@ +/** + * OpenAI to Cursor Request Translator + * Converts OpenAI messages to Cursor ask/agent format. + * + * Important: Cursor can loop when tool outputs are sent via protobuf tool_results + * with partial schema mismatches. For stability, tool outputs are represented as + * structured text blocks in user messages. + */ +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; + if (Array.isArray(content)) { + return content + .filter(part => { + if (!part || typeof part !== "object") return false; + return part.type === OPENAI_BLOCK.TEXT && typeof part.text === "string"; + }) + .map(part => part.text || "") + .join(""); + } + return ""; +} + +function sanitizeToolResultText(text) { + // Strip non-printable control chars that can produce backend request errors + return text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ""); +} + +function escapeXml(text) { + return text.replace(/&/g, "&").replace(//g, ">"); +} + +function buildToolResultBlock(toolName, toolCallId, resultText) { + const cleanResult = sanitizeToolResultText(resultText || ""); + return [ + "", + `${escapeXml(toolName || "tool")}`, + `${escapeXml(toolCallId || "")}`, + `${escapeXml(cleanResult)}`, + "" + ].join("\n"); +} + +function normalizeToolCallId(id) { + return typeof id === "string" ? id.split("\n")[0] : ""; +} + +function convertMessages(messages) { + const result = []; + + // Build a map of tool_call_id -> tool name from assistant tool calls + const toolCallMetaMap = new Map(); + const rememberToolMeta = (toolCallId, toolName) => { + if (!toolCallId) return; + const name = toolName || "tool"; + toolCallMetaMap.set(toolCallId, { name }); + const normalized = normalizeToolCallId(toolCallId); + if (normalized && normalized !== toolCallId) { + toolCallMetaMap.set(normalized, { name }); + } + }; + + for (const msg of messages) { + 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 === ROLE.ASSISTANT && Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part?.type !== CLAUDE_BLOCK.TOOL_USE) continue; + rememberToolMeta(part.id || "", part.name || "tool"); + } + } + } + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + + if (msg.role === ROLE.SYSTEM) { + result.push({ + role: ROLE.USER, + content: `[System Instructions]\n${extractContent(msg.content)}` + }); + continue; + } + + 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: ROLE.USER, + content: buildToolResultBlock(toolName, toolCallId, toolContent) + }); + continue; + } + + 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 === CLAUDE_BLOCK.TEXT) { + if (typeof block.text === "string") { + parts.push(block.text || ""); + } + continue; + } + if (block.type === CLAUDE_BLOCK.TOOL_RESULT) { + const toolCallId = block.tool_use_id || ""; + const toolMeta = + toolCallMetaMap.get(toolCallId) || + toolCallMetaMap.get(normalizeToolCallId(toolCallId)); + const toolName = toolMeta?.name || "tool"; + const toolContent = extractContent(block.content); + parts.push(buildToolResultBlock(toolName, toolCallId, toolContent)); + } + } + const joined = parts.filter(Boolean).join("\n"); + if (joined) result.push({ role: ROLE.USER, content: joined }); + continue; + } + + const content = extractContent(msg.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 === ROLE.ASSISTANT && Array.isArray(msg.content)) { + const extractedToolCalls = msg.content + .filter(b => b?.type === CLAUDE_BLOCK.TOOL_USE) + .map(b => ({ + id: b.id || "", + type: OPENAI_BLOCK.FUNCTION, + function: { + name: b.name || "tool", + arguments: JSON.stringify(b.input || {}) + } + })) + .filter(tc => tc.id); + + if (extractedToolCalls.length > 0) { + result.push({ + role: ROLE.ASSISTANT, + content: content || "", + tool_calls: extractedToolCalls + }); + } else if (content) { + result.push({ role: ROLE.ASSISTANT, content }); + } + } else { + if (content) { + result.push({ role: msg.role, content }); + } + } + } + } + + return result; +} + +export function openaiToCursorRequest(model, body, stream, credentials) { + const messages = convertMessages(body.messages || []); + + // Strip fields irrelevant to Cursor (OpenAI/Anthropic-specific) + const { user, metadata, tool_choice, stream_options, system, ...rest } = body; + + return { + ...rest, + messages, + max_tokens: DEFAULT_MIN_TOKENS + }; +} + +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 new file mode 100644 index 0000000000000000000000000000000000000000..fe181631a7a69a6eef6d3a3ebf07203a2b450148 --- /dev/null +++ b/open-sse/translator/request/openai-to-gemini.js @@ -0,0 +1,452 @@ +import { register } from "../index.js"; +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(); +} + +import { + DEFAULT_SAFETY_SETTINGS, + convertOpenAIContentToParts, + extractTextContent, + tryParseJSON, + generateRequestId, + generateSessionId, + generateProjectId, + cleanJSONSchemaForAntigravity +} 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. +// Replace any invalid character with '_' and truncate to 64. +function sanitizeGeminiFunctionName(name) { + if (!name) return "_unknown"; + // Replace any char not in [a-zA-Z0-9_.:\-] with '_' + let sanitized = name.replace(/[^a-zA-Z0-9_.:\-]/g, "_"); + // First char must be letter or underscore + if (!/^[a-zA-Z_]/.test(sanitized)) { + sanitized = "_" + sanitized; + } + // Truncate to 64 chars + return sanitized.substring(0, 64); +} + +// Core: Convert OpenAI request to Gemini format (base for all variants) +function openaiToGeminiBase(model, body, stream, signature = DEFAULT_THINKING_AG_SIGNATURE) { + const result = { + model: model, + contents: [], + generationConfig: {}, + safetySettings: DEFAULT_SAFETY_SETTINGS + }; + + // Generation config + if (body.temperature !== undefined) { + result.generationConfig.temperature = body.temperature; + } + if (body.top_p !== undefined) { + result.generationConfig.topP = body.top_p; + } + if (body.top_k !== undefined) { + result.generationConfig.topK = body.top_k; + } + if (body.max_tokens !== undefined) { + result.generationConfig.maxOutputTokens = body.max_tokens; + } + + // Build tool_call_id -> name map + const tcID2Name = {}; + if (body.messages && Array.isArray(body.messages)) { + for (const msg of body.messages) { + if (msg.role === ROLE.ASSISTANT && msg.tool_calls) { + for (const tc of msg.tool_calls) { + if (tc.type === OPENAI_BLOCK.FUNCTION && tc.id && tc.function?.name) { + tcID2Name[tc.id] = tc.function.name; + } + } + } + } + } + + // Build tool responses cache + const toolResponses = {}; + if (body.messages && Array.isArray(body.messages)) { + for (const msg of body.messages) { + if (msg.role === ROLE.TOOL && msg.tool_call_id) { + toolResponses[msg.tool_call_id] = msg.content; + } + } + } + + // Convert messages + if (body.messages && Array.isArray(body.messages)) { + for (let i = 0; i < body.messages.length; i++) { + const msg = body.messages[i]; + const role = msg.role; + const content = msg.content; + + if (role === ROLE.SYSTEM && body.messages.length > 1) { + result.systemInstruction = { + role: GEMINI_ROLE.USER, + parts: [{ text: typeof content === "string" ? content : extractTextContent(content) }] + }; + } else if (role === ROLE.USER || (role === ROLE.SYSTEM && body.messages.length === 1)) { + const parts = convertOpenAIContentToParts(content); + if (parts.length > 0) { + result.contents.push({ role: GEMINI_ROLE.USER, parts }); + } + } else if (role === ROLE.ASSISTANT) { + const parts = []; + + // Thinking/reasoning → thought part with signature + if (msg.reasoning_content) { + parts.push({ + thought: true, + text: msg.reasoning_content + }); + parts.push({ + thoughtSignature: signature, + text: "" + }); + } + + if (content) { + const text = typeof content === "string" ? content : extractTextContent(content); + if (text) { + parts.push({ text }); + } + } + + if (msg.tool_calls && Array.isArray(msg.tool_calls)) { + const toolCallIds = []; + for (const tc of msg.tool_calls) { + if (tc.type !== OPENAI_BLOCK.FUNCTION) continue; + + const args = tryParseJSON(tc.function?.arguments || "{}"); + parts.push({ + thoughtSignature: signature, + functionCall: { + id: tc.id, + name: sanitizeGeminiFunctionName(tc.function.name), + args: args + } + }); + toolCallIds.push(tc.id); + } + + if (parts.length > 0) { + result.contents.push({ role: GEMINI_ROLE.MODEL, parts }); + } + + // Check if there are actual tool responses in the next messages + const hasActualResponses = toolCallIds.some(fid => toolResponses[fid]); + + if (hasActualResponses) { + const toolParts = []; + for (const fid of toolCallIds) { + if (!toolResponses[fid]) continue; + + let name = tcID2Name[fid]; + if (!name) { + const idParts = fid.split("-"); + if (idParts.length > 2) { + name = idParts.slice(0, -2).join("-"); + } else { + name = fid; + } + } + + let resp = toolResponses[fid]; + let parsedResp = tryParseJSON(resp); + if (parsedResp === null) { + parsedResp = { result: resp }; + } else if (typeof parsedResp !== "object") { + parsedResp = { result: parsedResp }; + } + + toolParts.push({ + functionResponse: { + id: fid, + name: sanitizeGeminiFunctionName(name), + response: { result: parsedResp } + } + }); + } + if (toolParts.length > 0) { + result.contents.push({ role: GEMINI_ROLE.USER, parts: toolParts }); + } + } + } else if (parts.length > 0) { + result.contents.push({ role: GEMINI_ROLE.MODEL, parts }); + } + } + } + } + + // Convert tools + if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) { + const functionDeclarations = []; + for (const t of body.tools) { + // Check if already in Anthropic/Claude format (no type field, direct name/description/input_schema) + if (t.name && t.input_schema) { + const cleanedSchema = cleanJSONSchemaForAntigravity(structuredClone(t.input_schema || { type: "object", properties: {} })); + functionDeclarations.push({ + name: sanitizeGeminiFunctionName(t.name), + description: t.description || "", + parameters: cleanedSchema + }); + } + // OpenAI format + else if (t.type === OPENAI_BLOCK.FUNCTION && t.function) { + const fn = t.function; + const cleanedSchema = cleanJSONSchemaForAntigravity(structuredClone(fn.parameters || { type: "object", properties: {} })); + functionDeclarations.push({ + name: sanitizeGeminiFunctionName(fn.name), + description: fn.description || "", + parameters: cleanedSchema + }); + } + } + + if (functionDeclarations.length > 0) { + result.tools = [{ functionDeclarations }]; + } + } + + return result; +} + +// OpenAI -> Gemini (standard API) +export function openaiToGeminiRequest(model, body, stream) { + return openaiToGeminiBase(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); + // Thinking is normalized centrally by applyThinking (thinkingUnified.js) after translation. + + // Clean schema for tools + if (gemini.tools?.[0]?.functionDeclarations) { + for (const fn of gemini.tools[0].functionDeclarations) { + if (fn.parameters) { + const cleanedSchema = cleanJSONSchemaForAntigravity(fn.parameters); + fn.parameters = cleanedSchema; + // if (isClaude) { + // fn.parameters = cleanedSchema; + // } else { + // fn.parametersJsonSchema = cleanedSchema; + // delete fn.parameters; + // } + } + } + } + + return gemini; +} + +// Wrap Gemini CLI format in Cloud Code wrapper +function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigravity = false) { + const projectId = credentials?.projectId || generateProjectId(); + + const envelope = { + project: projectId, + model: model, + userAgent: isAntigravity ? "antigravity" : "gemini-cli", + requestId: isAntigravity ? `agent-${generateUUID()}` : generateRequestId(), + request: { + sessionId: toNumericSessionId(credentials?._clientSessionId) || (isAntigravity ? deriveSessionId(credentials?.email || credentials?.connectionId) : generateSessionId()), + contents: geminiCLI.contents, + systemInstruction: geminiCLI.systemInstruction, + generationConfig: geminiCLI.generationConfig, + tools: geminiCLI.tools, + } + }; + + // Antigravity specific fields + if (isAntigravity) { + envelope.requestType = "agent"; + + // Inject required default system prompt for Antigravity + // Inject required default system prompt for Antigravity (double injection) + const systemParts = [ + { text: ANTIGRAVITY_DEFAULT_SYSTEM }, + { text: `Please ignore the following [ignore]${ANTIGRAVITY_DEFAULT_SYSTEM}[/ignore]` } + ]; + + if (envelope.request.systemInstruction?.parts) { + envelope.request.systemInstruction.parts.unshift(...systemParts); + } else { + envelope.request.systemInstruction = { role: GEMINI_ROLE.USER, parts: systemParts }; + } + + // Add toolConfig for Antigravity + if (geminiCLI.tools?.length > 0) { + envelope.request.toolConfig = { + functionCallingConfig: { mode: "VALIDATED" } + }; + } + } else { + // Keep safetySettings for Gemini CLI + envelope.request.safetySettings = geminiCLI.safetySettings; + } + + return envelope; +} + +// Wrap Claude format in Cloud Code envelope for Antigravity +function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = null) { + const projectId = credentials?.projectId || generateProjectId(); + + const envelope = { + project: projectId, + model: model, + userAgent: "antigravity", + requestId: `agent-${generateUUID()}`, + requestType: "agent", + request: { + sessionId: toNumericSessionId(credentials?._clientSessionId) || deriveSessionId(credentials?.email || credentials?.connectionId), + contents: [], + generationConfig: { + temperature: claudeRequest.temperature || 1, + maxOutputTokens: claudeRequest.max_tokens || 4096 + } + } + }; + + // Build tool_use id -> name map so functionResponse can use the correct name + const toolUseIdToName = {}; + if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) { + for (const msg of claudeRequest.messages) { + if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === CLAUDE_BLOCK.TOOL_USE && block.id && block.name) { + toolUseIdToName[block.id] = block.name; + } + } + } + } + } + + // Convert Claude messages to Gemini contents + if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) { + for (const msg of claudeRequest.messages) { + const parts = []; + + if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === CLAUDE_BLOCK.TEXT) { + parts.push({ text: block.text }); + } else if (block.type === CLAUDE_BLOCK.TOOL_USE) { + parts.push({ + functionCall: { + id: block.id, + name: sanitizeGeminiFunctionName(block.name), + args: block.input || {} + } + }); + } else if (block.type === CLAUDE_BLOCK.TOOL_RESULT) { + let content = block.content; + if (Array.isArray(content)) { + 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] + ? sanitizeGeminiFunctionName(toolUseIdToName[block.tool_use_id]) + : "tool"; + parts.push({ + functionResponse: { + id: block.tool_use_id, + name: resolvedName, + response: { result: tryParseJSON(content) || content } + } + }); + } + } + } else if (typeof msg.content === "string") { + parts.push({ text: msg.content }); + } + + if (parts.length > 0) { + envelope.request.contents.push({ + role: msg.role === ROLE.ASSISTANT ? GEMINI_ROLE.MODEL : GEMINI_ROLE.USER, + parts + }); + } + } + } + + // Convert Claude tools to Gemini functionDeclarations + if (claudeRequest.tools && Array.isArray(claudeRequest.tools)) { + const functionDeclarations = []; + for (const tool of claudeRequest.tools) { + if (tool.name && tool.input_schema) { + const cleanedSchema = cleanJSONSchemaForAntigravity(tool.input_schema); + functionDeclarations.push({ + name: sanitizeGeminiFunctionName(tool.name), + description: tool.description || "", + parameters: cleanedSchema + }); + } + } + if (functionDeclarations.length > 0) { + envelope.request.tools = [{ functionDeclarations }]; + envelope.request.toolConfig = { + functionCallingConfig: { mode: "VALIDATED" } + }; + } + } + + // Add system instruction (Antigravity default - double injection + user system prompt) + const systemParts = [ + { text: ANTIGRAVITY_DEFAULT_SYSTEM }, + { text: `Please ignore the following [ignore]${ANTIGRAVITY_DEFAULT_SYSTEM}[/ignore]` } + ]; + + // Merge user system prompt from claudeRequest + if (claudeRequest.system) { + if (Array.isArray(claudeRequest.system)) { + for (const block of claudeRequest.system) { + if (block.text) systemParts.push({ text: block.text }); + } + } else if (typeof claudeRequest.system === "string") { + systemParts.push({ text: claudeRequest.system }); + } + } + + // Merge existing systemInstruction parts (from contents conversion) + if (envelope.request.systemInstruction?.parts) { + envelope.request.systemInstruction.parts.unshift(...systemParts); + } else { + envelope.request.systemInstruction = { role: GEMINI_ROLE.USER, parts: systemParts }; + } + + return envelope; +} + +// Detect if model should use Claude backend in Antigravity +// Claude models have specific ID patterns — more reliable than caps at routing level +function isClaudeModel(model) { + return model.toLowerCase().includes("claude"); +} + +// OpenAI -> Antigravity (Sandbox Cloud Code with wrapper) +export function openaiToAntigravityRequest(model, body, stream, credentials = null) { + if (isClaudeModel(model)) { + const claudeRequest = openaiToClaudeRequestForAntigravity(model, body, stream); + return wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials); + } + + const geminiCLI = openaiToGeminiCLIRequest(model, body, stream); + return wrapInCloudCodeEnvelope(model, geminiCLI, credentials, true); +} + +// Register +register(FORMATS.OPENAI, FORMATS.GEMINI, openaiToGeminiRequest, null); +register(FORMATS.OPENAI, FORMATS.GEMINI_CLI, (model, body, stream, credentials) => wrapInCloudCodeEnvelope(model, openaiToGeminiCLIRequest(model, body, stream), credentials), null); +register(FORMATS.OPENAI, FORMATS.ANTIGRAVITY, openaiToAntigravityRequest, null); + diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js new file mode 100644 index 0000000000000000000000000000000000000000..e3e7f475ee79fd8c4019d85f29876e4d1c71241e --- /dev/null +++ b/open-sse/translator/request/openai-to-kiro.js @@ -0,0 +1,596 @@ +/** + * 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"; +import { resolveSessionId } from "../../utils/sessionManager.js"; +import { + resolveKiroModel, + 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) { + let argStr; + try { + argStr = typeof input === "string" ? input : JSON.stringify(input ?? {}); + } catch { + argStr = "{}"; + } + return `[Tool call: ${name || "unknown"}(${argStr})]`; +} + +/** Render a tool result (string or content-block array) as a text line. */ +function toolResultToText(content) { + const text = Array.isArray(content) + ? content.map(c => (typeof c === "string" ? c : c.text || "")).join("\n") + : (typeof content === "string" ? content : ""); + return `[Tool result: ${text}]`; +} + +/** + * Flatten all tool calls/results in a conversation into plain text. + * + * Kiro's schema validator requires a non-empty + * currentMessage.userInputMessageContext.tools array whenever the history + * references any tool use; otherwise it returns "Improperly formed request" + * (HTTP 400). A client can hit this by omitting the `tools` array on a + * follow-up request — typically after client-side compaction (e.g. OpenCode). + * + * Rather than fabricate stub tool specs — which would advertise tool-calling + * capability the client never requested and may not handle, risking a phantom + * tool call on an otherwise plain turn — we collapse the tool interaction into + * text. The request stays honest, and since no structured tool content + * remains, the validator's "tools required" rule never fires. + * + * Only invoked when the client did NOT send tools; when tools are present the + * structured form is preserved. + */ +function flattenToolInteractions(messages) { + const out = []; + + for (const msg of messages) { + // OpenAI tool-result message → user text line + if (msg.role === ROLE.TOOL) { + out.push({ role: ROLE.USER, content: toolResultToText(msg.content) }); + continue; + } + + if (msg.role === ROLE.ASSISTANT) { + const parts = []; + if (Array.isArray(msg.content)) { + for (const c of msg.content) { + if (c.type === CLAUDE_BLOCK.TOOL_USE) { + parts.push(toolCallToText(c.name, c.input)); + } else if (c.type === OPENAI_BLOCK.TEXT || c.text) { + parts.push(c.text || ""); + } + } + } else if (typeof msg.content === "string") { + parts.push(msg.content); + } + for (const tc of msg.tool_calls || []) { + parts.push(toolCallToText(tc.function?.name, tc.function?.arguments)); + } + out.push({ role: ROLE.ASSISTANT, content: parts.filter(Boolean).join("\n") }); + continue; + } + + // User messages: replace tool_result blocks with text, keep text + images. + if (msg.role === ROLE.USER && Array.isArray(msg.content)) { + const newContent = msg.content.map(c => + c.type === CLAUDE_BLOCK.TOOL_RESULT + ? { type: OPENAI_BLOCK.TEXT, text: toolResultToText(c.content) } + : c + ); + out.push({ ...msg, content: newContent }); + continue; + } + + out.push(msg); + } + + return out; +} + +/** + * Reconcile orphaned toolResults — those whose toolUseId has no matching + * toolUse in any assistant message. This happens when client-side compaction + * truncates the conversation and removes the assistant message containing the + * tool_use, but keeps the user message with the corresponding tool_result. + * + * A dangling structured reference makes Kiro return 400, so it must be removed. + * But the client deliberately kept the result content through compaction, so + * rather than discard it we fold it back into the user message as text — the + * same shape flattenToolInteractions() produces. The 400 trigger (the + * structured reference) is gone; the content survives. + * + * `messages` is every carrier that can hold toolResults — both history items + * and the popped-out currentMessage (orphans can land on either). + */ +function reconcileOrphanedToolResults(history, currentMessage) { + // Phase 1: collect all valid toolUseIds from assistant messages in history. + // (currentMessage is always a user turn, so it carries no toolUses.) + const validIds = new Set(); + for (const h of history) { + const arm = h.assistantResponseMessage; + if (!arm) continue; + for (const tu of arm.toolUses || []) { + if (tu.toolUseId) validIds.add(tu.toolUseId); + } + } + + // Phase 2: across history + currentMessage, keep results with a matching + // toolUse and salvage the rest as text. + const carriers = currentMessage ? [...history, currentMessage] : history; + for (const item of carriers) { + const uim = item.userInputMessage; + const ctx = uim?.userInputMessageContext; + if (!ctx?.toolResults?.length) continue; + + const kept = []; + const salvaged = []; + for (const tr of ctx.toolResults) { + if (validIds.has(tr.toolUseId)) { + kept.push(tr); + } else { + salvaged.push(toolResultToText(tr.content)); + } + } + + if (salvaged.length === 0) continue; // no orphans — leave untouched + + // Fold orphaned result content into the user text so it is not lost + const extra = salvaged.join("\n"); + uim.content = uim.content ? `${uim.content}\n\n${extra}` : extra; + + ctx.toolResults = kept; + if (kept.length === 0 && !ctx.tools?.length) { + delete uim.userInputMessageContext; + } + } +} + +/** + * Safely parse JSON string, returning fallback on failure. + */ +function safeJSONParse(str, fallback) { + if (typeof str !== "string") return str ?? fallback; + try { return JSON.parse(str); } catch { return fallback; } +} + +/** + * Convert OpenAI messages to Kiro format + * Rules: system/tool/user -> user role, merge consecutive same roles. + * + * Returns { history, currentMessage }. + */ +function convertMessages(messages, tools, model) { + let history = []; + let currentMessage = null; + + const clientProvidedTools = tools && tools.length > 0; + + // When the client did not send tools, flatten any tool calls/results in the + // history into plain text (see flattenToolInteractions). This keeps the + // request honest and sidesteps Kiro's "tools required" 400, since no + // structured tool content survives to trigger it. + if (!clientProvidedTools) { + messages = flattenToolInteractions(messages); + } + + let pendingUserContent = []; + let pendingAssistantContent = []; + let pendingToolResults = []; + let pendingImages = []; + let currentRole = null; + let toolsInjectedToFirstUserMsg = false; + + const flushPending = () => { + if (currentRole === "user") { + const content = pendingUserContent.join("\n\n").trim() || "continue"; + const userMsg = { + userInputMessage: { + content: content, + modelId: "" + } + }; + + // Attach images if present (Kiro API supports images field) + if (pendingImages.length > 0) { + userMsg.userInputMessage.images = pendingImages; + } + + if (pendingToolResults.length > 0) { + userMsg.userInputMessage.userInputMessageContext = { + toolResults: pendingToolResults + }; + } + + // Add tools to the user message that has no preceding assistant messages, + // OR the first user message (whichever comes first after any opening + // assistant messages). We track whether any user message has already + // received tools via a flag on the history array. + if (clientProvidedTools && !toolsInjectedToFirstUserMsg) { + if (!userMsg.userInputMessage.userInputMessageContext) { + userMsg.userInputMessage.userInputMessageContext = {}; + } + userMsg.userInputMessage.userInputMessageContext.tools = tools.map(t => { + const name = t.function?.name || t.name; + let description = t.function?.description || t.description || ""; + + if (!description.trim()) { + description = `Tool: ${name}`; + } + + const schema = t.function?.parameters || t.parameters || t.input_schema || {}; + // Normalize schema: Kiro requires required[] and proper type/properties + const normalizedSchema = Object.keys(schema).length === 0 + ? { type: "object", properties: {}, required: [] } + : { ...schema, required: schema.required ?? [] }; + + return { + toolSpecification: { + name, + description, + inputSchema: { json: normalizedSchema } + } + }; + }); + toolsInjectedToFirstUserMsg = true; + } + + history.push(userMsg); + currentMessage = userMsg; + pendingUserContent = []; + pendingToolResults = []; + pendingImages = []; + } else if (currentRole === "assistant") { + const content = pendingAssistantContent.join("\n\n").trim() || "..."; + const assistantMsg = { + assistantResponseMessage: { + content: content + } + }; + history.push(assistantMsg); + pendingAssistantContent = []; + } + }; + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + let role = msg.role; + + // Normalize: system/tool -> user + if (role === ROLE.SYSTEM || role === ROLE.TOOL) { + role = ROLE.USER; + } + + // If role changes, flush pending + if (role !== currentRole && currentRole !== null) { + flushPending(); + } + currentRole = role; + + if (role === ROLE.USER) { + // Extract content + let content = ""; + if (typeof msg.content === "string") { + content = msg.content; + } else if (Array.isArray(msg.content)) { + const textParts = []; + for (const c of msg.content) { + if (c.type === OPENAI_BLOCK.TEXT || c.text) { + textParts.push(c.text || ""); + } else if (c.type === OPENAI_BLOCK.IMAGE_URL) { + // OpenAI format: image_url.url with data URI + const url = c.image_url?.url || ""; + 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 === 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 || DEFAULT_IMAGE_MIME; + const format = mediaType.split("/")[1] || mediaType; + pendingImages.push({ format, source: { bytes: c.source.data } }); + } + } + } + content = textParts.join("\n"); + + // Check for tool_result blocks + 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) + ? block.content.map(c => c.text || "").join("\n") + : (typeof block.content === "string" ? block.content : ""); + + pendingToolResults.push({ + toolUseId: block.tool_use_id, + status: "success", + content: [{ text: text }] + }); + }); + } + } + + // Handle tool role (from normalized) + if (msg.role === ROLE.TOOL) { + const toolContent = typeof msg.content === "string" ? msg.content : ""; + pendingToolResults.push({ + toolUseId: msg.tool_call_id, + status: "success", + content: [{ text: toolContent }] + }); + } else if (content) { + pendingUserContent.push(content); + } + } 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 === OPENAI_BLOCK.TEXT); + textContent = textBlocks.map(b => b.text).join("\n").trim(); + + const toolUseBlocks = msg.content.filter(c => c.type === CLAUDE_BLOCK.TOOL_USE); + toolUses = toolUseBlocks; + } else if (typeof msg.content === "string") { + textContent = msg.content.trim(); + } + + if (msg.tool_calls && msg.tool_calls.length > 0) { + toolUses = msg.tool_calls; + } + + if (textContent) { + pendingAssistantContent.push(textContent); + } + + // Store tool uses in last assistant message + if (toolUses.length > 0) { + // Flush to create assistant message with toolUses + flushPending(); + + const lastMsg = history[history.length - 1]; + if (lastMsg?.assistantResponseMessage) { + lastMsg.assistantResponseMessage.toolUses = toolUses.map(tc => { + if (tc.function) { + return { + toolUseId: tc.id || uuidv4(), + name: tc.function.name, + input: safeJSONParse(tc.function.arguments, {}) + }; + } else { + return { + toolUseId: tc.id || uuidv4(), + name: tc.name, + input: tc.input || {} + }; + } + }); + } + + currentRole = null; + } + } + } + + // Flush remaining + if (currentRole !== null) { + flushPending(); + } + + // Pop last userInputMessage as currentMessage (search from end, skip trailing assistant messages) + for (let i = history.length - 1; i >= 0; i--) { + if (history[i].userInputMessage) { + currentMessage = history.splice(i, 1)[0]; + break; + } + } + + // Grab tools from first history item BEFORE cleanup removes them + const firstHistoryTools = history[0]?.userInputMessage?.userInputMessageContext?.tools; + + // Clean up history for Kiro API compatibility + history.forEach(item => { + if (item.userInputMessage?.userInputMessageContext?.tools) { + delete item.userInputMessage.userInputMessageContext.tools; + } + if (item.userInputMessage?.userInputMessageContext && + Object.keys(item.userInputMessage.userInputMessageContext).length === 0) { + delete item.userInputMessage.userInputMessageContext; + } + if (item.userInputMessage && !item.userInputMessage.modelId) { + item.userInputMessage.modelId = model; + } + }); + + // Merge consecutive user messages (Kiro requires alternating user/assistant) + // When merging, also combine userInputMessageContext fields so toolResults + // and images from the second message are not silently dropped. + 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; + // Merge context: combine toolResults, images, etc. + 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); + } + } + + // When currentMessage is null (no user messages at all — edge case where + // input is only assistant messages), create a minimal currentMessage so + // tools and content can be injected. + if (!currentMessage) { + currentMessage = { + userInputMessage: { + content: "", + modelId: model, + } + }; + } + + // Reconcile orphaned toolResults across history AND currentMessage — when + // client-side compaction removes assistant messages containing tool_use but + // keeps the tool_result, the dangling reference triggers a Kiro 400. Fold the + // content back into the user text instead of discarding it. Run after + // currentMessage is finalized (an orphan can be merged into it) and before + // tool injection (which may re-add userInputMessageContext). + // + // Only needed on the tools-present path: when the client sent no tools, + // flattenToolInteractions already collapsed every toolResult to text, so + // there is nothing structured left to orphan. + if (clientProvidedTools) { + reconcileOrphanedToolResults(mergedHistory, currentMessage); + } + + // Inject tools into currentMessage AFTER cleanup. Tools only exist here when + // the client explicitly sent them (otherwise flattenToolInteractions already + // collapsed all tool content to text upstream, so there is nothing to carry). + const resolvedTools = firstHistoryTools; + + if (resolvedTools?.length > 0 && + !currentMessage.userInputMessage.userInputMessageContext?.tools) { + if (!currentMessage.userInputMessage.userInputMessageContext) { + currentMessage.userInputMessage.userInputMessageContext = {}; + } + currentMessage.userInputMessage.userInputMessageContext.tools = resolvedTools; + } + + return { history: mergedHistory, currentMessage }; +} + +/** + * Build Kiro payload from OpenAI format + * + * Two 9router-specific behaviours implemented here: + * + * 1. `-agentic` model suffix. Synthetic variant — same upstream model, but we + * inject a chunked-write system prompt to keep large file writes under + * Kiro's 2-3 minute server timeout. The suffix is stripped before being + * sent upstream. + * + * 2. Thinking / reasoning. Kiro does not accept `thinking.type` or + * `reasoning_effort` natively. The only way to enable reasoning is to + * inject `enabled` into the user content + * sent upstream. Detection covers Anthropic-Beta header, Claude API + * `thinking`, OpenAI `reasoning_effort`, AMP/Cursor magic tags, and model + * name hints. + */ +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 } = resolveKiroModel(model); + const thinkingBudget = resolveKiroThinkingBudget(body, credentials?.rawHeaders, model); + + const { history, currentMessage } = convertMessages(messages, tools, upstreamModel); + + // 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 || ""; + + const timestamp = new Date().toISOString(); + + // Build the system-prompt prefix that goes ABOVE the user message body. + // 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 (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: resolveSessionId({ headers: credentials?.rawHeaders, body, connectionId: credentials?.connectionId, scope: "kiro" }), + currentMessage: { + userInputMessage: { + content: finalContent, + modelId: upstreamModel, + origin: "AI_EDITOR", + ...(currentMessage?.userInputMessage?.images?.length > 0 && { + images: currentMessage.userInputMessage.images + }), + ...(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; + } + + // Tag payload so the executor can route the upstream model id correctly. + Object.defineProperty(payload, "_kiroUpstreamModel", { + value: upstreamModel, + enumerable: false + }); + + return payload; +} + +register(FORMATS.OPENAI, FORMATS.KIRO, openaiToKiroRequest, null); diff --git a/open-sse/translator/request/openai-to-ollama.js b/open-sse/translator/request/openai-to-ollama.js new file mode 100644 index 0000000000000000000000000000000000000000..9ecdb67f47e393e23fcd45bb21c5ea30808ec3b9 --- /dev/null +++ b/open-sse/translator/request/openai-to-ollama.js @@ -0,0 +1,195 @@ +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 + * + * Ollama expects: + * - model: string + * - messages: Array<{role: string, content: string, images?: string[] }> + * - stream: boolean + * - options?: {temperature?: number, num_predict?: number} + * + * Key differences from OpenAI: + * - Content must be string, not array + * - Multimodal images should be mapped to `message.images[]` (raw base64, no data: prefix) + * - tool role maps to tool (Ollama supports tool messages) + */ +export function openaiToOllamaRequest(model, body, stream) { + const result = { + model: model, + messages: normalizeMessages(body.messages), + stream: stream + }; + + // Temperature + if (body.temperature !== undefined) { + result.options = result.options || {}; + result.options.temperature = body.temperature; + } + + // Max tokens (Ollama uses num_predict) + if (body.max_tokens !== undefined) { + result.options = result.options || {}; + result.options.num_predict = body.max_tokens; + } + + // Top_p + if (body.top_p !== undefined) { + result.options = result.options || {}; + result.options.top_p = body.top_p; + } + + // Tools (Ollama supports tools in OpenAI format) + if (body.tools && Array.isArray(body.tools)) { + result.tools = body.tools; + } + + // Tool choice + if (body.tool_choice) { + result.tool_choice = body.tool_choice; + } + + return result; +} + +/** + * Normalize messages to Ollama format + * - Content must be string + * - tool messages: convert tool_call_id to tool_name + * - assistant messages: keep tool_calls as-is + */ +function normalizeMessages(messages) { + if (!Array.isArray(messages)) return messages; + + const result = []; + const toolCallMap = new Map(); // Map tool_call_id -> tool_name + + // First pass: build tool_call_id -> tool_name map from assistant messages + for (const msg of messages) { + 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); + } + } + } + } + + // Second pass: convert messages + for (const msg of messages) { + // Handle tool result messages (OpenAI format -> Ollama format) + if (msg.role === ROLE.TOOL) { + const toolResult = normalizeContent(msg.content); + if (!toolResult) continue; + + // Get tool_name from map or use msg.name as fallback + const toolName = toolCallMap.get(msg.tool_call_id) || msg.name || "unknown_tool"; + + result.push({ + role: ROLE.TOOL, + tool_name: toolName, + content: toolResult + }); + continue; + } + + // Handle assistant messages with 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: OPENAI_BLOCK.FUNCTION, + function: { + index: tc.index || 0, + name: tc.function?.name || "", + arguments: typeof tc.function?.arguments === "string" + ? safeParseJSON(tc.function.arguments || "{}", {}) + : tc.function?.arguments || {} + } + })); + + result.push({ + role: ROLE.ASSISTANT, + content: content, + tool_calls: ollamaToolCalls + }); + continue; + } + + // Normal messages + const role = msg.role; + const content = normalizeContent(msg.content); + const images = extractImagesFromContent(msg.content); + + // Skip empty messages (except assistant) + if (!content && role !== ROLE.ASSISTANT) continue; + + const out = { + role: role, + content: content + }; + + if (images.length > 0) { + out.images = images; + } + + result.push(out); + } + + return result; +} + +/** + * Normalize content to string + * Ollama only accepts string content + */ +function normalizeContent(content) { + if (typeof content === "string") { + return content; + } + + if (Array.isArray(content)) { + // Extract text from content array + const textParts = content + .filter(block => block && block.type === OPENAI_BLOCK.TEXT && block.text) + .map(block => block.text); + + return textParts.join("\n") || ""; + } + + return ""; +} + +/** + * Extract base64 images from OpenAI multimodal content blocks. + * OpenAI image block format: + * { type: "image_url", image_url: { url: "data:image/png;base64,..." } } + * Ollama expects raw base64 strings in message.images[]. + */ +function extractImagesFromContent(content) { + if (!Array.isArray(content)) return []; + + const images = []; + + for (const block of content) { + 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 parsed = parseDataUri(url); + if (!parsed) continue; + + images.push(parsed.base64); + } + + return images; +} + +// Register translator +register(FORMATS.OPENAI, FORMATS.OLLAMA, openaiToOllamaRequest, null); diff --git a/open-sse/translator/request/openai-to-vertex.js b/open-sse/translator/request/openai-to-vertex.js new file mode 100644 index 0000000000000000000000000000000000000000..8fe8e6378fe2e086a4dbdc2a35e50a4c56b2d361 --- /dev/null +++ b/open-sse/translator/request/openai-to-vertex.js @@ -0,0 +1,42 @@ +import { register } from "../index.js"; +import { FORMATS } from "../formats.js"; +import { openaiToGeminiRequest } from "./openai-to-gemini.js"; +import { DEFAULT_THINKING_VERTEX_SIGNATURE } from "../../config/defaultThinkingSignature.js"; + +/** + * Post-process a Gemini-format body for Vertex AI compatibility: + * + * 1. Replace all synthetic thoughtSignatures with Vertex-native signature. + * 2. Strip `id` from functionCall and functionResponse (Vertex rejects these). + */ +function postProcessForVertex(body) { + if (!body?.contents) return body; + + for (const turn of body.contents) { + if (!Array.isArray(turn.parts)) continue; + + for (const part of turn.parts) { + // Replace any synthetic signature with Vertex-native one + if (part.thoughtSignature !== undefined) { + part.thoughtSignature = DEFAULT_THINKING_VERTEX_SIGNATURE; + } + // Strip id from functionCall + if (part.functionCall && "id" in part.functionCall) { + delete part.functionCall.id; + } + // Strip id from functionResponse + if (part.functionResponse && "id" in part.functionResponse) { + delete part.functionResponse.id; + } + } + } + + return body; +} + +export function openaiToVertexRequest(model, body, stream, credentials) { + const gemini = openaiToGeminiRequest(model, body, stream, credentials); + return postProcessForVertex(gemini); +} + +register(FORMATS.OPENAI, FORMATS.VERTEX, openaiToVertexRequest, null); diff --git a/open-sse/translator/response/claude-to-openai.js b/open-sse/translator/response/claude-to-openai.js new file mode 100644 index 0000000000000000000000000000000000000000..9dfd74d0561ab15073407947190523a6f92fd9ce --- /dev/null +++ b/open-sse/translator/response/claude-to-openai.js @@ -0,0 +1,167 @@ +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 buildChunk( + { id: `chatcmpl-${state.messageId}`, created: Math.floor(Date.now() / 1000), model: state.model }, + delta, + finishReason + ); +} + +// Convert Claude stream chunk to OpenAI format +export function claudeToOpenAIResponse(chunk, state) { + if (!chunk) return null; + + const results = []; + const event = chunk.type; + + switch (event) { + case "message_start": { + state.messageId = chunk.message?.id || `msg_${Date.now()}`; + state.model = chunk.message?.model; + state.toolCallIndex = 0; + results.push(createChunk(state, { role: ROLE.ASSISTANT })); + break; + } + + case "content_block_start": { + const block = chunk.content_block; + if (block?.type === "server_tool_use") { + // Built-in tool (web search) - Claude handles internally, skip + state.serverToolBlockIndex = chunk.index; + break; + } + if (block?.type === CLAUDE_BLOCK.TEXT) { + state.textBlockStarted = true; + } else if (block?.type === CLAUDE_BLOCK.THINKING) { + state.inThinkingBlock = true; + state.currentBlockIndex = chunk.index; + results.push(createChunk(state, { content: "" })); + } 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: OPENAI_BLOCK.FUNCTION, + function: { + name: toolName, + arguments: "" + } + }; + state.toolCalls.set(chunk.index, toolCall); + results.push(createChunk(state, { tool_calls: [toolCall] })); + } + break; + } + + case "content_block_delta": { + // Skip deltas for built-in server tool blocks (web search) + if (chunk.index === state.serverToolBlockIndex) break; + const delta = chunk.delta; + 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, reasoningDelta(delta.thinking))); + } else if (delta?.type === "input_json_delta" && delta.partial_json) { + const toolCall = state.toolCalls.get(chunk.index); + if (toolCall) { + toolCall.function.arguments += delta.partial_json; + results.push(createChunk(state, { + tool_calls: [{ + index: toolCall.index, + id: toolCall.id, + function: { arguments: delta.partial_json } + }] + })); + } + } + break; + } + + case "content_block_stop": { + // Skip stop for built-in server tool blocks (web search) + if (chunk.index === state.serverToolBlockIndex) { + state.serverToolBlockIndex = -1; + break; + } + if (state.inThinkingBlock && chunk.index === state.currentBlockIndex) { + results.push(createChunk(state, { content: "" })); + state.inThinkingBlock = false; + } + state.textBlockStarted = false; + state.thinkingBlockStarted = false; + break; + } + + case "message_delta": { + // Extract usage from message_delta event (Claude native format) + // Normalize to OpenAI format (prompt_tokens/completion_tokens) for consistent logging + if (chunk.usage && typeof chunk.usage === "object") { + const inputTokens = typeof chunk.usage.input_tokens === "number" ? chunk.usage.input_tokens : 0; + const outputTokens = typeof chunk.usage.output_tokens === "number" ? chunk.usage.output_tokens : 0; + const cacheReadTokens = typeof chunk.usage.cache_read_input_tokens === "number" ? chunk.usage.cache_read_input_tokens : 0; + const cacheCreationTokens = typeof chunk.usage.cache_creation_input_tokens === "number" ? chunk.usage.cache_creation_input_tokens : 0; + + // prompt_tokens = input_tokens + cache_read + cache_creation (all prompt-side tokens) + const promptTokens = inputTokens + cacheReadTokens + cacheCreationTokens; + + state.usage = { + prompt_tokens: promptTokens, + completion_tokens: outputTokens, + total_tokens: promptTokens + outputTokens, + input_tokens: inputTokens, + output_tokens: outputTokens + }; + + if (cacheReadTokens > 0) state.usage.cache_read_input_tokens = cacheReadTokens; + if (cacheCreationTokens > 0) state.usage.cache_creation_input_tokens = cacheCreationTokens; + } + + if (chunk.delta?.stop_reason) { + state.finishReason = convertStopReason(chunk.delta.stop_reason); + const finalChunk = createChunk(state, {}, state.finishReason); + + if (state.usage) { + finalChunk.usage = toOpenAIUsage(chunk.usage, "claude"); + } + + results.push(finalChunk); + state.finishReasonSent = true; + } + break; + } + + case "message_stop": { + if (!state.finishReasonSent) { + 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, + completion_tokens: state.usage.output_tokens || 0, + total_tokens: (state.usage.input_tokens || 0) + (state.usage.output_tokens || 0) + } + } : {}; + results.push({ ...createChunk(state, {}, finishReason), ...usageObj }); + state.finishReasonSent = true; + } + break; + } + } + + return results.length > 0 ? results : null; +} + +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 new file mode 100644 index 0000000000000000000000000000000000000000..ab3d7d7b669feb268274f96b0f1e10a4824a6ebb --- /dev/null +++ b/open-sse/translator/response/commandcode-to-openai.js @@ -0,0 +1,184 @@ +/** + * CommandCode → OpenAI response translator + * + * CommandCode upstream emits NDJSON-style AI SDK v5 stream events: + * {"type":"start"} {"type":"start-step", ...} + * {"type":"reasoning-start","id":"..."} {"type":"reasoning-delta","text":"..."} + * {"type":"text-start","id":"..."} {"type":"text-delta","text":"..."} + * {"type":"tool-input-start","id","toolName"} + * {"type":"tool-input-delta","id","delta"} + * {"type":"tool-input-end","id"} + * {"type":"tool-call","toolCallId","toolName","input"} + * {"type":"finish-step","finishReason","usage": {...}, ...} + * {"type":"finish",...} + * + * Each upstream "event" arrives as one JSON object per line — we receive it as a string chunk + * already split per line by the upstream SSE/JSON-line reader in 9router. + */ +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) { + state.responseId = `chatcmpl-${Date.now()}`; + state.created = Math.floor(Date.now() / 1000); + state.model = state.model || model || "commandcode"; + state.chunkIndex = 0; + state.toolIndex = 0; + state.toolIndexById = new Map(); + state.openTools = new Set(); + state.openText = false; + state.finishReason = null; + state.usage = null; + } +} + +function makeChunk(state, delta, finishReason = null) { + return buildChunk( + { id: state.responseId, created: state.created, model: state.model }, + delta, + finishReason + ); +} + +const mapFinishReason = (reason) => toOpenAIFinish(reason, "commandcode"); + +export function commandCodeToOpenAIResponse(chunk, state) { + if (!chunk) return null; + + // Already-OpenAI chunk: pass through + if (chunk && typeof chunk === "object" && chunk.object === "chat.completion.chunk") { + return chunk; + } + + // Parse string lines coming out of upstream + let event = chunk; + if (typeof chunk === "string") { + const line = chunk.trim(); + if (!line) return null; + // Tolerate raw "data: {...}" framing if the upstream wrapper inserts it + const json = line.startsWith("data:") ? line.slice(5).trim() : line; + if (!json || json === "[DONE]") return null; + try { + event = JSON.parse(json); + } catch { + return null; + } + } + + if (!event || typeof event !== "object" || !event.type) return null; + + ensureState(state, event.model); + const out = []; + + switch (event.type) { + case "text-delta": { + const text = event.text || event.delta || ""; + if (!text) break; + const delta = state.chunkIndex === 0 ? { role: ROLE.ASSISTANT, content: text } : { content: text }; + state.chunkIndex++; + state.openText = true; + out.push(makeChunk(state, delta)); + break; + } + case "reasoning-delta": { + const text = event.text || ""; + if (!text) break; + // Map reasoning to OpenAI "reasoning_content" field (used by deepseek-reasoner-style clients). + 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 || fallbackToolCallId(state.toolIndex); + let idx = state.toolIndexById.get(id); + if (idx == null) { + idx = state.toolIndex++; + state.toolIndexById.set(id, idx); + } + state.openTools.add(id); + const delta = { + ...(state.chunkIndex === 0 ? { role: ROLE.ASSISTANT } : {}), + tool_calls: [{ + index: idx, + id, + type: OPENAI_BLOCK.FUNCTION, + function: { name: event.toolName || "", arguments: "" }, + }], + }; + state.chunkIndex++; + out.push(makeChunk(state, delta)); + break; + } + case "tool-input-delta": { + const id = event.id || event.toolCallId; + const idx = state.toolIndexById.get(id); + if (idx == null) break; + const delta = { + tool_calls: [{ + index: idx, + function: { arguments: event.delta || event.inputTextDelta || "" }, + }], + }; + out.push(makeChunk(state, delta)); + break; + } + case "tool-call": { + // Final consolidated tool call — only emit if we never saw tool-input-* deltas. + const id = event.toolCallId; + if (state.toolIndexById.has(id)) break; + const idx = state.toolIndex++; + state.toolIndexById.set(id, idx); + const argsStr = typeof event.input === "string" ? event.input : JSON.stringify(event.input ?? {}); + const delta = { + ...(state.chunkIndex === 0 ? { role: ROLE.ASSISTANT } : {}), + tool_calls: [{ + index: idx, + id, + type: OPENAI_BLOCK.FUNCTION, + function: { name: event.toolName || "", arguments: argsStr }, + }], + }; + state.chunkIndex++; + out.push(makeChunk(state, delta)); + break; + } + case "finish-step": { + state.finishReason = mapFinishReason(event.finishReason); + if (event.usage) state.usage = event.usage; + break; + } + case "finish": { + const finishReason = state.finishReason || mapFinishReason(event.finishReason || "stop"); + const finalChunk = makeChunk(state, {}, finishReason); + const totalUsage = event.totalUsage || state.usage; + const usage = toOpenAIUsage(totalUsage, "commandcode"); + if (usage) finalChunk.usage = usage; + out.push(finalChunk); + break; + } + case "error": { + 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, {}, OPENAI_FINISH.STOP)); + break; + } + // Silently ignore: start, start-step, reasoning-start, reasoning-end, text-start, text-end, + // provider-metadata, message-metadata, etc. They carry no client-visible content. + default: + break; + } + + return out.length ? out : null; +} + +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 new file mode 100644 index 0000000000000000000000000000000000000000..012abcce9c452a4d49cc4e10759e528907329a2d --- /dev/null +++ b/open-sse/translator/response/cursor-to-openai.js @@ -0,0 +1,30 @@ +/** + * Cursor to OpenAI Response Translator + * CursorExecutor already emits OpenAI format - this is a passthrough + */ +import { register } from "../index.js"; +import { FORMATS } from "../formats.js"; + +/** + * Convert Cursor response to OpenAI format + * Since CursorExecutor.transformProtobufToSSE/JSON already emits OpenAI chunks, + * this is a passthrough translator (similar to Kiro pattern) + */ +export function cursorToOpenAIResponse(chunk, state) { + if (!chunk) return null; + + // If chunk is already in OpenAI format (from executor transform), return as-is + if (chunk.object === "chat.completion.chunk" && chunk.choices) { + return chunk; + } + + // If chunk is a completion object (non-streaming), return as-is + if (chunk.object === "chat.completion" && chunk.choices) { + return chunk; + } + + // Fallback: return chunk as-is (should not reach here) + return chunk; +} + +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 new file mode 100644 index 0000000000000000000000000000000000000000..28e7d39ab974d4de29e9d88de1646ba71bcbf10c --- /dev/null +++ b/open-sse/translator/response/gemini-to-openai.js @@ -0,0 +1,143 @@ +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) { + if (!chunk) return null; + + // Handle Antigravity wrapper + const response = chunk.response || chunk; + if (!response || !response.candidates?.[0]) return null; + + const results = []; + const candidate = response.candidates[0]; + const content = candidate.content; + + // Initialize state + if (!state.messageId) { + state.messageId = response.responseId || `msg_${Date.now()}`; + state.model = response.modelVersion || "gemini"; + state.functionIndex = 0; + results.push(buildChunk(chunkMeta(state), { role: ROLE.ASSISTANT }, null)); + } + + // Process parts + if (content?.parts) { + for (const part of content.parts) { + const hasThoughtSig = part.thoughtSignature || part.thought_signature; + const isThought = part.thought === true; + + // Handle thought signature (thinking mode) + if (hasThoughtSig) { + const hasTextContent = part.text !== undefined && part.text !== ""; + const hasFunctionCall = !!part.functionCall; + + if (hasTextContent) { + results.push(buildChunk( + chunkMeta(state), + isThought ? reasoningDelta(part.text) : { content: part.text }, + null + )); + } + + if (hasFunctionCall) { + results.push(emitFunctionCall(part.functionCall, state)); + } + continue; + } + + // Text content. Gemini marks model-internal thinking with `thought: true`. + // Some responses include a thoughtSignature, but Google AI Studio/Gemini API + // 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(buildChunk( + chunkMeta(state), + isThought ? reasoningDelta(part.text) : { content: part.text }, + null + )); + } + + // Function call + if (part.functionCall) { + 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 || 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; + const geminiUsage = toOpenAIUsage(usageMeta, "gemini"); + if (geminiUsage) state.usage = geminiUsage; + + // Finish reason - include usage in final chunk + if (candidate.finishReason) { + let finishReason = toOpenAIFinish(candidate.finishReason, "gemini"); + if (finishReason === OPENAI_FINISH.STOP && state.toolCalls.size > 0) { + finishReason = OPENAI_FINISH.TOOL_CALLS; + } + + const finalChunk = buildChunk(chunkMeta(state), {}, finishReason); + + // Include usage in final chunk for downstream translators + if (state.usage) { + finalChunk.usage = state.usage; + } + + results.push(finalChunk); + state.finishReason = finishReason; + } + + return results.length > 0 ? results : null; +} + +// Register +register(FORMATS.GEMINI, FORMATS.OPENAI, null, geminiToOpenAIResponse); +register(FORMATS.GEMINI_CLI, FORMATS.OPENAI, null, geminiToOpenAIResponse); +register(FORMATS.ANTIGRAVITY, FORMATS.OPENAI, null, geminiToOpenAIResponse); +register(FORMATS.VERTEX, FORMATS.OPENAI, null, geminiToOpenAIResponse); + 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 0000000000000000000000000000000000000000..1c9ece5b4689f826da94c73c487b8efd7a7094b5 --- /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 new file mode 100644 index 0000000000000000000000000000000000000000..7059a8516a52cac780ce802e05a05953c95be790 --- /dev/null +++ b/open-sse/translator/response/kiro-to-openai.js @@ -0,0 +1,160 @@ +/** + * Kiro to OpenAI Response Translator + * Converts Kiro/AWS CodeWhisperer streaming events to OpenAI SSE format + */ +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 kiroToOpenAIResponse(chunk, state) { + + if (!chunk) return null; + + // If chunk is already in OpenAI format (from executor transform), return as-is + if (chunk.object === "chat.completion.chunk" && chunk.choices) { + return chunk; + } + + // Handle string chunk (raw SSE data) + let data = chunk; + if (typeof chunk === "string") { + // Parse SSE format: event:xxx\ndata:xxx + const lines = chunk.split("\n"); + let eventType = ""; + let eventData = ""; + + for (const line of lines) { + if (line.startsWith("event:")) { + eventType = line.slice(6).trim(); + } else if (line.startsWith(":event-type:")) { + eventType = line.slice(12).trim(); + } else if (line.startsWith("data:")) { + eventData = line.slice(5).trim(); + } else if (line.startsWith(":content-type:")) { + // Skip content-type header + } else if (line.trim() && !line.startsWith(":")) { + // Raw JSON data + eventData = line.trim(); + } + } + + if (!eventData) return null; + + try { + data = JSON.parse(eventData); + data._eventType = eventType; + } catch { + // Not JSON, might be raw text + data = { text: eventData, _eventType: eventType }; + } + } + + // Initialize state if needed + if (!state.responseId) { + state.responseId = `chatcmpl-${Date.now()}`; + state.created = Math.floor(Date.now() / 1000); + state.chunkIndex = 0; + } + + const eventType = data._eventType || data.event || ""; + + // Handle different Kiro event types + if (eventType === "assistantResponseEvent" || data.assistantResponseEvent) { + const content = data.assistantResponseEvent?.content || data.content || ""; + if (!content) return null; + + const openaiChunk = buildChunk(chunkMeta(state), { + ...(state.chunkIndex === 0 ? { role: ROLE.ASSISTANT } : {}), + content: content + }, null); + + state.chunkIndex++; + return openaiChunk; + } + + // Handle reasoning/thinking events. + // Kiro emits reasoningContentEvent when the request enabled thinking via + // the enabled system-prompt tag. We surface + // this as OpenAI delta.reasoning_content so downstream translators can map + // it to Claude thinking blocks / Anthropic reasoning / etc. + if (eventType === "reasoningContentEvent" || data.reasoningContentEvent) { + const reasoning = data.reasoningContentEvent || data; + const content = (typeof reasoning === "string") + ? reasoning + : (reasoning.text || reasoning.content || data.content || ""); + if (!content) return null; + + const openaiChunk = buildChunk(chunkMeta(state), reasoningDelta(content, state.chunkIndex === 0), null); + + state.chunkIndex++; + return openaiChunk; + } + + // Handle tool use events + if (eventType === "toolUseEvent" || data.toolUseEvent) { + state.hadToolUse = true; + const toolUse = data.toolUseEvent || data; + const toolCallId = toolUse.toolUseId || fallbackToolCallId(); + const toolName = toolUse.name || ""; + const toolInput = toolUse.input || {}; + + const openaiChunk = buildChunk(chunkMeta(state), { + ...(state.chunkIndex === 0 ? { role: ROLE.ASSISTANT } : {}), + tool_calls: [{ + index: 0, + id: toolCallId, + type: OPENAI_BLOCK.FUNCTION, + function: { + name: toolName, + arguments: JSON.stringify(toolInput) + } + }] + }, null); + + state.chunkIndex++; + return openaiChunk; + } + + // Handle completion/done events + if (eventType === "messageStopEvent" || eventType === "done" || data.messageStopEvent) { + // 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") { + openaiChunk.usage = state.usage; + } + + return openaiChunk; + } + +// Handle usage events + if (eventType === "usageEvent" || data.usageEvent) { + const usage = toOpenAIUsage(data.usageEvent || data, "kiro"); + if (usage) state.usage = usage; + return null; + } + + // Unknown event type - skip + return null; +} + +// Register translator +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 new file mode 100644 index 0000000000000000000000000000000000000000..f0a20042d167dc775523816df4f95514ad0d9f13 --- /dev/null +++ b/open-sse/translator/response/ollama-to-openai.js @@ -0,0 +1,134 @@ +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 + * + * Ollama response format: + * {"model": "...", "message": {"role": "assistant", "content": "..."}, "done": false} + * {"model": "...", "done": true, "prompt_eval_count": 123, "eval_count": 456} + * + * OpenAI format: + * {"id": "...", "object": "chat.completion.chunk", "created": 123, "model": "...", + * "choices": [{"index": 0, "delta": {"content": "..."}, "finish_reason": null}]} + */ +export function ollamaToOpenAIResponse(chunk, state) { + if (!chunk || typeof chunk !== "object") return null; + + // Initialize state on first chunk + if (!state.ollama) { + state.ollama = { + id: `chatcmpl-${Date.now()}`, + created: Math.floor(Date.now() / 1000), + model: chunk.model || state.model + }; + } + + const { id, created, model } = state.ollama; + + // Final chunk with done=true + if (chunk.done) { + const usage = extractUsage(chunk); + + // 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; + } + + const doneChunk = buildChunk({ id, created, model }, {}, finishReason); + doneChunk.usage = usage; + return doneChunk; + } + + // Content chunk + const message = chunk.message; + if (!message) return null; + + const content = typeof message.content === "string" ? message.content : ""; + const thinking = typeof message.thinking === "string" ? message.thinking : ""; + const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : null; + + // Skip empty chunks + if (!content && !thinking && !toolCalls) return null; + + // Accumulate content in state + if (content) { + state.accumulatedContent = (state.accumulatedContent || "") + content; + } + if (thinking) { + state.accumulatedThinking = (state.accumulatedThinking || "") + thinking; + } + + const delta = {}; + if (content) delta.content = content; + if (thinking) delta.reasoning_content = thinking; + + // Convert Ollama tool_calls to OpenAI format + if (toolCalls) { + state.hadToolCalls = true; + delta.tool_calls = convertToolCalls(toolCalls); + } + + return buildChunk({ id, created, model }, delta, null); +} + +/** + * Extract usage stats from Ollama response + */ +function extractUsage(ollamaChunk) { + return toOpenAIUsage(ollamaChunk, "ollama"); +} + +/** + * Convert tool_calls from Ollama format to OpenAI format + */ +function convertToolCalls(toolCalls) { + return toolCalls.map((tc, i) => ({ + index: tc.function?.index ?? i, + id: tc.id || fallbackToolCallId(i), + type: OPENAI_BLOCK.FUNCTION, + function: { + name: tc.function?.name || "", + arguments: typeof tc.function?.arguments === "string" + ? tc.function.arguments + : JSON.stringify(tc.function?.arguments || {}) + } + })); +} + +/** + * Convert Ollama non-streaming response body to OpenAI chat.completion format + */ +export function ollamaBodyToOpenAI(body) { + const msg = body.message || {}; + const content = msg.content || ""; + const thinking = msg.thinking || ""; + const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : []; + + 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 = toOpenAIFinish(body.done_reason, "ollama"); + if (toolCalls.length > 0) finishReason = OPENAI_FINISH.TOOL_CALLS; + + return { + id: `chatcmpl-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: body.model || "ollama", + choices: [{ index: 0, message, finish_reason: finishReason }], + usage: extractUsage(body) + }; +} + +// Register translator +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 new file mode 100644 index 0000000000000000000000000000000000000000..b93367853f4264cba2c1bdc70c7622e7bb9321ba --- /dev/null +++ b/open-sse/translator/response/openai-responses.js @@ -0,0 +1,535 @@ +/** + * Translator: OpenAI Chat Completions → OpenAI Responses API (response) + * Converts streaming chunks from Chat Completions to Responses API events + */ +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 + * @returns {Array} Array of events with { event, data } structure + */ +export function openaiToOpenAIResponsesResponse(chunk, state) { + if (!chunk) { + return flushEvents(state); + } + + if (!chunk.choices?.length) return []; + + const events = []; + const nextSeq = () => ++state.seq; + + const emit = (eventType, data) => { + data.sequence_number = nextSeq(); + events.push({ event: eventType, data }); + }; + + const choice = chunk.choices[0]; + const idx = choice.index || 0; + const delta = choice.delta || {}; + + // Emit initial events + if (!state.started) { + state.started = true; + state.responseId = chunk.id ? `resp_${chunk.id}` : state.responseId; + + emit("response.created", { + type: "response.created", + response: { + id: state.responseId, + object: "response", + created_at: state.created, + status: "in_progress", + background: false, + error: null, + output: [] + } + }); + + emit("response.in_progress", { + type: "response.in_progress", + response: { + id: state.responseId, + object: "response", + created_at: state.created, + status: "in_progress" + } + }); + } + + // Handle reasoning across vendor shapes (reasoning_content / reasoning / reasoning_details) + const reasoningText = extractReasoningText(delta); + if (reasoningText) { + startReasoning(state, emit, idx); + emitReasoningDelta(state, emit, reasoningText); + } + + // Handle text content + if (delta.content) { + let content = delta.content; + + if (content.includes("")) { + state.inThinking = true; + content = content.replace("", ""); + startReasoning(state, emit, idx); + } + + if (content.includes("")) { + const parts = content.split(""); + const thinkPart = parts[0]; + const textPart = parts.slice(1).join(""); + if (thinkPart) emitReasoningDelta(state, emit, thinkPart); + closeReasoning(state, emit); + state.inThinking = false; + content = textPart; + } + + if (state.inThinking && content) { + emitReasoningDelta(state, emit, content); + return events; + } + + if (content) { + emitTextContent(state, emit, idx, content); + } + } + + // Handle tool_calls + if (delta.tool_calls) { + closeMessage(state, emit, idx); + for (const tc of delta.tool_calls) { + emitToolCall(state, emit, tc); + } + } + + // Handle finish_reason + if (choice.finish_reason) { + for (const i in state.msgItemAdded) closeMessage(state, emit, i); + closeReasoning(state, emit); + for (const i in state.funcCallIds) closeToolCall(state, emit, i); + sendCompleted(state, emit); + } + + return events; +} + +// Helper functions +function startReasoning(state, emit, idx) { + if (!state.reasoningId) { + state.reasoningId = `rs_${state.responseId}_${idx}`; + state.reasoningIndex = idx; + + emit("response.output_item.added", { + type: "response.output_item.added", + output_index: idx, + item: { id: state.reasoningId, type: RESPONSES_ITEM.REASONING, summary: [] } + }); + + emit("response.reasoning_summary_part.added", { + type: "response.reasoning_summary_part.added", + item_id: state.reasoningId, + output_index: idx, + summary_index: 0, + part: { type: RESPONSES_ITEM.SUMMARY_TEXT, text: "" } + }); + state.reasoningPartAdded = true; + } +} + +function emitReasoningDelta(state, emit, text) { + if (!text) return; + state.reasoningBuf += text; + emit("response.reasoning_summary_text.delta", { + type: "response.reasoning_summary_text.delta", + item_id: state.reasoningId, + output_index: state.reasoningIndex, + summary_index: 0, + delta: text + }); +} + +function closeReasoning(state, emit) { + if (state.reasoningId && !state.reasoningDone) { + state.reasoningDone = true; + + emit("response.reasoning_summary_text.done", { + type: "response.reasoning_summary_text.done", + item_id: state.reasoningId, + output_index: state.reasoningIndex, + summary_index: 0, + text: state.reasoningBuf + }); + + emit("response.reasoning_summary_part.done", { + type: "response.reasoning_summary_part.done", + item_id: state.reasoningId, + output_index: state.reasoningIndex, + summary_index: 0, + part: { type: RESPONSES_ITEM.SUMMARY_TEXT, text: state.reasoningBuf } + }); + + emit("response.output_item.done", { + type: "response.output_item.done", + output_index: state.reasoningIndex, + item: { + id: state.reasoningId, + type: RESPONSES_ITEM.REASONING, + summary: [{ type: RESPONSES_ITEM.SUMMARY_TEXT, text: state.reasoningBuf }] + } + }); + } +} + +function emitTextContent(state, emit, idx, content) { + if (!state.msgItemAdded[idx]) { + state.msgItemAdded[idx] = true; + const msgId = `msg_${state.responseId}_${idx}`; + + emit("response.output_item.added", { + type: "response.output_item.added", + output_index: idx, + item: { id: msgId, type: RESPONSES_ITEM.MESSAGE, content: [], role: ROLE.ASSISTANT } + }); + } + + if (!state.msgContentAdded[idx]) { + state.msgContentAdded[idx] = true; + + emit("response.content_part.added", { + type: "response.content_part.added", + item_id: `msg_${state.responseId}_${idx}`, + output_index: idx, + content_index: 0, + part: { type: RESPONSES_ITEM.OUTPUT_TEXT, annotations: [], logprobs: [], text: "" } + }); + } + + emit("response.output_text.delta", { + type: "response.output_text.delta", + item_id: `msg_${state.responseId}_${idx}`, + output_index: idx, + content_index: 0, + delta: content, + logprobs: [] + }); + + if (!state.msgTextBuf[idx]) state.msgTextBuf[idx] = ""; + state.msgTextBuf[idx] += content; +} + +function closeMessage(state, emit, idx) { + if (state.msgItemAdded[idx] && !state.msgItemDone[idx]) { + state.msgItemDone[idx] = true; + const fullText = state.msgTextBuf[idx] || ""; + const msgId = `msg_${state.responseId}_${idx}`; + + emit("response.output_text.done", { + type: "response.output_text.done", + item_id: msgId, + output_index: parseInt(idx), + content_index: 0, + text: fullText, + logprobs: [] + }); + + emit("response.content_part.done", { + type: "response.content_part.done", + item_id: msgId, + output_index: parseInt(idx), + content_index: 0, + part: { type: RESPONSES_ITEM.OUTPUT_TEXT, annotations: [], logprobs: [], text: fullText } + }); + + emit("response.output_item.done", { + type: "response.output_item.done", + output_index: parseInt(idx), + item: { + id: msgId, + type: RESPONSES_ITEM.MESSAGE, + content: [{ type: RESPONSES_ITEM.OUTPUT_TEXT, annotations: [], logprobs: [], text: fullText }], + role: ROLE.ASSISTANT + } + }); + } +} + +function emitToolCall(state, emit, tc) { + const tcIdx = tc.index ?? 0; + const newCallId = tc.id; + const funcName = tc.function?.name; + + if (funcName) state.funcNames[tcIdx] = funcName; + + if (!state.funcCallIds[tcIdx] && newCallId) { + state.funcCallIds[tcIdx] = newCallId; + + emit("response.output_item.added", { + type: "response.output_item.added", + output_index: tcIdx, + item: { + id: `fc_${newCallId}`, + type: RESPONSES_ITEM.FUNCTION_CALL, + arguments: "", + call_id: newCallId, + name: state.funcNames[tcIdx] || "" + } + }); + } + + if (!state.funcArgsBuf[tcIdx]) state.funcArgsBuf[tcIdx] = ""; + + if (tc.function?.arguments) { + const refCallId = state.funcCallIds[tcIdx] || newCallId; + if (refCallId) { + emit("response.function_call_arguments.delta", { + type: "response.function_call_arguments.delta", + item_id: `fc_${refCallId}`, + output_index: tcIdx, + delta: tc.function.arguments + }); + } + state.funcArgsBuf[tcIdx] += tc.function.arguments; + } +} + +function closeToolCall(state, emit, idx) { + const callId = state.funcCallIds[idx]; + if (callId && !state.funcItemDone[idx]) { + const args = state.funcArgsBuf[idx] || "{}"; + + emit("response.function_call_arguments.done", { + type: "response.function_call_arguments.done", + item_id: `fc_${callId}`, + output_index: parseInt(idx), + arguments: args + }); + + emit("response.output_item.done", { + type: "response.output_item.done", + output_index: parseInt(idx), + item: { + id: `fc_${callId}`, + type: RESPONSES_ITEM.FUNCTION_CALL, + arguments: args, + call_id: callId, + name: state.funcNames[idx] || "" + } + }); + + state.funcItemDone[idx] = true; + state.funcArgsDone[idx] = true; + } +} + +function sendCompleted(state, emit) { + if (!state.completedSent) { + state.completedSent = true; + emit("response.completed", { + type: "response.completed", + response: { + id: state.responseId, + object: "response", + created_at: state.created, + status: "completed", + background: false, + error: null + } + }); + } +} + +function flushEvents(state) { + if (state.completedSent) return []; + + const events = []; + const nextSeq = () => ++state.seq; + const emit = (eventType, data) => { + data.sequence_number = nextSeq(); + events.push({ event: eventType, data }); + }; + + for (const i in state.msgItemAdded) closeMessage(state, emit, i); + closeReasoning(state, emit); + for (const i in state.funcCallIds) closeToolCall(state, emit, i); + sendCompleted(state, emit); + + return events; +} + +// currentToolCallId is intentionally sticky for the current turn so flush/completion + // 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 + ? OPENAI_FINISH.TOOL_CALLS + : OPENAI_FINISH.STOP; +} + +/** + * Translate OpenAI Responses API chunk to OpenAI Chat Completions format + * This is for when Codex returns data and we need to send it to an OpenAI-compatible client + */ +export function openaiResponsesToOpenAIResponse(chunk, state) { + if (!chunk) { + // Flush: send final chunk with finish_reason + if (state.finishReasonSent || !state.started) return null; + + const finishReason = computeFinishReason(state); + + state.finishReasonSent = true; + state.finishReason = 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; + } + + return finalChunk; + } + + // Handle different event types from Responses API + const eventType = chunk.type || chunk.event; + const data = chunk.data || chunk; + + // Initialize state + if (!state.started) { + state.started = true; + state.chatId = `chatcmpl-${Date.now()}`; + state.created = Math.floor(Date.now() / 1000); + state.toolCallIndex = 0; + state.currentToolCallId = null; + } + + // Text content delta + if (eventType === "response.output_text.delta") { + const delta = data.delta || ""; + if (!delta) return null; + + return buildChunk( + { id: state.chatId, created: state.created, model: state.model || MODEL_FALLBACK }, + { content: delta } + ); + } + + // Text content done (ignore, we handle via delta) + if (eventType === "response.output_text.done") { + return null; + } + + // Function call started (standard function_call or 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 || fallbackToolCallId(); + + 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) + if (eventType === "response.function_call_arguments.delta" || eventType === "response.custom_tool_call_input.delta") { + const argsDelta = data.delta || ""; + if (!argsDelta) return 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 === RESPONSES_ITEM.FUNCTION_CALL || data.item?.type === "custom_tool_call")) { + state.toolCallIndex++; + return null; + } + + // Response completed + if (eventType === "response.completed" || eventType === "response.done") { + // Extract usage from response.completed event + const responseUsage = data.response?.usage; + if (responseUsage && typeof responseUsage === "object") { + const inputTokens = responseUsage.input_tokens || responseUsage.prompt_tokens || 0; + const outputTokens = responseUsage.output_tokens || responseUsage.completion_tokens || 0; + // OpenAI Responses API: input_tokens already includes cached_tokens + // 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 = buildUsage({ promptTokens: inputTokens, completionTokens: outputTokens, totalTokens: inputTokens + outputTokens, cachedTokens: cacheReadTokens }); + } + + if (!state.finishReasonSent) { + const finishReason = computeFinishReason(state); + + state.finishReasonSent = true; + state.finishReason = finishReason; // Mark for usage injection in stream.js + + 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; + } + + return finalChunk; + } + return null; + } + + // Error events from Responses API (e.g. model_not_found) + if (eventType === "error" || eventType === "response.failed") { + // Avoid emitting duplicate errors (error + response.failed arrive back-to-back) + if (state.finishReasonSent) return null; + + const error = data.error || data.response?.error; + if (error) { + state.error = error; + state.finishReasonSent = true; + + // Surface the error as an OpenAI-compatible error chunk + 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; + } + + // Reasoning summary delta → emit as reasoning_content for client thinking display + if (eventType === "response.reasoning_summary_text.delta") { + const delta = data.delta || ""; + if (!delta) return null; + return buildChunk( + { id: state.chatId, created: state.created, model: state.model || MODEL_FALLBACK }, + reasoningDelta(delta) + ); + } + + // Ignore other events + return null; +} + +// Register both directions +register(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, null, openaiToOpenAIResponsesResponse); +register(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, null, openaiResponsesToOpenAIResponse); diff --git a/open-sse/translator/response/openai-to-antigravity.js b/open-sse/translator/response/openai-to-antigravity.js new file mode 100644 index 0000000000000000000000000000000000000000..b0b360a0de359398cbde1ddef9032d25938e82cb --- /dev/null +++ b/open-sse/translator/response/openai-to-antigravity.js @@ -0,0 +1,123 @@ +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: +// data: {"response":{"candidates":[{"content":{"role":"model","parts":[...]}, "finishReason":"STOP"}], "usageMetadata":{...}, "modelVersion":"...", "responseId":"..."}} +// Tool calls: OpenAI sends incremental args across chunks → accumulate and emit ONCE at finish +export function openaiToAntigravityResponse(chunk, state) { + if (!chunk) return null; + + const choice = chunk.choices?.[0]; + if (!choice) { + if (chunk.usage) { + state._usage = chunk.usage; + } + return null; + } + + const delta = choice.delta || {}; + const finishReason = choice.finish_reason; + + // Init state + if (!state._toolCallAccum) state._toolCallAccum = {}; + if (!state._responseId) state._responseId = chunk.id || `resp_${Date.now()}`; + if (!state._modelVersion) state._modelVersion = chunk.model || ""; + + const parts = []; + + // Thinking/reasoning → thought part + if (delta.reasoning_content) { + parts.push({ thought: true, text: delta.reasoning_content }); + } + + // Text content + if (delta.content) { + parts.push({ text: delta.content }); + } + + // Accumulate tool calls silently (no emit until finish) + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + const idx = tc.index ?? 0; + if (!state._toolCallAccum[idx]) { + state._toolCallAccum[idx] = { id: "", name: "", arguments: "" }; + } + const accum = state._toolCallAccum[idx]; + if (tc.id) accum.id = tc.id; + if (tc.function?.name) accum.name += tc.function.name; + if (tc.function?.arguments) accum.arguments += tc.function.arguments; + } + // Skip emit — wait for finish_reason + if (parts.length === 0 && !finishReason) return null; + } + + // On finish, emit accumulated tool calls as complete functionCall parts + if (finishReason) { + const indices = Object.keys(state._toolCallAccum); + for (const idx of indices) { + const accum = state._toolCallAccum[idx]; + let args = {}; + try { args = JSON.parse(accum.arguments); } catch { /* empty */ } + // Restore original tool name if it was prefixed during cloaking + const originalName = state.toolNameMap?.get(accum.name) || accum.name; + parts.push({ + functionCall: { + name: originalName, + args + } + }); + } + } + + // Skip empty non-finish chunks + if (parts.length === 0 && !finishReason) return null; + + // Ensure at least empty text part on finish with no content + if (parts.length === 0 && finishReason) { + parts.push({ text: "" }); + } + + // Build candidate + const candidate = { content: { role: GEMINI_ROLE.MODEL, parts } }; + + // Finish reason mapping + if (finishReason) { + const reasonMap = { + [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] || GEMINI_FINISH.STOP; + } + + // Build response + const response = { + candidates: [candidate], + modelVersion: state._modelVersion, + responseId: state._responseId + }; + + // Usage metadata + const usage = chunk.usage || state._usage; + if (usage) { + response.usageMetadata = { + promptTokenCount: usage.prompt_tokens || 0, + candidatesTokenCount: usage.completion_tokens || 0, + totalTokenCount: usage.total_tokens || 0 + }; + if (usage.completion_tokens_details?.reasoning_tokens) { + response.usageMetadata.thoughtsTokenCount = usage.completion_tokens_details.reasoning_tokens; + } + if (usage.prompt_tokens_details?.cached_tokens) { + response.usageMetadata.cachedContentTokenCount = usage.prompt_tokens_details.cached_tokens; + } + } + + return { response }; +} + +// Register +register(FORMATS.OPENAI, FORMATS.ANTIGRAVITY, null, openaiToAntigravityResponse); diff --git a/open-sse/translator/response/openai-to-claude.js b/open-sse/translator/response/openai-to-claude.js new file mode 100644 index 0000000000000000000000000000000000000000..e771c15458c3de6e719d0a7b4f7ed894261bab5a --- /dev/null +++ b/open-sse/translator/response/openai-to-claude.js @@ -0,0 +1,264 @@ +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"; + +// 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 +function sanitizeToolArgs(toolName, argsJson) { + try { + const args = JSON.parse(argsJson); + const name = toolName.startsWith(CLAUDE_OAUTH_TOOL_PREFIX) + ? toolName.slice(CLAUDE_OAUTH_TOOL_PREFIX.length) + : toolName; + if (name === "Read") sanitizeReadArgs(args); + return JSON.stringify(args); + } catch { + return argsJson; + } +} + +function sanitizeReadArgs(args) { + if (typeof args.limit === "string" && /^\d+$/.test(args.limit)) args.limit = Number(args.limit); + if (typeof args.offset === "string" && /^-?\d+$/.test(args.offset)) args.offset = Number(args.offset); + + if (typeof args.limit === "number") { + if (args.limit > 2000) args.limit = 2000; + if (args.limit < 1) delete args.limit; + } + if (typeof args.offset === "number" && args.offset < 0) args.offset = 0; + + if ("pages" in args && !isValidPdfPagesArg(args.file_path, args.pages)) { + delete args.pages; + } +} + +function isValidPdfPagesArg(filePath, pages) { + return typeof filePath === "string" && + filePath.toLowerCase().endsWith(".pdf") && + typeof pages === "string" && + /^\d+(?:-\d+)?$/.test(pages); +} + +// Helper: stop thinking block if started +function stopThinkingBlock(state, results) { + if (!state.thinkingBlockStarted) return; + results.push({ + type: "content_block_stop", + index: state.thinkingBlockIndex + }); + state.thinkingBlockStarted = false; +} + +// Helper: stop text block if started +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; +} + +// Convert OpenAI stream chunk to Claude format +export function openaiToClaudeResponse(chunk, state) { + if (!chunk || !chunk.choices?.[0]) return null; + + const results = []; + const choice = chunk.choices[0]; + const delta = choice.delta; + + // Track usage from OpenAI chunk if available + if (chunk.usage && typeof chunk.usage === "object") { + const promptTokens = typeof chunk.usage.prompt_tokens === "number" ? chunk.usage.prompt_tokens : 0; + const outputTokens = typeof chunk.usage.completion_tokens === "number" ? chunk.usage.completion_tokens : 0; + + // Extract cache tokens from prompt_tokens_details + const cachedTokens = chunk.usage.prompt_tokens_details?.cached_tokens; + const cacheCreationTokens = chunk.usage.prompt_tokens_details?.cache_creation_tokens; + const cacheReadTokens = typeof cachedTokens === "number" ? cachedTokens : 0; + const cacheCreateTokens = typeof cacheCreationTokens === "number" ? cacheCreationTokens : 0; + + // input_tokens = prompt_tokens - cached_tokens - cache_creation_tokens + // Because OpenAI's prompt_tokens includes all prompt-side tokens + const inputTokens = promptTokens - cacheReadTokens - cacheCreateTokens; + + state.usage = { + input_tokens: inputTokens, + output_tokens: outputTokens + }; + + // Add cache_read_input_tokens if present + if (cacheReadTokens > 0) { + state.usage.cache_read_input_tokens = cacheReadTokens; + } + + // Add cache_creation_input_tokens if present + if (cacheCreateTokens > 0) { + state.usage.cache_creation_input_tokens = cacheCreateTokens; + } + + // Note: completion_tokens_details.reasoning_tokens is already included in output_tokens + // No need to add separately as Claude expects total output_tokens + } + + // First chunk - ALWAYS send message_start first + if (!state.messageStartSent) { + state.messageStartSent = true; + state.messageId = chunk.id?.replace("chatcmpl-", "") || `msg_${Date.now()}`; + if (!state.messageId || state.messageId === "chat" || state.messageId.length < 8) { + state.messageId = chunk.extend_fields?.requestId || + chunk.extend_fields?.traceId || + `msg_${Date.now()}`; + } + state.model = chunk.model || MODEL_FALLBACK; + state.nextBlockIndex = 0; + results.push({ + type: "message_start", + message: { + id: state.messageId, + type: "message", + role: ROLE.ASSISTANT, + model: state.model, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 } + } + }); + } + + // Handle reasoning (thinking) across vendor shapes - GLM/DeepSeek/Qwen/MiniMax/etc. + const reasoningContent = extractReasoningText(delta); + 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: CLAUDE_BLOCK.THINKING, thinking: "" } + }); + } + + results.push({ + type: "content_block_delta", + index: state.thinkingBlockIndex, + delta: { type: "thinking_delta", thinking: reasoningContent } + }); + } + + // Handle regular 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: CLAUDE_BLOCK.TEXT, text: "" } + }); + } + + results.push({ + type: "content_block_delta", + index: state.textBlockIndex, + delta: { type: "text_delta", text: delta.content } + }); + } + + // Tool calls + if (delta?.tool_calls) { + 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 }); + + // Strip prefix from tool name for response + let toolName = tc.function?.name || ""; + if (toolName.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) { + toolName = toolName.slice(CLAUDE_OAUTH_TOOL_PREFIX.length); + } + + results.push({ + type: "content_block_start", + index: toolBlockIndex, + content_block: { + type: CLAUDE_BLOCK.TOOL_USE, + id: tc.id, + name: toolName, + input: {} + } + }); + } + + if (tc.function?.arguments) { + const toolInfo = state.toolCalls.get(idx); + if (toolInfo) { + // Buffer args instead of streaming — sanitize at finish to fix bad params + if (!state.toolArgBuffers) state.toolArgBuffers = new Map(); + state.toolArgBuffers.set(idx, (state.toolArgBuffers.get(idx) || "") + tc.function.arguments); + } + } + } + } + + // Finish + if (choice.finish_reason) { + stopThinkingBlock(state, results); + stopTextBlock(state, results); + + for (const [idx, toolInfo] of state.toolCalls) { + // Emit buffered + sanitized args as single delta before stop + const buffered = state.toolArgBuffers?.get(idx); + if (buffered) { + const sanitized = sanitizeToolArgs(toolInfo.name, buffered); + results.push({ + type: "content_block_delta", + index: toolInfo.blockIndex, + delta: { type: "input_json_delta", partial_json: sanitized } + }); + } + results.push({ + type: "content_block_stop", + index: toolInfo.blockIndex + }); + } + + // Mark finish for later usage injection in stream.js + state.finishReason = choice.finish_reason; + + // Use tracked usage (will be estimated in stream.js if not valid) + 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; +} + +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 0000000000000000000000000000000000000000..61c256459312e9fe496d8b86c1e9d7c85ceae786 --- /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 0000000000000000000000000000000000000000..9601a473eb367dd5f68f2681ddd5e484f0b289ec --- /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 0000000000000000000000000000000000000000..73535dae861b2ab71b67b86b92320fb6b2384491 --- /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 0000000000000000000000000000000000000000..3fada8c9080b751e0cd008cb173a1a996c225c66 --- /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 0000000000000000000000000000000000000000..1fe91a6d8a702cf159faa05fe858579fab7e457e --- /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/bypassHandler.js b/open-sse/utils/bypassHandler.js new file mode 100644 index 0000000000000000000000000000000000000000..57fa2ff2bbfa8cad0c70766189754fbdeda5f945 --- /dev/null +++ b/open-sse/utils/bypassHandler.js @@ -0,0 +1,298 @@ +import { detectFormat } from "../services/provider.js"; +import { translateResponse, initState } from "../translator/index.js"; +import { FORMATS } from "../translator/formats.js"; +import { SKIP_PATTERNS } from "../config/runtimeConfig.js"; +import { formatSSE } from "./stream.js"; + +/** + * Check for bypass patterns - return fake response without calling provider + * Only works for Claude CLI requests + */ +export function handleBypassRequest(body, model, userAgent = "", ccFilterNaming = false) { + if (!userAgent.includes("claude-cli")) return null; + if (!body.messages?.length) return null; + + const messages = body.messages; + const getText = (content) => { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content.filter(c => c.type === "text").map(c => c.text).join(" "); + } + return ""; + }; + + let shouldBypass = false; + let namingBypass = false; + + // Pattern 1: Title extraction (assistant message = "{") + const lastMsg = messages[messages.length - 1]; + if (lastMsg?.role === "assistant" && lastMsg.content?.[0]?.text === "{") { + shouldBypass = true; + } + + // Pattern 2: Warmup + if (!shouldBypass) { + const firstText = getText(messages[0]?.content); + if (firstText === "Warmup") { + shouldBypass = true; + } + } + + // Pattern 3: Count + if (!shouldBypass && messages.length === 1 && messages[0]?.role === "user") { + const firstText = getText(messages[0]?.content); + if (firstText === "count") { + shouldBypass = true; + } + } + + // Pattern 4: Skip patterns + if (!shouldBypass && SKIP_PATTERNS?.length) { + const userMessages = messages.filter(m => m.role === "user"); + const userText = userMessages.map(m => getText(m.content)).join(" "); + if (SKIP_PATTERNS.some(p => userText.includes(p))) { + shouldBypass = true; + } + } + + // Pattern 5: CC naming request (topic title extraction by Claude Code CLI) + // Claude format: system is top-level body.system field, not inside messages + if (!shouldBypass && ccFilterNaming) { + const systemMsg = messages.find(m => m.role === "system"); + const systemFromMessages = getText(systemMsg?.content); + const systemFromBody = Array.isArray(body.system) + ? body.system.filter(s => s.type === "text").map(s => s.text).join(" ") + : (typeof body.system === "string" ? body.system : ""); + const systemText = systemFromMessages || systemFromBody; + if (systemText.includes("isNewTopic")) { + shouldBypass = true; + namingBypass = true; + } + } + + if (!shouldBypass) return null; + + const sourceFormat = detectFormat(body); + const stream = body.stream !== false; + + // For naming bypass, generate title from user message + if (namingBypass) { + const userMsg = messages.find(m => m.role === "user"); + const userText = getText(userMsg?.content); + const title = userText.trim().split(/\s+/).slice(0, 3).join(" "); + const namingText = JSON.stringify({ isNewTopic: true, title }); + return stream + ? createStreamingResponse(sourceFormat, model, namingText) + : createNonStreamingResponse(sourceFormat, model, namingText); + } + + return stream + ? createStreamingResponse(sourceFormat, model) + : createNonStreamingResponse(sourceFormat, model); +} + +const DEFAULT_BYPASS_TEXT = "CLI Command Execution: Clear Terminal"; + +/** + * Create OpenAI standard format response + */ +function createOpenAIResponse(model, text = DEFAULT_BYPASS_TEXT) { + const id = `chatcmpl-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + + return { + id, + object: "chat.completion", + created, + model, + choices: [{ + index: 0, + message: { + role: "assistant", + content: text + }, + finish_reason: "stop" + }], + usage: { + prompt_tokens: 1, + completion_tokens: 1, + total_tokens: 2 + } + }; +} + +/** + * Create non-streaming response with translation + * Use translator to convert OpenAI → sourceFormat + */ +function createNonStreamingResponse(sourceFormat, model, text) { + const openaiResponse = createOpenAIResponse(model, text); + + // If sourceFormat is OpenAI, return directly + if (sourceFormat === FORMATS.OPENAI) { + return { + success: true, + response: new Response(JSON.stringify(openaiResponse), { + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*" + } + }) + }; + } + + // Use translator to convert: simulate streaming then collect all chunks + const state = initState(sourceFormat); + state.model = model; + + const openaiChunks = createOpenAIStreamingChunks(openaiResponse); + const allTranslated = []; + + for (const chunk of openaiChunks) { + const translated = translateResponse(FORMATS.OPENAI, sourceFormat, chunk, state); + if (translated?.length > 0) { + allTranslated.push(...translated); + } + } + + // Flush remaining + const flushed = translateResponse(FORMATS.OPENAI, sourceFormat, null, state); + if (flushed?.length > 0) { + allTranslated.push(...flushed); + } + + // For non-streaming, merge all chunks into final response + const finalResponse = mergeChunksToResponse(allTranslated, sourceFormat); + + return { + success: true, + response: new Response(JSON.stringify(finalResponse), { + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*" + } + }) + }; +} + +/** + * Create streaming response with translation + * Use translator to convert OpenAI chunks → sourceFormat + */ +function createStreamingResponse(sourceFormat, model, text) { + const openaiResponse = createOpenAIResponse(model, text); + const state = initState(sourceFormat); + state.model = model; + + // Create OpenAI streaming chunks + const openaiChunks = createOpenAIStreamingChunks(openaiResponse); + + // Translate each chunk to sourceFormat using translator + const translatedChunks = []; + + for (const chunk of openaiChunks) { + const translated = translateResponse(FORMATS.OPENAI, sourceFormat, chunk, state); + if (translated?.length > 0) { + for (const item of translated) { + translatedChunks.push(formatSSE(item, sourceFormat)); + } + } + } + + // Flush remaining events + const flushed = translateResponse(FORMATS.OPENAI, sourceFormat, null, state); + if (flushed?.length > 0) { + for (const item of flushed) { + translatedChunks.push(formatSSE(item, sourceFormat)); + } + } + + // Add [DONE] + translatedChunks.push("data: [DONE]\n\n"); + + return { + success: true, + response: new Response(translatedChunks.join(""), { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Access-Control-Allow-Origin": "*" + } + }) + }; +} + +/** + * Merge translated chunks into final response object (for non-streaming) + * Takes the last complete chunk as the final response + */ +function mergeChunksToResponse(chunks, sourceFormat) { + if (!chunks || chunks.length === 0) { + return createOpenAIResponse("unknown"); + } + + // For most formats, the last chunk before done contains the complete response + // Find the most complete chunk (usually the last one with content) + let finalChunk = chunks[chunks.length - 1]; + + // For Claude format, find the message_stop or final message + if (sourceFormat === FORMATS.CLAUDE) { + const messageStop = chunks.find(c => c.type === "message_stop"); + if (messageStop) { + // Reconstruct complete message from chunks + const contentDelta = chunks.find(c => c.type === "content_block_delta"); + const messageDelta = chunks.find(c => c.type === "message_delta"); + const messageStart = chunks.find(c => c.type === "message_start"); + + if (messageStart?.message) { + finalChunk = messageStart.message; + // Merge usage if available + if (messageDelta?.usage) { + finalChunk.usage = messageDelta.usage; + } + } + } + } + + return finalChunk; +} + +/** + * Create OpenAI streaming chunks from complete response + */ +function createOpenAIStreamingChunks(completeResponse) { + const { id, created, model, choices } = completeResponse; + const content = choices[0].message.content; + + return [ + // Chunk with content + { + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ + index: 0, + delta: { + role: "assistant", + content + }, + finish_reason: null + }] + }, + // Final chunk with finish_reason + { + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ + index: 0, + delta: {}, + finish_reason: "stop" + }], + usage: completeResponse.usage + } + ]; +} diff --git a/open-sse/utils/claudeCloaking.js b/open-sse/utils/claudeCloaking.js new file mode 100644 index 0000000000000000000000000000000000000000..46a44e4f95765c3ce71f3633d9e53fb9963d5cd2 --- /dev/null +++ b/open-sse/utils/claudeCloaking.js @@ -0,0 +1,165 @@ +import { createHash, randomBytes, randomUUID } from "crypto"; +import { CLAUDE_TOOL_SUFFIX, CC_DEFAULT_TOOLS } from "../config/appConstants.js"; + +const CLAUDE_VERSION = "2.1.92"; +const CC_ENTRYPOINT = "sdk-cli"; + +// Generate billing header matching real Claude Code 2.1.92+ format: +// x-anthropic-billing-header: cc_version=.; cc_entrypoint=sdk-cli; cch=; +function generateBillingHeader(payload) { + const content = JSON.stringify(payload); + const cch = createHash("sha256").update(content).digest("hex").slice(0, 5); + const buildHash = randomBytes(2).toString("hex").slice(0, 3); + 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":""} +// 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}"}`; +} + +/** + * Cloak tools before sending to Claude provider (anti-ban): + * - Rename non-CC client tools with _cc suffix in tools[] and messages[] + * - Skip tools that are already CC default names (they become decoys as-is) + * - Inject CC_DECOY_TOOLS after client tools + * Returns { body, toolNameMap } where toolNameMap maps suffixed → original + * @param {object} body - Claude API request body + * @returns {{ body: object, toolNameMap: Map|null }} + */ +export function cloakClaudeTools(body) { + const tools = body.tools; + if (!tools || tools.length === 0) return { body, toolNameMap: null }; + + const suffix = (name) => `${name}${CLAUDE_TOOL_SUFFIX}`; + const toolNameMap = new Map(); + const clientToolNames = new Set(); + const clientDeclarations = []; + + // 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); + clientDeclarations.push({ ...tool, name: suffixed }); + } + + // Client tools first, then CC decoy tools (no overlap: client tools all have _cc suffix) + const allTools = [...clientDeclarations, ...CC_DECOY_TOOLS]; + + // Rename tool_use in message history (all client tools get suffix) + const renamedMessages = body.messages?.map(msg => { + if (!Array.isArray(msg.content)) return msg; + const renamedContent = msg.content.map(block => + block.type === "tool_use" ? { ...block, name: suffix(block.name) } : block + ); + return { ...msg, content: renamedContent }; + }); + + const cloakedBody = { ...body, tools: allTools, messages: renamedMessages || body.messages }; + + // A forced tool_choice ({ type: "tool", name }) must point at the suffixed + // tool name, otherwise Claude rejects it: "Tool '' not found in provided tools". + // Only rewrite when the choice targets one of the client tools we actually + // renamed — never a decoy/built-in name (those are sent unsuffixed). + if ( + body.tool_choice?.type === "tool" && + clientToolNames.has(body.tool_choice.name) + ) { + cloakedBody.tool_choice = { ...body.tool_choice, name: suffix(body.tool_choice.name) }; + } + + return { + body: cloakedBody, + toolNameMap: toolNameMap.size > 0 ? toolNameMap : null + }; +} + +// Decloak tool_use names in non-streaming Claude response body (INPUT side) +export function decloakToolNames(body, toolNameMap) { + if (!toolNameMap?.size || !Array.isArray(body?.content)) return body; + const content = body.content.map(block => { + if (block?.type === "tool_use" && toolNameMap.has(block.name)) { + return { ...block, name: toolNameMap.get(block.name) }; + } + return block; + }); + return { ...body, content }; +} + +// CC decoy tools — Claude Code native tool names, marked unavailable +const CC_DECOY_TOOLS = [ + { name: "Task", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, + { name: "TaskOutput", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, + { name: "TaskStop", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, + { name: "TaskCreate", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, + { name: "TaskGet", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, + { name: "TaskUpdate", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, + { name: "TaskList", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, + { name: "Bash", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, + { name: "Glob", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, + { name: "Grep", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, + { name: "Read", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, + { name: "Edit", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, + { name: "Write", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, + { name: "NotebookEdit", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, + { name: "WebFetch", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, + { name: "WebSearch", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, + { name: "AskUserQuestion", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, + { name: "Skill", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, + { name: "EnterPlanMode", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, + { name: "ExitPlanMode", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } }, +]; + +/** + * Apply Claude cloaking to request body: + * 1. Inject billing header as first system block + * 2. Inject fake user ID into metadata (JSON format, session_id aligned with X-Claude-Code-Session-Id) + * Only applies when using OAuth token (sk-ant-oat). + * @param {object} body - Claude API request body + * @param {string} apiKey - API key or OAuth token + * @param {string} [sessionId] - Session ID to align with X-Claude-Code-Session-Id header + * @returns {object} Modified body + */ +export function applyCloaking(body, apiKey, sessionId) { + if (!apiKey || !apiKey.includes("sk-ant-oat")) return body; + + const result = { ...body }; + + // Inject billing header as system[0], preserve existing system blocks + const billingText = generateBillingHeader(body); + const billingBlock = { type: "text", text: billingText }; + + if (Array.isArray(result.system)) { + // Skip if already injected + if (!result.system[0]?.text?.startsWith("x-anthropic-billing-header:")) { + result.system = [billingBlock, ...result.system]; + } + } else if (typeof result.system === "string") { + result.system = [billingBlock, { type: "text", text: result.system }]; + } else { + result.system = [billingBlock]; + } + + // 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, apiKey) }; + } + + return result; +} diff --git a/open-sse/utils/claudeHeaderCache.js b/open-sse/utils/claudeHeaderCache.js new file mode 100644 index 0000000000000000000000000000000000000000..11b2eb81ee572369c29dd07448f7a0e568d05d69 --- /dev/null +++ b/open-sse/utils/claudeHeaderCache.js @@ -0,0 +1,70 @@ +/** + * Singleton cache for real Claude Code client headers. + * Captures headers from authentic Claude Code requests and makes them available + * for forwarding to api.anthropic.com, replacing static hardcoded values. + */ + +const CLAUDE_IDENTITY_HEADERS = [ + "user-agent", + "anthropic-beta", + "anthropic-version", + "anthropic-dangerous-direct-browser-access", + "x-app", + "x-stainless-helper-method", + "x-stainless-retry-count", + "x-stainless-runtime-version", + "x-stainless-package-version", + "x-stainless-runtime", + "x-stainless-lang", + "x-stainless-arch", + "x-stainless-os", + "x-stainless-timeout", + "x-claude-code-session-id", + "package-version", + "runtime-version", + "os", + "arch", +]; + +let cachedHeaders = null; + +/** + * Detect if request headers look like a real Claude Code client. + * @param {object} headers - Lowercase header key/value object + */ +function isClaudeCodeClient(headers) { + const ua = (headers["user-agent"] || "").toLowerCase(); + const xApp = (headers["x-app"] || "").toLowerCase(); + return ua.includes("claude-cli") || ua.includes("claude-code") || xApp === "cli"; +} + +/** + * Store Claude Code identity headers if this looks like a real client request. + * Called at the entry point before any translation/forwarding. + * @param {object} headers - Lowercase header key/value object (from request.headers.entries()) + */ +export function cacheClaudeHeaders(headers) { + if (!headers || typeof headers !== "object") return; + if (!isClaudeCodeClient(headers)) return; + + const captured = {}; + for (const key of CLAUDE_IDENTITY_HEADERS) { + if (headers[key] !== undefined && headers[key] !== null) { + captured[key] = headers[key]; + } + } + + if (Object.keys(captured).length > 0) { + cachedHeaders = captured; + console.log(`[ClaudeHeaders] Cached ${Object.keys(captured).length} identity headers from Claude Code client`); + } +} + +/** + * Get the most recently cached Claude Code identity headers. + * Returns null if no authentic client request has been seen yet (cold start). + * @returns {object|null} + */ +export function getCachedClaudeHeaders() { + return cachedHeaders; +} diff --git a/open-sse/utils/clientDetector.js b/open-sse/utils/clientDetector.js new file mode 100644 index 0000000000000000000000000000000000000000..2d1381bcef816710d53139bc35dab88a630b5c45 --- /dev/null +++ b/open-sse/utils/clientDetector.js @@ -0,0 +1,63 @@ +/** + * Detect CLI tool identity from request headers/body. + * Used to determine if a request can be passed through losslessly. + */ + +// Map of CLI tool identifiers to provider IDs they are "native" to +const NATIVE_PAIRS = { + "claude": ["claude", "anthropic"], + "gemini-cli": ["gemini-cli"], + "antigravity": ["antigravity"], + "codex": ["codex"], +}; + +/** + * Detect which CLI tool is making the request. + * Returns one of: "claude" | "gemini-cli" | "antigravity" | "codex" | null + * @param {object} headers - Lowercase header key/value object + * @param {object} body - Parsed request body + */ +export function detectClientTool(headers = {}, body = {}) { + const ua = (headers["user-agent"] || "").toLowerCase(); + const xApp = (headers["x-app"] || "").toLowerCase(); + const openaiIntent = (headers["openai-intent"] || "").toLowerCase(); + const initiator = (headers["x-initiator"] || headers["X-Initiator"] || "").toLowerCase(); + + // Antigravity: detected via body field (not header) + if (body.userAgent === "antigravity") return "antigravity"; + + // GitHub Copilot / OAI compatible extension using Copilot chat headers + if (ua.includes("githubcopilotchat") || openaiIntent === "conversation-panel" || initiator === "user") { + return "github-copilot"; + } + + // Claude Code / Claude CLI + if (ua.includes("claude-cli") || ua.includes("claude-code") || xApp === "cli") return "claude"; + + // Gemini CLI + if (ua.includes("gemini-cli")) return "gemini-cli"; + + // Codex CLI + if (ua.includes("codex-cli")) return "codex"; + + // DeepSeek TUI + if (ua.includes("deepseek-tui")) return "deepseek-tui"; + + return null; +} + +/** + * Check if this CLI tool + provider pair should be passed through losslessly. + * @param {string|null} clientTool - Result of detectClientTool() + * @param {string} provider - Provider ID (e.g. "claude", "gemini-cli") + */ +export function isNativePassthrough(clientTool, provider) { + if (!clientTool) return false; + const nativeProviders = NATIVE_PAIRS[clientTool]; + if (!nativeProviders) return false; + // Support anthropic-compatible-* variants + const normalizedProvider = provider.startsWith("anthropic-compatible") + ? "anthropic" + : provider; + return nativeProviders.includes(normalizedProvider); +} diff --git a/open-sse/utils/cursorChecksum.js b/open-sse/utils/cursorChecksum.js new file mode 100644 index 0000000000000000000000000000000000000000..961df7a12eed1bec6ae987c77ff240860145ea3f --- /dev/null +++ b/open-sse/utils/cursorChecksum.js @@ -0,0 +1,149 @@ +/** + * Cursor Checksum Utility (Jyh Cipher) + * + * Generates the x-cursor-checksum header required for Cursor API authentication. + * Based on the JavaScript implementation from Cursor IDE. + */ + +import crypto from "crypto"; +import { v5 as uuidv5 } from "uuid"; + +/** + * Generate SHA-256 hash like generateHashed64Hex + * @param {string} input - Input string + * @param {string} salt - Optional salt + * @returns {string} - 64-character hex string + */ +export function generateHashed64Hex(input, salt = "") { + return crypto.createHash("sha256").update(input + salt).digest("hex"); +} + +/** + * Generate session ID using UUID v5 with DNS namespace + * @param {string} authToken - Auth token + * @returns {string} - UUID string + */ +export function generateSessionId(authToken) { + return uuidv5(authToken, uuidv5.DNS); +} + +/** + * Generate cursor checksum (Jyh cipher) + * + * Algorithm: + * 1. Get Unix timestamp in specific format + * 2. XOR each byte with key (starting 165) + * 3. Update key: key = (key + byte) & 0xFF + * 4. URL-safe base64 encode + * 5. Format: {base64_encoded}{machineId} + * + * @param {string} machineId - Machine ID from Cursor storage or generated + * @returns {string} - Checksum string + */ +export function generateCursorChecksum(machineId) { + // Math.floor(Date.now() / 1e6) - same as Python implementation + const timestamp = Math.floor(Date.now() / 1000000); + + // Create byte array from timestamp (6 bytes, big-endian) + const byteArray = new Uint8Array([ + (timestamp >> 40) & 0xFF, + (timestamp >> 32) & 0xFF, + (timestamp >> 24) & 0xFF, + (timestamp >> 16) & 0xFF, + (timestamp >> 8) & 0xFF, + timestamp & 0xFF + ]); + + // Jyh cipher obfuscation + let t = 165; + for (let i = 0; i < byteArray.length; i++) { + byteArray[i] = ((byteArray[i] ^ t) + (i % 256)) & 0xFF; + t = byteArray[i]; + } + + // URL-safe base64 encode (without padding) + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let encoded = ""; + + for (let i = 0; i < byteArray.length; i += 3) { + const a = byteArray[i]; + const b = i + 1 < byteArray.length ? byteArray[i + 1] : 0; + const c = i + 2 < byteArray.length ? byteArray[i + 2] : 0; + + encoded += alphabet[a >> 2]; + encoded += alphabet[((a & 3) << 4) | (b >> 4)]; + + if (i + 1 < byteArray.length) { + encoded += alphabet[((b & 15) << 2) | (c >> 6)]; + } + if (i + 2 < byteArray.length) { + encoded += alphabet[c & 63]; + } + } + + return `${encoded}${machineId}`; +} + +/** + * Build all Cursor API headers + * + * @param {string} accessToken - Bearer token + * @param {string} machineId - Machine ID (or will be generated from token) + * @param {boolean} ghostMode - Enable ghost mode (privacy) + * @returns {Object} - Headers object + */ +export function buildCursorHeaders(accessToken, machineId = null, ghostMode = true) { + // Clean token if it has prefix + const cleanToken = accessToken.includes("::") + ? accessToken.split("::")[1] + : accessToken; + + // Generate machine ID if not provided + const effectiveMachineId = machineId || generateHashed64Hex(cleanToken, "machineId"); + + // Generate derived values + const sessionId = generateSessionId(cleanToken); + const clientKey = generateHashed64Hex(cleanToken); + const checksum = generateCursorChecksum(effectiveMachineId); + + // Detect OS + let os = "linux"; + if (typeof process !== "undefined") { + if (process.platform === "win32") os = "windows"; + else if (process.platform === "darwin") os = "macos"; + } + + // Detect architecture + let arch = "x64"; + if (typeof process !== "undefined") { + if (process.arch === "arm64") arch = "aarch64"; + } + + return { + "authorization": `Bearer ${cleanToken}`, + "connect-accept-encoding": "gzip", + "connect-protocol-version": "1", + "content-type": "application/connect+proto", + "user-agent": "connect-es/1.6.1", + "x-amzn-trace-id": `Root=${crypto.randomUUID()}`, + "x-client-key": clientKey, + "x-cursor-checksum": checksum, + "x-cursor-client-version": "3.1.0", + "x-cursor-client-type": "ide", + "x-cursor-client-os": os, + "x-cursor-client-arch": arch, + "x-cursor-client-device-type": "desktop", + "x-cursor-config-version": crypto.randomUUID(), + "x-cursor-timezone": Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + "x-ghost-mode": ghostMode ? "true" : "false", + "x-request-id": crypto.randomUUID(), + "x-session-id": sessionId + }; +} + +export default { + generateCursorChecksum, + buildCursorHeaders, + generateHashed64Hex, + generateSessionId +}; diff --git a/open-sse/utils/cursorProtobuf.js b/open-sse/utils/cursorProtobuf.js new file mode 100644 index 0000000000000000000000000000000000000000..c870921b558efa2f00635088fba9e02403d8beeb --- /dev/null +++ b/open-sse/utils/cursorProtobuf.js @@ -0,0 +1,904 @@ +/** + * Cursor Protobuf Encoder/Decoder + * Implements ConnectRPC protobuf wire format for Cursor API + */ + +import { v4 as uuidv4 } from "uuid"; +import zlib from "zlib"; + +const DEBUG = process.env.CURSOR_PROTOBUF_DEBUG === "1"; +const log = (tag, ...args) => DEBUG && console.log(`[PROTOBUF:${tag}]`, ...args); +const textDecoder = new TextDecoder(); + +const PROTOBUF_SCHEMA_VERSION = "1.1.3"; + +// ==================== SCHEMAS ==================== + +const WIRE_TYPE = { VARINT: 0, FIXED64: 1, LEN: 2, FIXED32: 5 }; + +const ROLE = { USER: 1, ASSISTANT: 2 }; + +const UNIFIED_MODE = { CHAT: 1, AGENT: 2 }; + +const THINKING_LEVEL = { UNSPECIFIED: 0, MEDIUM: 1, HIGH: 2 }; +const CLIENT_SIDE_TOOL_V2 = { MCP: 19 }; +const CLIENT_SIDE_TOOL_V2_MCP = 19; + +const FIELD = { + // StreamUnifiedChatRequestWithTools (top level) + REQUEST: 1, + + // StreamUnifiedChatRequest + MESSAGES: 1, + UNKNOWN_2: 2, + INSTRUCTION: 3, + UNKNOWN_4: 4, + MODEL: 5, + WEB_TOOL: 8, + UNKNOWN_13: 13, + CURSOR_SETTING: 15, + UNKNOWN_19: 19, + CONVERSATION_ID: 23, + METADATA: 26, + IS_AGENTIC: 27, + SUPPORTED_TOOLS: 29, + MESSAGE_IDS: 30, + MCP_TOOLS: 34, + LARGE_CONTEXT: 35, + UNKNOWN_38: 38, + UNIFIED_MODE: 46, + UNKNOWN_47: 47, + SHOULD_DISABLE_TOOLS: 48, + THINKING_LEVEL: 49, + UNKNOWN_51: 51, + UNKNOWN_53: 53, + UNIFIED_MODE_NAME: 54, + + // ConversationMessage + MSG_CONTENT: 1, + MSG_ROLE: 2, + MSG_ID: 13, + MSG_TOOL_RESULTS: 18, + MSG_IS_AGENTIC: 29, + MSG_SERVER_BUBBLE_ID: 32, + MSG_UNIFIED_MODE: 47, + MSG_SUPPORTED_TOOLS: 51, + + // ConversationMessage.ToolResult + TOOL_RESULT_CALL_ID: 1, + TOOL_RESULT_NAME: 2, + TOOL_RESULT_INDEX: 3, + TOOL_RESULT_RAW_ARGS: 5, + TOOL_RESULT_RESULT: 8, + TOOL_RESULT_TOOL_CALL: 11, + TOOL_RESULT_MODEL_CALL_ID: 12, + + // ClientSideToolV2Result (nested inside ToolResult.result) + CLIENT_RESULT_TOOL: 1, + CLIENT_RESULT_MCP_RESULT: 28, + CLIENT_RESULT_TOOL_CALL_ID: 35, + CLIENT_RESULT_MODEL_CALL_ID: 48, + CLIENT_RESULT_TOOL_INDEX: 49, + // Aliases used by encodeClientSideToolV2Result + CV2R_TOOL: 1, + CV2R_MCP_RESULT: 28, + CV2R_CALL_ID: 35, + CV2R_MODEL_CALL_ID: 48, + CV2R_TOOL_INDEX: 49, + + // MCPResult (nested inside ClientSideToolV2Result.mcp_result) + MCP_RESULT_SELECTED_TOOL: 1, + MCP_RESULT_RESULT: 2, + // Aliases used by encodeMcpResult + MCPR_SELECTED_TOOL: 1, + MCPR_RESULT: 2, + + // ClientSideToolV2Call (nested inside ToolResult.tool_call) + CLIENT_CALL_TOOL: 1, + CLIENT_CALL_MCP_PARAMS: 27, + CLIENT_CALL_TOOL_CALL_ID: 3, + CLIENT_CALL_NAME: 9, + CLIENT_CALL_RAW_ARGS: 10, + CLIENT_CALL_TOOL_INDEX: 48, + CLIENT_CALL_MODEL_CALL_ID: 49, + // Aliases used by encodeClientSideToolV2Call + CV2C_TOOL: 1, + CV2C_MCP_PARAMS: 27, + CV2C_CALL_ID: 3, + CV2C_NAME: 9, + CV2C_RAW_ARGS: 10, + CV2C_TOOL_INDEX: 48, + CV2C_MODEL_CALL_ID: 49, + + // Model + MODEL_NAME: 1, + MODEL_EMPTY: 4, + + // Instruction + INSTRUCTION_TEXT: 1, + + // CursorSetting + SETTING_PATH: 1, + SETTING_UNKNOWN_3: 3, + SETTING_UNKNOWN_6: 6, + SETTING_UNKNOWN_8: 8, + SETTING_UNKNOWN_9: 9, + + // CursorSetting.Unknown6 + SETTING6_FIELD_1: 1, + SETTING6_FIELD_2: 2, + + // Metadata + META_PLATFORM: 1, + META_ARCH: 2, + META_VERSION: 3, + META_CWD: 4, + META_TIMESTAMP: 5, + + // MessageId + MSGID_ID: 1, + MSGID_SUMMARY: 2, + MSGID_ROLE: 3, + + // MCPTool + MCP_TOOL_NAME: 1, + MCP_TOOL_DESC: 2, + MCP_TOOL_PARAMS: 3, + MCP_TOOL_SERVER: 4, + + // StreamUnifiedChatResponseWithTools (response) + TOOL_CALL: 1, + RESPONSE: 2, + + // ClientSideToolV2Call + TOOL_ID: 3, + TOOL_NAME: 9, + TOOL_RAW_ARGS: 10, + TOOL_IS_LAST: 11, + TOOL_IS_LAST_ALT: 15, + TOOL_MCP_PARAMS: 27, + + // MCPParams + MCP_TOOLS_LIST: 1, + + // MCPParams.Tool (nested) + MCP_NESTED_NAME: 1, + MCP_NESTED_PARAMS: 3, + + // StreamUnifiedChatResponse + RESPONSE_TEXT: 1, + THINKING: 25, + + // Thinking + THINKING_TEXT: 1 +}; + +// Known response field numbers — used to detect unknown fields from protocol updates +const KNOWN_RESPONSE_FIELDS = new Set([ + FIELD.TOOL_CALL, + FIELD.RESPONSE, + FIELD.TOOL_ID, + FIELD.TOOL_NAME, + FIELD.TOOL_RAW_ARGS, + FIELD.TOOL_IS_LAST, + FIELD.TOOL_MCP_PARAMS, + FIELD.RESPONSE_TEXT, + FIELD.THINKING +]); + +// ==================== PRIMITIVE ENCODING ==================== + +export function encodeVarint(value) { + const bytes = []; + while (value >= 0x80) { + bytes.push((value & 0x7F) | 0x80); + value >>>= 7; + } + bytes.push(value & 0x7F); + return new Uint8Array(bytes); +} + +export function encodeField(fieldNum, wireType, value) { + const tag = (fieldNum << 3) | wireType; + const tagBytes = encodeVarint(tag); + + if (wireType === WIRE_TYPE.VARINT) { + const valueBytes = encodeVarint(value); + return concatArrays(tagBytes, valueBytes); + } + + if (wireType === WIRE_TYPE.LEN) { + const dataBytes = typeof value === "string" + ? new TextEncoder().encode(value) + : value instanceof Uint8Array ? value + : Buffer.isBuffer(value) ? new Uint8Array(value) + : new Uint8Array(0); + + const lengthBytes = encodeVarint(dataBytes.length); + return concatArrays(tagBytes, lengthBytes, dataBytes); + } + + return new Uint8Array(0); +} + +function concatArrays(...arrays) { + const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + return result; +} + +// ==================== MESSAGE ENCODING ==================== + +/** + * Format tool name: "toolName" → "mcp_custom_toolName" + * Also handles: "mcp__server__tool" → "mcp_server_tool" + */ +function formatToolName(name) { + const base = typeof name === "string" && name.length > 0 ? name : "tool"; + + if (base.startsWith("mcp__")) { + const rest = base.slice("mcp__".length); + const splitIdx = rest.indexOf("__"); + if (splitIdx >= 0) { + const server = rest.slice(0, splitIdx) || "custom"; + const toolName = rest.slice(splitIdx + 2) || "tool"; + return `mcp_${server}_${toolName}`; + } + return `mcp_custom_${rest || "tool"}`; + } + + if (base.startsWith("mcp_")) return base; + return `mcp_custom_${base}`; +} + +/** + * Parse formatted tool name: "mcp_server_tool" → { serverName, selectedTool } + */ +function parseToolName(formattedName) { + if (typeof formattedName !== "string" || !formattedName.startsWith("mcp_")) { + return { serverName: "custom", selectedTool: formattedName || "tool" }; + } + + const tail = formattedName.slice("mcp_".length); + const splitIdx = tail.indexOf("_"); + if (splitIdx < 0) { + return { serverName: "custom", selectedTool: tail || "tool" }; + } + + return { + serverName: tail.slice(0, splitIdx) || "custom", + selectedTool: tail.slice(splitIdx + 1) || "tool" + }; +} + +/** + * Parse tool_call_id into { toolCallId, modelCallId } + * Cursor uses "\nmc_" delimiter for model_call_id + */ +function parseToolId(id) { + const delimiter = "\nmc_"; + const idx = id.indexOf(delimiter); + if (idx >= 0) { + return { toolCallId: id.slice(0, idx), modelCallId: id.slice(idx + delimiter.length) }; + } + return { toolCallId: id, modelCallId: null }; +} + +/** + * Encode MCPResult proto: { selected_tool, result } + */ +function encodeMcpResult(selectedTool, resultContent) { + return concatArrays( + encodeField(FIELD.MCPR_SELECTED_TOOL, WIRE_TYPE.LEN, selectedTool), + encodeField(FIELD.MCPR_RESULT, WIRE_TYPE.LEN, resultContent) + ); +} + +/** + * Encode ClientSideToolV2Result proto: { tool, mcp_result, call_id, model_call_id, tool_index } + * Represents the result of executing a tool + */ +function encodeClientSideToolV2Result(toolCallId, modelCallId, selectedTool, resultContent, toolIndex = 1) { + return concatArrays( + encodeField(FIELD.CV2R_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2_MCP), + encodeField(FIELD.CV2R_MCP_RESULT, WIRE_TYPE.LEN, encodeMcpResult(selectedTool, resultContent)), + encodeField(FIELD.CV2R_CALL_ID, WIRE_TYPE.LEN, toolCallId), + ...(modelCallId ? [encodeField(FIELD.CV2R_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)] : []), + encodeField(FIELD.CV2R_TOOL_INDEX, WIRE_TYPE.VARINT, toolIndex > 0 ? toolIndex : 1) + ); +} + +/** + * Encode MCPParams.Tool nested inside ClientSideToolV2Call + */ +function encodeMcpParamsForCall(toolName, rawArgs, serverName) { + const tool = concatArrays( + encodeField(FIELD.MCP_TOOL_NAME, WIRE_TYPE.LEN, toolName), + encodeField(FIELD.MCP_TOOL_PARAMS, WIRE_TYPE.LEN, rawArgs), + encodeField(FIELD.MCP_TOOL_SERVER, WIRE_TYPE.LEN, serverName) + ); + return encodeField(FIELD.MCP_TOOLS_LIST, WIRE_TYPE.LEN, tool); +} + +/** + * Encode ClientSideToolV2Call proto: { tool, mcp_params, call_id, name, raw_args, tool_index, model_call_id } + * Represents a tool call definition + */ +function encodeClientSideToolV2Call(toolCallId, toolName, selectedTool, serverName, rawArgs, modelCallId, toolIndex = 1) { + return concatArrays( + encodeField(FIELD.CV2C_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2_MCP), + encodeField(FIELD.CV2C_MCP_PARAMS, WIRE_TYPE.LEN, encodeMcpParamsForCall(selectedTool, rawArgs, serverName)), + encodeField(FIELD.CV2C_CALL_ID, WIRE_TYPE.LEN, toolCallId), + encodeField(FIELD.CV2C_NAME, WIRE_TYPE.LEN, toolName), + encodeField(FIELD.CV2C_RAW_ARGS, WIRE_TYPE.LEN, rawArgs), + encodeField(FIELD.CV2C_TOOL_INDEX, WIRE_TYPE.VARINT, toolIndex > 0 ? toolIndex : 1), + ...(modelCallId ? [encodeField(FIELD.CV2C_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)] : []) + ); +} + +/** + * Encode ConversationMessage.ToolResult with full structure + * Matches Cursor proto: tool_call_id, tool_name, tool_index, raw_args, result, tool_call + */ +export function encodeToolResult(toolResult) { + const originalName = toolResult.tool_name || toolResult.name || ""; + const toolName = formatToolName(originalName); + const rawArgs = toolResult.raw_args || "{}"; + const resultContent = toolResult.result_content || toolResult.result || ""; + const { toolCallId, modelCallId } = parseToolId(toolResult.tool_call_id || ""); + const toolIndex = toolResult.tool_index || toolResult.index || 1; + + // Parse tool name to extract server and selected tool + const { serverName, selectedTool } = parseToolName(toolName); + + return concatArrays( + encodeField(FIELD.TOOL_RESULT_CALL_ID, WIRE_TYPE.LEN, toolCallId), + encodeField(FIELD.TOOL_RESULT_NAME, WIRE_TYPE.LEN, toolName), + encodeField(FIELD.TOOL_RESULT_INDEX, WIRE_TYPE.VARINT, toolIndex > 0 ? toolIndex : 1), + ...(modelCallId ? [encodeField(FIELD.TOOL_RESULT_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)] : []), + encodeField(FIELD.TOOL_RESULT_RAW_ARGS, WIRE_TYPE.LEN, rawArgs), + encodeField(FIELD.TOOL_RESULT_RESULT, WIRE_TYPE.LEN, + encodeClientSideToolV2Result(toolCallId, modelCallId, selectedTool, resultContent, toolIndex) + ), + encodeField(FIELD.TOOL_RESULT_TOOL_CALL, WIRE_TYPE.LEN, + encodeClientSideToolV2Call(toolCallId, toolName, selectedTool, serverName, rawArgs, modelCallId, toolIndex) + ) + ); +} + +export function encodeMessage(content, role, messageId, chatModeEnum = null, isLast = false, hasTools = false, toolResults = [], serverBubbleId = null) { + const hasToolResults = toolResults.length > 0; + return concatArrays( + encodeField(FIELD.MSG_CONTENT, WIRE_TYPE.LEN, content), + encodeField(FIELD.MSG_ROLE, WIRE_TYPE.VARINT, role), + encodeField(FIELD.MSG_ID, WIRE_TYPE.LEN, messageId), + // Only include server_bubble_id if explicitly provided (last assistant message only) + ...(serverBubbleId ? [encodeField(FIELD.MSG_SERVER_BUBBLE_ID, WIRE_TYPE.LEN, serverBubbleId)] : []), + ...(hasToolResults ? toolResults.map(tr => + encodeField(FIELD.MSG_TOOL_RESULTS, WIRE_TYPE.LEN, encodeToolResult(tr)) + ) : []), + encodeField(FIELD.MSG_IS_AGENTIC, WIRE_TYPE.VARINT, hasTools ? 1 : 0), + encodeField(FIELD.MSG_UNIFIED_MODE, WIRE_TYPE.VARINT, hasTools ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT), + ...(isLast && hasTools ? [encodeField(FIELD.MSG_SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))] : []) + ); +} + +export function encodeInstruction(text) { + return text ? encodeField(FIELD.INSTRUCTION_TEXT, WIRE_TYPE.LEN, text) : new Uint8Array(0); +} + +export function encodeModel(modelName) { + return concatArrays( + encodeField(FIELD.MODEL_NAME, WIRE_TYPE.LEN, modelName), + encodeField(FIELD.MODEL_EMPTY, WIRE_TYPE.LEN, new Uint8Array(0)) + ); +} + +export function encodeCursorSetting() { + const unknown6 = concatArrays( + encodeField(FIELD.SETTING6_FIELD_1, WIRE_TYPE.LEN, new Uint8Array(0)), + encodeField(FIELD.SETTING6_FIELD_2, WIRE_TYPE.LEN, new Uint8Array(0)) + ); + + return concatArrays( + encodeField(FIELD.SETTING_PATH, WIRE_TYPE.LEN, "cursor\\aisettings"), + encodeField(FIELD.SETTING_UNKNOWN_3, WIRE_TYPE.LEN, new Uint8Array(0)), + encodeField(FIELD.SETTING_UNKNOWN_6, WIRE_TYPE.LEN, unknown6), + encodeField(FIELD.SETTING_UNKNOWN_8, WIRE_TYPE.VARINT, 1), + encodeField(FIELD.SETTING_UNKNOWN_9, WIRE_TYPE.VARINT, 1) + ); +} + +export function encodeMetadata() { + return concatArrays( + encodeField(FIELD.META_PLATFORM, WIRE_TYPE.LEN, process.platform || "linux"), + encodeField(FIELD.META_ARCH, WIRE_TYPE.LEN, process.arch || "x64"), + encodeField(FIELD.META_VERSION, WIRE_TYPE.LEN, process.version || "v20.0.0"), + encodeField(FIELD.META_CWD, WIRE_TYPE.LEN, process.cwd?.() || "/"), + encodeField(FIELD.META_TIMESTAMP, WIRE_TYPE.LEN, new Date().toISOString()) + ); +} + +export function encodeMessageId(messageId, role, summaryId = null) { + return concatArrays( + encodeField(FIELD.MSGID_ID, WIRE_TYPE.LEN, messageId), + ...(summaryId ? [encodeField(FIELD.MSGID_SUMMARY, WIRE_TYPE.LEN, summaryId)] : []), + encodeField(FIELD.MSGID_ROLE, WIRE_TYPE.VARINT, role) + ); +} + +export function encodeMcpTool(tool) { + const toolName = tool.function?.name || tool.name || ""; + const toolDesc = tool.function?.description || tool.description || ""; + const inputSchema = tool.function?.parameters || tool.input_schema || {}; + + return concatArrays( + ...(toolName ? [encodeField(FIELD.MCP_TOOL_NAME, WIRE_TYPE.LEN, toolName)] : []), + ...(toolDesc ? [encodeField(FIELD.MCP_TOOL_DESC, WIRE_TYPE.LEN, toolDesc)] : []), + ...(Object.keys(inputSchema).length > 0 ? [encodeField(FIELD.MCP_TOOL_PARAMS, WIRE_TYPE.LEN, JSON.stringify(inputSchema))] : []), + encodeField(FIELD.MCP_TOOL_SERVER, WIRE_TYPE.LEN, "custom") + ); +} + +// ==================== REQUEST BUILDING ==================== + +export function encodeRequest(messages, modelName, tools = [], reasoningEffort = null, forceAgentMode = false) { + const hasTools = tools?.length > 0; + const isAgentic = hasTools || forceAgentMode; + const formattedMessages = []; + const messageIds = []; + const normalizedMessages = []; + + // Guardrail: split mixed assistant payload into separate assistant messages + // This prevents protobuf encoding errors when tool calls and results are in same message + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + const hasToolCalls = Array.isArray(msg?.tool_calls) && msg.tool_calls.length > 0; + const hasToolResults = Array.isArray(msg?.tool_results) && msg.tool_results.length > 0; + + if (msg?.role === "assistant" && hasToolCalls && hasToolResults) { + log( + "ENCODE", + `normalizing mixed assistant tool payload at msg[${i}] (calls=${msg.tool_calls.length}, results=${msg.tool_results.length})` + ); + + // Keep assistant tool call message without embedded results + normalizedMessages.push({ + ...msg, + tool_results: [] + }); + + // Avoid inserting duplicate assistant tool-result message if next one already matches + const nextMsg = messages[i + 1]; + const nextHasToolResults = + nextMsg?.role === "assistant" && + Array.isArray(nextMsg?.tool_results) && + nextMsg.tool_results.length > 0; + const currentIds = new Set( + msg.tool_results.map(tr => tr?.tool_call_id).filter(id => typeof id === "string") + ); + const nextIds = new Set( + (nextMsg?.tool_results || []) + .map(tr => tr?.tool_call_id) + .filter(id => typeof id === "string") + ); + let sameIds = currentIds.size > 0 && currentIds.size === nextIds.size; + if (sameIds) { + for (const id of currentIds) { + if (!nextIds.has(id)) { + sameIds = false; + break; + } + } + } + + if (!(nextHasToolResults && sameIds)) { + normalizedMessages.push({ + role: "assistant", + content: "", + tool_results: msg.tool_results + }); + } + + continue; + } + + normalizedMessages.push(msg); + } + + // Prepare messages + for (let i = 0; i < normalizedMessages.length; i++) { + const msg = normalizedMessages[i]; + const role = msg.role === "user" ? ROLE.USER : ROLE.ASSISTANT; + const msgId = uuidv4(); + const isLast = i === normalizedMessages.length - 1; + + formattedMessages.push({ + content: msg.content, + role, + messageId: msgId, + isLast, + hasTools, + toolResults: msg.tool_results || [] + }); + + messageIds.push({ messageId: msgId, role }); + } + + // Map reasoning effort to thinking level + let thinkingLevel = THINKING_LEVEL.UNSPECIFIED; + if (reasoningEffort === "medium") thinkingLevel = THINKING_LEVEL.MEDIUM; + else if (reasoningEffort === "high") thinkingLevel = THINKING_LEVEL.HIGH; + + // Build request + return concatArrays( + // Messages + ...formattedMessages.map(fm => + encodeField(FIELD.MESSAGES, WIRE_TYPE.LEN, + encodeMessage(fm.content, fm.role, fm.messageId, null, fm.isLast, fm.hasTools, fm.toolResults) + ) + ), + + // Static fields + encodeField(FIELD.UNKNOWN_2, WIRE_TYPE.VARINT, 1), + encodeField(FIELD.INSTRUCTION, WIRE_TYPE.LEN, encodeInstruction("")), + encodeField(FIELD.UNKNOWN_4, WIRE_TYPE.VARINT, 1), + encodeField(FIELD.MODEL, WIRE_TYPE.LEN, encodeModel(modelName)), + encodeField(FIELD.WEB_TOOL, WIRE_TYPE.LEN, ""), + encodeField(FIELD.UNKNOWN_13, WIRE_TYPE.VARINT, 1), + encodeField(FIELD.CURSOR_SETTING, WIRE_TYPE.LEN, encodeCursorSetting()), + encodeField(FIELD.UNKNOWN_19, WIRE_TYPE.VARINT, 1), + encodeField(FIELD.CONVERSATION_ID, WIRE_TYPE.LEN, uuidv4()), + encodeField(FIELD.METADATA, WIRE_TYPE.LEN, encodeMetadata()), + + // Tool-related fields + encodeField(FIELD.IS_AGENTIC, WIRE_TYPE.VARINT, isAgentic ? 1 : 0), + ...(isAgentic ? [encodeField(FIELD.SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))] : []), + + // Message IDs + ...messageIds.map(mid => + encodeField(FIELD.MESSAGE_IDS, WIRE_TYPE.LEN, encodeMessageId(mid.messageId, mid.role)) + ), + + // MCP Tools + ...(tools?.length > 0 ? tools.map(tool => + encodeField(FIELD.MCP_TOOLS, WIRE_TYPE.LEN, encodeMcpTool(tool)) + ) : []), + + // Mode fields + encodeField(FIELD.LARGE_CONTEXT, WIRE_TYPE.VARINT, 0), + encodeField(FIELD.UNKNOWN_38, WIRE_TYPE.VARINT, 0), + encodeField(FIELD.UNIFIED_MODE, WIRE_TYPE.VARINT, isAgentic ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT), + encodeField(FIELD.UNKNOWN_47, WIRE_TYPE.LEN, ""), + encodeField(FIELD.SHOULD_DISABLE_TOOLS, WIRE_TYPE.VARINT, isAgentic ? 0 : 1), + encodeField(FIELD.THINKING_LEVEL, WIRE_TYPE.VARINT, thinkingLevel), + encodeField(FIELD.UNKNOWN_51, WIRE_TYPE.VARINT, 0), + encodeField(FIELD.UNKNOWN_53, WIRE_TYPE.VARINT, 1), + encodeField(FIELD.UNIFIED_MODE_NAME, WIRE_TYPE.LEN, isAgentic ? "Agent" : "Ask") + ); +} + +export function buildChatRequest(messages, modelName, tools = [], reasoningEffort = null, forceAgentMode = false) { + return encodeField(FIELD.REQUEST, WIRE_TYPE.LEN, encodeRequest(messages, modelName, tools, reasoningEffort, forceAgentMode)); +} + +/** + * Encode a tool result as ClientSideToolV2Result (field 2 of StreamUnifiedChatRequestWithTools) + * This is sent as a SEPARATE request frame, not inside conversation messages. + * Proto: StreamUnifiedChatRequestWithTools.client_side_tool_v2_result = 2 + */ +export function buildToolResultRequest(toolResult) { + const { toolCallId, modelCallId } = parseToolId(toolResult.tool_call_id || ""); + const rawName = toolResult.tool_name || ""; + const resultContent = toolResult.result_content || ""; + + // selected_tool = raw tool name (e.g. "Write", "Read") per cursor-api Rust source: + // McpResult { selected_tool: tool_name, result } where tool_name is the mcpParams.tools[0].name + // which is the name AFTER server prefix stripping (e.g. "custom_Write" -> name = "Write") + // Actually cursor-api uses: name = tool_name.slice_unchecked(d+1..) → raw name without "custom_" + // So selected_tool = raw tool name without any prefix + const selectedTool = rawName.startsWith("mcp_custom_") + ? rawName.slice("mcp_custom_".length) + : rawName.startsWith("mcp_") + ? rawName.slice(4) + : rawName; + + // ClientSideToolV2Result per proto: + // field 1 (tool): varint = 19 (MCP) + // field 28 (mcp_result): LEN { field 1: selected_tool, field 2: result } + // field 35 (tool_call_id): string + // field 48 (model_call_id): string (optional) + // NO tool_index (None in Rust source: encode_tool_result sets tool_index: None) + const cv2Result = concatArrays( + encodeField(FIELD.CV2R_TOOL, WIRE_TYPE.VARINT, CLIENT_SIDE_TOOL_V2_MCP), + encodeField(FIELD.CV2R_MCP_RESULT, WIRE_TYPE.LEN, encodeMcpResult(selectedTool, resultContent)), + encodeField(FIELD.CV2R_CALL_ID, WIRE_TYPE.LEN, toolCallId), + ...(modelCallId ? [encodeField(FIELD.CV2R_MODEL_CALL_ID, WIRE_TYPE.LEN, modelCallId)] : []) + // tool_index intentionally omitted (None per Rust source) + ); + + // StreamUnifiedChatRequestWithTools: field 2 = client_side_tool_v2_result + return encodeField(2, WIRE_TYPE.LEN, cv2Result); +} + +export function wrapConnectRPCFrame(payload, compress = false) { + let finalPayload = payload; + let flags = 0x00; + + if (compress) { + finalPayload = new Uint8Array(zlib.gzipSync(Buffer.from(payload))); + flags = 0x01; + } + + const frame = new Uint8Array(5 + finalPayload.length); + frame[0] = flags; + frame[1] = (finalPayload.length >> 24) & 0xFF; + frame[2] = (finalPayload.length >> 16) & 0xFF; + frame[3] = (finalPayload.length >> 8) & 0xFF; + frame[4] = finalPayload.length & 0xFF; + frame.set(finalPayload, 5); + + return frame; +} + +export function generateCursorBody(messages, modelName, tools = [], reasoningEffort = null, forceAgentMode = false) { + log("BODY", `Generating: ${messages.length} msgs, model=${modelName}, tools=${tools.length}, reasoning=${reasoningEffort || "none"}, forceAgentMode=${forceAgentMode}`); + + const protobuf = buildChatRequest(messages, modelName, tools, reasoningEffort, forceAgentMode); + const framed = wrapConnectRPCFrame(protobuf, false); // Cursor doesn't support compressed requests + + log("BODY", `Protobuf=${protobuf.length}B, Framed=${framed.length}B`); + return framed; +} + +/** + * Generate a framed tool result body to send as a separate request frame. + * Uses field 2 (client_side_tool_v2_result) of StreamUnifiedChatRequestWithTools. + */ +export function generateToolResultBody(toolResult) { + const protobuf = buildToolResultRequest(toolResult); + return wrapConnectRPCFrame(protobuf, false); +} + +// ==================== PRIMITIVE DECODING ==================== + +export function decodeVarint(buffer, offset) { + let result = 0; + let shift = 0; + let pos = offset; + + while (pos < buffer.length) { + const b = buffer[pos]; + result |= (b & 0x7F) << shift; + pos++; + if (!(b & 0x80)) break; + shift += 7; + } + + return [result, pos]; +} + +export function decodeField(buffer, offset) { + if (offset >= buffer.length) return [null, null, null, offset]; + + const [tag, pos1] = decodeVarint(buffer, offset); + const fieldNum = tag >> 3; + const wireType = tag & 0x07; + + let value; + let pos = pos1; + + if (wireType === WIRE_TYPE.VARINT) { + [value, pos] = decodeVarint(buffer, pos); + } else if (wireType === WIRE_TYPE.LEN) { + const [length, pos2] = decodeVarint(buffer, pos); + value = buffer.slice(pos2, pos2 + length); + pos = pos2 + length; + } else if (wireType === WIRE_TYPE.FIXED64) { + value = buffer.slice(pos, pos + 8); + pos += 8; + } else if (wireType === WIRE_TYPE.FIXED32) { + value = buffer.slice(pos, pos + 4); + pos += 4; + } else { + value = null; + } + + return [fieldNum, wireType, value, pos]; +} + +export function decodeMessage(data) { + const fields = new Map(); + let pos = 0; + + while (pos < data.length) { + const [fieldNum, wireType, value, newPos] = decodeField(data, pos); + if (fieldNum === null) break; + + if (!fields.has(fieldNum)) fields.set(fieldNum, []); + fields.get(fieldNum).push({ wireType, value }); + pos = newPos; + } + + return fields; +} + +// ==================== RESPONSE PARSING ==================== + +export function parseConnectRPCFrame(buffer) { + if (buffer.length < 5) return null; + + const flags = buffer[0]; + const length = (buffer[1] << 24) | (buffer[2] << 16) | (buffer[3] << 8) | buffer[4]; + + if (buffer.length < 5 + length) return null; + + let payload = buffer.slice(5, 5 + length); + + // Decompress if gzip + if (flags === 0x01) { + try { + payload = new Uint8Array(zlib.gunzipSync(Buffer.from(payload))); + } catch (err) { + log("PARSE", `Decompression failed: ${err.message}`); + } + } + + return { flags, length, payload, consumed: 5 + length }; +} + +function extractToolCall(toolCallData) { + const toolCall = decodeMessage(toolCallData); + let toolCallId = ""; + let toolName = ""; + let rawArgs = ""; + let isLast = false; + + // Extract tool call ID + if (toolCall.has(FIELD.TOOL_ID)) { + const fullId = new TextDecoder().decode(toolCall.get(FIELD.TOOL_ID)[0].value); + toolCallId = fullId.split("\n")[0]; // Cursor returns multi-line ID, take first line + } + + // Extract tool name + if (toolCall.has(FIELD.TOOL_NAME)) { + toolName = new TextDecoder().decode(toolCall.get(FIELD.TOOL_NAME)[0].value); + } + + // Extract is_last flag + if (toolCall.has(FIELD.TOOL_IS_LAST)) { + isLast = toolCall.get(FIELD.TOOL_IS_LAST)[0].value !== 0; + } + + // Extract MCP params - nested real tool info + if (toolCall.has(FIELD.TOOL_MCP_PARAMS)) { + try { + const mcpParams = decodeMessage(toolCall.get(FIELD.TOOL_MCP_PARAMS)[0].value); + + if (mcpParams.has(FIELD.MCP_TOOLS_LIST)) { + const tool = decodeMessage(mcpParams.get(FIELD.MCP_TOOLS_LIST)[0].value); + + if (tool.has(FIELD.MCP_NESTED_NAME)) { + toolName = new TextDecoder().decode(tool.get(FIELD.MCP_NESTED_NAME)[0].value); + } + + if (tool.has(FIELD.MCP_NESTED_PARAMS)) { + rawArgs = new TextDecoder().decode(tool.get(FIELD.MCP_NESTED_PARAMS)[0].value); + } + } + } catch (err) { + log("EXTRACT", `MCP parse error: ${err.message}`); + } + } + + // Fallback to raw_args + if (!rawArgs && toolCall.has(FIELD.TOOL_RAW_ARGS)) { + rawArgs = new TextDecoder().decode(toolCall.get(FIELD.TOOL_RAW_ARGS)[0].value); + } + + if (toolCallId && toolName) { + return { + id: toolCallId, + type: "function", + function: { + name: toolName, + arguments: rawArgs || "{}" + }, + isLast + }; + } + + return null; +} + +function extractTextAndThinking(responseData) { + const nested = decodeMessage(responseData); + let text = null; + let thinking = null; + + // Extract text + if (nested.has(FIELD.RESPONSE_TEXT)) { + text = new TextDecoder().decode(nested.get(FIELD.RESPONSE_TEXT)[0].value); + } + + // Extract thinking + if (nested.has(FIELD.THINKING)) { + try { + const thinkingMsg = decodeMessage(nested.get(FIELD.THINKING)[0].value); + if (thinkingMsg.has(FIELD.THINKING_TEXT)) { + thinking = new TextDecoder().decode(thinkingMsg.get(FIELD.THINKING_TEXT)[0].value); + } + } catch (err) { + log("EXTRACT", `Thinking parse error: ${err.message}`); + } + } + + return { text, thinking }; +} + +export function extractTextFromResponse(payload) { + try { + const fields = decodeMessage(payload); + + // Warn about unknown field numbers — may indicate a Cursor protocol update + for (const fieldNum of fields.keys()) { + if (!KNOWN_RESPONSE_FIELDS.has(fieldNum)) { + log( + "SCHEMA", + `Unknown response field #${fieldNum} detected. Schema v${PROTOBUF_SCHEMA_VERSION} may be outdated.` + ); + } + } + + // Field 1: ClientSideToolV2Call + if (fields.has(FIELD.TOOL_CALL)) { + const toolCall = extractToolCall(fields.get(FIELD.TOOL_CALL)[0].value); + if (toolCall) { + log("EXTRACT", `Tool call: ${toolCall.function.name}`); + return { text: null, error: null, toolCall, thinking: null }; + } + } + + // Field 2: StreamUnifiedChatResponse + if (fields.has(FIELD.RESPONSE)) { + const { text, thinking } = extractTextAndThinking(fields.get(FIELD.RESPONSE)[0].value); + + if (text || thinking) { + return { text, error: null, toolCall: null, thinking }; + } + } + + return { text: null, error: null, toolCall: null, thinking: null }; + } catch (err) { + log("EXTRACT", `Decode failed (schema v${PROTOBUF_SCHEMA_VERSION}): ${err.message}`); + return { + text: null, + error: null, + toolCall: null, + thinking: null, + raw: Buffer.from(payload).toString("base64"), + decodeError: err.message + }; + } +} + +// ==================== EXPORTS ==================== + +export default { + encodeVarint, + encodeField, + encodeMessage, + buildChatRequest, + wrapConnectRPCFrame, + generateCursorBody, + decodeVarint, + decodeField, + decodeMessage, + parseConnectRPCFrame, + extractTextFromResponse +}; diff --git a/open-sse/utils/debugLog.js b/open-sse/utils/debugLog.js new file mode 100644 index 0000000000000000000000000000000000000000..67cdc31f86f3388a36dfe60f7dd05c10a1c7eb7a --- /dev/null +++ b/open-sse/utils/debugLog.js @@ -0,0 +1,14 @@ +// Debug logging utility — only active in dev mode (NODE_ENV !== "production") +// Outputs are tagged with [DBG:tag] for easy grep/filter +const isDev = process.env.NODE_ENV !== "production"; + +function ts() { + return new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +export function dbg(tag, msg) { + if (!isDev) return; + console.log(`[${ts()}] 🐛 [DBG:${tag}] ${msg}`); +} + +export const isDebugEnabled = isDev; diff --git a/open-sse/utils/error.js b/open-sse/utils/error.js new file mode 100644 index 0000000000000000000000000000000000000000..315723e303d7414b2c7157ca41d96a9b7ca46c68 --- /dev/null +++ b/open-sse/utils/error.js @@ -0,0 +1,147 @@ +import { ERROR_TYPES, DEFAULT_ERROR_MESSAGES } from "../config/errorConfig.js"; + +/** + * Build OpenAI-compatible error response body + * @param {number} statusCode - HTTP status code + * @param {string} message - Error message + * @returns {object} Error response object + */ +export function buildErrorBody(statusCode, message) { + const errorInfo = ERROR_TYPES[statusCode] || + (statusCode >= 500 + ? { type: "server_error", code: "internal_server_error" } + : { type: "invalid_request_error", code: "" }); + + return { + error: { + message: message || DEFAULT_ERROR_MESSAGES[statusCode] || "An error occurred", + type: errorInfo.type, + code: errorInfo.code + } + }; +} + +/** + * Create error Response object (for non-streaming) + * @param {number} statusCode - HTTP status code + * @param {string} message - Error message + * @returns {Response} HTTP Response object + */ +export function errorResponse(statusCode, message) { + return new Response(JSON.stringify(buildErrorBody(statusCode, message)), { + status: statusCode, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*" + } + }); +} + +/** + * Write error to SSE stream (for streaming) + * @param {WritableStreamDefaultWriter} writer - Stream writer + * @param {number} statusCode - HTTP status code + * @param {string} message - Error message + */ +export async function writeStreamError(writer, statusCode, message) { + const errorBody = buildErrorBody(statusCode, message); + const encoder = new TextEncoder(); + await writer.write(encoder.encode(`data: ${JSON.stringify(errorBody)}\n\n`)); +} + +/** + * Parse upstream provider error response + * @param {Response} response - Fetch response from provider + * @param {object} [executor] - Optional executor with parseError() override for provider-specific parsing + * @returns {Promise<{statusCode: number, message: string, resetsAtMs?: number}>} + */ +export async function parseUpstreamError(response, executor = null) { + let bodyText = ""; + try { + bodyText = await response.text(); + } catch { + bodyText = ""; + } + + // Let executor-specific parser extract provider-specific fields (e.g. codex resetsAtMs) + if (executor && typeof executor.parseError === "function") { + try { + const parsed = executor.parseError(response, bodyText); + if (parsed && typeof parsed === "object") { + const msg = parsed.message || DEFAULT_ERROR_MESSAGES[response.status] || `Upstream error: ${response.status}`; + return { statusCode: parsed.status || response.status, message: msg, resetsAtMs: parsed.resetsAtMs }; + } + } catch { /* fall through to default parsing */ } + } + + let message = ""; + try { + const json = JSON.parse(bodyText); + message = json.error?.message || json.message || json.error || bodyText; + } catch { + message = bodyText; + } + + const messageStr = typeof message === "string" ? message : JSON.stringify(message); + const finalMessage = messageStr || DEFAULT_ERROR_MESSAGES[response.status] || `Upstream error: ${response.status}`; + + return { statusCode: response.status, message: finalMessage }; +} + +/** + * Create error result for chatCore handler + * @param {number} statusCode - HTTP status code + * @param {string} message - Error message + * @param {number} [resetsAtMs] - Optional precise cooldown expiry (ms epoch) for provider-specific quota errors + * @returns {{ success: false, status: number, error: string, response: Response, resetsAtMs?: number }} + */ +export function createErrorResult(statusCode, message, resetsAtMs) { + return { + success: false, + status: statusCode, + error: message, + resetsAtMs, + response: errorResponse(statusCode, message) + }; +} + +/** + * Create unavailable response when all accounts are rate limited + * @param {number} statusCode - Original error status code + * @param {string} message - Error message (without retry info) + * @param {string} retryAfter - ISO timestamp when earliest account becomes available + * @param {string} retryAfterHuman - Human-readable retry info e.g. "reset after 30s" + * @returns {Response} + */ +export function unavailableResponse(statusCode, message, retryAfter, retryAfterHuman) { + const retryAfterSec = Math.max(Math.ceil((new Date(retryAfter).getTime() - Date.now()) / 1000), 1); + const msg = `${message} (${retryAfterHuman})`; + return new Response( + JSON.stringify({ error: { message: msg } }), + { + status: statusCode, + headers: { + "Content-Type": "application/json", + "Retry-After": String(retryAfterSec) + } + } + ); +} + +/** + * Format provider error with context + * @param {Error} error - Original error + * @param {string} provider - Provider name + * @param {string} model - Model name + * @param {number|string} statusCode - HTTP status code or error code + * @returns {string} Formatted error message + */ +export function formatProviderError(error, provider, model, statusCode) { + const code = statusCode || error.code || "FETCH_FAILED"; + const message = error.message || "Unknown error"; + // Expose low-level cause (e.g. UND_ERR_SOCKET, ECONNRESET, ETIMEDOUT) for diagnosing fetch failures + const causeCode = error.cause?.code; + const causeMsg = error.cause?.message; + const causeStr = causeCode || causeMsg ? ` (cause: ${[causeCode, causeMsg].filter(Boolean).join(": ")})` : ""; + return `[${code}]: ${message}${causeStr}`; +} diff --git a/open-sse/utils/ollamaTransform.js b/open-sse/utils/ollamaTransform.js new file mode 100644 index 0000000000000000000000000000000000000000..b4fb6a6526652dcad41a96274628a7d1086b72f2 --- /dev/null +++ b/open-sse/utils/ollamaTransform.js @@ -0,0 +1,85 @@ +// Transform OpenAI SSE stream to Ollama JSON lines format +export function transformToOllama(response, model) { + let buffer = ""; + let pendingToolCalls = {}; + + const transform = new TransformStream({ + transform(chunk, controller) { + const text = new TextDecoder().decode(chunk); + buffer += text; + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + + if (data === "[DONE]") { + const ollamaEnd = JSON.stringify({ model, message: { role: "assistant", content: "" }, done: true }) + "\n"; + controller.enqueue(new TextEncoder().encode(ollamaEnd)); + return; + } + + try { + const parsed = JSON.parse(data); + const delta = parsed.choices?.[0]?.delta || {}; + const content = delta.content || ""; + const toolCalls = delta.tool_calls; + + if (toolCalls) { + for (const tc of toolCalls) { + const idx = tc.index; + if (!pendingToolCalls[idx]) { + pendingToolCalls[idx] = { id: tc.id, function: { name: "", arguments: "" } }; + } + if (tc.function?.name) pendingToolCalls[idx].function.name += tc.function.name; + if (tc.function?.arguments) pendingToolCalls[idx].function.arguments += tc.function.arguments; + } + } + + if (content) { + const ollama = JSON.stringify({ model, message: { role: "assistant", content }, done: false }) + "\n"; + controller.enqueue(new TextEncoder().encode(ollama)); + } + + const finishReason = parsed.choices?.[0]?.finish_reason; + if (finishReason === "tool_calls" || finishReason === "stop") { + const toolCallsArr = Object.values(pendingToolCalls); + if (toolCallsArr.length > 0) { + const formattedCalls = toolCallsArr.map(tc => ({ + function: { + name: tc.function.name, + arguments: (() => { try { return JSON.parse(tc.function.arguments || "{}"); } catch { return {}; } })() + } + })); + const ollama = JSON.stringify({ + model, + message: { role: "assistant", content: "", tool_calls: formattedCalls }, + done: true + }) + "\n"; + controller.enqueue(new TextEncoder().encode(ollama)); + pendingToolCalls = {}; + } else if (finishReason === "stop") { + const ollamaEnd = JSON.stringify({ model, message: { role: "assistant", content: "" }, done: true }) + "\n"; + controller.enqueue(new TextEncoder().encode(ollamaEnd)); + } + } + } catch (e) { + // Silently ignore parse errors + } + } + }, + flush(controller) { + const ollamaEnd = JSON.stringify({ model, message: { role: "assistant", content: "" }, done: true }) + "\n"; + controller.enqueue(new TextEncoder().encode(ollamaEnd)); + } + }); + + if (!response.body) { + return new Response("", { status: response.status, headers: { "Content-Type": "application/x-ndjson" } }); + } + return new Response(response.body.pipeThrough(transform), { + headers: { "Content-Type": "application/x-ndjson", "Access-Control-Allow-Origin": "*" } + }); +} + diff --git a/open-sse/utils/proxyFetch.js b/open-sse/utils/proxyFetch.js new file mode 100644 index 0000000000000000000000000000000000000000..341b815883a31c844edf4fce3fe562b654ce2e52 --- /dev/null +++ b/open-sse/utils/proxyFetch.js @@ -0,0 +1,368 @@ +import { Readable } from "stream"; +import { MEMORY_CONFIG } from "../config/runtimeConfig.js"; +import { dbg } from "./debugLog.js"; + +const originalFetch = globalThis.fetch; +const proxyDispatchers = new Map(); + +// ─── TLS fingerprinting via got-scraping (browser-like JA3) ─────────────── +// Disabled: not in use. Kept commented for future re-enable. +// Restore the original block to re-enable per-host JA3 spoofing. +/* +let _gotScraping = null; +let _gotScrapingChecked = false; +const _gotScrapingLoggedHosts = new Set(); + +async function getGotScraping() { + if (_gotScrapingChecked) return _gotScraping; + _gotScrapingChecked = true; + try { + const mod = await import("got-scraping"); + _gotScraping = typeof mod.gotScraping === "function" ? mod.gotScraping : null; + if (_gotScraping) dbg("TLS", "got-scraping loaded (browser-like JA3 enabled)"); + } catch (e) { + console.warn(`[ProxyFetch] got-scraping unavailable, falling back to native fetch: ${e.message}`); + _gotScraping = null; + } + return _gotScraping; +} + +async function gotScrapingFetch(url, options) { + const gs = await getGotScraping(); + if (!gs) return null; + + const method = (options.method || "GET").toUpperCase(); + const headersInit = options.headers || {}; + const headers = headersInit instanceof Headers + ? Object.fromEntries(headersInit.entries()) + : { ...headersInit }; + + return new Promise((resolve, reject) => { + let settled = false; + const stream = gs.stream({ + url, + method, + headers, + body: method === "GET" || method === "HEAD" ? undefined : options.body, + throwHttpErrors: false, + retry: { limit: 0 }, + timeout: { request: undefined }, + followRedirect: false, + decompress: true, + }); + + if (options.signal) { + const onAbort = () => { try { stream.destroy(new Error("aborted")); } catch { } }; + if (options.signal.aborted) onAbort(); + else options.signal.addEventListener("abort", onAbort, { once: true }); + } + + stream.once("response", (res) => { + if (settled) return; + settled = true; + const resHeaders = new Headers(); + for (const [k, v] of Object.entries(res.headers || {})) { + if (Array.isArray(v)) v.forEach((x) => resHeaders.append(k, String(x))); + else if (v != null) resHeaders.set(k, String(v)); + } + const body = Readable.toWeb(stream); + resolve(new Response(body, { status: res.statusCode, statusText: res.statusMessage || "", headers: resHeaders })); + }); + + stream.once("error", (err) => { + if (settled) return; + settled = true; + reject(err); + }); + }); +} + +async function tryGotScrapingFetch(url, options) { + try { + const res = await gotScrapingFetch(url, options); + if (res) { + try { + const host = new URL(typeof url === "string" ? url : url.toString()).hostname; + if (!_gotScrapingLoggedHosts.has(host)) { + _gotScrapingLoggedHosts.add(host); + dbg("TLS", `using got-scraping for ${host}`); + } + } catch { } + } + return res; + } catch (e) { + console.warn(`[ProxyFetch] got-scraping request failed, fallback to native fetch: ${e.message}`); + return null; + } +} +*/ + +// DNS cache — use Map to avoid prototype pollution via malformed hostnames +const DNS_CACHE = new Map(); +const MITM_BYPASS_HOSTS = [ + "cloudcode-pa.googleapis.com", + "daily-cloudcode-pa.googleapis.com", + "api.individual.githubcopilot.com", + "q.us-east-1.amazonaws.com", + "codewhisperer.us-east-1.amazonaws.com", + "api2.cursor.sh", +]; +const GOOGLE_DNS_SERVERS = ["8.8.8.8", "8.8.4.4"]; +const HTTPS_PORT = 443; +const HTTP_SUCCESS_MIN = 200; +const HTTP_SUCCESS_MAX = 300; + +function normalizeString(value) { + if (value === undefined || value === null) return ""; + return String(value).trim(); +} + +/** + * Resolve real IP using Google DNS (bypass system DNS) + */ +async function resolveRealIP(hostname) { + const cached = DNS_CACHE.get(hostname); + if (cached && Date.now() < cached.expiry) return cached.ip; + + try { + const dns = await import("dns"); + const { promisify } = await import("util"); + const resolver = new dns.Resolver(); + resolver.setServers(GOOGLE_DNS_SERVERS); + const resolve4 = promisify(resolver.resolve4.bind(resolver)); + const addresses = await resolve4(hostname); + DNS_CACHE.set(hostname, { ip: addresses[0], expiry: Date.now() + MEMORY_CONFIG.dnsCacheTtlMs }); + return addresses[0]; + } catch (error) { + console.warn(`[ProxyFetch] DNS resolve failed for ${hostname}:`, error.message); + return null; + } +} + +/** + * Check if request should bypass MITM DNS redirect + */ +function shouldBypassMitmDns(url) { + try { + const hostname = new URL(url).hostname; + return MITM_BYPASS_HOSTS.some(host => hostname.includes(host)); + } catch { return false; } +} + +function shouldBypassByNoProxy(targetUrl, noProxyValue) { + const noProxy = normalizeString(noProxyValue); + if (!noProxy) return false; + + let hostname; + try { hostname = new URL(targetUrl).hostname.toLowerCase(); } catch { return false; } + const patterns = noProxy.split(",").map((p) => p.trim().toLowerCase()).filter(Boolean); + + return patterns.some((pattern) => { + if (pattern === "*") return true; + if (pattern.startsWith(".")) return hostname.endsWith(pattern) || hostname === pattern.slice(1); + return hostname === pattern || hostname.endsWith(`.${pattern}`); + }); +} + +/** + * Get proxy URL from environment + */ +function getEnvProxyUrl(targetUrl) { + const noProxy = process.env.NO_PROXY || process.env.no_proxy; + if (shouldBypassByNoProxy(targetUrl, noProxy)) return null; + + let protocol; + try { protocol = new URL(targetUrl).protocol; } catch { return null; } + + if (protocol === "https:") { + return process.env.HTTPS_PROXY || process.env.https_proxy || + process.env.ALL_PROXY || process.env.all_proxy; + } + + return process.env.HTTP_PROXY || process.env.http_proxy || + process.env.ALL_PROXY || process.env.all_proxy; +} + +/** + * Normalize proxy URL (allow host:port) + */ +function normalizeProxyUrl(proxyUrl) { + const normalizedInput = normalizeString(proxyUrl); + if (!normalizedInput) return null; + + try { + + new URL(normalizedInput); + return normalizedInput; + } catch { + // Allow "127.0.0.1:7890" style values + return `http://${normalizedInput}`; + } +} + +function resolveConnectionProxyUrl(targetUrl, proxyOptions) { + const enabled = proxyOptions?.enabled === true || proxyOptions?.connectionProxyEnabled === true; + if (!enabled) return null; + + const proxyUrlRaw = normalizeString(proxyOptions?.url ?? proxyOptions?.connectionProxyUrl); + if (!proxyUrlRaw) return null; + + const noProxy = normalizeString(proxyOptions?.noProxy ?? proxyOptions?.connectionNoProxy); + if (noProxy && shouldBypassByNoProxy(targetUrl, noProxy)) return null; + + return normalizeProxyUrl(proxyUrlRaw); +} + +/** + * Create proxy dispatcher lazily (undici-compatible) + */ +async function getDispatcher(proxyUrl) { + const normalized = normalizeProxyUrl(proxyUrl); + if (!normalized) return null; + + if (!proxyDispatchers.has(normalized)) { + // Evict oldest entry if max size reached + if (proxyDispatchers.size >= MEMORY_CONFIG.proxyDispatchersMaxSize) { + proxyDispatchers.delete(proxyDispatchers.keys().next().value); + } + const { ProxyAgent } = await import("undici"); + proxyDispatchers.set(normalized, new ProxyAgent({ uri: normalized })); + } + + return proxyDispatchers.get(normalized); +} + +/** + * Create HTTPS request with manual socket connection (bypass DNS) + */ +async function createBypassRequest(parsedUrl, realIP, options) { + const httpsModule = await import("https"); + const netModule = await import("net"); + // CJS modules expose exports via .default in ESM dynamic import context + const https = httpsModule.default ?? httpsModule; + const net = netModule.default ?? netModule; + + return new Promise((resolve, reject) => { + const socket = new net.Socket(); + + socket.connect(HTTPS_PORT, realIP, () => { + const reqOptions = { + socket, + // SNI + cert hostname are validated against the hostname the caller + // asked for, not the IP we connected to. This keeps the DNS-bypass + // (avoiding /etc/hosts MITM) while still rejecting on-path attackers + // that present a different cert. The MITM_BYPASS_HOSTS targets are + // all public-CA-issued (Google / GitHub / AWS / Cursor) so default + // verification works without any extra trust store. + servername: parsedUrl.hostname, + path: parsedUrl.pathname + parsedUrl.search, + method: options.method || "POST", + headers: { + ...options.headers, + Host: parsedUrl.hostname, + }, + }; + + const req = https.request(reqOptions, (res) => { + const response = { + ok: res.statusCode >= HTTP_SUCCESS_MIN && res.statusCode < HTTP_SUCCESS_MAX, + status: res.statusCode, + statusText: res.statusMessage, + headers: new Map(Object.entries(res.headers)), + body: Readable.toWeb(res), + text: async () => { + const chunks = []; + for await (const chunk of res) chunks.push(chunk); + return Buffer.concat(chunks).toString(); + }, + json: async () => JSON.parse(await response.text()), + }; + resolve(response); + }); + + req.on("error", reject); + if (options.body) { + req.write(typeof options.body === "string" ? options.body : JSON.stringify(options.body)); + } + req.end(); + }); + + socket.on("error", reject); + }); +} + +export async function proxyAwareFetch(url, options = {}, proxyOptions = null) { + const targetUrl = typeof url === "string" ? url : url.toString(); + + // Vercel relay: forward request via relay headers + const vercelRelayUrl = normalizeString(proxyOptions?.vercelRelayUrl); + if (vercelRelayUrl) { + const parsed = new URL(targetUrl); + const relayHeaders = { + ...options.headers, + "x-relay-target": `${parsed.protocol}//${parsed.host}`, + "x-relay-path": `${parsed.pathname}${parsed.search}`, + }; + return originalFetch(vercelRelayUrl, { ...options, headers: relayHeaders }); + } + + const connectionProxyUrl = resolveConnectionProxyUrl(targetUrl, proxyOptions); + const envProxyUrl = connectionProxyUrl ? null : normalizeProxyUrl(getEnvProxyUrl(targetUrl)); + const proxyUrl = connectionProxyUrl || envProxyUrl; + + // MITM DNS bypass: for known MITM-intercepted hosts, resolve real IP to avoid DNS spoof + if (shouldBypassMitmDns(targetUrl)) { + if (proxyUrl) { + // Proxy resolves DNS externally (not affected by /etc/hosts) — use proxy directly + try { + const dispatcher = await getDispatcher(proxyUrl); + return await originalFetch(url, { ...options, dispatcher }); + } catch (proxyError) { + if (proxyOptions?.strictProxy === true) { + throw new Error(`[ProxyFetch] Proxy required but failed (strictProxy=true): ${proxyError.message}`); + } + console.warn(`[ProxyFetch] Proxy failed, falling back to direct bypass: ${proxyError.message}`); + } + } + // No proxy — manually resolve real IP to bypass DNS spoof + try { + const parsedUrl = new URL(targetUrl); + const realIP = await resolveRealIP(parsedUrl.hostname); + if (realIP) return await createBypassRequest(parsedUrl, realIP, options); + } catch (error) { + console.warn(`[ProxyFetch] MITM bypass failed: ${error.message}`); + } + } + + if (proxyUrl) { + try { + const dispatcher = await getDispatcher(proxyUrl); + return await originalFetch(url, { ...options, dispatcher }); + } catch (proxyError) { + // If strictProxy is enabled, fail hard instead of falling back to direct + if (proxyOptions?.strictProxy === true) { + throw new Error(`[ProxyFetch] Proxy required but failed (strictProxy=true): ${proxyError.message}`); + } + console.warn(`[ProxyFetch] Proxy failed, falling back to direct: ${proxyError.message}`); + return originalFetch(url, options); + } + } + + // got-scraping disabled — use native fetch directly + // (Re-enable per-host by wrapping with tryGotScrapingFetch when needed) + return originalFetch(url, options); +} + +/** + * Patched global fetch with env-proxy support and MITM DNS bypass + */ +async function patchedFetch(url, options = {}) { + return proxyAwareFetch(url, options, null); +} + +// Idempotency guard — only patch once to avoid wrapping multiple times +if (globalThis.fetch !== patchedFetch) { + globalThis.fetch = patchedFetch; +} + +export default patchedFetch; diff --git a/open-sse/utils/reasoningContentInjector.js b/open-sse/utils/reasoningContentInjector.js new file mode 100644 index 0000000000000000000000000000000000000000..14fd27b74d26bae7471c611076a302fbf1fdd4e5 --- /dev/null +++ b/open-sse/utils/reasoningContentInjector.js @@ -0,0 +1,76 @@ +// 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 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 = [ + { match: m => /^kimi-/i.test(m || ""), scope: "toolCalls" }, + { match: m => /deepseek/i.test(m || ""), scope: "all" } +]; + +const DEEPSEEK_V4_PRO = "deepseek-v4-pro"; +const DEEPSEEK_V4_PRO_ALIASES = { + [`${DEEPSEEK_V4_PRO}-max`]: { + thinkingType: "enabled", + reasoningEffort: "max" + }, + [`${DEEPSEEK_V4_PRO}-none`]: { + thinkingType: "disabled", + reasoningEffort: null + } +}; + +function shouldInject(message, scope) { + if (message?.role !== "assistant") return false; + const rc = message.reasoning_content; + if (typeof rc === "string" && rc.length > 0) return false; + if (scope === "toolCalls") return Array.isArray(message.tool_calls) && message.tool_calls.length > 0; + return true; +} + +function applyRule(body, rule) { + if (!rule || !body?.messages) return body; + const messages = body.messages.map(m => + shouldInject(m, rule.scope) ? { ...m, reasoning_content: PLACEHOLDER } : m + ); + return { ...body, messages }; +} + +function applyDeepSeekV4ProAlias({ provider, model, body }) { + const alias = DEEPSEEK_V4_PRO_ALIASES[model]; + if (provider !== "deepseek" || !alias || !body) return body; + + const nextBody = { + ...body, + model: DEEPSEEK_V4_PRO, + extra_body: { + ...(body.extra_body || {}), + thinking: { + ...(body.extra_body?.thinking || {}), + type: alias.thinkingType + } + } + }; + + if (alias.reasoningEffort) { + nextBody.reasoning_effort = alias.reasoningEffort; + } else { + delete nextBody.reasoning_effort; + } + + return nextBody; +} + +export function injectReasoningContent({ provider, model, body }) { + const providerRule = providerRuleFor(provider); + const modelRule = MODEL_RULES.find(r => r.match(model)); + const rule = providerRule || modelRule; + const nextBody = applyDeepSeekV4ProAlias({ provider, model, body }); + return applyRule(nextBody, rule); +} diff --git a/open-sse/utils/requestLogger.js b/open-sse/utils/requestLogger.js new file mode 100644 index 0000000000000000000000000000000000000000..010153d3208b6a4633b4835e1a5299c569494ccd --- /dev/null +++ b/open-sse/utils/requestLogger.js @@ -0,0 +1,260 @@ +// Check if running in Node.js environment (has fs module) +const isNode = typeof process !== "undefined" && process.versions?.node && typeof window === "undefined"; + +// Check if logging is enabled via environment variable (default: false) +const LOGGING_ENABLED = typeof process !== "undefined" && process.env?.ENABLE_REQUEST_LOGS === 'true'; + +let fs = null; +let path = null; +let LOGS_DIR = null; + +// Lazy load Node.js modules (avoid top-level await) +async function ensureNodeModules() { + if (!isNode || !LOGGING_ENABLED || fs) return; + try { + fs = await import("fs"); + path = await import("path"); + LOGS_DIR = path.join(typeof process !== "undefined" && process.cwd ? process.cwd() : ".", "logs"); + } catch { + // Running in non-Node environment (Worker, Browser, etc.) + } +} + +// Format timestamp for folder name: 20251228_143045_123 +function formatTimestamp(date = new Date()) { + const pad = (n) => String(n).padStart(2, "0"); + const y = date.getFullYear(); + const m = pad(date.getMonth() + 1); + const d = pad(date.getDate()); + const h = pad(date.getHours()); + const min = pad(date.getMinutes()); + const s = pad(date.getSeconds()); + const ms = String(date.getMilliseconds()).padStart(3, "0"); + return `${y}${m}${d}_${h}${min}${s}_${ms}`; +} + +// Create log session folder: {sourceFormat}_{targetFormat}_{model}_{timestamp} +async function createLogSession(sourceFormat, targetFormat, model) { + await ensureNodeModules(); + if (!fs || !LOGS_DIR) return null; + + try { + if (!fs.existsSync(LOGS_DIR)) { + fs.mkdirSync(LOGS_DIR, { recursive: true }); + } + + const timestamp = formatTimestamp(); + const safeModel = (model || "unknown").replace(/[/:]/g, "-"); + const folderName = `${sourceFormat}_${targetFormat}_${safeModel}_${timestamp}`; + const sessionPath = path.join(LOGS_DIR, folderName); + + fs.mkdirSync(sessionPath, { recursive: true }); + + return sessionPath; + } catch (err) { + console.log("[LOG] Failed to create log session:", err.message); + return null; + } +} + +// Write JSON file +function writeJsonFile(sessionPath, filename, data) { + if (!fs || !sessionPath) return; + + try { + const filePath = path.join(sessionPath, filename); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); + } catch (err) { + console.log(`[LOG] Failed to write ${filename}:`, err.message); + } +} + +// Mask sensitive data in headers (DISABLED - keep full token for testing) +function maskSensitiveHeaders(headers) { + if (!headers) return {}; + return { ...headers }; + + // Old masking code (disabled): + // const masked = { ...headers }; + // const sensitiveKeys = ["authorization", "x-api-key", "cookie", "token"]; + // + // for (const key of Object.keys(masked)) { + // const lowerKey = key.toLowerCase(); + // if (sensitiveKeys.some(sk => lowerKey.includes(sk))) { + // const value = masked[key]; + // if (value && value.length > 20) { + // masked[key] = value.slice(0, 10) + "..." + value.slice(-5); + // } + // } + // } + // return masked; +} + +// No-op logger when logging is disabled +function createNoOpLogger() { + return { + sessionPath: null, + logClientRawRequest() {}, + logRawRequest() {}, + logOpenAIRequest() {}, + logTargetRequest() {}, + logProviderResponse() {}, + appendProviderChunk() {}, + appendOpenAIChunk() {}, + logConvertedResponse() {}, + appendConvertedChunk() {}, + logError() {} + }; +} + +/** + * Create a new log session and return logger functions + * @param {string} sourceFormat - Source format from client (claude, openai, etc.) + * @param {string} targetFormat - Target format to provider (antigravity, gemini-cli, etc.) + * @param {string} model - Model name + * @returns {Promise} Promise that resolves to logger object with methods to log each stage + */ +export async function createRequestLogger(sourceFormat, targetFormat, model) { + // Return no-op logger if logging is disabled + if (!LOGGING_ENABLED) { + return createNoOpLogger(); + } + + // Wait for session to be created before returning logger + const sessionPath = await createLogSession(sourceFormat, targetFormat, model); + + return { + get sessionPath() { return sessionPath; }, + + // 1. Log client raw request (before any conversion) + logClientRawRequest(endpoint, body, headers = {}) { + writeJsonFile(sessionPath, "1_req_client.json", { + timestamp: new Date().toISOString(), + endpoint, + headers: maskSensitiveHeaders(headers), + body + }); + }, + + // 2. Log raw request from client (after initial conversion like responsesApi) + logRawRequest(body, headers = {}) { + writeJsonFile(sessionPath, "2_req_source.json", { + timestamp: new Date().toISOString(), + headers: maskSensitiveHeaders(headers), + body + }); + }, + + // 3. Log OpenAI intermediate format (source → openai) + logOpenAIRequest(body) { + writeJsonFile(sessionPath, "3_req_openai.json", { + timestamp: new Date().toISOString(), + body + }); + }, + + // 4. Log target format request (openai → target) + logTargetRequest(url, headers, body) { + writeJsonFile(sessionPath, "4_req_target.json", { + timestamp: new Date().toISOString(), + url, + headers: maskSensitiveHeaders(headers), + body + }); + }, + + // 5. Log provider response (for non-streaming or error) + logProviderResponse(status, statusText, headers, body) { + const filename = "5_res_provider.json"; + writeJsonFile(sessionPath, filename, { + timestamp: new Date().toISOString(), + status, + statusText, + headers: headers ? (typeof headers.entries === "function" ? Object.fromEntries(headers.entries()) : headers) : {}, + body + }); + }, + + // 5. Append streaming chunk to provider response + appendProviderChunk(chunk) { + if (!fs || !sessionPath) return; + try { + const filePath = path.join(sessionPath, "5_res_provider.txt"); + fs.appendFileSync(filePath, chunk); + } catch (err) { + // Ignore append errors + } + }, + + // 6. Append OpenAI intermediate chunks (target → openai) + appendOpenAIChunk(chunk) { + if (!fs || !sessionPath) return; + try { + const filePath = path.join(sessionPath, "6_res_openai.txt"); + fs.appendFileSync(filePath, chunk); + } catch (err) { + // Ignore append errors + } + }, + + // 7. Log converted response to client (for non-streaming) + logConvertedResponse(body) { + writeJsonFile(sessionPath, "7_res_client.json", { + timestamp: new Date().toISOString(), + body + }); + }, + + // 7. Append streaming chunk to converted response + appendConvertedChunk(chunk) { + if (!fs || !sessionPath) return; + try { + const filePath = path.join(sessionPath, "7_res_client.txt"); + fs.appendFileSync(filePath, chunk); + } catch (err) { + // Ignore append errors + } + }, + + // 6. Log error + logError(error, requestBody = null) { + writeJsonFile(sessionPath, "6_error.json", { + timestamp: new Date().toISOString(), + error: error?.message || String(error), + stack: error?.stack, + requestBody + }); + } + }; +} + +// Legacy functions for backward compatibility +export function logRequest() {} +export function logResponse() {} +export function logError(provider, { error, url, model, requestBody }) { + if (!fs || !LOGS_DIR) return; + + try { + if (!fs.existsSync(LOGS_DIR)) { + fs.mkdirSync(LOGS_DIR, { recursive: true }); + } + + const date = new Date().toISOString().split("T")[0]; + const logPath = path.join(LOGS_DIR, `${provider}-${date}.log`); + + const logEntry = { + timestamp: new Date().toISOString(), + type: "error", + provider, + model, + url, + error: error?.message || String(error), + stack: error?.stack, + requestBody + }; + + fs.appendFileSync(logPath, JSON.stringify(logEntry) + "\n"); + } catch (err) { + console.log("[LOG] Failed to write error log:", err.message); + } +} diff --git a/open-sse/utils/responsesStreamHelpers.js b/open-sse/utils/responsesStreamHelpers.js new file mode 100644 index 0000000000000000000000000000000000000000..6f90a0c13fdc9dbce835c8ddc2b67e04b14d7503 --- /dev/null +++ b/open-sse/utils/responsesStreamHelpers.js @@ -0,0 +1,49 @@ +// Helpers for OpenAI Responses API streaming termination + event framing +import { FORMATS } from "../translator/formats.js"; +import { formatSSE } from "./streamHelpers.js"; + +// Responses API events that signal the stream has reached a terminal state +const OPENAI_RESPONSES_TERMINAL_EVENTS = new Set([ + "response.completed", + "response.failed", + "error" +]); + +export function getOpenAIResponsesEventName(eventName, chunk) { + if (eventName) return eventName; + if (chunk && typeof chunk.type === "string") return chunk.type; + return null; +} + +export function isOpenAIResponsesTerminalEvent(eventName, chunk) { + const type = getOpenAIResponsesEventName(eventName, chunk); + if (OPENAI_RESPONSES_TERMINAL_EVENTS.has(type)) return true; + const status = chunk?.response?.status; + return status === "completed" || status === "failed"; +} + +const sharedEncoder = new TextEncoder(); + +// Encoded response.failed + [DONE] payload for aborted/stalled Responses passthrough streams +export function buildAbortedResponsesTerminalBytes() { + return sharedEncoder.encode(`${formatIncompleteOpenAIResponsesStreamFailure()}data: [DONE]\n\n`); +} + +// Synthesize a response.failed event for streams that close without a terminal event +export function formatIncompleteOpenAIResponsesStreamFailure() { + return formatSSE({ + event: "response.failed", + data: { + type: "response.failed", + response: { + id: `resp_${Date.now()}`, + status: "failed", + error: { + type: "stream_error", + code: "stream_disconnected", + message: "stream closed before response.completed" + } + } + } + }, FORMATS.OPENAI_RESPONSES); +} diff --git a/open-sse/utils/sessionManager.js b/open-sse/utils/sessionManager.js new file mode 100644 index 0000000000000000000000000000000000000000..05f908964d3dfc0cd5f0346d951f6c56b4d6a87c --- /dev/null +++ b/open-sse/utils/sessionManager.js @@ -0,0 +1,231 @@ +/** + * Session Manager for Antigravity Cloud Code + * + * Handles session ID generation and caching for prompt caching continuity. + * Mimics the Antigravity binary behavior: generates a session ID at startup + * and keeps it for the process lifetime, scoped per account/connection. + * + * Reference: antigravity-claude-proxy/src/cloudcode/session-manager.js + */ + +import crypto from "crypto"; +import { MEMORY_CONFIG } from "../config/runtimeConfig.js"; + +// Runtime storage: Key = connectionId, Value = { sessionId, lastUsed } +const runtimeSessionStore = new Map(); + +// Periodically evict entries that haven't been used within TTL +const cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of runtimeSessionStore) { + if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) { + runtimeSessionStore.delete(key); + } + } +}, MEMORY_CONFIG.sessionCleanupIntervalMs); + +// Allow Node.js to exit even if interval is still active +if (cleanupInterval.unref) cleanupInterval.unref(); + +/** + * Get or create a session ID for the given connection. + * + * The binary generates a session ID once at startup: `rs() + Date.now()`. + * Since 9router is long-running, we simulate this "per-launch" behavior by + * storing a generated ID in memory for each connection. + * + * - If 9router restarts, the ID changes (matching binary restart behavior). + * - Within a running instance, the ID is stable for that connection. + * - This enables prompt caching while using the EXACT random logic of the binary. + * + * @param {string} connectionId - The connection identifier (email or unique ID) + * @returns {string} A stable session ID string matching binary format + */ +export function deriveSessionId(connectionId) { + if (!connectionId) { + return generateBinaryStyleId(); + } + + const existing = runtimeSessionStore.get(connectionId); + if (existing) { + existing.lastUsed = Date.now(); + return existing.sessionId; + } + + // Evict oldest entry if store exceeds max size (safety cap between cleanup cycles) + const MAX_SESSIONS = 1000; + if (runtimeSessionStore.size >= MAX_SESSIONS) { + const oldest = runtimeSessionStore.keys().next().value; + runtimeSessionStore.delete(oldest); + } + + const sessionId = generateBinaryStyleId(); + runtimeSessionStore.set(connectionId, { sessionId, lastUsed: Date.now() }); + return sessionId; +} + +/** + * Generate a Session ID using the binary's exact logic. + * Format: `rs() + Date.now()` where `rs()` is randomUUID + * + * @returns {string} A session ID in binary format + */ +export function generateBinaryStyleId() { + return crypto.randomUUID() + Date.now().toString(); +} + +/** + * Clears all session IDs (e.g. useful for testing or explicit reset) + */ +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 0000000000000000000000000000000000000000..2fa47e9d9ed6957af1e314260daec7e6604e458e --- /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 0000000000000000000000000000000000000000..680a05ca33b115910d5d2fccda3d15c76f90d0b6 --- /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 new file mode 100644 index 0000000000000000000000000000000000000000..7f8437347759564a2f6ff6e43d058e425c9d50ed --- /dev/null +++ b/open-sse/utils/stream.js @@ -0,0 +1,465 @@ +import { translateResponse, initState } from "../translator/index.js"; +import { FORMATS } from "../translator/formats.js"; +import { trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js"; +import { extractUsage, hasValidUsage, estimateUsage, logUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js"; +import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./streamHelpers.js"; +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(); + +/** + * Stream modes + */ +const STREAM_MODE = { + TRANSLATE: "translate", // Full translation between formats + PASSTHROUGH: "passthrough" // No translation, normalize output, extract usage +}; + +/** + * Create unified SSE transform stream + * @param {object} options + * @param {string} options.mode - Stream mode: translate, passthrough + * @param {string} options.targetFormat - Provider format (for translate mode) + * @param {string} options.sourceFormat - Client format (for translate mode) + * @param {string} options.provider - Provider name + * @param {object} options.reqLogger - Request logger instance + * @param {string} options.model - Model name + * @param {string} options.connectionId - Connection ID for usage tracking + * @param {object} options.body - Request body (for input token estimation) + * @param {function} options.onStreamComplete - Callback when stream completes (content, usage) + * @param {string} options.apiKey - API key for usage tracking + */ +export function createSSEStream(options = {}) { + const { + mode = STREAM_MODE.TRANSLATE, + targetFormat, + sourceFormat, + provider = null, + reqLogger = null, + toolNameMap = null, + model = null, + connectionId = null, + body = null, + onStreamComplete = null, + apiKey = null + } = options; + + let buffer = ""; + let usage = null; + + // Per-stream decoder with stream:true to correctly handle multi-byte chars split across chunks + const decoder = new TextDecoder("utf-8", { fatal: false }); + + const state = mode === STREAM_MODE.TRANSLATE ? { ...initState(sourceFormat), provider, toolNameMap, model } : null; + + let totalContentLength = 0; + let accumulatedContent = ""; + let accumulatedThinking = ""; + let ttftAt = null; + let sseLineCount = 0; + let sseEmittedCount = 0; + const eventTypeCounts = {}; + + // Track Responses API event framing for same-format passthrough (codex) + let currentOpenAIResponsesEvent = null; + let openAIResponsesTerminalSeen = false; + let openAIResponsesDoneSent = false; + + return new TransformStream({ + transform(chunk, controller) { + if (!ttftAt) ttftAt = Date.now(); + const text = decoder.decode(chunk, { stream: true }); + buffer += text; + reqLogger?.appendProviderChunk?.(text); + + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (isDebugEnabled && trimmed) { + sseLineCount++; + if (trimmed.startsWith("event:")) { + const evt = trimmed.slice(6).trim(); + eventTypeCounts[evt] = (eventTypeCounts[evt] || 0) + 1; + } + } + + // Capture Responses API event name to preserve framing in same-format passthrough + if (mode === STREAM_MODE.TRANSLATE && targetFormat === FORMATS.OPENAI_RESPONSES && trimmed.startsWith("event:")) { + currentOpenAIResponsesEvent = trimmed.slice(6).trim(); + } + + // Passthrough mode: normalize and forward + if (mode === STREAM_MODE.PASSTHROUGH) { + let output; + let injectedUsage = false; + + if (trimmed.startsWith("data:") && trimmed.slice(5).trim() !== "[DONE]") { + try { + const parsed = JSON.parse(trimmed.slice(5).trim()); + + const idFixed = fixInvalidId(parsed); + + // Ensure OpenAI-required fields are present on streaming chunks (Letta compat) + let fieldsInjected = false; + if (parsed.choices !== undefined) { + if (!parsed.object) { parsed.object = "chat.completion.chunk"; fieldsInjected = true; } + if (!parsed.created) { parsed.created = Math.floor(Date.now() / 1000); fieldsInjected = true; } + } + + // Strip Azure-specific non-standard fields from streaming chunks + if (parsed.prompt_filter_results !== undefined) { + delete parsed.prompt_filter_results; + fieldsInjected = true; + } + if (parsed?.choices) { + for (const choice of parsed.choices) { + if (choice.content_filter_results !== undefined) { + delete choice.content_filter_results; + fieldsInjected = true; + } + } + } + + if (!hasValuableContent(parsed, FORMATS.OPENAI)) { + continue; + } + + const delta = parsed.choices?.[0]?.delta; + const content = delta?.content; + const reasoning = delta?.reasoning_content; + if (content && typeof content === "string") { + totalContentLength += content.length; + accumulatedContent += content; + } + if (reasoning && typeof reasoning === "string") { + totalContentLength += reasoning.length; + accumulatedThinking += reasoning; + } + + const extracted = extractUsage(parsed); + if (extracted) { + usage = extracted; + } + + const isFinishChunk = parsed.choices?.[0]?.finish_reason; + if (isFinishChunk && !hasValidUsage(parsed.usage)) { + const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI); + parsed.usage = filterUsageForFormat(estimated, FORMATS.OPENAI); + output = `data: ${JSON.stringify(parsed)}\n`; + usage = estimated; + injectedUsage = true; + } else if (isFinishChunk && usage) { + const buffered = addBufferToUsage(usage); + parsed.usage = filterUsageForFormat(buffered, FORMATS.OPENAI); + output = `data: ${JSON.stringify(parsed)}\n`; + injectedUsage = true; + } else if (idFixed || fieldsInjected) { + output = `data: ${JSON.stringify(parsed)}\n`; + injectedUsage = true; + } + } catch { } + } + + if (!injectedUsage) { + if (line.startsWith("data:") && !line.startsWith("data: ")) { + output = "data: " + line.slice(5) + "\n"; + } else { + output = line + "\n"; + } + } + + reqLogger?.appendConvertedChunk?.(output); + controller.enqueue(sharedEncoder.encode(output)); + continue; + } + + // Translate mode + if (!trimmed) continue; + + const parsed = parseSSELine(trimmed, targetFormat); + if (!parsed) continue; + + // Responses API same-format passthrough: preserve event framing + track terminal state + const isOpenAIResponsesStream = targetFormat === FORMATS.OPENAI_RESPONSES; + const keepsOpenAIResponsesFormat = isOpenAIResponsesStream && sourceFormat === FORMATS.OPENAI_RESPONSES; + const openAIResponsesEventName = isOpenAIResponsesStream + ? getOpenAIResponsesEventName(currentOpenAIResponsesEvent, parsed) + : null; + + if (isOpenAIResponsesStream && isOpenAIResponsesTerminalEvent(openAIResponsesEventName, parsed)) { + openAIResponsesTerminalSeen = true; + } + + // For Ollama: done=true is the final chunk with finish_reason/usage, must translate + // For other formats: done=true is the [DONE] sentinel, skip + if (parsed && parsed.done && targetFormat !== FORMATS.OLLAMA) { + // Synthesize response.failed if the Responses stream never sent a terminal event + if (keepsOpenAIResponsesFormat && !openAIResponsesTerminalSeen) { + const failedOutput = formatIncompleteOpenAIResponsesStreamFailure(); + reqLogger?.appendConvertedChunk?.(failedOutput); + controller.enqueue(sharedEncoder.encode(failedOutput)); + openAIResponsesTerminalSeen = true; + sseEmittedCount++; + } + + const output = "data: [DONE]\n\n"; + reqLogger?.appendConvertedChunk?.(output); + controller.enqueue(sharedEncoder.encode(output)); + if (keepsOpenAIResponsesFormat) openAIResponsesDoneSent = true; + continue; + } + + // Claude format - content + if (parsed.delta?.text) { + totalContentLength += parsed.delta.text.length; + accumulatedContent += parsed.delta.text; + } + // Claude format - thinking + if (parsed.delta?.thinking) { + totalContentLength += parsed.delta.thinking.length; + accumulatedThinking += parsed.delta.thinking; + } + + // OpenAI format - content + if (parsed.choices?.[0]?.delta?.content) { + totalContentLength += parsed.choices[0].delta.content.length; + accumulatedContent += parsed.choices[0].delta.content; + } + // OpenAI format - reasoning + if (parsed.choices?.[0]?.delta?.reasoning_content) { + totalContentLength += parsed.choices[0].delta.reasoning_content.length; + accumulatedThinking += parsed.choices[0].delta.reasoning_content; + } + + // Gemini format + if (parsed.candidates?.[0]?.content?.parts) { + for (const part of parsed.candidates[0].content.parts) { + if (part.text && typeof part.text === "string") { + totalContentLength += part.text.length; + // Check if this is thinking content + if (part.thought === true) { + accumulatedThinking += part.text; + } else { + accumulatedContent += part.text; + } + } + } + } + + // Extract usage + const extracted = extractUsage(parsed); + if (extracted) state.usage = extracted; // Keep original usage for logging + + // Responses same-format passthrough: re-emit with original event framing + if (keepsOpenAIResponsesFormat && openAIResponsesEventName) { + const output = formatSSE({ event: openAIResponsesEventName, data: parsed }, sourceFormat); + reqLogger?.appendConvertedChunk?.(output); + controller.enqueue(sharedEncoder.encode(output)); + currentOpenAIResponsesEvent = null; + sseEmittedCount++; + continue; + } + + currentOpenAIResponsesEvent = null; + + // Translate: targetFormat -> openai -> sourceFormat + const translated = translateResponse(targetFormat, sourceFormat, parsed, state); + + // Log OpenAI intermediate chunks (if available) + if (translated?._openaiIntermediate) { + for (const item of translated._openaiIntermediate) { + const openaiOutput = formatSSE(item, FORMATS.OPENAI); + reqLogger?.appendOpenAIChunk?.(openaiOutput); + } + } + + if (translated?.length > 0) { + for (const item of translated) { + if (item === null || item === undefined) continue; + // Filter empty chunks + if (!hasValuableContent(item, sourceFormat)) { + continue; // Skip this empty chunk + } + + // Inject estimated usage if finish chunk has no valid usage + const isFinishChunk = item.type === "message_delta" || item.choices?.[0]?.finish_reason; + if (state.finishReason && isFinishChunk && !hasValidUsage(item.usage) && totalContentLength > 0) { + const estimated = estimateUsage(body, totalContentLength, sourceFormat); + item.usage = filterUsageForFormat(estimated, sourceFormat); // Filter + already has buffer + state.usage = estimated; + } else if (state.finishReason && isFinishChunk && state.usage) { + // Add buffer and filter usage for client (but keep original in state.usage for logging) + const buffered = addBufferToUsage(state.usage); + item.usage = filterUsageForFormat(buffered, sourceFormat); + } + + const output = formatSSE(item, sourceFormat); + reqLogger?.appendConvertedChunk?.(output); + controller.enqueue(sharedEncoder.encode(output)); + sseEmittedCount++; + } + } + } + }, + + flush(controller) { + const evtSummary = Object.entries(eventTypeCounts).map(([k, v]) => `${k}=${v}`).join(",") || "none"; + dbg("SSE", `flush | provider=${provider} | model=${model} | recvLines=${sseLineCount} | emitted=${sseEmittedCount} | events=[${evtSummary}]`); + trackPendingRequest(model, provider, connectionId, false); + try { + const remaining = decoder.decode(); + if (remaining) buffer += remaining; + + if (mode === STREAM_MODE.PASSTHROUGH) { + if (buffer) { + let output = buffer; + if (buffer.startsWith("data:") && !buffer.startsWith("data: ")) { + output = "data: " + buffer.slice(5); + } + reqLogger?.appendConvertedChunk?.(output); + controller.enqueue(sharedEncoder.encode(output)); + } + + if (!hasValidUsage(usage) && totalContentLength > 0) { + usage = estimateUsage(body, totalContentLength, FORMATS.OPENAI); + } + + if (hasValidUsage(usage)) { + logUsage(provider, usage, model, connectionId, apiKey); + } else { + appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { }); + } + + // IMPORTANT: In passthrough mode we still must terminate the SSE stream. + // 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 (onStreamComplete) { + onStreamComplete({ + content: accumulatedContent, + thinking: accumulatedThinking + }, usage, ttftAt); + } + return; + } + + if (buffer.trim()) { + const parsed = parseSSELine(buffer.trim()); + if (parsed && !parsed.done) { + const translated = translateResponse(targetFormat, sourceFormat, parsed, state); + + if (translated?._openaiIntermediate) { + for (const item of translated._openaiIntermediate) { + const openaiOutput = formatSSE(item, FORMATS.OPENAI); + reqLogger?.appendOpenAIChunk?.(openaiOutput); + } + } + + if (translated?.length > 0) { + for (const item of translated) { + if (item === null || item === undefined) continue; + const output = formatSSE(item, sourceFormat); + reqLogger?.appendConvertedChunk?.(output); + controller.enqueue(sharedEncoder.encode(output)); + } + } + } + } + + const flushed = translateResponse(targetFormat, sourceFormat, null, state); + + if (flushed?._openaiIntermediate) { + for (const item of flushed._openaiIntermediate) { + const openaiOutput = formatSSE(item, FORMATS.OPENAI); + reqLogger?.appendOpenAIChunk?.(openaiOutput); + } + } + + if (flushed?.length > 0) { + for (const item of flushed) { + if (item === null || item === undefined) continue; + const output = formatSSE(item, sourceFormat); + reqLogger?.appendConvertedChunk?.(output); + controller.enqueue(sharedEncoder.encode(output)); + } + } + + // Synthesize response.failed if a Responses passthrough stream never reached a terminal event + const keepsOpenAIResponsesFormat = targetFormat === FORMATS.OPENAI_RESPONSES && sourceFormat === FORMATS.OPENAI_RESPONSES; + if (keepsOpenAIResponsesFormat && !openAIResponsesTerminalSeen) { + const failedOutput = formatIncompleteOpenAIResponsesStreamFailure(); + reqLogger?.appendConvertedChunk?.(failedOutput); + controller.enqueue(sharedEncoder.encode(failedOutput)); + openAIResponsesTerminalSeen = true; + } + + if (!keepsOpenAIResponsesFormat || !openAIResponsesDoneSent) { + const doneOutput = "data: [DONE]\n\n"; + reqLogger?.appendConvertedChunk?.(doneOutput); + controller.enqueue(sharedEncoder.encode(doneOutput)); + } + + if (!hasValidUsage(state?.usage) && totalContentLength > 0) { + state.usage = estimateUsage(body, totalContentLength, sourceFormat); + } + + if (hasValidUsage(state?.usage)) { + logUsage(state.provider || targetFormat, state.usage, model, connectionId, apiKey); + } else { + appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { }); + } + + if (onStreamComplete) { + onStreamComplete({ + content: accumulatedContent, + thinking: accumulatedThinking + }, state?.usage, ttftAt); + } + } catch (error) { + console.log("Error in flush:", error); + } + } + }); +} + +export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null) { + return createSSEStream({ + mode: STREAM_MODE.TRANSLATE, + targetFormat, + sourceFormat, + provider, + reqLogger, + toolNameMap, + model, + connectionId, + body, + onStreamComplete, + apiKey + }); +} + +export function createPassthroughStreamWithLogger(provider = null, reqLogger = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null) { + return createSSEStream({ + mode: STREAM_MODE.PASSTHROUGH, + provider, + reqLogger, + model, + connectionId, + body, + onStreamComplete, + apiKey + }); +} diff --git a/open-sse/utils/streamHandler.js b/open-sse/utils/streamHandler.js new file mode 100644 index 0000000000000000000000000000000000000000..b8a06e2f54024e82531ead9816cbb5078afa3a04 --- /dev/null +++ b/open-sse/utils/streamHandler.js @@ -0,0 +1,253 @@ +// Stream handler with disconnect detection - shared for all providers +import { STREAM_STALL_TIMEOUT_MS } from "../config/runtimeConfig.js"; +import { dbg, isDebugEnabled } from "./debugLog.js"; + +// Get HH:MM:SS timestamp +function getTimeString() { + return new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +/** + * Create stream controller with abort and disconnect detection + * @param {object} options + * @param {function} options.onDisconnect - Callback when client disconnects + * @param {object} options.log - Logger instance + * @param {string} options.provider - Provider name + * @param {string} options.model - Model name + */ +export function createStreamController({ onDisconnect, onError, log, provider, model } = {}) { + const abortController = new AbortController(); + const startTime = Date.now(); + let disconnected = false; + let abortTimeout = null; + + const logStream = (status) => { + const duration = Date.now() - startTime; + const p = provider?.toUpperCase() || "UNKNOWN"; + console.log(`[${getTimeString()}] 🌊 [STREAM] ${p} | ${model || "unknown"} | ${duration}ms | ${status}`); + }; + + return { + signal: abortController.signal, + startTime, + + isConnected: () => !disconnected, + + // Call when client disconnects + handleDisconnect: (reason = "client_closed") => { + if (disconnected) return; + disconnected = true; + + logStream(`disconnect: ${reason}`); + dbg("CTRL", `${provider}/${model} | disconnect=${reason} | dur=${Date.now() - startTime}ms`); + + // Delay abort to allow cleanup + abortTimeout = setTimeout(() => { + abortController.abort(); + }, 500); + + onDisconnect?.({ reason, duration: Date.now() - startTime }); + }, + + // Call when stream completes normally + handleComplete: () => { + if (disconnected) return; + disconnected = true; + + logStream("complete"); + + if (abortTimeout) { + clearTimeout(abortTimeout); + abortTimeout = null; + } + }, + + // Call on error + handleError: (error) => { + if (disconnected) return; + disconnected = true; + + if (abortTimeout) { + clearTimeout(abortTimeout); + abortTimeout = null; + } + + if (error.name === "AbortError") { + logStream("aborted"); + return; + } + + logStream(`error: ${error.message}`); + onError?.(error); + }, + + abort: () => abortController.abort() + }; +} + +/** + * Create transform stream with disconnect detection + * Wraps existing transform stream and adds abort capability. + * + * Stall detection lives in pipeWithDisconnect (tied to upstream byte + * activity), not here — output of the transform stream may be silent + * for long periods while raw bytes still flow (e.g. Kiro EventStream + * binary frames buffering, Claude reasoning streams). + */ +export function createDisconnectAwareStream(transformStream, streamController, onAbortTerminal = null) { + const reader = transformStream.readable.getReader(); + const writer = transformStream.writable.getWriter(); + let terminalEmitted = false; + + // Emit a synthesized terminal payload (e.g. Responses response.failed + [DONE]) once + const emitTerminal = (controller) => { + if (terminalEmitted || !onAbortTerminal) return; + terminalEmitted = true; + try { + const bytes = onAbortTerminal(); + if (bytes) controller.enqueue(bytes); + } catch { /* best-effort terminal */ } + }; + + return new ReadableStream({ + async pull(controller) { + if (!streamController.isConnected()) { + emitTerminal(controller); + controller.close(); + return; + } + + try { + const { done, value } = await reader.read(); + + if (done) { + streamController.handleComplete(); + controller.close(); + return; + } + controller.enqueue(value); + } catch (error) { + const wasConnected = streamController.isConnected(); + // 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(() => {}); + + // Treat network resets / socket hang up / abort as graceful close + const msg = error?.message || ""; + const code = error?.code || error?.cause?.code || ""; + const isNetworkClose = + error.name === "AbortError" || + msg.includes("aborted") || + msg.includes("socket hang up") || + msg.includes("ECONNRESET") || + msg.includes("ETIMEDOUT") || + msg.includes("EPIPE") || + code === "ECONNRESET" || + code === "ETIMEDOUT" || + code === "EPIPE" || + code === "UND_ERR_SOCKET"; + + // Graceful close on network/abort, or when a structured terminal is available + // (Responses passthrough prefers response.failed + [DONE] over a raw transport error) + try { + if (!wasConnected || isNetworkClose || onAbortTerminal) { + emitTerminal(controller); + controller.close(); + } else { + controller.error(error); + } + } catch (e) { /* already closed or cancelled */ } + } + }, + + cancel(reason) { + streamController.handleDisconnect(reason || "cancelled"); + reader.cancel(); + writer.abort(); + } + }); +} + +/** + * Pipe provider response through transform with disconnect detection. + * + * Stall watchdog tracks raw upstream byte activity, not transform output. + * Reasoning models (Claude thinking via Kiro, etc.) can produce zero SSE + * output for long stretches while partial EventStream frames keep arriving. + * Measuring stall on the transform output caused false stalls and the + * "failed to pipe response" error in Next. + * + * Any upstream chunk resets the timer. If no bytes arrive for + * STREAM_STALL_TIMEOUT_MS, abort the underlying fetch via the controller. + * + * @param {Response} providerResponse - Response from provider + * @param {TransformStream} transformStream - Transform stream for SSE + * @param {object} streamController - Stream controller from createStreamController + */ +export function pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal = null, stallTimeoutMs = STREAM_STALL_TIMEOUT_MS) { + let stallTimer = null; + let chunkCount = 0; + let totalBytes = 0; + let lastChunkAt = Date.now(); + const t0 = Date.now(); + const tag = "STREAM"; + const clearStall = () => { + if (stallTimer) { clearTimeout(stallTimer); stallTimer = null; } + }; + const armStall = () => { + clearStall(); + stallTimer = setTimeout(() => { + stallTimer = null; + dbg(tag, `STALL TIMEOUT ${stallTimeoutMs}ms | chunks=${chunkCount} | bytes=${totalBytes} | sinceLast=${Date.now() - lastChunkAt}ms`); + streamController.handleError?.(new Error("stream stall timeout")); + streamController.abort?.(); + }, stallTimeoutMs); + }; + + // Wrap controller so every termination path clears the stall timer. + // Without this, abort/cancel/downstream-error paths leave the timer armed + // and a stale abort could fire after the request has already ended. + const wrappedController = { + signal: streamController.signal, + startTime: streamController.startTime, + isConnected: () => streamController.isConnected(), + handleComplete: () => { dbg(tag, `complete | chunks=${chunkCount} | bytes=${totalBytes} | dur=${Date.now() - t0}ms`); clearStall(); streamController.handleComplete(); }, + handleError: (e) => { dbg(tag, `error: ${e?.message} | chunks=${chunkCount} | bytes=${totalBytes} | dur=${Date.now() - t0}ms`); clearStall(); streamController.handleError(e); }, + handleDisconnect: (r) => { dbg(tag, `disconnect: ${r} | chunks=${chunkCount} | bytes=${totalBytes} | dur=${Date.now() - t0}ms`); clearStall(); streamController.handleDisconnect(r); }, + abort: () => { clearStall(); streamController.abort(); } + }; + + armStall(); + dbg(tag, `pipe start | stallTimeout=${stallTimeoutMs}ms`); + + const upstreamTap = new TransformStream({ + transform(chunk, controller) { + chunkCount++; + const sz = chunk?.byteLength || chunk?.length || 0; + totalBytes += sz; + const now = Date.now(); + const gap = now - lastChunkAt; + lastChunkAt = now; + if (isDebugEnabled && (chunkCount <= 5 || chunkCount % 20 === 0 || gap > 5000)) { + dbg(tag, `chunk #${chunkCount} | size=${sz}B | gap=${gap}ms | total=${totalBytes}B`); + } + armStall(); + controller.enqueue(chunk); + }, + flush() { dbg(tag, `upstream EOF | chunks=${chunkCount} | bytes=${totalBytes} | dur=${Date.now() - t0}ms`); clearStall(); } + }); + + const transformedBody = providerResponse.body + .pipeThrough(upstreamTap) + .pipeThrough(transformStream); + + return createDisconnectAwareStream( + { readable: transformedBody, writable: { getWriter: () => ({ abort: () => Promise.resolve() }) } }, + wrappedController, + onAbortTerminal + ); +} + diff --git a/open-sse/utils/streamHelpers.js b/open-sse/utils/streamHelpers.js new file mode 100644 index 0000000000000000000000000000000000000000..a7a19180fed9c05ee2ad648ca2d2f449c1e6de93 --- /dev/null +++ b/open-sse/utils/streamHelpers.js @@ -0,0 +1,122 @@ +import { FORMATS } from "../translator/formats.js"; + +// Parse SSE data line +export function parseSSELine(line, format = null) { + if (!line) return null; + + // NDJSON format (Ollama): raw JSON lines without "data:" prefix + if (format === FORMATS.OLLAMA) { + const trimmed = line.trim(); + if (trimmed.startsWith("{")) { + try { + return JSON.parse(trimmed); + } catch (error) { + return null; + } + } + return null; + } + + // Standard SSE format: "data: {...}" + if (line.charCodeAt(0) !== 100) return null; // 'd' = 100 + + const data = line.slice(5).trim(); + if (data === "[DONE]") return { done: true }; + + try { + return JSON.parse(data); + } catch (error) { + if (data.length > 0 && data.length < 1000) { + console.log(`[WARN] Failed to parse SSE line (${data.length} chars): ${data.substring(0, 100)}...`); + } + return null; + } +} + +// Check if chunk has valuable content (not empty) +export function hasValuableContent(chunk, format) { + // OpenAI format + if (format === FORMATS.OPENAI && chunk.choices?.[0]?.delta) { + const delta = chunk.choices[0].delta; + return delta.content && delta.content !== "" || + delta.reasoning_content && delta.reasoning_content !== "" || + delta.tool_calls && delta.tool_calls.length > 0 || + chunk.choices[0].finish_reason || + delta.role; + } + + // Claude format + if (format === FORMATS.CLAUDE) { + const isContentBlockDelta = chunk.type === "content_block_delta"; + const hasText = chunk.delta?.text && chunk.delta.text !== ""; + const hasThinking = chunk.delta?.thinking && chunk.delta.thinking !== ""; + const hasInputJson = chunk.delta?.partial_json && chunk.delta.partial_json !== ""; + + if (isContentBlockDelta && !hasText && !hasThinking && !hasInputJson) { + return false; + } + return true; + } + + return true; // Other formats: keep all chunks +} + +// Fix invalid id (generic or too short) +export function fixInvalidId(parsed) { + if (parsed.id && (parsed.id === "chat" || parsed.id === "completion" || parsed.id.length < 8)) { + const fallbackId = parsed.extend_fields?.requestId || + parsed.extend_fields?.traceId || + Date.now().toString(36); + parsed.id = `chatcmpl-${fallbackId}`; + return true; + } + return false; +} + +function cleanUsagePayload(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return payload; + } + + let cleaned = payload; + + if ("usage" in cleaned) { + if (cleaned.usage === null) { + const { usage, ...payloadWithoutUsage } = cleaned; + cleaned = payloadWithoutUsage; + } else if (typeof cleaned.usage === "object" && cleaned.usage.perf_metrics === null) { + const { perf_metrics, ...usageWithoutPerf } = cleaned.usage; + cleaned = { ...cleaned, usage: usageWithoutPerf }; + } + } + + if (cleaned.response && typeof cleaned.response === "object" && !Array.isArray(cleaned.response)) { + const cleanedResponse = cleanUsagePayload(cleaned.response); + if (cleanedResponse !== cleaned.response) { + cleaned = { ...cleaned, response: cleanedResponse }; + } + } + + return cleaned; +} + +// Format output as SSE +export function formatSSE(data, sourceFormat) { + if (data === null || data === undefined) return "data: null\n\n"; + if (data && data.done) return "data: [DONE]\n\n"; + + // OpenAI Responses API format + if (data && data.event && data.data) { + const cleanedEventData = cleanUsagePayload(data.data); + return `event: ${data.event}\ndata: ${JSON.stringify(cleanedEventData)}\n\n`; + } + + data = cleanUsagePayload(data); + + // Claude format + if (sourceFormat === FORMATS.CLAUDE && data && data.type) { + return `event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`; + } + + return `data: ${JSON.stringify(data)}\n\n`; +} diff --git a/open-sse/utils/toolDeduper.js b/open-sse/utils/toolDeduper.js new file mode 100644 index 0000000000000000000000000000000000000000..20c47403032f030bd772a524e648f5b8f0987339 --- /dev/null +++ b/open-sse/utils/toolDeduper.js @@ -0,0 +1,49 @@ +/** + * Strip built-in/duplicate tools when equivalent MCP tools are present. + * Goal: reduce tool definitions token bloat for Claude clients. + */ + +const DEDUP_RULES = [ + { + // Exa MCP present → drop built-in web tools (Exa is preferred). + triggers: ["mcp__exa__web_search_exa", "mcp__exa__web_fetch_exa"], + strip: ["WebSearch", "WebFetch", "mcp__workspace__web_fetch"], + }, + { + // Tavily MCP present → drop built-in web tools. + triggers: ["mcp__tavily__tavily_search", "mcp__tavily__tavily_extract"], + strip: ["WebSearch", "WebFetch", "mcp__workspace__web_fetch"], + }, + { + // Browser MCP present → drop Cowork's duplicate Claude_in_Chrome connector. + triggers: [/^mcp__browsermcp__/], + strip: [/^mcp__Claude_in_Chrome__/], + }, +]; + +function getToolName(t) { + return t?.name || t?.function?.name || ""; +} + +function matches(name, pattern) { + if (typeof pattern === "string") return name === pattern; + return pattern instanceof RegExp ? pattern.test(name) : false; +} + +function dedupeTools(tools) { + if (!Array.isArray(tools) || tools.length === 0) return { tools, stripped: [] }; + const names = tools.map(getToolName); + const toStrip = new Set(); + for (const rule of DEDUP_RULES) { + const hasTrigger = names.some((n) => rule.triggers.some((p) => matches(n, p))); + if (!hasTrigger) continue; + for (const n of names) { + if (rule.strip.some((p) => matches(n, p))) toStrip.add(n); + } + } + if (toStrip.size === 0) return { tools, stripped: [] }; + const out = tools.filter((t) => !toStrip.has(getToolName(t))); + return { tools: out, stripped: Array.from(toStrip) }; +} + +export { dedupeTools }; diff --git a/open-sse/utils/usageTracking.js b/open-sse/utils/usageTracking.js new file mode 100644 index 0000000000000000000000000000000000000000..aed411189fa0dd09e8987188a0f216b5efbe8ea7 --- /dev/null +++ b/open-sse/utils/usageTracking.js @@ -0,0 +1,347 @@ +/** + * Token Usage Tracking - Extract, normalize, estimate and log token usage + */ + +import { saveRequestUsage, appendRequestLog } from "@/lib/usageDb.js"; +import { FORMATS } from "../translator/formats.js"; + +// ANSI color codes +export const COLORS = { + reset: "\x1b[0m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + cyan: "\x1b[36m" +}; + +// Buffer tokens to prevent context errors +const BUFFER_TOKENS = 2000; + +// Get HH:MM:SS timestamp +function getTimeString() { + return new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +/** + * Add buffer tokens to usage to prevent context errors + * @param {object} usage - Usage object (any format) + * @returns {object} Usage with buffer added + */ +export function addBufferToUsage(usage) { + if (!usage || typeof usage !== "object") return usage; + + const result = { ...usage }; + + // Claude format + if (result.input_tokens !== undefined) { + result.input_tokens += BUFFER_TOKENS; + } + + // OpenAI format + if (result.prompt_tokens !== undefined) { + result.prompt_tokens += BUFFER_TOKENS; + } + + // Calculate or update total_tokens + if (result.total_tokens !== undefined) { + result.total_tokens += BUFFER_TOKENS; + } else if (result.prompt_tokens !== undefined && result.completion_tokens !== undefined) { + // Calculate total_tokens if not exists + result.total_tokens = result.prompt_tokens + result.completion_tokens; + } + + return result; +} + +export function filterUsageForFormat(usage, targetFormat) { + if (!usage || typeof usage !== "object") return usage; + + // Helper to pick only defined fields from usage + const pickFields = (fields) => { + const filtered = {}; + for (const field of fields) { + if (usage[field] !== undefined) { + filtered[field] = usage[field]; + } + } + return filtered; + }; + + // Define allowed fields for each format + const formatFields = { + [FORMATS.CLAUDE]: [ + 'input_tokens', 'output_tokens', + 'cache_read_input_tokens', 'cache_creation_input_tokens', + 'estimated' + ], + [FORMATS.GEMINI]: [ + 'promptTokenCount', 'candidatesTokenCount', 'totalTokenCount', + 'cachedContentTokenCount', 'thoughtsTokenCount', + 'estimated' + ], + [FORMATS.OPENAI_RESPONSES]: [ + 'input_tokens', 'output_tokens', + 'input_tokens_details', 'output_tokens_details', + 'estimated' + ], + // OpenAI format (default for OPENAI, CODEX, KIRO, etc.) + default: [ + 'prompt_tokens', 'completion_tokens', 'total_tokens', + 'cached_tokens', 'reasoning_tokens', + 'prompt_tokens_details', 'completion_tokens_details', + 'estimated' + ] + }; + + // Get fields for target format + let fields = formatFields[targetFormat]; + + // Use same fields for similar formats + if (targetFormat === FORMATS.GEMINI_CLI || targetFormat === FORMATS.ANTIGRAVITY) { + fields = formatFields[FORMATS.GEMINI]; + } else if (targetFormat === FORMATS.OPENAI_RESPONSE) { + fields = formatFields[FORMATS.OPENAI_RESPONSES]; + } else if (!fields) { + fields = formatFields.default; + } + + return pickFields(fields); +} + +/** + * Normalize usage object - ensure all values are valid numbers + */ +export function normalizeUsage(usage) { + if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null; + + const normalized = {}; + const assignNumber = (key, value) => { + if (value === undefined || value === null) return; + const numeric = Number(value); + if (Number.isFinite(numeric)) normalized[key] = numeric; + }; + + assignNumber("prompt_tokens", usage?.prompt_tokens); + assignNumber("completion_tokens", usage?.completion_tokens); + assignNumber("total_tokens", usage?.total_tokens); + assignNumber("cache_read_input_tokens", usage?.cache_read_input_tokens); + assignNumber("cache_creation_input_tokens", usage?.cache_creation_input_tokens); + assignNumber("cached_tokens", usage?.cached_tokens); + assignNumber("reasoning_tokens", usage?.reasoning_tokens); + + // Preserve nested details objects for OpenAI format forwarding + if (usage?.prompt_tokens_details && typeof usage.prompt_tokens_details === "object") { + normalized.prompt_tokens_details = usage.prompt_tokens_details; + } + if (usage?.completion_tokens_details && typeof usage.completion_tokens_details === "object") { + normalized.completion_tokens_details = usage.completion_tokens_details; + } + + if (Object.keys(normalized).length === 0) return null; + return normalized; +} + +/** + * Check if usage has valid token data + * Valid = has at least one token field with value > 0 + * Invalid = empty object {}, null, undefined, no token fields, or all zeros + */ +export function hasValidUsage(usage) { + if (!usage || typeof usage !== "object") return false; + + // Check for any known token field with value > 0 + const tokenFields = [ + "prompt_tokens", "completion_tokens", "total_tokens", // OpenAI + "input_tokens", "output_tokens", // Claude + "promptTokenCount", "candidatesTokenCount" // Gemini + ]; + + for (const field of tokenFields) { + if (typeof usage[field] === "number" && usage[field] > 0) { + return true; + } + } + + return false; +} + +/** + * Extract usage from any format (Claude, OpenAI, Gemini, Responses API) + */ +export function extractUsage(chunk) { + if (!chunk || typeof chunk !== "object") return null; + + // Claude format (message_delta event) + if (chunk.type === "message_delta" && chunk.usage && typeof chunk.usage === "object") { + return normalizeUsage({ + prompt_tokens: chunk.usage.input_tokens || 0, + completion_tokens: chunk.usage.output_tokens || 0, + cache_read_input_tokens: chunk.usage.cache_read_input_tokens, + cache_creation_input_tokens: chunk.usage.cache_creation_input_tokens + }); + } + + // OpenAI Responses API format (response.completed or response.done) + if ((chunk.type === "response.completed" || chunk.type === "response.done") && chunk.response?.usage && typeof chunk.response.usage === "object") { + const usage = chunk.response.usage; + const cachedTokens = usage.input_tokens_details?.cached_tokens; + return normalizeUsage({ + prompt_tokens: usage.input_tokens || usage.prompt_tokens || 0, + completion_tokens: usage.output_tokens || usage.completion_tokens || 0, + cached_tokens: cachedTokens, + reasoning_tokens: usage.output_tokens_details?.reasoning_tokens, + prompt_tokens_details: cachedTokens ? { cached_tokens: cachedTokens } : undefined + }); + } + + // OpenAI format (also covers DeepSeek which uses prompt_cache_hit_tokens) + if (chunk.usage && typeof chunk.usage === "object" && chunk.usage.prompt_tokens !== undefined) { + return normalizeUsage({ + prompt_tokens: chunk.usage.prompt_tokens, + completion_tokens: chunk.usage.completion_tokens || 0, + cached_tokens: chunk.usage.prompt_tokens_details?.cached_tokens || chunk.usage.prompt_cache_hit_tokens, + reasoning_tokens: chunk.usage.completion_tokens_details?.reasoning_tokens, + prompt_tokens_details: chunk.usage.prompt_tokens_details, + completion_tokens_details: chunk.usage.completion_tokens_details + }); + } + + // Gemini format (Antigravity) + // Antigravity wraps usageMetadata inside response: { response: { usageMetadata: {...} } } + const usageMeta = chunk.usageMetadata || chunk.response?.usageMetadata; + if (usageMeta && typeof usageMeta === "object") { + return normalizeUsage({ + prompt_tokens: usageMeta.promptTokenCount || 0, + completion_tokens: usageMeta.candidatesTokenCount || 0, + total_tokens: usageMeta.totalTokenCount, + cached_tokens: usageMeta.cachedContentTokenCount, + reasoning_tokens: usageMeta.thoughtsTokenCount + }); + } + + // Ollama NDJSON format (raw from provider, before translation) + // Ollama sends: {"model":"...","done":true,"prompt_eval_count":N,"eval_count":M} + if (chunk.done === true && typeof chunk.prompt_eval_count === "number") { + return normalizeUsage({ + prompt_tokens: chunk.prompt_eval_count || 0, + completion_tokens: chunk.eval_count || 0, + total_tokens: (chunk.prompt_eval_count || 0) + (chunk.eval_count || 0) + }); + } + + return null; +} + +/** + * Estimate input tokens from request body + * Calculate total body size for more accurate estimation + */ +export function estimateInputTokens(body) { + if (!body || typeof body !== "object") return 0; + + try { + // Calculate total body size (includes messages, tools, system, thinking config, etc.) + const bodyStr = JSON.stringify(body); + const totalChars = bodyStr.length; + + // Estimate: ~4 chars per token (rough average across all tokenizers) + return Math.ceil(totalChars / 4); + } catch (err) { + // Fallback if stringify fails + return 0; + } +} + +/** + * Estimate output tokens from content length + */ +export function estimateOutputTokens(contentLength) { + if (!contentLength || contentLength <= 0) return 0; + return Math.max(1, Math.floor(contentLength / 4)); +} + +/** + * Format usage object based on target format + * @param {number} inputTokens - Input/prompt tokens + * @param {number} outputTokens - Output/completion tokens + * @param {string} targetFormat - Target format from FORMATS + */ +export function formatUsage(inputTokens, outputTokens, targetFormat) { + // Claude format uses input_tokens/output_tokens + if (targetFormat === FORMATS.CLAUDE) { + return addBufferToUsage({ + input_tokens: inputTokens, + output_tokens: outputTokens, + estimated: true + }); + } + + // Default: OpenAI format (works for openai, gemini, responses, etc.) + return addBufferToUsage({ + prompt_tokens: inputTokens, + completion_tokens: outputTokens, + total_tokens: inputTokens + outputTokens, + estimated: true + }); +} + +/** + * Estimate full usage when provider doesn't return it + * @param {object} body - Request body for input token estimation + * @param {number} contentLength - Content length for output token estimation + * @param {string} targetFormat - Target format from FORMATS constant + */ +export function estimateUsage(body, contentLength, targetFormat = FORMATS.OPENAI) { + return formatUsage( + estimateInputTokens(body), + estimateOutputTokens(contentLength), + 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); + + // Save to usage DB + const tokens = { + prompt_tokens: inTokens, + completion_tokens: outTokens, + cache_read_input_tokens: cacheRead || 0, + cache_creation_input_tokens: cacheCreation || 0, + reasoning_tokens: reasoning || 0 + }; + saveRequestUsage({ model, provider, connectionId, tokens, apiKey: apiKey || undefined }).catch(() => { }); + appendRequestLog({ model, provider, connectionId, tokens, status: "200 OK" }).catch(() => { }); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..8ad89cdce32d6aa3dc4399a2c40e1082ea2af030 --- /dev/null +++ b/package.json @@ -0,0 +1,60 @@ +{ + "name": "9router-app", + "version": "0.5.4", + "description": "9Router web dashboard", + "private": true, + "scripts": { + "dev": "next dev --webpack --port 20127", + "build": "next build --webpack", + "start": "next start", + "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", + "cli:publish": "npm --prefix cli run publish:cli" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@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", + "express": "^5.2.1", + "fs": "^0.0.1-security", + "http-proxy-middleware": "^3.0.5", + "jose": "^6.1.3", + "marked": "^18.0.1", + "material-symbols": "^0.44.6", + "monaco-editor": "^0.55.1", + "next": "^16.1.6", + "node-forge": "^1.3.3", + "node-machine-id": "^1.1.12", + "open": "^11.0.0", + "ora": "^9.1.0", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-is": "^16.13.1", + "recharts": "^3.7.0", + "selfsigned": "^5.5.0", + "socks-proxy-agent": "^8.0.5", + "sql.js": "^1.14.1", + "undici": "^7.19.2", + "uuid": "^13.0.0", + "zustand": "^5.0.10" + }, + "optionalDependencies": { + "better-sqlite3": "^12.6.2" + }, + "comment_better_sqlite3": "kept in optionalDependencies so npm install doesn't fail on systems without build tools — sql.js is used as fallback at runtime", + "devDependencies": { + "@tailwindcss/postcss": "^4.1.18", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "postcss": "^8.5.6", + "tailwindcss": "^4" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..17fa4232a294c83c0b6cca0440a4a3911ac5e0b0 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,12 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = path.dirname(fileURLToPath(import.meta.url)); + +export default { + plugins: { + "@tailwindcss/postcss": { + base: projectRoot, + }, + }, +}; diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000000000000000000000000000000000000..a72e45bef3c6921da383d84166485c58068317a7 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,11 @@ + + + 9 + + + + + + + + diff --git a/public/file.svg b/public/file.svg new file mode 100644 index 0000000000000000000000000000000000000000..004145cddf3f9db91b57b9cb596683c8eb420862 --- /dev/null +++ b/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg new file mode 100644 index 0000000000000000000000000000000000000000..567f17b0d7c7fb662c16d4357dd74830caf2dccb --- /dev/null +++ b/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/i18n/literals/ar.json b/public/i18n/literals/ar.json new file mode 100644 index 0000000000000000000000000000000000000000..32b72f8b9f7c17efdccd52c65a0413d253de77db --- /dev/null +++ b/public/i18n/literals/ar.json @@ -0,0 +1,195 @@ +{ + "Cancel": "إلغاء", + "Delete": "حذف", + "Edit": "تحرير", + "Save": "حفظ", + "Close": "إغلاق", + "Add": "إضافة", + "Remove": "إزالة", + "Settings": "الإعدادات", + "Profile": "الملف الشخصي", + "Dashboard": "لوحة التحكم", + "Logout": "تسجيل الخروج", + "Login": "تسجيل الدخول", + "Providers": "الموفرون", + "Usage": "الإحصائيات", + "API Key": "مفتاح API", + "Connected": "متصل", + "Disconnected": "غير متصل", + "Active": "نشط", + "Inactive": "غير نشط", + "Success": "نجح", + "Failed": "فشل", + "Error": "خطأ", + "Warning": "تحذير", + "Info": "معلومات", + "Loading": "جاري التحميل", + "Search": "بحث", + "Filter": "تصفية", + "Sort": "ترتيب", + "Export": "تصدير", + "Import": "استيراد", + "Refresh": "تحديث", + "Back": "رجوع", + "Next": "التالي", + "Previous": "السابق", + "Submit": "إرسال", + "Confirm": "تأكيد", + "Yes": "نعم", + "No": "لا", + "OK": "حسنا", + "Apply": "تطبيق", + "Reset": "إعادة تعيين", + "Clear": "مسح", + "Select": "تحديد", + "Upload": "تحميل", + "Download": "تنزيل", + "Copy": "نسخ", + "Paste": "لصق", + "Cut": "قص", + "Undo": "تراجع", + "Redo": "إعادة", + "Name": "الاسم", + "Description": "الوصف", + "Status": "الحالة", + "Type": "النوع", + "Date": "التاريخ", + "Time": "الوقت", + "Created": "تم إنشاء", + "Updated": "تم التحديث", + "Actions": "الإجراءات", + "Details": "التفاصيل", + "View": "عرض", + "New": "جديد", + "Total": "الإجمالي", + "Count": "العدد", + "Price": "السعر", + "Cost": "التكلفة", + "Free": "مجاني", + "Paid": "مدفوع", + "Enable": "تفعيل", + "Disable": "تعطيل", + "Enabled": "مفعل", + "Disabled": "معطل", + "Online": "متصل", + "Offline": "غير متصل", + "Available": "متاح", + "Unavailable": "غير متاح", + "Required": "مطلوب", + "Optional": "اختياري", + "Default": "افتراضي", + "Custom": "مخصص", + "Advanced": "متقدم", + "Basic": "أساسي", + "Help": "مساعدة", + "Support": "دعم", + "Documentation": "التوثيق", + "Version": "الإصدار", + "Language": "اللغة", + "Theme": "المظهر", + "Light": "فاتح", + "Dark": "داكن", + "Auto": "تلقائي", + "Endpoint": "نقطة نهاية", + "Combos": "تراكيب", + "Quota Tracker": "متتبع الحصة", + "MITM": "MITM", + "CLI Tools": "أدوات CLI", + "Console Log": "سجل وحدة التحكم", + "System": "النظام", + "Debug": "تصحيح", + "Shutdown": "إيقاف", + "Close Proxy": "إغلاق الوكيل", + "Are you sure you want to close the proxy server?": "هل أنت متأكد من أنك تريد إغلاق خادم الوكيل؟", + "Server Disconnected": "خادم غير متصل", + "The proxy server has been stopped.": "تم إيقاف خادم الوكيل.", + "Reload Page": "إعادة تحميل الصفحة", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "الخدمة تعمل في المحطة الطرفية. يمكنك إغلاق صفحة الويب هذه. سيؤدي الإيقاف إلى إيقاف الخدمة.", + "Manage your AI provider connections": "إدارة اتصالات موفر الذكاء الاصطناعي الخاصة بك", + "Model combos with fallback": "تراكيب النموذج مع الخيار البديل", + "Monitor your API usage, token consumption, and request logs": "راقب استخدام API وتناول الرموز وسجلات الطلب", + "Intercept CLI tool traffic and route through 9Router": "اعترض حركة مرور أداة CLI وحجم من خلال 9Router", + "Configure CLI tools": "تكوين أدوات CLI", + "API endpoint configuration": "تكوين نقطة نهاية API", + "Manage your preferences": "إدارة تفضيلاتك", + "Debug translation flow between formats": "تصحيح تدفق الترجمة بين الصيغ", + "Live server console output": "مخرجات وحدة التحكم على الخادم المباشر", + "Create model combos with fallback support": "إنشاء تراكيب نموذج مع دعم الخيار البديل", + "Local Mode": "الوضع المحلي", + "Running on your machine": "يعمل على جهازك", + "Database Location": "موقع قاعدة البيانات", + "Download Backup": "تنزيل النسخة الاحتياطية", + "Import Backup": "استيراد النسخة الاحتياطية", + "Database backup downloaded": "تم تنزيل النسخة الاحتياطية لقاعدة البيانات", + "Database imported successfully": "تم استيراد قاعدة البيانات بنجاح", + "Security": "الأمان", + "Require login": "يتطلب تسجيل الدخول", + "When ON, dashboard requires password. When OFF, access without login.": "عند التشغيل، يتطلب لوحة التحكم كلمة مرور. عند الإيقاف، الوصول بدون تسجيل دخول.", + "Current Password": "كلمة المرور الحالية", + "Enter current password": "أدخل كلمة المرور الحالية", + "New Password": "كلمة مرور جديدة", + "Enter new password": "أدخل كلمة مرور جديدة", + "Confirm New Password": "تأكيد كلمة المرور الجديدة", + "Confirm new password": "تأكيد كلمة المرور الجديدة", + "Update Password": "تحديث كلمة المرور", + "Set Password": "تعيين كلمة المرور", + "Password updated successfully": "تم تحديث كلمة المرور بنجاح", + "Passwords do not match": "كلمات المرور لا تتطابق", + "Routing Strategy": "استراتيجية التوجيه", + "Round Robin": "جولة روبن", + "Cycle through accounts to distribute load": "الدوران عبر الحسابات لتوزيع الحمل", + "Sticky Limit": "حد لزج", + "Calls per account before switching": "المكالمات لكل حساب قبل التبديل", + "Network": "الشبكة", + "Outbound Proxy": "وكيل الخروج", + "Enable proxy for OAuth + provider outbound requests.": "تفعيل الوكيل لطلبات OAuth + الخروج من الموفر.", + "Proxy URL": "عنوان URL الوكيل", + "Leave empty to inherit existing env proxy (if any).": "اترك فارغًا لوراثة وكيل env الموجود (إن وجد).", + "No Proxy": "لا يوجد وكيل", + "Comma-separated hostnames/domains to bypass the proxy.": "أسماء المضيفين/النطاقات المفصولة بفواصل لتجاوز الوكيل.", + "Test proxy URL": "اختبر عنوان URL الوكيل", + "Proxy settings applied": "تم تطبيق إعدادات الوكيل", + "Proxy enabled": "الوكيل مفعل", + "Proxy disabled": "الوكيل معطل", + "Proxy test OK": "اختبار الوكيل حسنا", + "Proxy test failed": "فشل اختبار الوكيل", + "Please enter a Proxy URL to test": "يرجى إدخال عنوان URL الوكيل للاختبار", + "Observability": "القابلية للمراقبة", + "Enable Observability": "تفعيل القابلية للمراقبة", + "Turn request detail recording on/off globally": "تشغيل/إيقاف تسجيل تفاصيل الطلب بشكل عام", + "Max Records": "أقصى عدد من السجلات", + "Maximum request detail records to keep (older records are auto-deleted)": "الحد الأقصى من سجلات تفاصيل الطلب للاحتفاظ بها (يتم حذف السجلات الأقدم تلقائيًا)", + "Batch Size": "حجم الدفعة", + "Number of items to accumulate before writing to database (higher = better performance)": "عدد العناصر المراد تجميعها قبل الكتابة إلى قاعدة البيانات (أعلى = أداء أفضل)", + "Flush Interval (ms)": "فترة المسح (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "الحد الأقصى للانتظار قبل مسح المخزن المؤقت (يمنع فقدان البيانات أثناء الحركة المنخفضة)", + "Max JSON Size (KB)": "أقصى حجم JSON (KB)", + "Maximum size for each JSON field (request/response) before truncation": "الحد الأقصى لحجم كل حقل JSON (الطلب/الرد) قبل القطع", + "All data stored on your machine": "جميع البيانات المخزنة على جهازك", + "MITM Server": "خادم MITM", + "Running": "يجري", + "Stopped": "متوقف", + "Cert": "شهادة", + "Server": "الخادم", + "Purpose:": "الغرض:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "استخدم Antigravity IDE و GitHub Copilot → مع أي موفر/نموذج من 9Router", + "How it works:": "كيف يعمل:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "طلب Antigravity/Copilot IDE → إعادة توجيه DNS إلى localhost:443 → يعترض وكيل MITM → 9Router → الرد إلى Antigravity/Copilot", + "No API keys — create one in Keys page": "لا توجد مفاتيح API — قم بإنشاء واحدة في صفحة المفاتيح", + "sk_9router (default)": "sk_9router (افتراضي)", + "Server started": "تم بدء الخادم", + "Failed to start server": "فشل في بدء الخادم", + "Server stopped — all DNS cleared": "تم إيقاف الخادم — تم مسح جميع DNS", + "Failed to stop server": "فشل في إيقاف الخادم", + "Sudo password is required": "كلمة مرور sudo مطلوبة", + "Stop Server": "إيقاف الخادم", + "Start Server": "بدء الخادم", + "Enable DNS per tool below to activate interception": "قم بتفعيل DNS لكل أداة أدناه لتفعيل الاعتراض", + "Sudo Password Required": "كلمة مرور Sudo مطلوبة", + "Enter your sudo password to start/stop MITM server": "أدخل كلمة مرور sudo للبدء/الإيقاف من خادم MITM", + "Sudo Password": "كلمة مرور Sudo", + "Click to add, click again to remove. Changes are saved automatically.": "انقر للإضافة، انقر مرة أخرى للإزالة. يتم حفظ التغييرات تلقائيًا.", + "⚠️ 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.": "⚠️ تنبيه مخاطر: يستخدم هذا الموفر اشتراكًا/جلسة OAuth غير مرخصة رسميًا للاستخدام عبر البروكسي/الراوتر. قد يتم تقييد الحساب أو حظره. الاستخدام على مسؤوليتك الخاصة.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ يعترض MITM حركة مرور HTTPS لأدوات IDE (Antigravity، GitHub Copilot، Kiro) عبر CA محلية لإعادة توجيه الطلبات إلى مزوديك. قد ينتهك شروط الخدمة → خطر حظر الحساب. الاستخدام على مسؤوليتك الخاصة.", + "Endpoint is exposed without an API key.": "نقطة النهاية مكشوفة بدون مفتاح API." +} diff --git a/public/i18n/literals/bn.json b/public/i18n/literals/bn.json new file mode 100644 index 0000000000000000000000000000000000000000..ef71ddf8d5a4a391e942fb58c1d4ea55d21b70f2 --- /dev/null +++ b/public/i18n/literals/bn.json @@ -0,0 +1,195 @@ +{ + "Cancel": "বাতিল করুন", + "Delete": "মুছুন", + "Edit": "সম্পাদনা করুন", + "Save": "সংরক্ষণ করুন", + "Close": "বন্ধ করুন", + "Add": "যোগ করুন", + "Remove": "সরান", + "Settings": "সেটিংস", + "Profile": "প্রোফাইল", + "Dashboard": "ড্যাশবোর্ড", + "Logout": "লগ আউট", + "Login": "লগ ইন", + "Providers": "সরবরাহকারী", + "Usage": "ব্যবহারের পরিসংখ্যান", + "API Key": "API কী", + "Connected": "সংযুক্ত", + "Disconnected": "বিচ্ছিন্ন", + "Active": "সক্রিয়", + "Inactive": "নিষ্ক্রিয়", + "Success": "সফল", + "Failed": "ব্যর্থ", + "Error": "ত্রুটি", + "Warning": "সতর্কতা", + "Info": "তথ্য", + "Loading": "লোড হচ্ছে", + "Search": "অনুসন্ধান করুন", + "Filter": "ফিল্টার", + "Sort": "সাজান", + "Export": "রপ্তানি করুন", + "Import": "আমদানি করুন", + "Refresh": "রিফ্রেশ করুন", + "Back": "ফিরে যান", + "Next": "পরবর্তী", + "Previous": "পূর্ববর্তী", + "Submit": "জমা দিন", + "Confirm": "নিশ্চিত করুন", + "Yes": "হ্যাঁ", + "No": "না", + "OK": "ঠিক আছে", + "Apply": "প্রয়োগ করুন", + "Reset": "পুনরায় সেট করুন", + "Clear": "সাফ করুন", + "Select": "নির্বাচন করুন", + "Upload": "আপলোড করুন", + "Download": "ডাউনলোড করুন", + "Copy": "অনুলিপি করুন", + "Paste": "পেস্ট করুন", + "Cut": "কাটুন", + "Undo": "পূর্বাবস্থায় ফিরিয়ে আনুন", + "Redo": "পুনরায় করুন", + "Name": "নাম", + "Description": "বর্ণনা", + "Status": "অবস্থা", + "Type": "ধরন", + "Date": "তারিখ", + "Time": "সময়", + "Created": "তৈরি করা হয়েছে", + "Updated": "আপডেট করা হয়েছে", + "Actions": "পদক্ষেপ", + "Details": "বিশদ", + "View": "দেখুন", + "New": "নতুন", + "Total": "মোট", + "Count": "গণনা", + "Price": "দাম", + "Cost": "খরচ", + "Free": "বিনামূল্যে", + "Paid": "পেইড", + "Enable": "সক্ষম করুন", + "Disable": "অক্ষম করুন", + "Enabled": "সক্ষম", + "Disabled": "অক্ষম", + "Online": "অনলাইন", + "Offline": "অফলাইন", + "Available": "উপলব্ধ", + "Unavailable": "অনুপলব্ধ", + "Required": "প্রয়োজনীয়", + "Optional": "ঐচ্ছিক", + "Default": "ডিফল্ট", + "Custom": "কাস্টম", + "Advanced": "উন্নত", + "Basic": "মৌলিক", + "Help": "সহায়তা", + "Support": "সহায়তা", + "Documentation": "ডকুমেন্টেশন", + "Version": "সংস্করণ", + "Language": "ভাষা", + "Theme": "থিম", + "Light": "হালকা", + "Dark": "গাঢ়", + "Auto": "স্বয়ংক্রিয়", + "Endpoint": "এন্ডপয়েন্ট", + "Combos": "কম্বো", + "Quota Tracker": "কোটা ট্র্যাকার", + "MITM": "MITM", + "CLI Tools": "সরঞ্জাম", + "Console Log": "কনসোল লগ", + "System": "সিস্টেম", + "Debug": "ডিবাগ", + "Shutdown": "বন্ধ করুন", + "Close Proxy": "প্রক্সি বন্ধ করুন", + "Are you sure you want to close the proxy server?": "আপনি কি নিশ্চিত যে আপনি প্রক্সি সার্ভার বন্ধ করতে চান?", + "Server Disconnected": "সার্ভার সংযোগ বিচ্ছিন্ন", + "The proxy server has been stopped.": "প্রক্সি সার্ভার বন্ধ করা হয়েছে।", + "Reload Page": "পৃষ্ঠা পুনরায় লোড করুন", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "সেবা টার্মিনালে চলছে। আপনি এই ওয়েব পৃষ্ঠাটি বন্ধ করতে পারেন। শাটডাউন সেবা বন্ধ করবে।", + "Manage your AI provider connections": "আপনার AI সরবরাহকারী সংযোগ পরিচালনা করুন", + "Model combos with fallback": "ফলব্যাক সহ মডেল কম্বো", + "Monitor your API usage, token consumption, and request logs": "আপনার API ব্যবহার, টোকেন খরচ এবং অনুরোধ লগ পর্যবেক্ষণ করুন", + "Intercept CLI tool traffic and route through 9Router": "CLI টুল ট্রাফিক ইন্টারসেপ্ট করুন এবং 9Router এর মাধ্যমে রুট করুন", + "Configure CLI tools": "CLI সরঞ্জাম কনফিগার করুন", + "API endpoint configuration": "API এন্ডপয়েন্ট কনফিগারেশন", + "Manage your preferences": "আপনার পছন্দগুলি পরিচালনা করুন", + "Debug translation flow between formats": "ফর্ম্যাটগুলির মধ্যে অনুবাদ প্রবাহ ডিবাগ করুন", + "Live server console output": "লাইভ সার্ভার কনসোল আউটপুট", + "Create model combos with fallback support": "ফলব্যাক সমর্থন সহ মডেল কম্বো তৈরি করুন", + "Local Mode": "স্থানীয় মোড", + "Running on your machine": "আপনার মেশিনে চলছে", + "Database Location": "ডাটাবেস অবস্থান", + "Download Backup": "ব্যাকআপ ডাউনলোড করুন", + "Import Backup": "ব্যাকআপ আমদানি করুন", + "Database backup downloaded": "ডাটাবেস ব্যাকআপ ডাউনলোড করা হয়েছে", + "Database imported successfully": "ডাটাবেস সফলভাবে আমদানি করা হয়েছে", + "Security": "নিরাপত্তা", + "Require login": "লগইন প্রয়োজন", + "When ON, dashboard requires password. When OFF, access without login.": "চালু থাকলে, ড্যাশবোর্ড পাসওয়ার্ড প্রয়োজন। বন্ধ থাকলে, লগইন ছাড়াই অ্যাক্সেস করুন।", + "Current Password": "বর্তমান পাসওয়ার্ড", + "Enter current password": "বর্তমান পাসওয়ার্ড প্রবেশ করুন", + "New Password": "নতুন পাসওয়ার্ড", + "Enter new password": "নতুন পাসওয়ার্ড প্রবেশ করুন", + "Confirm New Password": "নতুন পাসওয়ার্ড নিশ্চিত করুন", + "Confirm new password": "নতুন পাসওয়ার্ড নিশ্চিত করুন", + "Update Password": "পাসওয়ার্ড আপডেট করুন", + "Set Password": "পাসওয়ার্ড সেট করুন", + "Password updated successfully": "পাসওয়ার্ড সফলভাবে আপডেট করা হয়েছে", + "Passwords do not match": "পাসওয়ার্ড মেলে না", + "Routing Strategy": "রাউটিং কৌশল", + "Round Robin": "রাউন্ড রবিন", + "Cycle through accounts to distribute load": "লোড বিতরণের জন্য অ্যাকাউন্টগুলির মধ্য দিয়ে চক্র", + "Sticky Limit": "স্টিকি সীমা", + "Calls per account before switching": "স্যুইচিংয়ের আগে অ্যাকাউন্ট প্রতি কল", + "Network": "নেটওয়ার্ক", + "Outbound Proxy": "আউটবাউন্ড প্রক্সি", + "Enable proxy for OAuth + provider outbound requests.": "OAuth + সরবরাহকারী আউটবাউন্ড অনুরোধের জন্য প্রক্সি সক্ষম করুন।", + "Proxy URL": "প্রক্সি URL", + "Leave empty to inherit existing env proxy (if any).": "বিদ্যমান env প্রক্সি উত্তরাধিকার করতে খালি রেখে দিন (থাকলে)।", + "No Proxy": "কোন প্রক্সি নেই", + "Comma-separated hostnames/domains to bypass the proxy.": "প্রক্সি বাইপাস করার জন্য কমা-পৃথক হোস্টনাম/ডোমেইন।", + "Test proxy URL": "প্রক্সি URL পরীক্ষা করুন", + "Proxy settings applied": "প্রক্সি সেটিংস প্রয়োগ করা হয়েছে", + "Proxy enabled": "প্রক্সি সক্ষম", + "Proxy disabled": "প্রক্সি অক্ষম", + "Proxy test OK": "প্রক্সি পরীক্ষা ঠিক আছে", + "Proxy test failed": "প্রক্সি পরীক্ষা ব্যর্থ", + "Please enter a Proxy URL to test": "পরীক্ষার জন্য দয়া করে একটি প্রক্সি URL প্রবেশ করুন", + "Observability": "পর্যবেক্ষণযোগ্যতা", + "Enable Observability": "পর্যবেক্ষণযোগ্যতা সক্ষম করুন", + "Turn request detail recording on/off globally": "অনুরোধ বিস্তারিত রেকর্ডিং বিশ্বব্যাপী চালু/বন্ধ করুন", + "Max Records": "সর্বাধিক রেকর্ড", + "Maximum request detail records to keep (older records are auto-deleted)": "রাখার জন্য সর্বাধিক অনুরোধ বিস্তারিত রেকর্ড (পুরানো রেকর্ড স্বয়ংক্রিয়ভাবে মুছে যায়)", + "Batch Size": "ব্যাচ আকার", + "Number of items to accumulate before writing to database (higher = better performance)": "ডাটাবেসে লেখার আগে জমা করার জন্য আইটেমের সংখ্যা (উচ্চতর = ভাল কর্মক্ষমতা)", + "Flush Interval (ms)": "ফ্লাশ ইন্টারভাল (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "বাফার ফ্লাশ করার আগে অপেক্ষা করার সর্বাধিক সময় (কম ট্রাফিক সময় ডেটা হারানো প্রতিরোধ করে)", + "Max JSON Size (KB)": "সর্বাধিক JSON আকার (KB)", + "Maximum size for each JSON field (request/response) before truncation": "ট্রাঙ্কেশনের আগে প্রতিটি JSON ফিল্ডের সর্বাধিক আকার (অনুরোধ/প্রতিক্রিয়া)", + "All data stored on your machine": "সমস্ত ডেটা আপনার মেশিনে সংরক্ষিত", + "MITM Server": "MITM সার্ভার", + "Running": "চলছে", + "Stopped": "বন্ধ", + "Cert": "সার্টিফিকেট", + "Server": "সার্ভার", + "Purpose:": "উদ্দেশ্য:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Antigravity IDE এবং GitHub Copilot ব্যবহার করুন → 9Router থেকে যেকোনো সরবরাহকারী/মডেলের সাথে", + "How it works:": "এটি কীভাবে কাজ করে:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE অনুরোধ → DNS কে localhost:443 তে রিডিরেক্ট করুন → MITM প্রক্সি ইন্টারসেপ্ট করে → 9Router → Antigravity/Copilot এ প্রতিক্রিয়া", + "No API keys — create one in Keys page": "কোন API কী নেই — Keys পৃষ্ঠায় একটি তৈরি করুন", + "sk_9router (default)": "sk_9router (ডিফল্ট)", + "Server started": "সার্ভার শুরু হয়েছে", + "Failed to start server": "সার্ভার শুরু করতে ব্যর্থ", + "Server stopped — all DNS cleared": "সার্ভার বন্ধ — সমস্ত DNS সাফ করা হয়েছে", + "Failed to stop server": "সার্ভার বন্ধ করতে ব্যর্থ", + "Sudo password is required": "Sudo পাসওয়ার্ড প্রয়োজন", + "Stop Server": "সার্ভার বন্ধ করুন", + "Start Server": "সার্ভার শুরু করুন", + "Enable DNS per tool below to activate interception": "ইন্টারসেপশন সক্রিয় করতে নীচে প্রতিটি সরঞ্জামের জন্য DNS সক্ষম করুন", + "Sudo Password Required": "Sudo পাসওয়ার্ড প্রয়োজন", + "Enter your sudo password to start/stop MITM server": "MITM সার্ভার শুরু/বন্ধ করতে আপনার sudo পাসওয়ার্ড প্রবেশ করুন", + "Sudo Password": "Sudo পাসওয়ার্ড", + "Click to add, click again to remove. Changes are saved automatically.": "যোগ করতে ক্লিক করুন, সরাতে আবার ক্লিক করুন। পরিবর্তনগুলি স্বয়ংক্রিয়ভাবে সংরক্ষিত হয়।", + "⚠️ 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.": "⚠️ ঝুঁকি বিজ্ঞপ্তি: এই প্রদানকারী একটি সাবস্ক্রিপশন/OAuth সেশন ব্যবহার করে যা প্রক্সি/রাউটার ব্যবহারের জন্য আনুষ্ঠানিকভাবে লাইসেন্সপ্রাপ্ত নয়। অ্যাকাউন্ট সীমাবদ্ধ বা নিষিদ্ধ হতে পারে। নিজের ঝুঁকিতে ব্যবহার করুন।", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM স্থানীয় CA এর মাধ্যমে IDE টুলগুলির (Antigravity, GitHub Copilot, Kiro) HTTPS ট্রাফিক ইন্টারসেপ্ট করে আপনার প্রদানকারীদের কাছে অনুরোধ পুনঃনির্দেশ করতে। ToS লঙ্ঘন করতে পারে → অ্যাকাউন্ট নিষিদ্ধ ঝুঁকি। নিজের ঝুঁকিতে ব্যবহার করুন।", + "Endpoint is exposed without an API key.": "এপিআই কী ছাড়াই এন্ডপয়েন্ট উন্মুক্ত।" +} diff --git a/public/i18n/literals/cs.json b/public/i18n/literals/cs.json new file mode 100644 index 0000000000000000000000000000000000000000..ed0d991ac96f9c8bdf3c5c90de33f089fd1794fe --- /dev/null +++ b/public/i18n/literals/cs.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Zrušit", + "Delete": "Smazat", + "Edit": "Upravit", + "Save": "Uložit", + "Close": "Zavřít", + "Add": "Přidat", + "Remove": "Odebrat", + "Settings": "Nastavení", + "Profile": "Profil", + "Dashboard": "Řídicí panel", + "Logout": "Odhlásit se", + "Login": "Přihlásit se", + "Providers": "Poskytovatelé", + "Usage": "Statistika", + "API Key": "Klíč API", + "Connected": "Připojeno", + "Disconnected": "Odpojeno", + "Active": "Aktivní", + "Inactive": "Neaktivní", + "Success": "Úspěch", + "Failed": "Selhalo", + "Error": "Chyba", + "Warning": "Upozornění", + "Info": "Informace", + "Loading": "Načítání", + "Search": "Hledání", + "Filter": "Filtr", + "Sort": "Řazení", + "Export": "Exportovat", + "Import": "Importovat", + "Refresh": "Aktualizovat", + "Back": "Zpět", + "Next": "Další", + "Previous": "Předchozí", + "Submit": "Odeslat", + "Confirm": "Potvrdit", + "Yes": "Ano", + "No": "Ne", + "OK": "OK", + "Apply": "Aplikovat", + "Reset": "Obnovit", + "Clear": "Vymazat", + "Select": "Vybrat", + "Upload": "Nahrát", + "Download": "Stáhnout", + "Copy": "Kopírovat", + "Paste": "Vložit", + "Cut": "Vyjmout", + "Undo": "Vrátit zpět", + "Redo": "Znovu", + "Name": "Název", + "Description": "Popis", + "Status": "Stav", + "Type": "Typ", + "Date": "Datum", + "Time": "Čas", + "Created": "Vytvořeno", + "Updated": "Aktualizováno", + "Actions": "Akce", + "Details": "Podrobnosti", + "View": "Zobrazit", + "New": "Nový", + "Total": "Celkem", + "Count": "Počet", + "Price": "Cena", + "Cost": "Náklady", + "Free": "Zdarma", + "Paid": "Placené", + "Enable": "Povolit", + "Disable": "Zakázat", + "Enabled": "Povoleno", + "Disabled": "Zakázáno", + "Online": "Online", + "Offline": "Offline", + "Available": "K dispozici", + "Unavailable": "Není k dispozici", + "Required": "Povinné", + "Optional": "Volitelné", + "Default": "Výchozí", + "Custom": "Vlastní", + "Advanced": "Pokročilé", + "Basic": "Základní", + "Help": "Pomoc", + "Support": "Podpora", + "Documentation": "Dokumentace", + "Version": "Verze", + "Language": "Jazyk", + "Theme": "Motiv", + "Light": "Světlý", + "Dark": "Tmavý", + "Auto": "Automaticky", + "Endpoint": "Koncový bod", + "Combos": "Kombinace", + "Quota Tracker": "Sledování kvót", + "MITM": "MITM", + "CLI Tools": "Nástroje CLI", + "Console Log": "Protokol konzole", + "System": "Systém", + "Debug": "Ladění", + "Shutdown": "Vypnutí", + "Close Proxy": "Zavřít proxy", + "Are you sure you want to close the proxy server?": "Opravdu chcete zavřít proxy server?", + "Server Disconnected": "Server odpojen", + "The proxy server has been stopped.": "Proxy server byl zastaven.", + "Reload Page": "Obnovit stránku", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "Služba běží v terminálu. Tuto webovou stránku si můžete zavřít. Vypnutí zastaví službu.", + "Manage your AI provider connections": "Spravujte svá připojení poskytovatele AI", + "Model combos with fallback": "Kombinace modelů s zálohou", + "Monitor your API usage, token consumption, and request logs": "Monitorujte použití API, spotřebu tokenů a protokoly žádostí", + "Intercept CLI tool traffic and route through 9Router": "Zachycujte provoz nástrojů CLI a směrujte jej přes 9Router", + "Configure CLI tools": "Konfigurace nástrojů CLI", + "API endpoint configuration": "Konfigurace koncového bodu API", + "Manage your preferences": "Spravujte své preferences", + "Debug translation flow between formats": "Ladění toku překladu mezi formáty", + "Live server console output": "Výstup konzole serveru v přímém čase", + "Create model combos with fallback support": "Vytvářejte kombinace modelů s podporou zálohy", + "Local Mode": "Místní režim", + "Running on your machine": "Běží na vašem počítači", + "Database Location": "Umístění databáze", + "Download Backup": "Stáhnout zálohu", + "Import Backup": "Importovat zálohu", + "Database backup downloaded": "Záloha databáze stažena", + "Database imported successfully": "Databáze byla úspěšně importována", + "Security": "Bezpečnost", + "Require login": "Vyžadovat přihlášení", + "When ON, dashboard requires password. When OFF, access without login.": "Když je ZAPNUTO, řídicí panel vyžaduje heslo. Když je VYPNUTO, přístup bez přihlášení.", + "Current Password": "Aktuální heslo", + "Enter current password": "Zadejte aktuální heslo", + "New Password": "Nové heslo", + "Enter new password": "Zadejte nové heslo", + "Confirm New Password": "Potvrzení nového hesla", + "Confirm new password": "Potvrďte nové heslo", + "Update Password": "Aktualizovat heslo", + "Set Password": "Nastavit heslo", + "Password updated successfully": "Heslo bylo úspěšně aktualizováno", + "Passwords do not match": "Hesla se neshodují", + "Routing Strategy": "Strategie směrování", + "Round Robin": "Round Robin", + "Cycle through accounts to distribute load": "Procházejte účty pro distribuci zátěže", + "Sticky Limit": "Lepkavý limit", + "Calls per account before switching": "Volání na účet před přepnutím", + "Network": "Síť", + "Outbound Proxy": "Odchozí proxy", + "Enable proxy for OAuth + provider outbound requests.": "Povolte proxy pro OAuth + odchozí požadavky poskytovatele.", + "Proxy URL": "URL proxy", + "Leave empty to inherit existing env proxy (if any).": "Ponechte prázdné pro dědění existujícího env proxy (pokud existuje).", + "No Proxy": "Bez proxy", + "Comma-separated hostnames/domains to bypass the proxy.": "Čárkou oddělené názvy hostitelů/domény pro obejití proxy.", + "Test proxy URL": "Testovat URL proxy", + "Proxy settings applied": "Nastavení proxy aplikováno", + "Proxy enabled": "Proxy povolena", + "Proxy disabled": "Proxy zakázána", + "Proxy test OK": "Test proxy OK", + "Proxy test failed": "Test proxy se nezdařil", + "Please enter a Proxy URL to test": "Zadejte prosím URL proxy k testování", + "Observability": "Pozorovatelnost", + "Enable Observability": "Povolit pozorovatelnost", + "Turn request detail recording on/off globally": "Zapnutí/vypnutí záznamů podrobností požadavku globálně", + "Max Records": "Maximální počet záznamů", + "Maximum request detail records to keep (older records are auto-deleted)": "Maximální počet záznamů o podrobnostech požadavku k uchování (starší záznamy se automaticky odstraňují)", + "Batch Size": "Velikost dávky", + "Number of items to accumulate before writing to database (higher = better performance)": "Počet položek k hromadění před zápisem do databáze (vyšší = lepší výkon)", + "Flush Interval (ms)": "Interval vyprazdňování (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Maximální čas čekání před vyprazdněním vyrovnávací paměti (zabraňuje ztrátě dat při nízkém provozu)", + "Max JSON Size (KB)": "Maximální velikost JSON (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Maximální velikost každého pole JSON (požadavek/odpověď) před zkrácením", + "All data stored on your machine": "Všechna data jsou uložena na vašem počítači", + "MITM Server": "Server MITM", + "Running": "Běžící", + "Stopped": "Zastaveno", + "Cert": "Certifikát", + "Server": "Server", + "Purpose:": "Účel:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Použijte Antigravity IDE a GitHub Copilot → s JAKÝMKOLIV poskytovatelem/modelem z 9Router", + "How it works:": "Jak to funguje:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Požadavek Antigravity/Copilot IDE → Přesměrování DNS na localhost:443 → Proxy MITM zachycuje → 9Router → odpověď na Antigravity/Copilot", + "No API keys — create one in Keys page": "Žádné klíče API — vytvořte jeden na stránce Klíče", + "sk_9router (default)": "sk_9router (výchozí)", + "Server started": "Server spuštěn", + "Failed to start server": "Spuštění serveru se nezdařilo", + "Server stopped — all DNS cleared": "Server zastaven — veškerý DNS vymazán", + "Failed to stop server": "Zastavení serveru se nezdařilo", + "Sudo password is required": "Je vyžadováno heslo sudo", + "Stop Server": "Zastavit server", + "Start Server": "Spustit server", + "Enable DNS per tool below to activate interception": "Povolte DNS pro každý nástroj níže a aktivujte zachycování", + "Sudo Password Required": "Je vyžadováno heslo Sudo", + "Enter your sudo password to start/stop MITM server": "Zadejte heslo sudo pro spuštění/zastavení serveru MITM", + "Sudo Password": "Heslo sudo", + "Click to add, click again to remove. Changes are saved automatically.": "Kliknutím přidáte, dalším kliknutím odeberete. Změny se ukládají automaticky.", + "⚠️ 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.": "⚠️ Upozornění na riziko: Tento poskytovatel používá předplatné/OAuth relaci, která není oficiálně licencována pro použití přes proxy/router. Účet může být omezen nebo zablokován. Používejte na vlastní riziko.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM zachytává HTTPS provoz IDE nástrojů (Antigravity, GitHub Copilot, Kiro) přes místní CA pro přesměrování požadavků na vaše poskytovatele. Může porušit ToS → riziko zákazu účtu. Používejte na vlastní riziko.", + "Endpoint is exposed without an API key.": "Koncový bod je vystaven bez API klíče." +} diff --git a/public/i18n/literals/da.json b/public/i18n/literals/da.json new file mode 100644 index 0000000000000000000000000000000000000000..c81bbe79e203579f3c0ff9280185d120c1359777 --- /dev/null +++ b/public/i18n/literals/da.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Annuller", + "Delete": "Slet", + "Edit": "Rediger", + "Save": "Gem", + "Close": "Luk", + "Add": "Tilføj", + "Remove": "Fjern", + "Settings": "Indstillinger", + "Profile": "Profil", + "Dashboard": "Kontrolpanel", + "Logout": "Log ud", + "Login": "Log ind", + "Providers": "Udbydere", + "Usage": "Forbrugsstatistik", + "API Key": "API-nøgle", + "Connected": "Forbundet", + "Disconnected": "Afbrudt", + "Active": "Aktiv", + "Inactive": "Inaktiv", + "Success": "Succes", + "Failed": "Mislykket", + "Error": "Fejl", + "Warning": "Advarsel", + "Info": "Info", + "Loading": "Indlæser", + "Search": "Søg", + "Filter": "Filter", + "Sort": "Sorter", + "Export": "Eksporter", + "Import": "Importer", + "Refresh": "Opdater", + "Back": "Tilbage", + "Next": "Næste", + "Previous": "Forrige", + "Submit": "Indsend", + "Confirm": "Bekræft", + "Yes": "Ja", + "No": "Nej", + "OK": "OK", + "Apply": "Anvend", + "Reset": "Nulstil", + "Clear": "Ryd", + "Select": "Vælg", + "Upload": "Upload", + "Download": "Download", + "Copy": "Kopier", + "Paste": "Indsæt", + "Cut": "Klip", + "Undo": "Fortryd", + "Redo": "Gentag", + "Name": "Navn", + "Description": "Beskrivelse", + "Status": "Status", + "Type": "Type", + "Date": "Dato", + "Time": "Tid", + "Created": "Oprettet", + "Updated": "Opdateret", + "Actions": "Handlinger", + "Details": "Detaljer", + "View": "Vis", + "New": "Ny", + "Total": "Total", + "Count": "Antal", + "Price": "Pris", + "Cost": "Omkostning", + "Free": "Gratis", + "Paid": "Betalt", + "Enable": "Aktivér", + "Disable": "Deaktivér", + "Enabled": "Aktiveret", + "Disabled": "Deaktiveret", + "Online": "Online", + "Offline": "Offline", + "Available": "Tilgængelig", + "Unavailable": "Ikke tilgængelig", + "Required": "Påkrævet", + "Optional": "Valgfrit", + "Default": "Standard", + "Custom": "Brugerdefineret", + "Advanced": "Avanceret", + "Basic": "Grundlæggende", + "Help": "Hjælp", + "Support": "Support", + "Documentation": "Dokumentation", + "Version": "Version", + "Language": "Sprog", + "Theme": "Tema", + "Light": "Lys", + "Dark": "Mørk", + "Auto": "Automatisk", + "Endpoint": "Slutpunkt", + "Combos": "Kombinationer", + "Quota Tracker": "Kvotetracker", + "MITM": "MITM", + "CLI Tools": "Værktøjer", + "Console Log": "Konsollog", + "System": "System", + "Debug": "Debug", + "Shutdown": "Luk af", + "Close Proxy": "Luk proxy", + "Are you sure you want to close the proxy server?": "Er du sikker på, at du vil lukke proxyserveren?", + "Server Disconnected": "Server afbrudt", + "The proxy server has been stopped.": "Proxyserveren er blevet stoppet.", + "Reload Page": "Genindlæs siden", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "Tjenesten kører i terminalen. Du kan lukke denne webside. Aflukning stopper tjenesten.", + "Manage your AI provider connections": "Administrer dine AI-udbyderforbindelser", + "Model combos with fallback": "Modelkombinationer med fallback", + "Monitor your API usage, token consumption, and request logs": "Overvåg dit API-forbrug, tokenforbrugning og anmodningslogger", + "Intercept CLI tool traffic and route through 9Router": "Aflyt CLI-værktøj trafikk og rute gennem 9Router", + "Configure CLI tools": "Konfigurer CLI-værktøjer", + "API endpoint configuration": "API-slutpunktkonfiguration", + "Manage your preferences": "Administrer dine præferencer", + "Debug translation flow between formats": "Debug oversættelsesflow mellem formater", + "Live server console output": "Live serverkonsoloutput", + "Create model combos with fallback support": "Opret modelkombinationer med fallback-understøttelse", + "Local Mode": "Lokalt tilstand", + "Running on your machine": "Kørende på din maskine", + "Database Location": "Databaseplacering", + "Download Backup": "Download sikkerhedskopi", + "Import Backup": "Importer sikkerhedskopi", + "Database backup downloaded": "Databasesikkerhedskopi downloadet", + "Database imported successfully": "Database importeret med succes", + "Security": "Sikkerhed", + "Require login": "Kræv login", + "When ON, dashboard requires password. When OFF, access without login.": "Når TIL kræves adgangskode på kontrolpanelet. Når FRA tillades adgang uden login.", + "Current Password": "Nuværende adgangskode", + "Enter current password": "Indtast nuværende adgangskode", + "New Password": "Ny adgangskode", + "Enter new password": "Indtast ny adgangskode", + "Confirm New Password": "Bekræft ny adgangskode", + "Confirm new password": "Bekræft ny adgangskode", + "Update Password": "Opdater adgangskode", + "Set Password": "Indstil adgangskode", + "Password updated successfully": "Adgangskode opdateret med succes", + "Passwords do not match": "Adgangskoderne stemmer ikke overens", + "Routing Strategy": "Rutestrategi", + "Round Robin": "Rundetabel", + "Cycle through accounts to distribute load": "Cyklus gennem konti for at distribuere belastningen", + "Sticky Limit": "Klæbrig grænse", + "Calls per account before switching": "Opkald pr. konto før skift", + "Network": "Netværk", + "Outbound Proxy": "Udgående proxy", + "Enable proxy for OAuth + provider outbound requests.": "Aktivér proxy til OAuth + udbyder udgående anmodninger.", + "Proxy URL": "Proxy-URL", + "Leave empty to inherit existing env proxy (if any).": "Lad være tomt for at nedarve eksisterende env-proxy (hvis nogen).", + "No Proxy": "Ingen proxy", + "Comma-separated hostnames/domains to bypass the proxy.": "Kommaseparerede værtsnavne/domæner for at omgå proxyen.", + "Test proxy URL": "Test proxy-URL", + "Proxy settings applied": "Proxyindstillinger anvendt", + "Proxy enabled": "Proxy aktiveret", + "Proxy disabled": "Proxy deaktiveret", + "Proxy test OK": "Proxy-test OK", + "Proxy test failed": "Proxy-test mislykket", + "Please enter a Proxy URL to test": "Indtast en proxy-URL til test", + "Observability": "Observerbarhed", + "Enable Observability": "Aktivér observerbarhed", + "Turn request detail recording on/off globally": "Slå anmodningsdetaljeoptagelse til/fra globalt", + "Max Records": "Max-poster", + "Maximum request detail records to keep (older records are auto-deleted)": "Maksimum anmodningsdetaljeposter at beholde (ældre poster bliver automatisk slettet)", + "Batch Size": "Batch-størrelse", + "Number of items to accumulate before writing to database (higher = better performance)": "Antal elementer der skal akkumuleres før skrivning til database (højere = bedre ydeevne)", + "Flush Interval (ms)": "Flush-interval (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Maksimal ventetid før bufferrensing (forhindrer datatab ved lavt trafikk)", + "Max JSON Size (KB)": "Max JSON-størrelse (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Maksimal størrelse for hvert JSON-felt (anmodning/svar) før afkortning", + "All data stored on your machine": "Alle data lagret på din maskine", + "MITM Server": "MITM-server", + "Running": "Kørende", + "Stopped": "Stoppet", + "Cert": "Certifikat", + "Server": "Server", + "Purpose:": "Formål:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Brug Antigravity IDE & GitHub Copilot → med ENHVER udbyder/model fra 9Router", + "How it works:": "Sådan virker det:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE-anmodning → DNS-omdirigering til localhost:443 → MITM-proxy aflytter → 9Router → svar til Antigravity/Copilot", + "No API keys — create one in Keys page": "Ingen API-nøgler — opret en på Keys-siden", + "sk_9router (default)": "sk_9router (standard)", + "Server started": "Server startet", + "Failed to start server": "Fejl ved start af server", + "Server stopped — all DNS cleared": "Server stoppet — alle DNS slettet", + "Failed to stop server": "Fejl ved stopning af server", + "Sudo password is required": "Sudo-adgangskode er påkrævet", + "Stop Server": "Stop server", + "Start Server": "Start server", + "Enable DNS per tool below to activate interception": "Aktivér DNS for hvert værktøj nedenfor for at aktivere aflytning", + "Sudo Password Required": "Sudo-adgangskode påkrævet", + "Enter your sudo password to start/stop MITM server": "Indtast din sudo-adgangskode for at starte/stoppe MITM-server", + "Sudo Password": "Sudo-adgangskode", + "Click to add, click again to remove. Changes are saved automatically.": "Klik for at tilføje, klik igen for at fjerne. Ændringer gemmes automatisk.", + "⚠️ 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.": "⚠️ Risikomeddelelse: Denne udbyder bruger et abonnement/OAuth-session, der ikke er officielt licenseret til proxy/router-brug. Kontoen kan blive begrænset eller forbudt. Brug på eget ansvar.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM opfanger HTTPS-trafik fra IDE-værktøjer (Antigravity, GitHub Copilot, Kiro) via lokal CA for at omdirigere anmodninger til dine udbydere. Kan overtræde ToS → risiko for kontoforbud. Brug på eget ansvar.", + "Endpoint is exposed without an API key.": "Endpointet er eksponeret uden en API-nøgle." +} diff --git a/public/i18n/literals/de.json b/public/i18n/literals/de.json new file mode 100644 index 0000000000000000000000000000000000000000..57bf3ef39de0316b1966699777b4188d62f93dc9 --- /dev/null +++ b/public/i18n/literals/de.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Abbrechen", + "Delete": "Löschen", + "Edit": "Bearbeiten", + "Save": "Speichern", + "Close": "Schließen", + "Add": "Hinzufügen", + "Remove": "Entfernen", + "Settings": "Einstellungen", + "Profile": "Profil", + "Dashboard": "Dashboard", + "Logout": "Abmelden", + "Login": "Anmelden", + "Providers": "Anbieter", + "Usage": "Statistiken", + "API Key": "API-Schlüssel", + "Connected": "Verbunden", + "Disconnected": "Getrennt", + "Active": "Aktiv", + "Inactive": "Inaktiv", + "Success": "Erfolg", + "Failed": "Fehlgeschlagen", + "Error": "Fehler", + "Warning": "Warnung", + "Info": "Info", + "Loading": "Wird geladen", + "Search": "Suche", + "Filter": "Filtern", + "Sort": "Sortieren", + "Export": "Exportieren", + "Import": "Importieren", + "Refresh": "Aktualisieren", + "Back": "Zurück", + "Next": "Weiter", + "Previous": "Zurück", + "Submit": "Absenden", + "Confirm": "Bestätigen", + "Yes": "Ja", + "No": "Nein", + "OK": "OK", + "Apply": "Anwenden", + "Reset": "Zurücksetzen", + "Clear": "Löschen", + "Select": "Wählen", + "Upload": "Hochladen", + "Download": "Herunterladen", + "Copy": "Kopieren", + "Paste": "Einfügen", + "Cut": "Ausschneiden", + "Undo": "Rückgängig", + "Redo": "Wiederherstellen", + "Name": "Name", + "Description": "Beschreibung", + "Status": "Status", + "Type": "Typ", + "Date": "Datum", + "Time": "Uhrzeit", + "Created": "Erstellt", + "Updated": "Aktualisiert", + "Actions": "Aktionen", + "Details": "Details", + "View": "Anzeigen", + "New": "Neu", + "Total": "Gesamt", + "Count": "Anzahl", + "Price": "Preis", + "Cost": "Kosten", + "Free": "Kostenlos", + "Paid": "Bezahlt", + "Enable": "Aktivieren", + "Disable": "Deaktivieren", + "Enabled": "Aktiviert", + "Disabled": "Deaktiviert", + "Online": "Online", + "Offline": "Offline", + "Available": "Verfügbar", + "Unavailable": "Nicht verfügbar", + "Required": "Erforderlich", + "Optional": "Optional", + "Default": "Standard", + "Custom": "Benutzerdefiniert", + "Advanced": "Erweitert", + "Basic": "Grundlagen", + "Help": "Hilfe", + "Support": "Unterstützung", + "Documentation": "Dokumentation", + "Version": "Version", + "Language": "Sprache", + "Theme": "Design", + "Light": "Hell", + "Dark": "Dunkel", + "Auto": "Automatisch", + "Endpoint": "Endpunkt", + "Combos": "Kombinationen", + "Quota Tracker": "Kontingenttracker", + "MITM": "MITM", + "CLI Tools": "CLI-Tools", + "Console Log": "Konsolenprotokoll", + "System": "System", + "Debug": "Debuggen", + "Shutdown": "Herunterfahren", + "Close Proxy": "Proxy schließen", + "Are you sure you want to close the proxy server?": "Möchten Sie den Proxy-Server wirklich schließen?", + "Server Disconnected": "Server getrennt", + "The proxy server has been stopped.": "Der Proxy-Server wurde gestoppt.", + "Reload Page": "Seite neu laden", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "Der Service läuft im Terminal. Sie können diese Webseite schließen. Das Herunterfahren stoppt den Service.", + "Manage your AI provider connections": "Verwalten Sie Ihre KI-Anbieterverbindungen", + "Model combos with fallback": "Modellkombinationen mit Fallback", + "Monitor your API usage, token consumption, and request logs": "Überwachen Sie Ihre API-Nutzung, den Token-Verbrauch und die Anforderungsprotokolle", + "Intercept CLI tool traffic and route through 9Router": "CLI-Tool-Verkehr abfangen und über 9Router leiten", + "Configure CLI tools": "CLI-Tools konfigurieren", + "API endpoint configuration": "API-Endpunkt-Konfiguration", + "Manage your preferences": "Verwalten Sie Ihre Vorlieben", + "Debug translation flow between formats": "Übersetzungsfluss zwischen Formaten debuggen", + "Live server console output": "Ausgabe der Live-Server-Konsole", + "Create model combos with fallback support": "Erstellen Sie Modellkombinationen mit Fallback-Unterstützung", + "Local Mode": "Lokaler Modus", + "Running on your machine": "Wird auf Ihrem Computer ausgeführt", + "Database Location": "Datenbankort", + "Download Backup": "Sicherung herunterladen", + "Import Backup": "Sicherung importieren", + "Database backup downloaded": "Datenbanksicherung heruntergeladen", + "Database imported successfully": "Datenbank erfolgreich importiert", + "Security": "Sicherheit", + "Require login": "Login erforderlich", + "When ON, dashboard requires password. When OFF, access without login.": "Wenn AN, erfordert das Dashboard ein Passwort. Wenn AUS, Zugriff ohne Login.", + "Current Password": "Aktuelles Passwort", + "Enter current password": "Aktuelles Passwort eingeben", + "New Password": "Neues Passwort", + "Enter new password": "Neues Passwort eingeben", + "Confirm New Password": "Neues Passwort bestätigen", + "Confirm new password": "Neues Passwort bestätigen", + "Update Password": "Passwort aktualisieren", + "Set Password": "Passwort festlegen", + "Password updated successfully": "Passwort erfolgreich aktualisiert", + "Passwords do not match": "Passwörter stimmen nicht überein", + "Routing Strategy": "Routing-Strategie", + "Round Robin": "Round-Robin", + "Cycle through accounts to distribute load": "Konten durchlaufen, um die Last zu verteilen", + "Sticky Limit": "Klebrige Grenze", + "Calls per account before switching": "Anrufe pro Konto vor dem Wechsel", + "Network": "Netzwerk", + "Outbound Proxy": "Ausgehender Proxy", + "Enable proxy for OAuth + provider outbound requests.": "Aktivieren Sie den Proxy für OAuth + Anfragen der ausgehenden Anbieter.", + "Proxy URL": "Proxy-URL", + "Leave empty to inherit existing env proxy (if any).": "Lassen Sie leer, um einen vorhandenen Umgebungs-Proxy zu erben (falls vorhanden).", + "No Proxy": "Kein Proxy", + "Comma-separated hostnames/domains to bypass the proxy.": "Kommagetrennte Hostnamen/Domänen zum Umgehen des Proxys.", + "Test proxy URL": "Proxy-URL testen", + "Proxy settings applied": "Proxy-Einstellungen angewendet", + "Proxy enabled": "Proxy aktiviert", + "Proxy disabled": "Proxy deaktiviert", + "Proxy test OK": "Proxy-Test OK", + "Proxy test failed": "Proxy-Test fehlgeschlagen", + "Please enter a Proxy URL to test": "Bitte geben Sie eine Proxy-URL zum Testen ein", + "Observability": "Beobachtbarkeit", + "Enable Observability": "Beobachtbarkeit aktivieren", + "Turn request detail recording on/off globally": "Aufzeichnung von Anforderungsdetails global ein-/ausschalten", + "Max Records": "Maximale Datensätze", + "Maximum request detail records to keep (older records are auto-deleted)": "Maximale Anzahl der zu speichernden Anforderungsdetaildatensätze (ältere Datensätze werden automatisch gelöscht)", + "Batch Size": "Batch-Größe", + "Number of items to accumulate before writing to database (higher = better performance)": "Anzahl der Elemente, die sich ansammeln, bevor in die Datenbank geschrieben wird (höher = bessere Leistung)", + "Flush Interval (ms)": "Leerungsintervall (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Maximale Wartezeit vor dem Leeren des Puffers (verhindert Datenverlust bei niedrigem Datenverkehr)", + "Max JSON Size (KB)": "Maximale JSON-Größe (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Maximale Größe für jedes JSON-Feld (Anforderung/Antwort) vor dem Kürzen", + "All data stored on your machine": "Alle Daten auf Ihrem Computer gespeichert", + "MITM Server": "MITM-Server", + "Running": "Läuft", + "Stopped": "Gestoppt", + "Cert": "Zertifikat", + "Server": "Server", + "Purpose:": "Zweck:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Verwenden Sie Antigravity IDE und GitHub Copilot → mit JEDEM Anbieter/Modell von 9Router", + "How it works:": "So funktioniert es:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE-Anforderung → DNS-Umleitung auf localhost:443 → MITM-Proxy abfangen → 9Router → Antwort auf Antigravity/Copilot", + "No API keys — create one in Keys page": "Keine API-Schlüssel — erstellen Sie einen auf der Seite Schlüssel", + "sk_9router (default)": "sk_9router (Standard)", + "Server started": "Server gestartet", + "Failed to start server": "Server konnte nicht gestartet werden", + "Server stopped — all DNS cleared": "Server gestoppt — alle DNS gelöscht", + "Failed to stop server": "Server konnte nicht gestoppt werden", + "Sudo password is required": "Sudo-Passwort erforderlich", + "Stop Server": "Server stoppen", + "Start Server": "Server starten", + "Enable DNS per tool below to activate interception": "Aktivieren Sie DNS für jedes Tool unten, um die Abfangung zu aktivieren", + "Sudo Password Required": "Sudo-Passwort erforderlich", + "Enter your sudo password to start/stop MITM server": "Geben Sie Ihr Sudo-Passwort ein, um den MITM-Server zu starten/stoppen", + "Sudo Password": "Sudo-Passwort", + "Click to add, click again to remove. Changes are saved automatically.": "Klicken zum Hinzufügen, erneut klicken zum Entfernen. Änderungen werden automatisch gespeichert.", + "⚠️ 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.": "⚠️ Risikohinweis: Dieser Anbieter verwendet eine Abonnement-/OAuth-Sitzung, die nicht offiziell für die Proxy-/Router-Nutzung lizenziert ist. Das Konto kann eingeschränkt oder gesperrt werden. Nutzung auf eigene Gefahr.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM fängt HTTPS-Verkehr von IDE-Tools (Antigravity, GitHub Copilot, Kiro) über lokale CA ab, um Anfragen an Ihre Anbieter umzuleiten. Kann gegen ToS verstoßen → Risiko der Kontosperrung. Nutzung auf eigene Gefahr.", + "Endpoint is exposed without an API key.": "Der Endpunkt ist ohne API-Schlüssel offengelegt." +} diff --git a/public/i18n/literals/el.json b/public/i18n/literals/el.json new file mode 100644 index 0000000000000000000000000000000000000000..bfbf8888651568caaabc6e7245f3d87debf641c7 --- /dev/null +++ b/public/i18n/literals/el.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Ακύρωση", + "Delete": "Διαγραφή", + "Edit": "Επεξεργασία", + "Save": "Αποθήκευση", + "Close": "Κλείσιμο", + "Add": "Προσθήκη", + "Remove": "Αφαίρεση", + "Settings": "Ρυθμίσεις", + "Profile": "Προφίλ", + "Dashboard": "Πίνακας ελέγχου", + "Logout": "Έξοδος", + "Login": "Σύνδεση", + "Providers": "Παρόχοι", + "Usage": "Στατιστικά χρήσης", + "API Key": "Κλειδί API", + "Connected": "Συνδεδεμένο", + "Disconnected": "Αποσυνδεδεμένο", + "Active": "Ενεργό", + "Inactive": "Ανενεργό", + "Success": "Επιτυχία", + "Failed": "Απέτυχε", + "Error": "Σφάλμα", + "Warning": "Προειδοποίηση", + "Info": "Πληροφορίες", + "Loading": "Φόρτωση", + "Search": "Αναζήτηση", + "Filter": "Φίλτρο", + "Sort": "Ταξινόμηση", + "Export": "Εξαγωγή", + "Import": "Εισαγωγή", + "Refresh": "Ανανέωση", + "Back": "Πίσω", + "Next": "Επόμενο", + "Previous": "Προηγούμενο", + "Submit": "Υποβολή", + "Confirm": "Επιβεβαίωση", + "Yes": "Ναι", + "No": "Όχι", + "OK": "ΟΚ", + "Apply": "Εφαρμογή", + "Reset": "Επαναφορά", + "Clear": "Εκκαθάριση", + "Select": "Επιλογή", + "Upload": "Μεταφόρτωση", + "Download": "Λήψη", + "Copy": "Αντιγραφή", + "Paste": "Επικόλληση", + "Cut": "Αποκοπή", + "Undo": "Αναίρεση", + "Redo": "Επανάληψη", + "Name": "Όνομα", + "Description": "Περιγραφή", + "Status": "Κατάσταση", + "Type": "Τύπος", + "Date": "Ημερομηνία", + "Time": "Ώρα", + "Created": "Δημιουργήθηκε", + "Updated": "Ενημερώθηκε", + "Actions": "Ενέργειες", + "Details": "Λεπτομέρειες", + "View": "Προβολή", + "New": "Νέο", + "Total": "Σύνολο", + "Count": "Μέτρηση", + "Price": "Τιμή", + "Cost": "Κόστος", + "Free": "Δωρεάν", + "Paid": "Πληρωμένο", + "Enable": "Ενεργοποίηση", + "Disable": "Απενεργοποίηση", + "Enabled": "Ενεργοποιημένο", + "Disabled": "Απενεργοποιημένο", + "Online": "Σε σύνδεση", + "Offline": "Χωρίς σύνδεση", + "Available": "Διαθέσιμο", + "Unavailable": "Μη διαθέσιμο", + "Required": "Απαιτούμενο", + "Optional": "Προαιρετικό", + "Default": "Προεπιλεγμένο", + "Custom": "Προσαρμοσμένο", + "Advanced": "Προχωρημένο", + "Basic": "Βασικό", + "Help": "Βοήθεια", + "Support": "Υποστήριξη", + "Documentation": "Τεκμηρίωση", + "Version": "Έκδοση", + "Language": "Γλώσσα", + "Theme": "Θέμα", + "Light": "Ανοιχτό", + "Dark": "Σκοτεινό", + "Auto": "Αυτόματο", + "Endpoint": "Τελικό σημείο", + "Combos": "Συνδυασμοί", + "Quota Tracker": "Παρακολούθηση ποσόστωσης", + "MITM": "MITM", + "CLI Tools": "Εργαλεία", + "Console Log": "Αρχείο καταγραφής κονσόλας", + "System": "Σύστημα", + "Debug": "Αποσφαλμάτωση", + "Shutdown": "Τερματισμός", + "Close Proxy": "Κλείσιμο διακομιστή μεσολάβησης", + "Are you sure you want to close the proxy server?": "Είστε σίγουρος ότι θέλετε να κλείσετε τον διακομιστή μεσολάβησης;", + "Server Disconnected": "Διακομιστής αποσυνδεδεμένος", + "The proxy server has been stopped.": "Ο διακομιστής μεσολάβησης έχει σταματήσει.", + "Reload Page": "Επαναφόρτωση σελίδας", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "Η υπηρεσία εκτελείται στο τερματικό. Μπορείτε να κλείσετε αυτή τη σελίδα ιστού. Ο τερματισμός θα σταματήσει την υπηρεσία.", + "Manage your AI provider connections": "Διαχειριστείτε τις συνδέσεις του παρόχου AI σας", + "Model combos with fallback": "Συνδυασμοί μοντέλων με αποπροσπέλαση", + "Monitor your API usage, token consumption, and request logs": "Παρακολουθήστε τη χρήση API, την κατανάλωση token και τα αρχεία καταγραφής αιτημάτων", + "Intercept CLI tool traffic and route through 9Router": "Αναχαίτηση κίνησης εργαλείου CLI και δρομολόγηση μέσω 9Router", + "Configure CLI tools": "Ρύθμιση εργαλείων CLI", + "API endpoint configuration": "Διαμόρφωση τελικού σημείου API", + "Manage your preferences": "Διαχείριση προτιμήσεών σας", + "Debug translation flow between formats": "Αποσφαλμάτωση ροής μετάφρασης μεταξύ μορφών", + "Live server console output": "Έξοδος κονσόλας διακομιστή σε πραγματικό χρόνο", + "Create model combos with fallback support": "Δημιουργήστε συνδυασμούς μοντέλων με υποστήριξη αποπροσπέλασης", + "Local Mode": "Τοπική λειτουργία", + "Running on your machine": "Εκτελείται στον υπολογιστή σας", + "Database Location": "Τοποθεσία βάσης δεδομένων", + "Download Backup": "Λήψη ασφαλείας", + "Import Backup": "Εισαγωγή ασφαλείας", + "Database backup downloaded": "Ασφάλεια βάσης δεδομένων λήφθηκε", + "Database imported successfully": "Η βάση δεδομένων εισήχθη με επιτυχία", + "Security": "Ασφάλεια", + "Require login": "Απαιτείται σύνδεση", + "When ON, dashboard requires password. When OFF, access without login.": "Όταν είναι ΕΝ, ο πίνακας ελέγχου απαιτεί κωδικό πρόσβασης. Όταν είναι ΑΠΕΝΕΡΓΟΠΟΙΗΜΕΝΟ, πρόσβαση χωρίς σύνδεση.", + "Current Password": "Τρέχων κωδικός πρόσβασης", + "Enter current password": "Εισαγάγετε τον τρέχοντα κωδικό πρόσβασης", + "New Password": "Νέος κωδικός πρόσβασης", + "Enter new password": "Εισαγάγετε τον νέο κωδικό πρόσβασης", + "Confirm New Password": "Επιβεβαίωση νέου κωδικού πρόσβασης", + "Confirm new password": "Επιβεβαίωση νέου κωδικού πρόσβασης", + "Update Password": "Ενημέρωση κωδικού πρόσβασης", + "Set Password": "Ορισμός κωδικού πρόσβασης", + "Password updated successfully": "Ο κωδικός πρόσβασης ενημερώθηκε με επιτυχία", + "Passwords do not match": "Οι κωδικοί πρόσβασης δεν ταιριάζουν", + "Routing Strategy": "Στρατηγική δρομολόγησης", + "Round Robin": "Κυκλική δρομολόγηση", + "Cycle through accounts to distribute load": "Κύκλος μέσω λογαριασμών για κατανομή φορτίου", + "Sticky Limit": "Περιορισμός κόλλησης", + "Calls per account before switching": "Κλήσεις ανά λογαριασμό πριν από την εναλλαγή", + "Network": "Δίκτυο", + "Outbound Proxy": "Εξερχόμενος διακομιστής μεσολάβησης", + "Enable proxy for OAuth + provider outbound requests.": "Ενεργοποιήστε τον διακομιστή μεσολάβησης για αιτήματα εξόδου OAuth + παρόχου.", + "Proxy URL": "URL διακομιστή μεσολάβησης", + "Leave empty to inherit existing env proxy (if any).": "Αφήστε κενό για να κληρονομήσετε υπάρχοντα env proxy (εάν υπάρχει).", + "No Proxy": "Χωρίς διακομιστή μεσολάβησης", + "Comma-separated hostnames/domains to bypass the proxy.": "Ονόματα κεντρικών υπολογιστών/τομείς χωρισμένοι με κόμμα για να παραστούν τον διακομιστή μεσολάβησης.", + "Test proxy URL": "Δοκιμή URL διακομιστή μεσολάβησης", + "Proxy settings applied": "Ρυθμίσεις διακομιστή μεσολάβησης εφαρμόστηκαν", + "Proxy enabled": "Διακομιστής μεσολάβησης ενεργοποιημένος", + "Proxy disabled": "Διακομιστής μεσολάβησης απενεργοποιημένος", + "Proxy test OK": "Δοκιμή διακομιστή μεσολάβησης OK", + "Proxy test failed": "Η δοκιμή διακομιστή μεσολάβησης απέτυχε", + "Please enter a Proxy URL to test": "Παρακαλώ εισάγετε ένα URL διακομιστή μεσολάβησης για δοκιμή", + "Observability": "Δυνατότητα παρατήρησης", + "Enable Observability": "Ενεργοποίηση δυνατότητας παρατήρησης", + "Turn request detail recording on/off globally": "Ενεργοποιήστε/απενεργοποιήστε την καταγραφή λεπτομερειών αιτήματος σε παγκόσμιο επίπεδο", + "Max Records": "Μέγιστα αρχεία", + "Maximum request detail records to keep (older records are auto-deleted)": "Μέγιστα αρχεία λεπτομερειών αιτήματος για διατήρηση (τα παλαιότερα αρχεία διαγράφονται αυτόματα)", + "Batch Size": "Μέγεθος δέσμης", + "Number of items to accumulate before writing to database (higher = better performance)": "Αριθμός στοιχείων που πρέπει να συσσωρευθούν πριν από τη σύνταξη στη βάση δεδομένων (υψηλότερο = καλύτερη απόδοση)", + "Flush Interval (ms)": "Διάστημα ξεπλύματος (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Μέγιστος χρόνος αναμονής πριν από το ξέπλυμα του buffer (αποτρέπει την απώλεια δεδομένων κατά την χαμηλή κίνηση)", + "Max JSON Size (KB)": "Μέγιστο μέγεθος JSON (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Μέγιστο μέγεθος για κάθε πεδίο JSON (αίτημα/απάντηση) πριν από την περικοπή", + "All data stored on your machine": "Όλα τα δεδομένα αποθηκεύονται στον υπολογιστή σας", + "MITM Server": "Διακομιστής MITM", + "Running": "Εκτελείται", + "Stopped": "Διακοπή", + "Cert": "Πιστοποιητικό", + "Server": "Διακομιστής", + "Purpose:": "Σκοπός:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Χρησιμοποιήστε Antigravity IDE & GitHub Copilot → με ΟΠΟΙΟΝΔΗΠΟΤΕ πάροχο/μοντέλο από 9Router", + "How it works:": "Πώς λειτουργεί:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Αίτημα Antigravity/Copilot IDE → Ανακατεύθυνση DNS στο localhost:443 → Ο διακομιστής μεσολάβησης MITM παρεμβαίνει → 9Router → απάντηση στο Antigravity/Copilot", + "No API keys — create one in Keys page": "Δεν υπάρχουν κλειδιά API — δημιουργήστε ένα στη σελίδα Keys", + "sk_9router (default)": "sk_9router (προεπιλεγμένο)", + "Server started": "Ο διακομιστής ξεκίνησε", + "Failed to start server": "Αποτυχία εκκίνησης διακομιστή", + "Server stopped — all DNS cleared": "Ο διακομιστής σταμάτησε — όλα τα DNS διαγράφηκαν", + "Failed to stop server": "Αποτυχία διακοπής διακομιστή", + "Sudo password is required": "Απαιτείται κωδικός πρόσβασης sudo", + "Stop Server": "Διακοπή διακομιστή", + "Start Server": "Διακομιστή ξεκινήματος", + "Enable DNS per tool below to activate interception": "Ενεργοποιήστε το DNS για κάθε εργαλείο παρακάτω για ενεργοποίηση παρεμβολής", + "Sudo Password Required": "Απαιτείται κωδικός πρόσβασης Sudo", + "Enter your sudo password to start/stop MITM server": "Εισαγάγετε τον κωδικό πρόσβασης sudo για να ξεκινήσετε/διακόψετε τον διακομιστή MITM", + "Sudo Password": "Κωδικός πρόσβασης Sudo", + "Click to add, click again to remove. Changes are saved automatically.": "Κάντε κλικ για προσθήκη, κάντε ξανά κλικ για αφαίρεση. Οι αλλαγές αποθηκεύονται αυτόματα.", + "⚠️ 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.": "⚠️ Ειδοποίηση κινδύνου: Αυτός ο πάροχος χρησιμοποιεί συνδρομή/συνεδρία OAuth που δεν έχει επίσημη άδεια για χρήση μέσω proxy/router. Ο λογαριασμός ενδέχεται να περιοριστεί ή να αποκλειστεί. Χρήση με δική σας ευθύνη.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ Το MITM υποκλέπτει την κίνηση HTTPS των εργαλείων IDE (Antigravity, GitHub Copilot, Kiro) μέσω τοπικού CA για ανακατεύθυνση αιτημάτων στους παρόχους σας. Μπορεί να παραβιάσει τους ToS → κίνδυνος αποκλεισμού λογαριασμού. Χρήση με δική σας ευθύνη.", + "Endpoint is exposed without an API key.": "Το τελικό σημείο είναι εκτεθειμένο χωρίς κλειδί API." +} diff --git a/public/i18n/literals/es.json b/public/i18n/literals/es.json new file mode 100644 index 0000000000000000000000000000000000000000..69d71e8fcfbcb0aba1c38c5b4d0a2926fc36fcfc --- /dev/null +++ b/public/i18n/literals/es.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Cancelar", + "Delete": "Eliminar", + "Edit": "Editar", + "Save": "Guardar", + "Close": "Cerrar", + "Add": "Añadir", + "Remove": "Quitar", + "Settings": "Configuración", + "Profile": "Perfil", + "Dashboard": "Panel de control", + "Logout": "Cerrar sesión", + "Login": "Iniciar sesión", + "Providers": "Proveedores", + "Usage": "Estadísticas", + "API Key": "Clave API", + "Connected": "Conectado", + "Disconnected": "Desconectado", + "Active": "Activo", + "Inactive": "Inactivo", + "Success": "Éxito", + "Failed": "Fallido", + "Error": "Error", + "Warning": "Advertencia", + "Info": "Información", + "Loading": "Cargando", + "Search": "Buscar", + "Filter": "Filtrar", + "Sort": "Ordenar", + "Export": "Exportar", + "Import": "Importar", + "Refresh": "Actualizar", + "Back": "Atrás", + "Next": "Siguiente", + "Previous": "Anterior", + "Submit": "Enviar", + "Confirm": "Confirmar", + "Yes": "Sí", + "No": "No", + "OK": "OK", + "Apply": "Aplicar", + "Reset": "Restablecer", + "Clear": "Limpiar", + "Select": "Seleccionar", + "Upload": "Cargar", + "Download": "Descargar", + "Copy": "Copiar", + "Paste": "Pegar", + "Cut": "Cortar", + "Undo": "Deshacer", + "Redo": "Rehacer", + "Name": "Nombre", + "Description": "Descripción", + "Status": "Estado", + "Type": "Tipo", + "Date": "Fecha", + "Time": "Hora", + "Created": "Creado", + "Updated": "Actualizado", + "Actions": "Acciones", + "Details": "Detalles", + "View": "Ver", + "New": "Nuevo", + "Total": "Total", + "Count": "Cantidad", + "Price": "Precio", + "Cost": "Costo", + "Free": "Gratuito", + "Paid": "Pagado", + "Enable": "Habilitar", + "Disable": "Deshabilitar", + "Enabled": "Habilitado", + "Disabled": "Deshabilitado", + "Online": "En línea", + "Offline": "Desconectado", + "Available": "Disponible", + "Unavailable": "No disponible", + "Required": "Requerido", + "Optional": "Opcional", + "Default": "Predeterminado", + "Custom": "Personalizado", + "Advanced": "Avanzado", + "Basic": "Básico", + "Help": "Ayuda", + "Support": "Soporte", + "Documentation": "Documentación", + "Version": "Versión", + "Language": "Idioma", + "Theme": "Tema", + "Light": "Claro", + "Dark": "Oscuro", + "Auto": "Automático", + "Endpoint": "Punto final", + "Combos": "Combinaciones", + "Quota Tracker": "Rastreador de cuota", + "MITM": "MITM", + "CLI Tools": "Herramientas CLI", + "Console Log": "Registro de consola", + "System": "Sistema", + "Debug": "Depuración", + "Shutdown": "Apagar", + "Close Proxy": "Cerrar proxy", + "Are you sure you want to close the proxy server?": "¿Está seguro de que desea cerrar el servidor proxy?", + "Server Disconnected": "Servidor desconectado", + "The proxy server has been stopped.": "El servidor proxy se ha detenido.", + "Reload Page": "Recargar página", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "El servicio se está ejecutando en la terminal. Puede cerrar esta página web. El apagado detendrá el servicio.", + "Manage your AI provider connections": "Administre sus conexiones de proveedor de IA", + "Model combos with fallback": "Combinaciones de modelos con respaldo", + "Monitor your API usage, token consumption, and request logs": "Monitoree su uso de API, consumo de tokens y registros de solicitudes", + "Intercept CLI tool traffic and route through 9Router": "Interceptar el tráfico de herramientas CLI y enrutar a través de 9Router", + "Configure CLI tools": "Configurar herramientas CLI", + "API endpoint configuration": "Configuración del punto final de API", + "Manage your preferences": "Administrar sus preferencias", + "Debug translation flow between formats": "Depurar el flujo de traducción entre formatos", + "Live server console output": "Salida de consola del servidor en vivo", + "Create model combos with fallback support": "Crear combinaciones de modelos con soporte de respaldo", + "Local Mode": "Modo local", + "Running on your machine": "En ejecución en su máquina", + "Database Location": "Ubicación de la base de datos", + "Download Backup": "Descargar respaldo", + "Import Backup": "Importar respaldo", + "Database backup downloaded": "Respaldo de la base de datos descargado", + "Database imported successfully": "Base de datos importada correctamente", + "Security": "Seguridad", + "Require login": "Requerir inicio de sesión", + "When ON, dashboard requires password. When OFF, access without login.": "Cuando está ACTIVADO, el panel requiere contraseña. Cuando está DESACTIVADO, acceso sin iniciar sesión.", + "Current Password": "Contraseña actual", + "Enter current password": "Ingrese la contraseña actual", + "New Password": "Nueva contraseña", + "Enter new password": "Ingrese la nueva contraseña", + "Confirm New Password": "Confirmar nueva contraseña", + "Confirm new password": "Confirme la nueva contraseña", + "Update Password": "Actualizar contraseña", + "Set Password": "Establecer contraseña", + "Password updated successfully": "Contraseña actualizada correctamente", + "Passwords do not match": "Las contraseñas no coinciden", + "Routing Strategy": "Estrategia de enrutamiento", + "Round Robin": "Round Robin", + "Cycle through accounts to distribute load": "Ciclo a través de cuentas para distribuir la carga", + "Sticky Limit": "Límite pegajoso", + "Calls per account before switching": "Llamadas por cuenta antes de cambiar", + "Network": "Red", + "Outbound Proxy": "Proxy de salida", + "Enable proxy for OAuth + provider outbound requests.": "Habilite el proxy para OAuth + solicitudes de salida del proveedor.", + "Proxy URL": "URL del proxy", + "Leave empty to inherit existing env proxy (if any).": "Deje en blanco para heredar el proxy env existente (si lo hay).", + "No Proxy": "Sin proxy", + "Comma-separated hostnames/domains to bypass the proxy.": "Nombres de host/dominios separados por comas para omitir el proxy.", + "Test proxy URL": "Prueba URL del proxy", + "Proxy settings applied": "Configuración de proxy aplicada", + "Proxy enabled": "Proxy habilitado", + "Proxy disabled": "Proxy deshabilitado", + "Proxy test OK": "Prueba de proxy OK", + "Proxy test failed": "Falha en la prueba de proxy", + "Please enter a Proxy URL to test": "Por favor ingrese una URL de proxy para probar", + "Observability": "Observabilidad", + "Enable Observability": "Habilitar observabilidad", + "Turn request detail recording on/off globally": "Activar/desactivar globalmente el registro de detalles de solicitud", + "Max Records": "Número máximo de registros", + "Maximum request detail records to keep (older records are auto-deleted)": "Número máximo de registros de detalle de solicitud a mantener (los registros más antiguos se eliminan automáticamente)", + "Batch Size": "Tamaño del lote", + "Number of items to accumulate before writing to database (higher = better performance)": "Número de elementos a acumular antes de escribir en la base de datos (más alto = mejor rendimiento)", + "Flush Interval (ms)": "Intervalo de vaciado (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Tiempo máximo de espera antes de vaciar el búfer (evita pérdida de datos durante tráfico bajo)", + "Max JSON Size (KB)": "Tamaño máximo de JSON (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Tamaño máximo para cada campo JSON (solicitud/respuesta) antes del truncamiento", + "All data stored on your machine": "Todos los datos almacenados en su máquina", + "MITM Server": "Servidor MITM", + "Running": "En ejecución", + "Stopped": "Detenido", + "Cert": "Certificado", + "Server": "Servidor", + "Purpose:": "Propósito:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Use Antigravity IDE y GitHub Copilot → con CUALQUIER proveedor/modelo de 9Router", + "How it works:": "Cómo funciona:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Solicitud de Antigravity/Copilot IDE → Redireccionamiento DNS a localhost:443 → El proxy MITM intercepta → 9Router → respuesta a Antigravity/Copilot", + "No API keys — create one in Keys page": "Sin claves API — cree una en la página Claves", + "sk_9router (default)": "sk_9router (predeterminado)", + "Server started": "Servidor iniciado", + "Failed to start server": "Error al iniciar el servidor", + "Server stopped — all DNS cleared": "Servidor detenido — todo DNS borrado", + "Failed to stop server": "Error al detener el servidor", + "Sudo password is required": "Se requiere contraseña de sudo", + "Stop Server": "Detener servidor", + "Start Server": "Iniciar servidor", + "Enable DNS per tool below to activate interception": "Habilite DNS para cada herramienta a continuación para activar la intercepción", + "Sudo Password Required": "Contraseña de Sudo requerida", + "Enter your sudo password to start/stop MITM server": "Ingrese su contraseña de sudo para iniciar/detener el servidor MITM", + "Sudo Password": "Contraseña de sudo", + "Click to add, click again to remove. Changes are saved automatically.": "Haz clic para agregar, haz clic de nuevo para eliminar. Los cambios se guardan automáticamente.", + "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ Aviso de Riesgo: Este proveedor usa una sesión de suscripción/OAuth no licenciada oficialmente para uso de proxy/router. La cuenta puede ser restringida o baneada. Use bajo su propio riesgo.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM intercepta el tráfico HTTPS de herramientas IDE (Antigravity, GitHub Copilot, Kiro) mediante CA local para redirigir solicitudes a sus proveedores. Puede violar los ToS → riesgo de baneo de cuenta. Use bajo su propio riesgo.", + "Endpoint is exposed without an API key.": "El endpoint está expuesto sin una clave de API." +} diff --git a/public/i18n/literals/fi.json b/public/i18n/literals/fi.json new file mode 100644 index 0000000000000000000000000000000000000000..dc8b116c3177a349676abf25c80b723338ee8964 --- /dev/null +++ b/public/i18n/literals/fi.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Peruuta", + "Delete": "Poista", + "Edit": "Muokkaa", + "Save": "Tallenna", + "Close": "Sulje", + "Add": "Lisää", + "Remove": "Poista", + "Settings": "Asetukset", + "Profile": "Profiili", + "Dashboard": "Kojelauta", + "Logout": "Kirjaudu ulos", + "Login": "Kirjaudu sisään", + "Providers": "Palveluntarjoajat", + "Usage": "Käyttötilastot", + "API Key": "API-avain", + "Connected": "Yhdistetty", + "Disconnected": "Yhteys katkaistu", + "Active": "Aktiivinen", + "Inactive": "Passiivinen", + "Success": "Onnistui", + "Failed": "Epäonnistui", + "Error": "Virhe", + "Warning": "Varoitus", + "Info": "Tiedot", + "Loading": "Ladataan", + "Search": "Hae", + "Filter": "Suodin", + "Sort": "Lajittele", + "Export": "Vie", + "Import": "Tuo", + "Refresh": "Päivitä", + "Back": "Takaisin", + "Next": "Seuraava", + "Previous": "Edellinen", + "Submit": "Lähetä", + "Confirm": "Vahvista", + "Yes": "Kyllä", + "No": "Ei", + "OK": "OK", + "Apply": "Käytä", + "Reset": "Nollaa", + "Clear": "Tyhjennä", + "Select": "Valitse", + "Upload": "Lataa", + "Download": "Lataa", + "Copy": "Kopioi", + "Paste": "Liitä", + "Cut": "Leikkaa", + "Undo": "Kumoa", + "Redo": "Tee uudelleen", + "Name": "Nimi", + "Description": "Kuvaus", + "Status": "Tila", + "Type": "Tyyppi", + "Date": "Päivämäärä", + "Time": "Aika", + "Created": "Luotu", + "Updated": "Päivitetty", + "Actions": "Toiminnot", + "Details": "Tiedot", + "View": "Näytä", + "New": "Uusi", + "Total": "Yhteensä", + "Count": "Määrä", + "Price": "Hinta", + "Cost": "Kustannus", + "Free": "Ilmainen", + "Paid": "Maksettu", + "Enable": "Ota käyttöön", + "Disable": "Poista käytöstä", + "Enabled": "Käytössä", + "Disabled": "Pois käytöstä", + "Online": "Online", + "Offline": "Offline", + "Available": "Saatavilla", + "Unavailable": "Ei saatavilla", + "Required": "Pakollinen", + "Optional": "Valinnainen", + "Default": "Oletus", + "Custom": "Mukautettu", + "Advanced": "Lisäasetukset", + "Basic": "Perus", + "Help": "Apua", + "Support": "Tuki", + "Documentation": "Dokumentaatio", + "Version": "Versio", + "Language": "Kieli", + "Theme": "Teema", + "Light": "Vaalea", + "Dark": "Tumma", + "Auto": "Automaattinen", + "Endpoint": "Pääteeksi", + "Combos": "Yhdistelmät", + "Quota Tracker": "Kiintiyden seuranta", + "MITM": "MITM", + "CLI Tools": "Työkalut", + "Console Log": "Konsolilokit", + "System": "Järjestelmä", + "Debug": "Virheenkorjaus", + "Shutdown": "Sammuta", + "Close Proxy": "Sulje välityspalvelin", + "Are you sure you want to close the proxy server?": "Oletko varma, että haluat sulkea välityspalvelimen?", + "Server Disconnected": "Palvelin katkaistiin", + "The proxy server has been stopped.": "Välityspalvelin on pysäytetty.", + "Reload Page": "Päivitä sivu", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "Palvelu on käynnissä terminaalissa. Voit sulkea tämän verkkosivun. Sammutus pysäyttää palvelun.", + "Manage your AI provider connections": "Hallitse AI-palveluntarjoajayhteyksääsi", + "Model combos with fallback": "Mallien yhdistelmät palautuksella", + "Monitor your API usage, token consumption, and request logs": "Valvo API-käyttöä, tunnusmerkkien kulutusta ja pyyntölokeja", + "Intercept CLI tool traffic and route through 9Router": "Sieppaa CLI-työkaluliikenteen ja reitit 9Routerin kautta", + "Configure CLI tools": "Konfiguroi CLI-työkalut", + "API endpoint configuration": "API-päätepisteen konfiguraatio", + "Manage your preferences": "Hallitse asetuksiasi", + "Debug translation flow between formats": "Virheenkorjaus kääntämisvirta formaattien välillä", + "Live server console output": "Palvelimen konsolin tulostus reaaliajassa", + "Create model combos with fallback support": "Luo malliyhdistelmiä palautustuen kanssa", + "Local Mode": "Paikallinen tila", + "Running on your machine": "Käynnissä koneellasi", + "Database Location": "Tietokannan sijainti", + "Download Backup": "Lataa varmuuskopio", + "Import Backup": "Tuo varmuuskopio", + "Database backup downloaded": "Tietokannan varmuuskopio ladattiin", + "Database imported successfully": "Tietokanta tuotiin onnistuneesti", + "Security": "Turvallisuus", + "Require login": "Vaadi kirjautumista", + "When ON, dashboard requires password. When OFF, access without login.": "Kun ON, kojelauta vaatii salasanaa. Kun OFF, pääsy ilman kirjautumista.", + "Current Password": "Nykyinen salasana", + "Enter current password": "Kirjoita nykyinen salasana", + "New Password": "Uusi salasana", + "Enter new password": "Kirjoita uusi salasana", + "Confirm New Password": "Vahvista uusi salasana", + "Confirm new password": "Vahvista uusi salasana", + "Update Password": "Päivitä salasana", + "Set Password": "Aseta salasana", + "Password updated successfully": "Salasana päivitettiin onnistuneesti", + "Passwords do not match": "Salasanat eivät täsmää", + "Routing Strategy": "Reititysstrategia", + "Round Robin": "Kiertelevä robotti", + "Cycle through accounts to distribute load": "Kierrä tileillä kuormituksen jakamiseksi", + "Sticky Limit": "Kiinteä raja", + "Calls per account before switching": "Puhelut tiliä kohti ennen vaihtamista", + "Network": "Verkko", + "Outbound Proxy": "Lähtevä välityspalvelin", + "Enable proxy for OAuth + provider outbound requests.": "Ota käyttöön välityspalvelin OAuth + palveluntarjoajan lähteviin pyyntöihin.", + "Proxy URL": "Välityspalvelimen URL", + "Leave empty to inherit existing env proxy (if any).": "Jätä tyhjäksi periä olemassa olevaa env-välityspalvelinta (jos sellainen on).", + "No Proxy": "Ei välityspalvelinta", + "Comma-separated hostnames/domains to bypass the proxy.": "Pilkulla erotetut isäntänimet/etäisyydet välityspalvelimen ohittamiseksi.", + "Test proxy URL": "Testaa välityspalvelimen URL", + "Proxy settings applied": "Välityspalvelimen asetukset käytössä", + "Proxy enabled": "Välityspalvelin otettu käyttöön", + "Proxy disabled": "Välityspalvelin poistettu käytöstä", + "Proxy test OK": "Välityspalvelimen testi OK", + "Proxy test failed": "Välityspalvelimen testi epäonnistui", + "Please enter a Proxy URL to test": "Kirjoita testattava välityspalvelimen URL", + "Observability": "Havainnointitarkkuus", + "Enable Observability": "Ota havainnointitarkkuus käyttöön", + "Turn request detail recording on/off globally": "Poista pyynnön yksityiskohtien tallentaminen käyttöön/pois käytöstä maailmanlaajuisesti", + "Max Records": "Max-tietueet", + "Maximum request detail records to keep (older records are auto-deleted)": "Maksimaalinen pyynnön yksityiskohtitietueet säilytettäväksi (vanhemmat tietueet poistetaan automaattisesti)", + "Batch Size": "Erän koko", + "Number of items to accumulate before writing to database (higher = better performance)": "Tietokantaan kirjoittamista edeltävien kerättävien kohteiden lukumäärä (korkeampi = parempi suorituskyky)", + "Flush Interval (ms)": "Tyhjennysväli (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Enimmäisaika odottaa ennen puskurin tyhjentämistä (estää tietojen menetyksen matalan liikenteen aikana)", + "Max JSON Size (KB)": "Maksimaalinen JSON-koko (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Kunkin JSON-kentän (pyynnön/vastauksen) enimmäiskoko ennen katkaisua", + "All data stored on your machine": "Kaikki tiedot tallennettu koneellasi", + "MITM Server": "MITM-palvelin", + "Running": "Käynnissä", + "Stopped": "Pysäytetty", + "Cert": "Sertifikaatti", + "Server": "Palvelin", + "Purpose:": "Tarkoitus:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Käytä Antigravity IDE:tä ja GitHub Copilot:ia → minkä tahansa palveluntarjoajan/mallin kanssa 9Routerista", + "How it works:": "Kuinka se toimii:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE-pyyntö → DNS-uudelleenohjaus localhost:443:iin → MITM-välityspalvelin sieppaa → 9Router → vastaus Antigravity/Copilot:ille", + "No API keys — create one in Keys page": "Ei API-avaimia — luo yksi Keys-sivulla", + "sk_9router (default)": "sk_9router (oletus)", + "Server started": "Palvelin käynnistetty", + "Failed to start server": "Palvelimen käynnistäminen epäonnistui", + "Server stopped — all DNS cleared": "Palvelin pysäytetty — kaikki DNS poistettu", + "Failed to stop server": "Palvelimen pysäyttäminen epäonnistui", + "Sudo password is required": "Sudo-salasana vaaditaan", + "Stop Server": "Pysäytä palvelin", + "Start Server": "Käynnistä palvelin", + "Enable DNS per tool below to activate interception": "Ota DNS käyttöön kunkin alla olevan työkalun osalta aktivoidaksesi sieppauksen", + "Sudo Password Required": "Sudo-salasana vaaditaan", + "Enter your sudo password to start/stop MITM server": "Kirjoita sudo-salasanasi MITM-palvelimen käynnistämiseksi/pysäyttämiseksi", + "Sudo Password": "Sudo-salasana", + "Click to add, click again to remove. Changes are saved automatically.": "Napsauta lisätäksesi, napsauta uudelleen poistaaksesi. Muutokset tallennetaan automaattisesti.", + "⚠️ 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.": "⚠️ Riski-ilmoitus: Tämä palveluntarjoaja käyttää tilaus-/OAuth-istuntoa, jota ei ole virallisesti lisensoitu välityspalvelin-/reititinkäyttöön. Tili voidaan rajoittaa tai estää. Käyttö omalla vastuulla.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM sieppaa IDE-työkalujen (Antigravity, GitHub Copilot, Kiro) HTTPS-liikennettä paikallisen CA:n kautta uudelleenohjatakseen pyyntöjä palveluntarjoajillesi. Voi rikkoa ToS:ää → tilin estoriski. Käyttö omalla vastuulla.", + "Endpoint is exposed without an API key.": "Päätepiste on alttiina ilman API-avainta." +} diff --git a/public/i18n/literals/fr.json b/public/i18n/literals/fr.json new file mode 100644 index 0000000000000000000000000000000000000000..bbf8854a4945d07b2538b2d13d14a0b37433882b --- /dev/null +++ b/public/i18n/literals/fr.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Annuler", + "Delete": "Supprimer", + "Edit": "Modifier", + "Save": "Enregistrer", + "Close": "Fermer", + "Add": "Ajouter", + "Remove": "Retirer", + "Settings": "Paramètres", + "Profile": "Profil", + "Dashboard": "Tableau de bord", + "Logout": "Déconnexion", + "Login": "Connexion", + "Providers": "Fournisseurs", + "Usage": "Statistiques", + "API Key": "Clé API", + "Connected": "Connecté", + "Disconnected": "Déconnecté", + "Active": "Actif", + "Inactive": "Inactif", + "Success": "Succès", + "Failed": "Échoué", + "Error": "Erreur", + "Warning": "Avertissement", + "Info": "Informations", + "Loading": "Chargement", + "Search": "Rechercher", + "Filter": "Filtrer", + "Sort": "Trier", + "Export": "Exporter", + "Import": "Importer", + "Refresh": "Actualiser", + "Back": "Retour", + "Next": "Suivant", + "Previous": "Précédent", + "Submit": "Soumettre", + "Confirm": "Confirmer", + "Yes": "Oui", + "No": "Non", + "OK": "OK", + "Apply": "Appliquer", + "Reset": "Réinitialiser", + "Clear": "Effacer", + "Select": "Sélectionner", + "Upload": "Télécharger", + "Download": "Télécharger", + "Copy": "Copier", + "Paste": "Coller", + "Cut": "Couper", + "Undo": "Annuler", + "Redo": "Refaire", + "Name": "Nom", + "Description": "Description", + "Status": "Statut", + "Type": "Type", + "Date": "Date", + "Time": "Heure", + "Created": "Créé", + "Updated": "Modifié", + "Actions": "Actions", + "Details": "Détails", + "View": "Afficher", + "New": "Nouveau", + "Total": "Total", + "Count": "Nombre", + "Price": "Prix", + "Cost": "Coût", + "Free": "Gratuit", + "Paid": "Payant", + "Enable": "Activer", + "Disable": "Désactiver", + "Enabled": "Activé", + "Disabled": "Désactivé", + "Online": "En ligne", + "Offline": "Hors ligne", + "Available": "Disponible", + "Unavailable": "Indisponible", + "Required": "Requis", + "Optional": "Facultatif", + "Default": "Par défaut", + "Custom": "Personnalisé", + "Advanced": "Avancé", + "Basic": "Basique", + "Help": "Aide", + "Support": "Support", + "Documentation": "Documentation", + "Version": "Version", + "Language": "Langue", + "Theme": "Thème", + "Light": "Clair", + "Dark": "Sombre", + "Auto": "Automatique", + "Endpoint": "Point final", + "Combos": "Combinaisons", + "Quota Tracker": "Suivi des quotas", + "MITM": "MITM", + "CLI Tools": "Outils CLI", + "Console Log": "Journaux de console", + "System": "Système", + "Debug": "Débogage", + "Shutdown": "Arrêt", + "Close Proxy": "Fermer le proxy", + "Are you sure you want to close the proxy server?": "Êtes-vous sûr de vouloir fermer le serveur proxy ?", + "Server Disconnected": "Serveur déconnecté", + "The proxy server has been stopped.": "Le serveur proxy a été arrêté.", + "Reload Page": "Recharger la page", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "Le service s'exécute dans le terminal. Vous pouvez fermer cette page web. L'arrêt arrêtera le service.", + "Manage your AI provider connections": "Gérez vos connexions de fournisseur IA", + "Model combos with fallback": "Combinaisons de modèles avec secours", + "Monitor your API usage, token consumption, and request logs": "Surveillez votre utilisation d'API, la consommation de jetons et les journaux de requête", + "Intercept CLI tool traffic and route through 9Router": "Interceptez le trafic des outils CLI et acheminez via 9Router", + "Configure CLI tools": "Configurer les outils CLI", + "API endpoint configuration": "Configuration du point final API", + "Manage your preferences": "Gérez vos préférences", + "Debug translation flow between formats": "Déboguer le flux de traduction entre les formats", + "Live server console output": "Sortie de la console du serveur en direct", + "Create model combos with fallback support": "Créer des combinaisons de modèles avec support de secours", + "Local Mode": "Mode local", + "Running on your machine": "S'exécute sur votre machine", + "Database Location": "Emplacement de la base de données", + "Download Backup": "Télécharger la sauvegarde", + "Import Backup": "Importer la sauvegarde", + "Database backup downloaded": "Sauvegarde de la base de données téléchargée", + "Database imported successfully": "Base de données importée avec succès", + "Security": "Sécurité", + "Require login": "Exiger une connexion", + "When ON, dashboard requires password. When OFF, access without login.": "Lorsqu'il est ACTIVÉ, le tableau de bord nécessite un mot de passe. Lorsqu'il est DÉSACTIVÉ, accès sans connexion.", + "Current Password": "Mot de passe actuel", + "Enter current password": "Entrez le mot de passe actuel", + "New Password": "Nouveau mot de passe", + "Enter new password": "Entrez le nouveau mot de passe", + "Confirm New Password": "Confirmer le nouveau mot de passe", + "Confirm new password": "Confirmez le nouveau mot de passe", + "Update Password": "Mettre à jour le mot de passe", + "Set Password": "Définir le mot de passe", + "Password updated successfully": "Mot de passe mis à jour avec succès", + "Passwords do not match": "Les mots de passe ne correspondent pas", + "Routing Strategy": "Stratégie d'acheminement", + "Round Robin": "Round Robin", + "Cycle through accounts to distribute load": "Parcourir les comptes pour distribuer la charge", + "Sticky Limit": "Limite collante", + "Calls per account before switching": "Appels par compte avant de changer", + "Network": "Réseau", + "Outbound Proxy": "Proxy sortant", + "Enable proxy for OAuth + provider outbound requests.": "Activez le proxy pour OAuth + les demandes sortantes du fournisseur.", + "Proxy URL": "URL du proxy", + "Leave empty to inherit existing env proxy (if any).": "Laissez vide pour hériter du proxy env existant (le cas échéant).", + "No Proxy": "Pas de proxy", + "Comma-separated hostnames/domains to bypass the proxy.": "Noms d'hôtes/domaines séparés par des virgules pour contourner le proxy.", + "Test proxy URL": "Tester l'URL du proxy", + "Proxy settings applied": "Paramètres de proxy appliqués", + "Proxy enabled": "Proxy activé", + "Proxy disabled": "Proxy désactivé", + "Proxy test OK": "Test de proxy OK", + "Proxy test failed": "Échec du test de proxy", + "Please enter a Proxy URL to test": "Veuillez entrer une URL de proxy à tester", + "Observability": "Observabilité", + "Enable Observability": "Activer l'observabilité", + "Turn request detail recording on/off globally": "Activer/désactiver l'enregistrement des détails de la requête globalement", + "Max Records": "Nombre maximum d'enregistrements", + "Maximum request detail records to keep (older records are auto-deleted)": "Nombre maximum d'enregistrements de détails de requête à conserver (les anciens enregistrements sont supprimés automatiquement)", + "Batch Size": "Taille du lot", + "Number of items to accumulate before writing to database (higher = better performance)": "Nombre d'éléments à accumuler avant d'écrire dans la base de données (plus élevé = meilleures performances)", + "Flush Interval (ms)": "Intervalle de vidage (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Temps maximum d'attente avant de vider le tampon (évite la perte de données en cas de faible trafic)", + "Max JSON Size (KB)": "Taille JSON maximale (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Taille maximale pour chaque champ JSON (demande/réponse) avant la troncature", + "All data stored on your machine": "Toutes les données stockées sur votre machine", + "MITM Server": "Serveur MITM", + "Running": "En cours d'exécution", + "Stopped": "Arrêté", + "Cert": "Certificat", + "Server": "Serveur", + "Purpose:": "Objectif :", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Utilisez Antigravity IDE et GitHub Copilot → avec N'IMPORTE QUEL fournisseur/modèle de 9Router", + "How it works:": "Comment ça marche :", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Demande Antigravity/Copilot IDE → Redirection DNS vers localhost:443 → Le proxy MITM intercepte → 9Router → réponse à Antigravity/Copilot", + "No API keys — create one in Keys page": "Aucune clé API — créez-en une dans la page Clés", + "sk_9router (default)": "sk_9router (par défaut)", + "Server started": "Serveur démarré", + "Failed to start server": "Impossible de démarrer le serveur", + "Server stopped — all DNS cleared": "Serveur arrêté — tout DNS effacé", + "Failed to stop server": "Impossible d'arrêter le serveur", + "Sudo password is required": "Le mot de passe sudo est requis", + "Stop Server": "Arrêter le serveur", + "Start Server": "Démarrer le serveur", + "Enable DNS per tool below to activate interception": "Activez le DNS pour chaque outil ci-dessous pour activer l'interception", + "Sudo Password Required": "Mot de passe Sudo requis", + "Enter your sudo password to start/stop MITM server": "Entrez votre mot de passe sudo pour démarrer/arrêter le serveur MITM", + "Sudo Password": "Mot de passe sudo", + "Click to add, click again to remove. Changes are saved automatically.": "Cliquez pour ajouter, cliquez à nouveau pour supprimer. Les modifications sont enregistrées automatiquement.", + "⚠️ 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.": "⚠️ Avis de risque : Ce fournisseur utilise une session d'abonnement/OAuth non officiellement autorisée pour une utilisation proxy/routeur. Le compte peut être restreint ou banni. Utilisez à vos propres risques.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM intercepte le trafic HTTPS des outils IDE (Antigravity, GitHub Copilot, Kiro) via une CA locale pour rediriger les requêtes vers vos fournisseurs. Peut violer les CGU → risque de bannissement de compte. Utilisez à vos propres risques.", + "Endpoint is exposed without an API key.": "Le point de terminaison est exposé sans clé API." +} diff --git a/public/i18n/literals/he.json b/public/i18n/literals/he.json new file mode 100644 index 0000000000000000000000000000000000000000..c144d01b832f2a214d33f185eb8b31068b52898a --- /dev/null +++ b/public/i18n/literals/he.json @@ -0,0 +1,195 @@ +{ + "Cancel": "ביטול", + "Delete": "מחק", + "Edit": "עריכה", + "Save": "שמור", + "Close": "סגור", + "Add": "הוספה", + "Remove": "הסרה", + "Settings": "הגדרות", + "Profile": "פרופיל", + "Dashboard": "לוח בקרה", + "Logout": "התנתקות", + "Login": "כניסה", + "Providers": "ספקים", + "Usage": "סטטיסטיקה", + "API Key": "מפתח API", + "Connected": "מחובר", + "Disconnected": "מנותק", + "Active": "פעיל", + "Inactive": "לא פעיל", + "Success": "הצלחה", + "Failed": "נכשל", + "Error": "שגיאה", + "Warning": "אזהרה", + "Info": "מידע", + "Loading": "טוען", + "Search": "חיפוש", + "Filter": "סינון", + "Sort": "מיון", + "Export": "ייצוא", + "Import": "ייבוא", + "Refresh": "רענן", + "Back": "חזור", + "Next": "הבא", + "Previous": "הקודם", + "Submit": "שלח", + "Confirm": "אישור", + "Yes": "כן", + "No": "לא", + "OK": "אישור", + "Apply": "החל", + "Reset": "אפס", + "Clear": "נקה", + "Select": "בחר", + "Upload": "העלאה", + "Download": "הורדה", + "Copy": "העתק", + "Paste": "הדבק", + "Cut": "גזור", + "Undo": "ביטול", + "Redo": "חזור על", + "Name": "שם", + "Description": "תיאור", + "Status": "סטטוס", + "Type": "סוג", + "Date": "תאריך", + "Time": "זמן", + "Created": "נוצר", + "Updated": "עודכן", + "Actions": "פעולות", + "Details": "פרטים", + "View": "צפה", + "New": "חדש", + "Total": "סה\"כ", + "Count": "ספירה", + "Price": "מחיר", + "Cost": "עלות", + "Free": "חינם", + "Paid": "בתשלום", + "Enable": "הפעל", + "Disable": "כבה", + "Enabled": "הופעל", + "Disabled": "מבוטל", + "Online": "מחובר", + "Offline": "לא מחובר", + "Available": "זמין", + "Unavailable": "לא זמין", + "Required": "נדרש", + "Optional": "אופציונלי", + "Default": "ברירת מחדל", + "Custom": "מותאם", + "Advanced": "מתקדם", + "Basic": "בסיסי", + "Help": "עזרה", + "Support": "תמיכה", + "Documentation": "תיעוד", + "Version": "גרסה", + "Language": "שפה", + "Theme": "עיצוב", + "Light": "בהיר", + "Dark": "אפל", + "Auto": "אוטומטי", + "Endpoint": "נקודת קצה", + "Combos": "שילובים", + "Quota Tracker": "עוקב הקצאה", + "MITM": "MITM", + "CLI Tools": "כלים CLI", + "Console Log": "יומן קונסול", + "System": "מערכת", + "Debug": "ניפוי שגיאות", + "Shutdown": "כיבוי", + "Close Proxy": "סגור פרוקסי", + "Are you sure you want to close the proxy server?": "האם אתה בטוח שברצונך לסגור את שרת הפרוקסי?", + "Server Disconnected": "השרת מנותק", + "The proxy server has been stopped.": "שרת הפרוקסי הופסק.", + "Reload Page": "טען מחדש את הדף", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "השירות פועל בטרמינל. אתה יכול לסגור את דף אינטרנט זה. כיבוי יעצור את השירות.", + "Manage your AI provider connections": "נהל את חיבורי ספק ה-AI שלך", + "Model combos with fallback": "שילובי מודל עם התחזוקה", + "Monitor your API usage, token consumption, and request logs": "עקוב אחרי השימוש ב-API, צריכת אסימונים ויומני בקשה", + "Intercept CLI tool traffic and route through 9Router": "תקוף את תנועת כלי CLI וניתוב דרך 9Router", + "Configure CLI tools": "הגדר כלים CLI", + "API endpoint configuration": "הגדרת נקודת קצה של API", + "Manage your preferences": "נהל את העדפותיך", + "Debug translation flow between formats": "ניפוי זרם התרגום בין פורמטים", + "Live server console output": "פלט קונסול שרת חי", + "Create model combos with fallback support": "יצור שילובי מודל עם תמיכה בהתחזוקה", + "Local Mode": "מצב מקומי", + "Running on your machine": "רץ על המחשב שלך", + "Database Location": "מיקום מסד הנתונים", + "Download Backup": "הורד גיבוי", + "Import Backup": "ייבא גיבוי", + "Database backup downloaded": "גיבוי מסד הנתונים הורד", + "Database imported successfully": "מסד הנתונים יובא בהצלחה", + "Security": "אבטחה", + "Require login": "דרוש כניסה", + "When ON, dashboard requires password. When OFF, access without login.": "כאשר כבוי, לוח הבקרה דורש סיסמה. כאשר מכובה, גישה ללא כניסה.", + "Current Password": "סיסמה נוכחית", + "Enter current password": "הזן את הסיסמה הנוכחית", + "New Password": "סיסמה חדשה", + "Enter new password": "הזן סיסמה חדשה", + "Confirm New Password": "אשר סיסמה חדשה", + "Confirm new password": "אשר סיסמה חדשה", + "Update Password": "עדכן סיסמה", + "Set Password": "הגדר סיסמה", + "Password updated successfully": "הסיסמה עודכנה בהצלחה", + "Passwords do not match": "הסיסמאות אינן תואמות", + "Routing Strategy": "אסטרטגיית ניתוב", + "Round Robin": "Round Robin", + "Cycle through accounts to distribute load": "מחזור בחשבונות לחלוקת עומס", + "Sticky Limit": "גבול דבוק", + "Calls per account before switching": "קריאות לפי חשבון לפני המעבר", + "Network": "רשת", + "Outbound Proxy": "פרוקסי יוצא", + "Enable proxy for OAuth + provider outbound requests.": "הפעל פרוקסי עבור בקשות יוצאות של OAuth + ספק.", + "Proxy URL": "URL פרוקסי", + "Leave empty to inherit existing env proxy (if any).": "השאר ריק כדי לרשת פרוקסי env קיים (אם יש).", + "No Proxy": "ללא פרוקסי", + "Comma-separated hostnames/domains to bypass the proxy.": "שמות משדר/תחומים מופרדים בפסיקים לעקיפת הפרוקסי.", + "Test proxy URL": "בדוק URL פרוקסי", + "Proxy settings applied": "הגדרות פרוקסי הופעלו", + "Proxy enabled": "פרוקסי הופעל", + "Proxy disabled": "פרוקסי מבוטל", + "Proxy test OK": "בדיקת פרוקסי בסדר", + "Proxy test failed": "בדיקת פרוקסי נכשלה", + "Please enter a Proxy URL to test": "אנא הזן URL פרוקסי לבדיקה", + "Observability": "יכולת תצפית", + "Enable Observability": "הפעל יכולת תצפית", + "Turn request detail recording on/off globally": "הפעל/כבה הקלטת פרטי בקשה בעולם", + "Max Records": "מרבי רשומות", + "Maximum request detail records to keep (older records are auto-deleted)": "מרבי רשומות פרטי בקשה לשמור (רשומות ישנות יותר נמחקות באופן אוטומטי)", + "Batch Size": "גודל אצווה", + "Number of items to accumulate before writing to database (higher = better performance)": "מספר הפריטים להצטברות לפני הכתיבה למסד הנתונים (גבוה יותר = ביצועים טובים יותר)", + "Flush Interval (ms)": "מרווח שטיפה (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "זמן מקסימום להמתנה לפני שטיפת ביפר (מונע הפסד נתונים תחת תנועה נמוכה)", + "Max JSON Size (KB)": "גודל JSON מקסימלי (KB)", + "Maximum size for each JSON field (request/response) before truncation": "גודל מקסימלי לכל שדה JSON (בקשה/תגובה) לפני חיתוך", + "All data stored on your machine": "כל הנתונים מאוחסנים במחשב שלך", + "MITM Server": "שרת MITM", + "Running": "רץ", + "Stopped": "עצור", + "Cert": "תעודה", + "Server": "שרת", + "Purpose:": "מטרה:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "השתמש ב-Antigravity IDE ו-GitHub Copilot → עם כל ספק/מודל מ-9Router", + "How it works:": "איך זה עובד:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "בקשת Antigravity/Copilot IDE → הפניה DNS ל-localhost:443 → פרוקסי MITM חוטף → 9Router → תגובה ל-Antigravity/Copilot", + "No API keys — create one in Keys page": "אין מפתחות API — צור אחד בעמוד Keys", + "sk_9router (default)": "sk_9router (ברירת מחדל)", + "Server started": "השרת התחיל", + "Failed to start server": "הפעלת השרת נכשלה", + "Server stopped — all DNS cleared": "השרת הופסק — כל ה-DNS נוקה", + "Failed to stop server": "עצירת השרת נכשלה", + "Sudo password is required": "נדרשת סיסמת sudo", + "Stop Server": "עצור שרת", + "Start Server": "הפעל שרת", + "Enable DNS per tool below to activate interception": "הפעל DNS לכל כלי למטה להפעלת היירוט", + "Sudo Password Required": "סיסמת Sudo נדרשת", + "Enter your sudo password to start/stop MITM server": "הזן את סיסמת sudo שלך כדי להתחיל/עצור שרת MITM", + "Sudo Password": "סיסמת Sudo", + "Click to add, click again to remove. Changes are saved automatically.": "לחץ להוספה, לחץ שוב להסרה. השינויים נשמרים אוטומטית.", + "⚠️ 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.": "⚠️ הודעת סיכון: ספק זה משתמש במנוי/הפעלת OAuth שאינה מורשית רשמית לשימוש פרוקסי/ראוטר. החשבון עלול להיות מוגבל או חסום. השימוש על אחריותך בלבד.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM יירוט תעבורת HTTPS של כלי IDE (Antigravity, GitHub Copilot, Kiro) באמצעות CA מקומי כדי להפנות בקשות לספקים שלך. עלול להפר את תנאי השירות → סיכון חסימת חשבון. השימוש על אחריותך.", + "Endpoint is exposed without an API key.": "נקודת הקצה חשופה ללא מפתח API." +} diff --git a/public/i18n/literals/hi.json b/public/i18n/literals/hi.json new file mode 100644 index 0000000000000000000000000000000000000000..2fc6b338b9008bcb4c5a722a249524a9aa33035c --- /dev/null +++ b/public/i18n/literals/hi.json @@ -0,0 +1,195 @@ +{ + "Cancel": "रद्द करें", + "Delete": "हटाएं", + "Edit": "संपादित करें", + "Save": "सहेजें", + "Close": "बंद करें", + "Add": "जोड़ें", + "Remove": "निकालें", + "Settings": "सेटिंग्स", + "Profile": "प्रोफ़ाइल", + "Dashboard": "डैशबोर्ड", + "Logout": "लॉग आउट", + "Login": "लॉगिन", + "Providers": "प्रदाता", + "Usage": "उपयोग के आंकड़े", + "API Key": "API कुंजी", + "Connected": "जुड़ा हुआ", + "Disconnected": "डिस्कनेक्ट किया गया", + "Active": "सक्रिय", + "Inactive": "निष्क्रिय", + "Success": "सफल", + "Failed": "विफल", + "Error": "त्रुटि", + "Warning": "चेतावनी", + "Info": "जानकारी", + "Loading": "लोड हो रहा है", + "Search": "खोज", + "Filter": "फिल्टर", + "Sort": "सॉर्ट करें", + "Export": "निर्यात", + "Import": "आयात", + "Refresh": "रीफ्रेश करें", + "Back": "वापस", + "Next": "आगे", + "Previous": "पिछला", + "Submit": "जमा करें", + "Confirm": "पुष्टि करें", + "Yes": "हां", + "No": "नहीं", + "OK": "ठीक है", + "Apply": "लागू करें", + "Reset": "रीसेट करें", + "Clear": "साफ़ करें", + "Select": "चुनें", + "Upload": "अपलोड करें", + "Download": "डाउनलोड करें", + "Copy": "कॉपी करें", + "Paste": "पेस्ट करें", + "Cut": "काटें", + "Undo": "पूर्ववत करें", + "Redo": "फिर से करें", + "Name": "नाम", + "Description": "विवरण", + "Status": "स्थिति", + "Type": "प्रकार", + "Date": "तारीख", + "Time": "समय", + "Created": "बनाया गया", + "Updated": "अपडेट किया गया", + "Actions": "कार्य", + "Details": "विवरण", + "View": "देखें", + "New": "नया", + "Total": "कुल", + "Count": "गिनती", + "Price": "कीमत", + "Cost": "लागत", + "Free": "मुक्त", + "Paid": "भुगतान किया गया", + "Enable": "सक्षम करें", + "Disable": "अक्षम करें", + "Enabled": "सक्षम", + "Disabled": "अक्षम", + "Online": "ऑनलाइन", + "Offline": "ऑफ़लाइन", + "Available": "उपलब्ध", + "Unavailable": "अनुपलब्ध", + "Required": "आवश्यक", + "Optional": "वैकल्पिक", + "Default": "डिफ़ॉल्ट", + "Custom": "कस्टम", + "Advanced": "उन्नत", + "Basic": "बुनियादी", + "Help": "मदद", + "Support": "समर्थन", + "Documentation": "दस्तावेज़", + "Version": "संस्करण", + "Language": "भाषा", + "Theme": "थीम", + "Light": "हल्का", + "Dark": "अंधेरा", + "Auto": "स्वचालित", + "Endpoint": "एंडपॉइंट", + "Combos": "कॉम्बो", + "Quota Tracker": "कोटा ट्रैकर", + "MITM": "MITM", + "CLI Tools": "उपकरण", + "Console Log": "कंसोल लॉग", + "System": "प्रणाली", + "Debug": "डीबग", + "Shutdown": "बंद करें", + "Close Proxy": "प्रॉक्सी बंद करें", + "Are you sure you want to close the proxy server?": "क्या आप वाकई प्रॉक्सी सर्वर को बंद करना चाहते हैं?", + "Server Disconnected": "सर्वर डिस्कनेक्ट किया गया", + "The proxy server has been stopped.": "प्रॉक्सी सर्वर को बंद कर दिया गया है।", + "Reload Page": "पृष्ठ को पुनः लोड करें", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "सेवा टर्मिनल में चल रही है। आप इस वेब पृष्ठ को बंद कर सकते हैं। शटडाउन सेवा को बंद कर देगा।", + "Manage your AI provider connections": "अपने AI प्रदाता कनेक्शन प्रबंधित करें", + "Model combos with fallback": "फॉलबैक के साथ मॉडल कॉम्बो", + "Monitor your API usage, token consumption, and request logs": "अपने API उपयोग, टोकन खपत और अनुरोध लॉग की निगरानी करें", + "Intercept CLI tool traffic and route through 9Router": "CLI टूल ट्रैफिक को इंटरसेप्ट करें और 9Router के माध्यम से रूट करें", + "Configure CLI tools": "CLI उपकरण कॉन्फ़िगर करें", + "API endpoint configuration": "API एंडपॉइंट कॉन्फ़िगरेशन", + "Manage your preferences": "अपनी प्राथमिकताएं प्रबंधित करें", + "Debug translation flow between formats": "फॉर्मेट के बीच अनुवाद प्रवाह डीबग करें", + "Live server console output": "लाइव सर्वर कंसोल आउटपुट", + "Create model combos with fallback support": "फॉलबैक समर्थन के साथ मॉडल कॉम्बो बनाएं", + "Local Mode": "स्थानीय मोड", + "Running on your machine": "आपकी मशीन पर चल रहा है", + "Database Location": "डेटाबेस स्थान", + "Download Backup": "बैकअप डाउनलोड करें", + "Import Backup": "बैकअप आयात करें", + "Database backup downloaded": "डेटाबेस बैकअप डाउनलोड किया गया", + "Database imported successfully": "डेटाबेस सफलतापूर्वक आयात किया गया", + "Security": "सुरक्षा", + "Require login": "लॉगिन की आवश्यकता है", + "When ON, dashboard requires password. When OFF, access without login.": "चालू होने पर, डैशबोर्ड को पासवर्ड की आवश्यकता होती है। बंद होने पर, लॉगिन के बिना एक्सेस करें।", + "Current Password": "वर्तमान पासवर्ड", + "Enter current password": "वर्तमान पासवर्ड दर्ज करें", + "New Password": "नया पासवर्ड", + "Enter new password": "नया पासवर्ड दर्ज करें", + "Confirm New Password": "नए पासवर्ड की पुष्टि करें", + "Confirm new password": "नए पासवर्ड की पुष्टि करें", + "Update Password": "पासवर्ड अपडेट करें", + "Set Password": "पासवर्ड सेट करें", + "Password updated successfully": "पासवर्ड सफलतापूर्वक अपडेट किया गया", + "Passwords do not match": "पासवर्ड मेल नहीं खाते", + "Routing Strategy": "रूटिंग रणनीति", + "Round Robin": "राउंड रॉबिन", + "Cycle through accounts to distribute load": "लोड वितरित करने के लिए खातों के माध्यम से साइकिल चलाएं", + "Sticky Limit": "स्टिकी सीमा", + "Calls per account before switching": "स्विच करने से पहले प्रति खाते कॉल", + "Network": "नेटवर्क", + "Outbound Proxy": "आउटबाउंड प्रॉक्सी", + "Enable proxy for OAuth + provider outbound requests.": "OAuth + प्रदाता आउटबाउंड अनुरोधों के लिए प्रॉक्सी सक्षम करें।", + "Proxy URL": "प्रॉक्सी URL", + "Leave empty to inherit existing env proxy (if any).": "मौजूदा env प्रॉक्सी को इनहेरिट करने के लिए खाली छोड़ें (यदि कोई हो)।", + "No Proxy": "कोई प्रॉक्सी नहीं", + "Comma-separated hostnames/domains to bypass the proxy.": "प्रॉक्सी को बायपास करने के लिए अल्पविराम से अलग की गई होस्टनाम/डोमेन।", + "Test proxy URL": "प्रॉक्सी URL का परीक्षण करें", + "Proxy settings applied": "प्रॉक्सी सेटिंग्स लागू की गई", + "Proxy enabled": "प्रॉक्सी सक्षम", + "Proxy disabled": "प्रॉक्सी अक्षम", + "Proxy test OK": "प्रॉक्सी परीक्षण ठीक है", + "Proxy test failed": "प्रॉक्सी परीक्षण विफल", + "Please enter a Proxy URL to test": "परीक्षण के लिए कृपया एक प्रॉक्सी URL दर्ज करें", + "Observability": "पर्यवेक्षणीयता", + "Enable Observability": "पर्यवेक्षणीयता सक्षम करें", + "Turn request detail recording on/off globally": "अनुरोध विवरण रिकॉर्डिंग को विश्व स्तर पर चालू/बंद करें", + "Max Records": "अधिकतम रिकॉर्ड", + "Maximum request detail records to keep (older records are auto-deleted)": "रखने के लिए अधिकतम अनुरोध विवरण रिकॉर्ड (पुराने रिकॉर्ड स्वचालित रूप से हटाए जाते हैं)", + "Batch Size": "बैच आकार", + "Number of items to accumulate before writing to database (higher = better performance)": "डेटाबेस में लिखने से पहले जमा करने के लिए आइटम की संख्या (अधिक = बेहतर प्रदर्शन)", + "Flush Interval (ms)": "फ्लश अंतराल (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "बफर को फ्लश करने से पहले प्रतीक्षा करने का अधिकतम समय (कम ट्रैफिक के दौरान डेटा नुकसान को रोकता है)", + "Max JSON Size (KB)": "अधिकतम JSON आकार (KB)", + "Maximum size for each JSON field (request/response) before truncation": "ट्रंकेशन से पहले प्रत्येक JSON फ़ील्ड (अनुरोध/प्रतिक्रिया) के लिए अधिकतम आकार", + "All data stored on your machine": "आपकी मशीन पर सभी डेटा संग्रहीत है", + "MITM Server": "MITM सर्वर", + "Running": "चल रहा है", + "Stopped": "रुका हुआ", + "Cert": "प्रमाणपत्र", + "Server": "सर्वर", + "Purpose:": "उद्देश्य:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Antigravity IDE और GitHub Copilot का उपयोग करें → 9Router से किसी भी प्रदाता/मॉडल के साथ", + "How it works:": "यह कैसे काम करता है:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE अनुरोध → DNS को localhost:443 में पुनर्निर्देशित करें → MITM प्रॉक्सी इंटरसेप्ट करता है → 9Router → Antigravity/Copilot को प्रतिक्रिया", + "No API keys — create one in Keys page": "कोई API कुंजी नहीं — Keys पृष्ठ में एक बनाएं", + "sk_9router (default)": "sk_9router (डिफ़ॉल्ट)", + "Server started": "सर्वर शुरू किया गया", + "Failed to start server": "सर्वर शुरू करने में विफल", + "Server stopped — all DNS cleared": "सर्वर बंद — सभी DNS साफ़ किए गए", + "Failed to stop server": "सर्वर को रोकने में विफल", + "Sudo password is required": "Sudo पासवर्ड की आवश्यकता है", + "Stop Server": "सर्वर बंद करें", + "Start Server": "सर्वर शुरू करें", + "Enable DNS per tool below to activate interception": "इंटरसेप्शन को सक्रिय करने के लिए नीचे प्रत्येक उपकरण के लिए DNS सक्षम करें", + "Sudo Password Required": "Sudo पासवर्ड आवश्यक है", + "Enter your sudo password to start/stop MITM server": "MITM सर्वर शुरू/रोकने के लिए अपना sudo पासवर्ड दर्ज करें", + "Sudo Password": "Sudo पासवर्ड", + "Click to add, click again to remove. Changes are saved automatically.": "जोड़ने के लिए क्लिक करें, हटाने के लिए फिर से क्लिक करें। परिवर्तन स्वचालित रूप से सहेजे जाते हैं।", + "⚠️ 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.": "⚠️ जोखिम सूचना: यह प्रदाता एक सब्सक्रिप्शन/OAuth सत्र का उपयोग करता है जो प्रॉक्सी/राउटर उपयोग के लिए आधिकारिक रूप से लाइसेंस प्राप्त नहीं है। खाता प्रतिबंधित या बैन हो सकता है। अपने जोखिम पर उपयोग करें।", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM स्थानीय CA के माध्यम से IDE टूल्स (Antigravity, GitHub Copilot, Kiro) के HTTPS ट्रैफिक को इंटरसेप्ट करता है ताकि अनुरोधों को आपके प्रदाताओं पर पुनर्निर्देशित किया जा सके। ToS का उल्लंघन हो सकता है → खाता बैन का जोखिम। अपने जोखिम पर उपयोग करें।", + "Endpoint is exposed without an API key.": "एंडपॉइंट बिना API कुंजी के उजागर है।" +} diff --git a/public/i18n/literals/hu.json b/public/i18n/literals/hu.json new file mode 100644 index 0000000000000000000000000000000000000000..8e3392914f6893217963fe39920902ca13453905 --- /dev/null +++ b/public/i18n/literals/hu.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Mégse", + "Delete": "Törlés", + "Edit": "Szerkesztés", + "Save": "Mentés", + "Close": "Bezárás", + "Add": "Hozzáadás", + "Remove": "Eltávolítás", + "Settings": "Beállítások", + "Profile": "Profil", + "Dashboard": "Irányítópult", + "Logout": "Kijelentkezés", + "Login": "Bejelentkezés", + "Providers": "Szolgáltatók", + "Usage": "Használati statisztika", + "API Key": "API-kulcs", + "Connected": "Csatlakoztatva", + "Disconnected": "Leválasztva", + "Active": "Aktív", + "Inactive": "Inaktív", + "Success": "Siker", + "Failed": "Sikertelen", + "Error": "Hiba", + "Warning": "Figyelmeztetés", + "Info": "Információ", + "Loading": "Betöltés", + "Search": "Keresés", + "Filter": "Szűrő", + "Sort": "Rendezés", + "Export": "Exportálás", + "Import": "Importálás", + "Refresh": "Frissítés", + "Back": "Vissza", + "Next": "Következő", + "Previous": "Előző", + "Submit": "Küldés", + "Confirm": "Megerősítés", + "Yes": "Igen", + "No": "Nem", + "OK": "OK", + "Apply": "Alkalmazás", + "Reset": "Visszaállítás", + "Clear": "Törlés", + "Select": "Kiválasztás", + "Upload": "Feltöltés", + "Download": "Letöltés", + "Copy": "Másolás", + "Paste": "Beillesztés", + "Cut": "Kivágás", + "Undo": "Visszavonás", + "Redo": "Ismét", + "Name": "Név", + "Description": "Leírás", + "Status": "Állapot", + "Type": "Típus", + "Date": "Dátum", + "Time": "Idő", + "Created": "Létrehozva", + "Updated": "Frissítve", + "Actions": "Műveletek", + "Details": "Részletek", + "View": "Megtekintés", + "New": "Új", + "Total": "Összes", + "Count": "Darabszám", + "Price": "Ár", + "Cost": "Költség", + "Free": "Ingyenes", + "Paid": "Fizetős", + "Enable": "Engedélyezés", + "Disable": "Letiltás", + "Enabled": "Engedélyezve", + "Disabled": "Letiltva", + "Online": "Online", + "Offline": "Offline", + "Available": "Elérhető", + "Unavailable": "Nem elérhető", + "Required": "Kötelező", + "Optional": "Opcionális", + "Default": "Alapértelmezett", + "Custom": "Egyéni", + "Advanced": "Haladó", + "Basic": "Alapvető", + "Help": "Súgó", + "Support": "Támogatás", + "Documentation": "Dokumentáció", + "Version": "Verzió", + "Language": "Nyelv", + "Theme": "Téma", + "Light": "Világos", + "Dark": "Sötét", + "Auto": "Automatikus", + "Endpoint": "Végpont", + "Combos": "Kombinációk", + "Quota Tracker": "Kvóta nyomkövetés", + "MITM": "MITM", + "CLI Tools": "Eszközök", + "Console Log": "Konzol napló", + "System": "Rendszer", + "Debug": "Hibakeresés", + "Shutdown": "Leállítás", + "Close Proxy": "Proxy bezárása", + "Are you sure you want to close the proxy server?": "Biztosan le akarja zárni a proxy szervert?", + "Server Disconnected": "Szerver leválasztva", + "The proxy server has been stopped.": "A proxy szerver leállt.", + "Reload Page": "Oldal újratöltése", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "A szolgáltatás a terminálon fut. Bezárhatja ezt a weboldalt. A leállítás megállítja a szolgáltatást.", + "Manage your AI provider connections": "Az AI-szolgáltatók kapcsolatainak kezelése", + "Model combos with fallback": "Modell kombinációk tartalékkal", + "Monitor your API usage, token consumption, and request logs": "Az API-használat, a token-fogyasztás és a kérési naplók figyelése", + "Intercept CLI tool traffic and route through 9Router": "CLI-eszköz forgalmának elfogása és az 9Router-en keresztüli irányítása", + "Configure CLI tools": "CLI-eszközök konfigurálása", + "API endpoint configuration": "API-végpont konfigurálása", + "Manage your preferences": "Előnyzeteinek kezelése", + "Debug translation flow between formats": "A fordítási folyamat hibakeresése a formátumok között", + "Live server console output": "Élő kiszolgáló konzol kimenete", + "Create model combos with fallback support": "Modell kombinációk létrehozása tartalék támogatással", + "Local Mode": "Helyi mód", + "Running on your machine": "A gépén futó", + "Database Location": "Adatbázis helye", + "Download Backup": "Biztonsági másolat letöltése", + "Import Backup": "Biztonsági másolat importálása", + "Database backup downloaded": "Adatbázis biztonsági másolat letöltve", + "Database imported successfully": "Az adatbázis sikeresen importálva", + "Security": "Biztonság", + "Require login": "Bejelentkezés szükséges", + "When ON, dashboard requires password. When OFF, access without login.": "Ha BEKAPCSOLT, az irányítópulthoz jelszó szükséges. Ha KIKAPCSOLT, bejelentkezés nélkül is hozzáférhet.", + "Current Password": "Jelenlegi jelszó", + "Enter current password": "Adja meg a jelenlegi jelszót", + "New Password": "Új jelszó", + "Enter new password": "Adja meg az új jelszót", + "Confirm New Password": "Új jelszó megerősítése", + "Confirm new password": "Erősítse meg az új jelszót", + "Update Password": "Jelszó frissítése", + "Set Password": "Jelszó beállítása", + "Password updated successfully": "A jelszó sikeresen frissítve", + "Passwords do not match": "A jelszavak nem egyeznek", + "Routing Strategy": "Útválasztási stratégia", + "Round Robin": "Fordított körforgalom", + "Cycle through accounts to distribute load": "Ciklikus váltakozás a fiókok között a terhelés elosztásához", + "Sticky Limit": "Ragadós korlát", + "Calls per account before switching": "Hívások fiókonként a váltás előtt", + "Network": "Hálózat", + "Outbound Proxy": "Kimenő proxy", + "Enable proxy for OAuth + provider outbound requests.": "Engedélyezze a proxy-t OAuth + szolgáltató kimenő kérésekhez.", + "Proxy URL": "Proxy URL", + "Leave empty to inherit existing env proxy (if any).": "Hagyja üresen a meglévő env proxy örökléséhez (ha van).", + "No Proxy": "Nincs proxy", + "Comma-separated hostnames/domains to bypass the proxy.": "Vesszővel elválasztott gazdanév/tartomány a proxy megkerüléséhez.", + "Test proxy URL": "Proxy URL-cím tesztelése", + "Proxy settings applied": "Proxy beállítások alkalmazva", + "Proxy enabled": "Proxy engedélyezve", + "Proxy disabled": "Proxy letiltva", + "Proxy test OK": "Proxy teszt OK", + "Proxy test failed": "Proxy teszt sikertelen", + "Please enter a Proxy URL to test": "Kérjük, adjon meg egy Proxy URL-t teszteléshez", + "Observability": "Megfigyelhetőség", + "Enable Observability": "Megfigyelhetőség engedélyezése", + "Turn request detail recording on/off globally": "Kérés részleteinak rögzítésének be/kikapcsolása globálisan", + "Max Records": "Maximális rekordok", + "Maximum request detail records to keep (older records are auto-deleted)": "Maximális kérés részleteit tartalmaz (a régebbi rekordok automatikusan törlődnek)", + "Batch Size": "Köteg mérete", + "Number of items to accumulate before writing to database (higher = better performance)": "Az adatbázisba írás előtt felhalmozandó elemek száma (magasabb = jobb teljesítmény)", + "Flush Interval (ms)": "Kiürítési intervallum (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Maximális várakozási idő a puffer kiürítése előtt (megelőzi az adatvesztést alacsony forgalom alatt)", + "Max JSON Size (KB)": "Maximális JSON méret (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Maximális méret az egyes JSON-mezőkhöz (kérés/válasz) a csonkítás előtt", + "All data stored on your machine": "Az összes adat a gépén tárolt", + "MITM Server": "MITM szerver", + "Running": "Futó", + "Stopped": "Leállítva", + "Cert": "Tanúsítvány", + "Server": "Kiszolgáló", + "Purpose:": "Cél:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Antigravity IDE és GitHub Copilot használata → az 9Router bármelyik szolgáltatójával/modelljével", + "How it works:": "Hogyan működik:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE kérés → DNS átirányítás a localhost:443-ra → MITM proxy elfogja → 9Router → válasz Antigravity/Copilot-nak", + "No API keys — create one in Keys page": "Nincsenek API-kulcsok — hozzon létre egyet a Keys oldalon", + "sk_9router (default)": "sk_9router (alapértelmezett)", + "Server started": "Szerver elindult", + "Failed to start server": "Nem sikerült elindítani a szervert", + "Server stopped — all DNS cleared": "A szerver leállt — az összes DNS törlésre kerül", + "Failed to stop server": "Nem sikerült leállítani a szervert", + "Sudo password is required": "Sudo jelszó szükséges", + "Stop Server": "Szerver leállítása", + "Start Server": "Szerver indítása", + "Enable DNS per tool below to activate interception": "Engedélyezze az alábbi DNS-t az elfogás aktiválásához", + "Sudo Password Required": "Sudo jelszó szükséges", + "Enter your sudo password to start/stop MITM server": "Adja meg sudo jelszavát a MITM szerver indításához/leállításához", + "Sudo Password": "Sudo jelszó", + "Click to add, click again to remove. Changes are saved automatically.": "Kattintson a hozzáadáshoz, kattintson újra az eltávolításhoz. A változtatások automatikusan mentésre kerülnek.", + "⚠️ 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.": "⚠️ Kockázati figyelmeztetés: Ez a szolgáltató olyan előfizetést/OAuth munkamenetet használ, amely hivatalosan nincs proxy/router használatra engedélyezve. A fiók korlátozható vagy letiltható. Saját felelősségre használja.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ A MITM elfogja az IDE eszközök (Antigravity, GitHub Copilot, Kiro) HTTPS forgalmát helyi CA-n keresztül, hogy átirányítsa a kéréseket a szolgáltatóidhoz. Megsértheti a ToS-t → fiók letiltási kockázat. Saját felelősségre használja.", + "Endpoint is exposed without an API key.": "A végpont API-kulcs nélkül van kitéve." +} diff --git a/public/i18n/literals/id.json b/public/i18n/literals/id.json new file mode 100644 index 0000000000000000000000000000000000000000..3e5097aa7d40787582763cfe26eb34dec79fa5a5 --- /dev/null +++ b/public/i18n/literals/id.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Batalkan", + "Delete": "Hapus", + "Edit": "Sunting", + "Save": "Simpan", + "Close": "Tutup", + "Add": "Tambah", + "Remove": "Hapus", + "Settings": "Pengaturan", + "Profile": "Profil", + "Dashboard": "Dasbor", + "Logout": "Keluar", + "Login": "Masuk", + "Providers": "Penyedia", + "Usage": "Statistik Penggunaan", + "API Key": "Kunci API", + "Connected": "Terhubung", + "Disconnected": "Terputus", + "Active": "Aktif", + "Inactive": "Nonaktif", + "Success": "Berhasil", + "Failed": "Gagal", + "Error": "Kesalahan", + "Warning": "Peringatan", + "Info": "Informasi", + "Loading": "Memuat", + "Search": "Cari", + "Filter": "Saring", + "Sort": "Urutkan", + "Export": "Ekspor", + "Import": "Impor", + "Refresh": "Segarkan", + "Back": "Kembali", + "Next": "Berikutnya", + "Previous": "Sebelumnya", + "Submit": "Kirim", + "Confirm": "Konfirmasi", + "Yes": "Ya", + "No": "Tidak", + "OK": "OK", + "Apply": "Terapkan", + "Reset": "Atur Ulang", + "Clear": "Hapus", + "Select": "Pilih", + "Upload": "Unggah", + "Download": "Unduh", + "Copy": "Salin", + "Paste": "Tempel", + "Cut": "Potong", + "Undo": "Batalkan", + "Redo": "Ulangi", + "Name": "Nama", + "Description": "Deskripsi", + "Status": "Status", + "Type": "Jenis", + "Date": "Tanggal", + "Time": "Waktu", + "Created": "Dibuat", + "Updated": "Diperbarui", + "Actions": "Tindakan", + "Details": "Detail", + "View": "Lihat", + "New": "Baru", + "Total": "Total", + "Count": "Jumlah", + "Price": "Harga", + "Cost": "Biaya", + "Free": "Gratis", + "Paid": "Berbayar", + "Enable": "Aktifkan", + "Disable": "Nonaktifkan", + "Enabled": "Diaktifkan", + "Disabled": "Dinonaktifkan", + "Online": "Daring", + "Offline": "Luring", + "Available": "Tersedia", + "Unavailable": "Tidak Tersedia", + "Required": "Diperlukan", + "Optional": "Opsional", + "Default": "Bawaan", + "Custom": "Kustom", + "Advanced": "Lanjutan", + "Basic": "Dasar", + "Help": "Bantuan", + "Support": "Dukungan", + "Documentation": "Dokumentasi", + "Version": "Versi", + "Language": "Bahasa", + "Theme": "Tema", + "Light": "Terang", + "Dark": "Gelap", + "Auto": "Otomatis", + "Endpoint": "Titik Akhir", + "Combos": "Kombinasi", + "Quota Tracker": "Pelacak Kuota", + "MITM": "MITM", + "CLI Tools": "Alat", + "Console Log": "Log Konsol", + "System": "Sistem", + "Debug": "Debug", + "Shutdown": "Matikan", + "Close Proxy": "Tutup Proxy", + "Are you sure you want to close the proxy server?": "Apakah Anda yakin ingin menutup server proxy?", + "Server Disconnected": "Server Terputus", + "The proxy server has been stopped.": "Server proxy telah dihentikan.", + "Reload Page": "Muat Ulang Halaman", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "Layanan sedang berjalan di terminal. Anda dapat menutup halaman web ini. Shutdown akan menghentikan layanan.", + "Manage your AI provider connections": "Kelola koneksi penyedia AI Anda", + "Model combos with fallback": "Kombinasi model dengan fallback", + "Monitor your API usage, token consumption, and request logs": "Pantau penggunaan API, konsumsi token, dan log permintaan Anda", + "Intercept CLI tool traffic and route through 9Router": "Intersep lalu lintas alat CLI dan rute melalui 9Router", + "Configure CLI tools": "Konfigurasi alat CLI", + "API endpoint configuration": "Konfigurasi titik akhir API", + "Manage your preferences": "Kelola preferensi Anda", + "Debug translation flow between formats": "Debug alur terjemahan antara format", + "Live server console output": "Output konsol server langsung", + "Create model combos with fallback support": "Buat kombinasi model dengan dukungan fallback", + "Local Mode": "Mode Lokal", + "Running on your machine": "Berjalan di mesin Anda", + "Database Location": "Lokasi Database", + "Download Backup": "Unduh Cadangan", + "Import Backup": "Impor Cadangan", + "Database backup downloaded": "Cadangan database telah diunduh", + "Database imported successfully": "Database berhasil diimpor", + "Security": "Keamanan", + "Require login": "Memerlukan Login", + "When ON, dashboard requires password. When OFF, access without login.": "Ketika AKTIF, dasbor memerlukan kata sandi. Ketika NONAKTIF, akses tanpa login.", + "Current Password": "Kata Sandi Saat Ini", + "Enter current password": "Masukkan kata sandi saat ini", + "New Password": "Kata Sandi Baru", + "Enter new password": "Masukkan kata sandi baru", + "Confirm New Password": "Konfirmasi Kata Sandi Baru", + "Confirm new password": "Konfirmasi kata sandi baru", + "Update Password": "Perbarui Kata Sandi", + "Set Password": "Atur Kata Sandi", + "Password updated successfully": "Kata sandi berhasil diperbarui", + "Passwords do not match": "Kata sandi tidak cocok", + "Routing Strategy": "Strategi Rute", + "Round Robin": "Putaran Bulat", + "Cycle through accounts to distribute load": "Siklus melalui akun untuk mendistribusikan beban", + "Sticky Limit": "Batas Lengket", + "Calls per account before switching": "Panggilan per akun sebelum beralih", + "Network": "Jaringan", + "Outbound Proxy": "Proxy Keluar", + "Enable proxy for OAuth + provider outbound requests.": "Aktifkan proxy untuk permintaan keluar OAuth + penyedia.", + "Proxy URL": "URL Proxy", + "Leave empty to inherit existing env proxy (if any).": "Biarkan kosong untuk mewarisi proxy env yang ada (jika ada).", + "No Proxy": "Tidak Ada Proxy", + "Comma-separated hostnames/domains to bypass the proxy.": "Nama host/domain yang dipisahkan koma untuk melewati proxy.", + "Test proxy URL": "Uji URL Proxy", + "Proxy settings applied": "Pengaturan proxy diterapkan", + "Proxy enabled": "Proxy diaktifkan", + "Proxy disabled": "Proxy dinonaktifkan", + "Proxy test OK": "Tes proxy OK", + "Proxy test failed": "Tes proxy gagal", + "Please enter a Proxy URL to test": "Masukkan URL Proxy untuk diuji", + "Observability": "Observabilitas", + "Enable Observability": "Aktifkan Observabilitas", + "Turn request detail recording on/off globally": "Aktifkan/nonaktifkan pencatatan detail permintaan secara global", + "Max Records": "Rekam Maksimal", + "Maximum request detail records to keep (older records are auto-deleted)": "Rekam detail permintaan maksimal untuk disimpan (rekam lama otomatis dihapus)", + "Batch Size": "Ukuran Batch", + "Number of items to accumulate before writing to database (higher = better performance)": "Jumlah item yang diakumulasikan sebelum menulis ke database (lebih tinggi = performa lebih baik)", + "Flush Interval (ms)": "Interval Flush (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Waktu maksimum untuk menunggu sebelum mem-flush buffer (mencegah kehilangan data saat lalu lintas rendah)", + "Max JSON Size (KB)": "Ukuran JSON Maks (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Ukuran maksimum untuk setiap bidang JSON (permintaan/respons) sebelum pemotongan", + "All data stored on your machine": "Semua data disimpan di mesin Anda", + "MITM Server": "Server MITM", + "Running": "Berjalan", + "Stopped": "Dihentikan", + "Cert": "Sertifikat", + "Server": "Server", + "Purpose:": "Tujuan:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Gunakan Antigravity IDE & GitHub Copilot → dengan PENYEDIA/model APA PUN dari 9Router", + "How it works:": "Cara kerjanya:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Permintaan Antigravity/Copilot IDE → Pengalihan DNS ke localhost:443 → Proxy MITM mengintersep → 9Router → respons ke Antigravity/Copilot", + "No API keys — create one in Keys page": "Tidak ada kunci API — buat satu di halaman Keys", + "sk_9router (default)": "sk_9router (bawaan)", + "Server started": "Server dimulai", + "Failed to start server": "Gagal memulai server", + "Server stopped — all DNS cleared": "Server dihentikan — semua DNS dihapus", + "Failed to stop server": "Gagal menghentikan server", + "Sudo password is required": "Kata sandi sudo diperlukan", + "Stop Server": "Hentikan Server", + "Start Server": "Mulai Server", + "Enable DNS per tool below to activate interception": "Aktifkan DNS untuk setiap alat di bawah untuk mengaktifkan intersepsi", + "Sudo Password Required": "Kata Sandi Sudo Diperlukan", + "Enter your sudo password to start/stop MITM server": "Masukkan kata sandi sudo Anda untuk memulai/menghentikan server MITM", + "Sudo Password": "Kata Sandi Sudo", + "Click to add, click again to remove. Changes are saved automatically.": "Klik untuk menambah, klik lagi untuk menghapus. Perubahan disimpan secara otomatis.", + "⚠️ 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.": "⚠️ Pemberitahuan Risiko: Penyedia ini menggunakan sesi langganan/OAuth yang tidak dilisensikan secara resmi untuk penggunaan proxy/router. Akun mungkin dibatasi atau diblokir. Gunakan dengan risiko Anda sendiri.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM mencegat lalu lintas HTTPS alat IDE (Antigravity, GitHub Copilot, Kiro) melalui CA lokal untuk mengalihkan permintaan ke penyedia Anda. Mungkin melanggar ToS → risiko ban akun. Gunakan dengan risiko Anda sendiri.", + "Endpoint is exposed without an API key.": "Endpoint terekspos tanpa kunci API." +} diff --git a/public/i18n/literals/it.json b/public/i18n/literals/it.json new file mode 100644 index 0000000000000000000000000000000000000000..7f684e8344a785861c5bed69858703a7670f92d5 --- /dev/null +++ b/public/i18n/literals/it.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Annulla", + "Delete": "Elimina", + "Edit": "Modifica", + "Save": "Salva", + "Close": "Chiudi", + "Add": "Aggiungi", + "Remove": "Rimuovi", + "Settings": "Impostazioni", + "Profile": "Profilo", + "Dashboard": "Pannello di controllo", + "Logout": "Esci", + "Login": "Accedi", + "Providers": "Provider", + "Usage": "Statistiche di utilizzo", + "API Key": "Chiave API", + "Connected": "Connesso", + "Disconnected": "Disconnesso", + "Active": "Attivo", + "Inactive": "Inattivo", + "Success": "Successo", + "Failed": "Non riuscito", + "Error": "Errore", + "Warning": "Avvertenza", + "Info": "Informazioni", + "Loading": "Caricamento", + "Search": "Cerca", + "Filter": "Filtro", + "Sort": "Ordina", + "Export": "Esporta", + "Import": "Importa", + "Refresh": "Aggiorna", + "Back": "Indietro", + "Next": "Avanti", + "Previous": "Precedente", + "Submit": "Invia", + "Confirm": "Conferma", + "Yes": "Sì", + "No": "No", + "OK": "OK", + "Apply": "Applica", + "Reset": "Ripristina", + "Clear": "Cancella", + "Select": "Seleziona", + "Upload": "Carica", + "Download": "Scarica", + "Copy": "Copia", + "Paste": "Incolla", + "Cut": "Taglia", + "Undo": "Annulla", + "Redo": "Ripeti", + "Name": "Nome", + "Description": "Descrizione", + "Status": "Stato", + "Type": "Tipo", + "Date": "Data", + "Time": "Ora", + "Created": "Creato", + "Updated": "Aggiornato", + "Actions": "Azioni", + "Details": "Dettagli", + "View": "Visualizza", + "New": "Nuovo", + "Total": "Totale", + "Count": "Conteggio", + "Price": "Prezzo", + "Cost": "Costo", + "Free": "Gratuito", + "Paid": "A pagamento", + "Enable": "Abilita", + "Disable": "Disabilita", + "Enabled": "Abilitato", + "Disabled": "Disabilitato", + "Online": "Online", + "Offline": "Offline", + "Available": "Disponibile", + "Unavailable": "Non disponibile", + "Required": "Obbligatorio", + "Optional": "Opzionale", + "Default": "Predefinito", + "Custom": "Personalizzato", + "Advanced": "Avanzate", + "Basic": "Di base", + "Help": "Aiuto", + "Support": "Supporto", + "Documentation": "Documentazione", + "Version": "Versione", + "Language": "Lingua", + "Theme": "Tema", + "Light": "Chiaro", + "Dark": "Scuro", + "Auto": "Automatico", + "Endpoint": "Endpoint", + "Combos": "Combinazioni", + "Quota Tracker": "Tracker quota", + "MITM": "MITM", + "CLI Tools": "Strumenti", + "Console Log": "Log della console", + "System": "Sistema", + "Debug": "Debug", + "Shutdown": "Spegni", + "Close Proxy": "Chiudi proxy", + "Are you sure you want to close the proxy server?": "Sei sicuro di voler chiudere il server proxy?", + "Server Disconnected": "Server disconnesso", + "The proxy server has been stopped.": "Il server proxy è stato arrestato.", + "Reload Page": "Ricarica pagina", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "Il servizio è in esecuzione nel terminale. Puoi chiudere questa pagina web. Lo spegnimento fermerà il servizio.", + "Manage your AI provider connections": "Gestisci le connessioni del tuo provider AI", + "Model combos with fallback": "Combinazioni di modelli con fallback", + "Monitor your API usage, token consumption, and request logs": "Monitora l'utilizzo dell'API, il consumo di token e i log delle richieste", + "Intercept CLI tool traffic and route through 9Router": "Intercetta il traffico dello strumento CLI e instradalo attraverso 9Router", + "Configure CLI tools": "Configura gli strumenti CLI", + "API endpoint configuration": "Configurazione dell'endpoint API", + "Manage your preferences": "Gestisci le tue preferenze", + "Debug translation flow between formats": "Debug del flusso di traduzione tra i formati", + "Live server console output": "Output della console del server in tempo reale", + "Create model combos with fallback support": "Crea combinazioni di modelli con supporto fallback", + "Local Mode": "Modalità locale", + "Running on your machine": "In esecuzione sulla tua macchina", + "Database Location": "Posizione del database", + "Download Backup": "Scarica backup", + "Import Backup": "Importa backup", + "Database backup downloaded": "Backup del database scaricato", + "Database imported successfully": "Database importato con successo", + "Security": "Sicurezza", + "Require login": "Richiedi accesso", + "When ON, dashboard requires password. When OFF, access without login.": "Quando ATTIVO, il pannello di controllo richiede la password. Quando SPENTO, accedi senza login.", + "Current Password": "Password attuale", + "Enter current password": "Inserisci la password attuale", + "New Password": "Nuova password", + "Enter new password": "Inserisci la nuova password", + "Confirm New Password": "Conferma nuova password", + "Confirm new password": "Conferma la nuova password", + "Update Password": "Aggiorna password", + "Set Password": "Imposta password", + "Password updated successfully": "Password aggiornata con successo", + "Passwords do not match": "Le password non corrispondono", + "Routing Strategy": "Strategia di routing", + "Round Robin": "Round robin", + "Cycle through accounts to distribute load": "Scorri gli account per distribuire il carico", + "Sticky Limit": "Limite appiccicoso", + "Calls per account before switching": "Chiamate per account prima del passaggio", + "Network": "Rete", + "Outbound Proxy": "Proxy in uscita", + "Enable proxy for OAuth + provider outbound requests.": "Abilita il proxy per le richieste in uscita OAuth + provider.", + "Proxy URL": "URL proxy", + "Leave empty to inherit existing env proxy (if any).": "Lascia vuoto per ereditare il proxy env esistente (se presente).", + "No Proxy": "Nessun proxy", + "Comma-separated hostnames/domains to bypass the proxy.": "Nomi host/domini separati da virgole per ignorare il proxy.", + "Test proxy URL": "Test URL proxy", + "Proxy settings applied": "Impostazioni proxy applicate", + "Proxy enabled": "Proxy abilitato", + "Proxy disabled": "Proxy disabilitato", + "Proxy test OK": "Test proxy OK", + "Proxy test failed": "Test proxy non riuscito", + "Please enter a Proxy URL to test": "Inserisci un URL proxy da testare", + "Observability": "Osservabilità", + "Enable Observability": "Abilita osservabilità", + "Turn request detail recording on/off globally": "Attiva/disattiva la registrazione dei dettagli della richiesta globalmente", + "Max Records": "Record massimi", + "Maximum request detail records to keep (older records are auto-deleted)": "Record di dettagli della richiesta massimi da mantenere (i record più vecchi vengono eliminati automaticamente)", + "Batch Size": "Dimensione batch", + "Number of items to accumulate before writing to database (higher = better performance)": "Numero di elementi da accumulare prima di scrivere nel database (più alto = migliori prestazioni)", + "Flush Interval (ms)": "Intervallo di scaricamento (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Tempo massimo di attesa prima dello scaricamento del buffer (previene la perdita di dati durante il traffico basso)", + "Max JSON Size (KB)": "Dimensione JSON massima (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Dimensione massima per ogni campo JSON (richiesta/risposta) prima del troncamento", + "All data stored on your machine": "Tutti i dati memorizzati sulla tua macchina", + "MITM Server": "Server MITM", + "Running": "In esecuzione", + "Stopped": "Arrestato", + "Cert": "Certificato", + "Server": "Server", + "Purpose:": "Scopo:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Usa Antigravity IDE & GitHub Copilot → con QUALSIASI provider/modello da 9Router", + "How it works:": "Come funziona:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Richiesta Antigravity/Copilot IDE → Reindirizzamento DNS a localhost:443 → Il proxy MITM intercetta → 9Router → Risposta a Antigravity/Copilot", + "No API keys — create one in Keys page": "Nessuna chiave API — crearne una nella pagina Chiavi", + "sk_9router (default)": "sk_9router (predefinito)", + "Server started": "Server avviato", + "Failed to start server": "Impossibile avviare il server", + "Server stopped — all DNS cleared": "Server arrestato — tutti i DNS cancellati", + "Failed to stop server": "Impossibile arrestare il server", + "Sudo password is required": "La password sudo è obbligatoria", + "Stop Server": "Arresta server", + "Start Server": "Avvia server", + "Enable DNS per tool below to activate interception": "Abilita DNS per ogni strumento sottostante per attivare l'intercettazione", + "Sudo Password Required": "Password Sudo richiesta", + "Enter your sudo password to start/stop MITM server": "Inserisci la tua password sudo per avviare/arrestare il server MITM", + "Sudo Password": "Password Sudo", + "Click to add, click again to remove. Changes are saved automatically.": "Clicca per aggiungere, clicca di nuovo per rimuovere. Le modifiche vengono salvate automaticamente.", + "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ Avviso di Rischio: Questo provider utilizza una sessione abbonamento/OAuth non ufficialmente autorizzata per l'uso proxy/router. L'account potrebbe essere limitato o bannato. Usa a tuo rischio.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM intercetta il traffico HTTPS degli strumenti IDE (Antigravity, GitHub Copilot, Kiro) tramite CA locale per reindirizzare le richieste ai tuoi provider. Può violare i ToS → rischio ban account. Usa a tuo rischio.", + "Endpoint is exposed without an API key.": "L'endpoint è esposto senza una chiave API." +} diff --git a/public/i18n/literals/ja.json b/public/i18n/literals/ja.json new file mode 100644 index 0000000000000000000000000000000000000000..e448c685fbad3973b611d6c46e49ba2226dac001 --- /dev/null +++ b/public/i18n/literals/ja.json @@ -0,0 +1,195 @@ +{ + "Cancel": "キャンセル", + "Delete": "削除", + "Edit": "編集", + "Save": "保存", + "Close": "閉じる", + "Add": "追加", + "Remove": "削除", + "Settings": "設定", + "Profile": "プロフィール", + "Dashboard": "ダッシュボード", + "Logout": "ログアウト", + "Login": "ログイン", + "Providers": "プロバイダー", + "Usage": "統計", + "API Key": "APIキー", + "Connected": "接続済み", + "Disconnected": "未接続", + "Active": "アクティブ", + "Inactive": "非アクティブ", + "Success": "成功", + "Failed": "失敗", + "Error": "エラー", + "Warning": "警告", + "Info": "情報", + "Loading": "読み込み中", + "Search": "検索", + "Filter": "フィルター", + "Sort": "並べ替え", + "Export": "エクスポート", + "Import": "インポート", + "Refresh": "更新", + "Back": "戻る", + "Next": "次へ", + "Previous": "前へ", + "Submit": "送信", + "Confirm": "確認", + "Yes": "はい", + "No": "いいえ", + "OK": "OK", + "Apply": "適用", + "Reset": "リセット", + "Clear": "クリア", + "Select": "選択", + "Upload": "アップロード", + "Download": "ダウンロード", + "Copy": "コピー", + "Paste": "貼り付け", + "Cut": "切り取り", + "Undo": "元に戻す", + "Redo": "やり直す", + "Name": "名前", + "Description": "説明", + "Status": "ステータス", + "Type": "タイプ", + "Date": "日付", + "Time": "時間", + "Created": "作成済み", + "Updated": "更新済み", + "Actions": "アクション", + "Details": "詳細", + "View": "表示", + "New": "新規", + "Total": "合計", + "Count": "カウント", + "Price": "価格", + "Cost": "コスト", + "Free": "無料", + "Paid": "有料", + "Enable": "有効", + "Disable": "無効", + "Enabled": "有効化済み", + "Disabled": "無効化済み", + "Online": "オンライン", + "Offline": "オフライン", + "Available": "利用可能", + "Unavailable": "利用不可", + "Required": "必須", + "Optional": "オプション", + "Default": "デフォルト", + "Custom": "カスタム", + "Advanced": "詳細", + "Basic": "基本", + "Help": "ヘルプ", + "Support": "サポート", + "Documentation": "ドキュメント", + "Version": "バージョン", + "Language": "言語", + "Theme": "テーマ", + "Light": "ライト", + "Dark": "ダーク", + "Auto": "自動", + "Endpoint": "エンドポイント", + "Combos": "コンボ", + "Quota Tracker": "クォータトラッカー", + "MITM": "MITM", + "CLI Tools": "CLIツール", + "Console Log": "コンソールログ", + "System": "システム", + "Debug": "デバッグ", + "Shutdown": "シャットダウン", + "Close Proxy": "プロキシを閉じる", + "Are you sure you want to close the proxy server?": "プロキシサーバーを閉じてもよろしいですか?", + "Server Disconnected": "サーバーが切断されました", + "The proxy server has been stopped.": "プロキシサーバーが停止しました。", + "Reload Page": "ページを再読み込み", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "サービスはターミナルで実行されています。このウェブページを閉じることができます。シャットダウンはサービスを停止します。", + "Manage your AI provider connections": "AIプロバイダーの接続を管理する", + "Model combos with fallback": "フォールバック付きモデルコンボ", + "Monitor your API usage, token consumption, and request logs": "APIの使用状況、トークン消費量、およびリクエストログを監視する", + "Intercept CLI tool traffic and route through 9Router": "CLIツールのトラフィックをインターセプトし、9Routerを通じてルーティングする", + "Configure CLI tools": "CLIツールを構成", + "API endpoint configuration": "APIエンドポイント構成", + "Manage your preferences": "設定を管理する", + "Debug translation flow between formats": "形式間の翻訳フローをデバッグ", + "Live server console output": "ライブサーバーコンソール出力", + "Create model combos with fallback support": "フォールバックサポート付きモデルコンボを作成", + "Local Mode": "ローカルモード", + "Running on your machine": "お使いのマシンで実行中", + "Database Location": "データベースの場所", + "Download Backup": "バックアップをダウンロード", + "Import Backup": "バックアップをインポート", + "Database backup downloaded": "データベースバックアップがダウンロードされました", + "Database imported successfully": "データベースが正常にインポートされました", + "Security": "セキュリティ", + "Require login": "ログインが必要", + "When ON, dashboard requires password. When OFF, access without login.": "ON の場合、ダッシュボードはパスワードが必要です。OFF の場合、ログインなしでアクセスできます。", + "Current Password": "現在のパスワード", + "Enter current password": "現在のパスワードを入力", + "New Password": "新しいパスワード", + "Enter new password": "新しいパスワードを入力", + "Confirm New Password": "新しいパスワードを確認", + "Confirm new password": "新しいパスワードを確認", + "Update Password": "パスワードを更新", + "Set Password": "パスワードを設定", + "Password updated successfully": "パスワードが正常に更新されました", + "Passwords do not match": "パスワードが一致しません", + "Routing Strategy": "ルーティング戦略", + "Round Robin": "ラウンドロビン", + "Cycle through accounts to distribute load": "アカウント間を循環してロードを分散", + "Sticky Limit": "スティッキーリミット", + "Calls per account before switching": "切り替え前のアカウントごとのコール数", + "Network": "ネットワーク", + "Outbound Proxy": "アウトバウンドプロキシ", + "Enable proxy for OAuth + provider outbound requests.": "OAuth + プロバイダーアウトバウンドリクエストのプロキシを有効にします。", + "Proxy URL": "プロキシURL", + "Leave empty to inherit existing env proxy (if any).": "既存の環境プロキシを継承するには空のままにしてください。", + "No Proxy": "プロキシなし", + "Comma-separated hostnames/domains to bypass the proxy.": "プロキシをバイパスするためのコンマ区切りのホスト名/ドメイン。", + "Test proxy URL": "プロキシURLをテスト", + "Proxy settings applied": "プロキシ設定が適用されました", + "Proxy enabled": "プロキシが有効になりました", + "Proxy disabled": "プロキシが無効になりました", + "Proxy test OK": "プロキシテストOK", + "Proxy test failed": "プロキシテストが失敗しました", + "Please enter a Proxy URL to test": "テストするプロキシURLを入力してください", + "Observability": "可観測性", + "Enable Observability": "可観測性を有効にする", + "Turn request detail recording on/off globally": "リクエスト詳細記録をグローバルにオン/オフにします", + "Max Records": "最大レコード数", + "Maximum request detail records to keep (older records are auto-deleted)": "保持するリクエスト詳細レコードの最大数(古いレコードは自動削除されます)", + "Batch Size": "バッチサイズ", + "Number of items to accumulate before writing to database (higher = better performance)": "データベースに書き込む前に蓄積するアイテム数(高い = パフォーマンス向上)", + "Flush Interval (ms)": "フラッシュ間隔 (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "バッファをフラッシュする前の最大待機時間(低トラフィック中のデータ損失を防ぎます)", + "Max JSON Size (KB)": "最大JSON サイズ (KB)", + "Maximum size for each JSON field (request/response) before truncation": "切り詰め前の各JSONフィールド(リクエスト/レスポンス)の最大サイズ", + "All data stored on your machine": "すべてのデータがお使いのマシンに保存されます", + "MITM Server": "MITMサーバー", + "Running": "実行中", + "Stopped": "停止済み", + "Cert": "証明書", + "Server": "サーバー", + "Purpose:": "目的:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Antigravity IDE & GitHub Copilot → 9Router の任意のプロバイダー/モデルを使用", + "How it works:": "しくみ:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE リクエスト → localhost:443 への DNS リダイレクト → MITM プロキシが傍受 → 9Router → Antigravity/Copilot への応答", + "No API keys — create one in Keys page": "APIキーがありません — キーページで1つ作成してください", + "sk_9router (default)": "sk_9router(デフォルト)", + "Server started": "サーバーが開始されました", + "Failed to start server": "サーバーの開始に失敗しました", + "Server stopped — all DNS cleared": "サーバーが停止しました — すべてのDNSがクリアされました", + "Failed to stop server": "サーバーの停止に失敗しました", + "Sudo password is required": "Sudoパスワードが必要です", + "Stop Server": "サーバーを停止", + "Start Server": "サーバーを開始", + "Enable DNS per tool below to activate interception": "以下の各ツールに対してDNSを有効にして、傍受をアクティブにします", + "Sudo Password Required": "Sudoパスワードが必要です", + "Enter your sudo password to start/stop MITM server": "MITMサーバーを開始/停止するには、sudoパスワードを入力してください", + "Sudo Password": "Sudoパスワード", + "Click to add, click again to remove. Changes are saved automatically.": "クリックで追加、もう一度クリックで削除。変更は自動的に保存されます。", + "⚠️ 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.": "⚠️ リスク通知: このプロバイダーは、プロキシ/ルーター使用について公式にライセンスされていないサブスクリプション/OAuthセッションを使用しています。アカウントが制限または禁止される可能性があります。自己責任でご使用ください。", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITMはローカルCAを介してIDEツール (Antigravity, GitHub Copilot, Kiro) のHTTPSトラフィックを傍受し、リクエストをプロバイダーにリダイレクトします。ToS違反の可能性 → アカウントBANリスク。自己責任でご使用ください。", + "Endpoint is exposed without an API key.": "API キーなしでエンドポイントが公開されています。" +} diff --git a/public/i18n/literals/ko.json b/public/i18n/literals/ko.json new file mode 100644 index 0000000000000000000000000000000000000000..1edb094ebfd69eb41caef283f1f060c82db37e61 --- /dev/null +++ b/public/i18n/literals/ko.json @@ -0,0 +1,195 @@ +{ + "Cancel": "취소", + "Delete": "삭제", + "Edit": "편집", + "Save": "저장", + "Close": "닫기", + "Add": "추가", + "Remove": "제거", + "Settings": "설정", + "Profile": "프로필", + "Dashboard": "대시보드", + "Logout": "로그아웃", + "Login": "로그인", + "Providers": "제공자", + "Usage": "통계", + "API Key": "API 키", + "Connected": "연결됨", + "Disconnected": "연결 해제됨", + "Active": "활성", + "Inactive": "비활성", + "Success": "성공", + "Failed": "실패", + "Error": "오류", + "Warning": "경고", + "Info": "정보", + "Loading": "로딩 중", + "Search": "검색", + "Filter": "필터", + "Sort": "정렬", + "Export": "내보내기", + "Import": "가져오기", + "Refresh": "새로고침", + "Back": "뒤로", + "Next": "다음", + "Previous": "이전", + "Submit": "제출", + "Confirm": "확인", + "Yes": "예", + "No": "아니오", + "OK": "확인", + "Apply": "적용", + "Reset": "재설정", + "Clear": "지우기", + "Select": "선택", + "Upload": "업로드", + "Download": "다운로드", + "Copy": "복사", + "Paste": "붙여넣기", + "Cut": "잘라내기", + "Undo": "실행 취소", + "Redo": "다시 실행", + "Name": "이름", + "Description": "설명", + "Status": "상태", + "Type": "유형", + "Date": "날짜", + "Time": "시간", + "Created": "생성됨", + "Updated": "업데이트됨", + "Actions": "작업", + "Details": "세부정보", + "View": "보기", + "New": "새로 만들기", + "Total": "합계", + "Count": "개수", + "Price": "가격", + "Cost": "비용", + "Free": "무료", + "Paid": "유료", + "Enable": "활성화", + "Disable": "비활성화", + "Enabled": "활성화됨", + "Disabled": "비활성화됨", + "Online": "온라인", + "Offline": "오프라인", + "Available": "사용 가능", + "Unavailable": "사용 불가", + "Required": "필수", + "Optional": "선택사항", + "Default": "기본값", + "Custom": "사용자 지정", + "Advanced": "고급", + "Basic": "기본", + "Help": "도움말", + "Support": "지원", + "Documentation": "설명서", + "Version": "버전", + "Language": "언어", + "Theme": "테마", + "Light": "밝음", + "Dark": "어두움", + "Auto": "자동", + "Endpoint": "엔드포인트", + "Combos": "조합", + "Quota Tracker": "할당량 추적", + "MITM": "MITM", + "CLI Tools": "CLI 도구", + "Console Log": "콘솔 로그", + "System": "시스템", + "Debug": "디버깅", + "Shutdown": "종료", + "Close Proxy": "프록시 닫기", + "Are you sure you want to close the proxy server?": "프록시 서버를 닫으시겠습니까?", + "Server Disconnected": "서버 연결 해제됨", + "The proxy server has been stopped.": "프록시 서버가 중지되었습니다.", + "Reload Page": "페이지 다시 로드", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "서비스가 터미널에서 실행 중입니다. 이 웹 페이지를 닫을 수 있습니다. 종료하면 서비스가 중지됩니다.", + "Manage your AI provider connections": "AI 제공자 연결 관리", + "Model combos with fallback": "폴백 기능이 있는 모델 조합", + "Monitor your API usage, token consumption, and request logs": "API 사용, 토큰 소비 및 요청 로그 모니터링", + "Intercept CLI tool traffic and route through 9Router": "CLI 도구 트래픽을 가로채고 9Router를 통해 라우팅", + "Configure CLI tools": "CLI 도구 구성", + "API endpoint configuration": "API 엔드포인트 구성", + "Manage your preferences": "기본 설정 관리", + "Debug translation flow between formats": "형식 간 변환 흐름 디버깅", + "Live server console output": "라이브 서버 콘솔 출력", + "Create model combos with fallback support": "폴백 지원이 있는 모델 조합 만들기", + "Local Mode": "로컬 모드", + "Running on your machine": "컴퓨터에서 실행 중", + "Database Location": "데이터베이스 위치", + "Download Backup": "백업 다운로드", + "Import Backup": "백업 가져오기", + "Database backup downloaded": "데이터베이스 백업 다운로드됨", + "Database imported successfully": "데이터베이스를 성공적으로 가져왔습니다", + "Security": "보안", + "Require login": "로그인 필요", + "When ON, dashboard requires password. When OFF, access without login.": "ON일 때 대시보드에서 암호가 필요합니다. OFF일 때 로그인 없이 접근할 수 있습니다.", + "Current Password": "현재 암호", + "Enter current password": "현재 암호 입력", + "New Password": "새 암호", + "Enter new password": "새 암호 입력", + "Confirm New Password": "새 암호 확인", + "Confirm new password": "새 암호 확인", + "Update Password": "암호 업데이트", + "Set Password": "암호 설정", + "Password updated successfully": "암호가 성공적으로 업데이트되었습니다", + "Passwords do not match": "암호가 일치하지 않습니다", + "Routing Strategy": "라우팅 전략", + "Round Robin": "라운드 로빈", + "Cycle through accounts to distribute load": "계정을 순환하여 로드 분산", + "Sticky Limit": "고정 제한", + "Calls per account before switching": "전환 전 계정당 호출 수", + "Network": "네트워크", + "Outbound Proxy": "아웃바운드 프록시", + "Enable proxy for OAuth + provider outbound requests.": "OAuth + 제공자 아웃바운드 요청에 대한 프록시를 활성화합니다.", + "Proxy URL": "프록시 URL", + "Leave empty to inherit existing env proxy (if any).": "기존 환경 프록시를 상속받으려면 비워두세요.", + "No Proxy": "프록시 없음", + "Comma-separated hostnames/domains to bypass the proxy.": "프록시를 우회하기 위한 쉼표로 구분된 호스트명/도메인입니다.", + "Test proxy URL": "프록시 URL 테스트", + "Proxy settings applied": "프록시 설정이 적용되었습니다", + "Proxy enabled": "프록시 활성화됨", + "Proxy disabled": "프록시 비활성화됨", + "Proxy test OK": "프록시 테스트 성공", + "Proxy test failed": "프록시 테스트 실패", + "Please enter a Proxy URL to test": "테스트할 프록시 URL을 입력하세요", + "Observability": "관찰 가능성", + "Enable Observability": "관찰 가능성 활성화", + "Turn request detail recording on/off globally": "요청 세부 정보 기록을 전역적으로 켜기/끄기", + "Max Records": "최대 레코드", + "Maximum request detail records to keep (older records are auto-deleted)": "보관할 최대 요청 세부 정보 레코드(오래된 레코드는 자동 삭제됨)", + "Batch Size": "배치 크기", + "Number of items to accumulate before writing to database (higher = better performance)": "데이터베이스에 쓰기 전에 누적할 항목 수(높을수록 더 나은 성능)", + "Flush Interval (ms)": "플러시 간격 (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "버퍼를 플러시하기 전 최대 대기 시간(낮은 트래픽 중 데이터 손실 방지)", + "Max JSON Size (KB)": "최대 JSON 크기 (KB)", + "Maximum size for each JSON field (request/response) before truncation": "자르기 전 각 JSON 필드(요청/응답)의 최대 크기", + "All data stored on your machine": "모든 데이터가 컴퓨터에 저장됨", + "MITM Server": "MITM 서버", + "Running": "실행 중", + "Stopped": "중지됨", + "Cert": "인증서", + "Server": "서버", + "Purpose:": "목적:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Antigravity IDE 및 GitHub Copilot 사용 → 9Router의 모든 제공자/모델과 함께", + "How it works:": "작동 방식:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE 요청 → localhost:443로 DNS 리디렉션 → MITM 프록시가 가로챔 → 9Router → Antigravity/Copilot으로 응답", + "No API keys — create one in Keys page": "API 키 없음 — 키 페이지에서 만들기", + "sk_9router (default)": "sk_9router (기본값)", + "Server started": "서버 시작됨", + "Failed to start server": "서버 시작 실패", + "Server stopped — all DNS cleared": "서버 중지됨 — 모든 DNS 지워짐", + "Failed to stop server": "서버 중지 실패", + "Sudo password is required": "Sudo 암호가 필요합니다", + "Stop Server": "서버 중지", + "Start Server": "서버 시작", + "Enable DNS per tool below to activate interception": "아래 각 도구에 대해 DNS를 활성화하여 가로채기 활성화", + "Sudo Password Required": "Sudo 암호 필요", + "Enter your sudo password to start/stop MITM server": "MITM 서버를 시작/중지하려면 sudo 암호를 입력하세요", + "Sudo Password": "Sudo 암호", + "Click to add, click again to remove. Changes are saved automatically.": "클릭하여 추가, 다시 클릭하여 제거. 변경 사항은 자동으로 저장됩니다.", + "⚠️ 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.": "⚠️ 위험 알림: 이 공급자는 프록시/라우터 사용에 대해 공식적으로 라이선스가 부여되지 않은 구독/OAuth 세션을 사용합니다. 계정이 제한되거나 차단될 수 있습니다. 사용에 대한 책임은 본인에게 있습니다.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM은 로컬 CA를 통해 IDE 도구(Antigravity, GitHub Copilot, Kiro)의 HTTPS 트래픽을 가로채 요청을 공급자로 리다이렉트합니다. ToS 위반 가능성 → 계정 차단 위험. 사용에 대한 책임은 본인에게 있습니다.", + "Endpoint is exposed without an API key.": "API 키 없이 엔드포인트가 노출되어 있습니다." +} diff --git a/public/i18n/literals/nl.json b/public/i18n/literals/nl.json new file mode 100644 index 0000000000000000000000000000000000000000..2eab85baf6debc755ef0a71469b6b3c1889440c9 --- /dev/null +++ b/public/i18n/literals/nl.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Annuleren", + "Delete": "Verwijderen", + "Edit": "Bewerken", + "Save": "Opslaan", + "Close": "Sluiten", + "Add": "Toevoegen", + "Remove": "Verwijderen", + "Settings": "Instellingen", + "Profile": "Profiel", + "Dashboard": "Dashboard", + "Logout": "Afmelden", + "Login": "Aanmelden", + "Providers": "Providers", + "Usage": "Statistieken", + "API Key": "API-sleutel", + "Connected": "Verbonden", + "Disconnected": "Verbroken", + "Active": "Actief", + "Inactive": "Inactief", + "Success": "Succes", + "Failed": "Mislukt", + "Error": "Fout", + "Warning": "Waarschuwing", + "Info": "Info", + "Loading": "Laden", + "Search": "Zoeken", + "Filter": "Filteren", + "Sort": "Sorteren", + "Export": "Exporteren", + "Import": "Importeren", + "Refresh": "Vernieuwen", + "Back": "Terug", + "Next": "Volgende", + "Previous": "Vorige", + "Submit": "Verzenden", + "Confirm": "Bevestigen", + "Yes": "Ja", + "No": "Nee", + "OK": "OK", + "Apply": "Toepassen", + "Reset": "Opnieuw instellen", + "Clear": "Wissen", + "Select": "Selecteren", + "Upload": "Uploaden", + "Download": "Downloaden", + "Copy": "Kopieëren", + "Paste": "Plakken", + "Cut": "Knippen", + "Undo": "Ongedaan maken", + "Redo": "Opnieuw uitvoeren", + "Name": "Naam", + "Description": "Beschrijving", + "Status": "Status", + "Type": "Type", + "Date": "Datum", + "Time": "Tijd", + "Created": "Gemaakt", + "Updated": "Bijgewerkt", + "Actions": "Acties", + "Details": "Details", + "View": "Weergeven", + "New": "Nieuw", + "Total": "Totaal", + "Count": "Aantal", + "Price": "Prijs", + "Cost": "Kosten", + "Free": "Gratis", + "Paid": "Betaald", + "Enable": "Inschakelen", + "Disable": "Uitschakelen", + "Enabled": "Ingeschakeld", + "Disabled": "Uitgeschakeld", + "Online": "Online", + "Offline": "Offline", + "Available": "Beschikbaar", + "Unavailable": "Niet beschikbaar", + "Required": "Verplicht", + "Optional": "Optioneel", + "Default": "Standaard", + "Custom": "Aangepast", + "Advanced": "Geavanceerd", + "Basic": "Basis", + "Help": "Help", + "Support": "Ondersteuning", + "Documentation": "Documentatie", + "Version": "Versie", + "Language": "Taal", + "Theme": "Thema", + "Light": "Licht", + "Dark": "Donker", + "Auto": "Automatisch", + "Endpoint": "Eindpunt", + "Combos": "Combinaties", + "Quota Tracker": "Quotabijhouder", + "MITM": "MITM", + "CLI Tools": "CLI-tools", + "Console Log": "Consolenlogboek", + "System": "Systeem", + "Debug": "Foutopsporing", + "Shutdown": "Afsluiten", + "Close Proxy": "Proxy sluiten", + "Are you sure you want to close the proxy server?": "Weet u zeker dat u de proxyserver wilt sluiten?", + "Server Disconnected": "Server verbroken", + "The proxy server has been stopped.": "De proxyserver is gestopt.", + "Reload Page": "Pagina vernieuwen", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "Service wordt uitgevoerd in terminal. U kunt deze webpagina sluiten. Afsluiten stopt de service.", + "Manage your AI provider connections": "Beheer uw AI-providerverbindingen", + "Model combos with fallback": "Modelcombinaties met fallback", + "Monitor your API usage, token consumption, and request logs": "Controleer uw API-gebruik, tokenverbruik en aanvraaglogboeken", + "Intercept CLI tool traffic and route through 9Router": "Onderschep CLI-toolverkeer en stuur het via 9Router", + "Configure CLI tools": "CLI-tools configureren", + "API endpoint configuration": "Configuratie van API-eindpunt", + "Manage your preferences": "Beheer uw voorkeuren", + "Debug translation flow between formats": "Debug de vertaalstroom tussen indelingen", + "Live server console output": "Live-serverconsoluitvoer", + "Create model combos with fallback support": "Maak modelcombinaties met fallback-ondersteuning", + "Local Mode": "Lokale modus", + "Running on your machine": "Actief op uw machine", + "Database Location": "Databaselocatie", + "Download Backup": "Backup downloaden", + "Import Backup": "Backup importeren", + "Database backup downloaded": "Databaseback-up gedownload", + "Database imported successfully": "Database succesvol geïmporteerd", + "Security": "Beveiliging", + "Require login": "Aanmelden vereist", + "When ON, dashboard requires password. When OFF, access without login.": "Wanneer AAN, vereist dashboard een wachtwoord. Wanneer UIT, toegang zonder aanmelden.", + "Current Password": "Huidig wachtwoord", + "Enter current password": "Voer het huidige wachtwoord in", + "New Password": "Nieuw wachtwoord", + "Enter new password": "Voer een nieuw wachtwoord in", + "Confirm New Password": "Nieuw wachtwoord bevestigen", + "Confirm new password": "Bevestig het nieuwe wachtwoord", + "Update Password": "Wachtwoord bijwerken", + "Set Password": "Wachtwoord instellen", + "Password updated successfully": "Wachtwoord succesvol bijgewerkt", + "Passwords do not match": "Wachtwoorden komen niet overeen", + "Routing Strategy": "Routeringsstrategie", + "Round Robin": "Round Robin", + "Cycle through accounts to distribute load": "Wisselen tussen accounts om belasting te verdelen", + "Sticky Limit": "Plakkerige limiet", + "Calls per account before switching": "Oproepen per account voordat u overschakelt", + "Network": "Netwerk", + "Outbound Proxy": "Uitgaande proxy", + "Enable proxy for OAuth + provider outbound requests.": "Schakel proxy in voor OAuth + uitgaande verzoeken van provider.", + "Proxy URL": "Proxy-URL", + "Leave empty to inherit existing env proxy (if any).": "Laat leeg om bestaande env-proxy over te nemen (indien aanwezig).", + "No Proxy": "Geen proxy", + "Comma-separated hostnames/domains to bypass the proxy.": "Door komma's gescheiden hostnamen/domeinen om de proxy te omzeilen.", + "Test proxy URL": "Proxy-URL testen", + "Proxy settings applied": "Proxy-instellingen toegepast", + "Proxy enabled": "Proxy ingeschakeld", + "Proxy disabled": "Proxy uitgeschakeld", + "Proxy test OK": "Proxy-test OK", + "Proxy test failed": "Proxy-test mislukt", + "Please enter a Proxy URL to test": "Voer een proxy-URL in om te testen", + "Observability": "Waarneembaarheid", + "Enable Observability": "Waarneembaarheid inschakelen", + "Turn request detail recording on/off globally": "Recordering van aanvraagdetails globaal in-/uitschakelen", + "Max Records": "Maximale records", + "Maximum request detail records to keep (older records are auto-deleted)": "Maximale aantal aanvraagdetailrecords om te behouden (oudere records worden automatisch verwijderd)", + "Batch Size": "Batchgrootte", + "Number of items to accumulate before writing to database (higher = better performance)": "Aantal items dat moet worden verzameld voordat naar database wordt geschreven (hoger = beter prestaties)", + "Flush Interval (ms)": "Spoelinterval (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Maximale wachttijd voordat buffer wordt leeggemaakt (voorkomt gegevensverlies bij laag verkeer)", + "Max JSON Size (KB)": "Maximale JSON-grootte (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Maximale grootte voor elk JSON-veld (aanvraag/antwoord) voordat afkappen", + "All data stored on your machine": "Alle gegevens opgeslagen op uw machine", + "MITM Server": "MITM-server", + "Running": "Actief", + "Stopped": "Gestopt", + "Cert": "Certificaat", + "Server": "Server", + "Purpose:": "Doel:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Gebruik Antigravity IDE en GitHub Copilot → met ELKE provider/model van 9Router", + "How it works:": "Hoe het werkt:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE-aanvraag → DNS-omleiding naar localhost:443 → MITM-proxy onderschept → 9Router → antwoord naar Antigravity/Copilot", + "No API keys — create one in Keys page": "Geen API-sleutels — maak er één aan op de pagina Sleutels", + "sk_9router (default)": "sk_9router (standaard)", + "Server started": "Server gestart", + "Failed to start server": "Server starten mislukt", + "Server stopped — all DNS cleared": "Server gestopt — alle DNS gewist", + "Failed to stop server": "Server stoppen mislukt", + "Sudo password is required": "Sudo-wachtwoord vereist", + "Stop Server": "Server stoppen", + "Start Server": "Server starten", + "Enable DNS per tool below to activate interception": "Schakel DNS in voor elk hulpmiddel hieronder om onderschepping te activeren", + "Sudo Password Required": "Sudo-wachtwoord vereist", + "Enter your sudo password to start/stop MITM server": "Voer uw sudo-wachtwoord in om de MITM-server te starten/stoppen", + "Sudo Password": "Sudo-wachtwoord", + "Click to add, click again to remove. Changes are saved automatically.": "Klik om toe te voegen, klik opnieuw om te verwijderen. Wijzigingen worden automatisch opgeslagen.", + "⚠️ 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.": "⚠️ Risicokennisgeving: Deze provider gebruikt een abonnement/OAuth-sessie die niet officieel is gelicentieerd voor proxy/router-gebruik. Account kan worden beperkt of verbannen. Gebruik op eigen risico.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM onderschept HTTPS-verkeer van IDE-tools (Antigravity, GitHub Copilot, Kiro) via lokale CA om verzoeken om te leiden naar uw providers. Kan ToS schenden → risico op accountban. Gebruik op eigen risico.", + "Endpoint is exposed without an API key.": "Het eindpunt is blootgesteld zonder API-sleutel." +} diff --git a/public/i18n/literals/no.json b/public/i18n/literals/no.json new file mode 100644 index 0000000000000000000000000000000000000000..e3f521556eb53023c45d15c4a1e175811c466349 --- /dev/null +++ b/public/i18n/literals/no.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Avbryt", + "Delete": "Slett", + "Edit": "Rediger", + "Save": "Lagre", + "Close": "Lukk", + "Add": "Legg til", + "Remove": "Fjern", + "Settings": "Innstillinger", + "Profile": "Profil", + "Dashboard": "Kontrollpanel", + "Logout": "Logg ut", + "Login": "Logg inn", + "Providers": "Leverandører", + "Usage": "Bruksstatistikk", + "API Key": "API-nøkkel", + "Connected": "Tilkoblet", + "Disconnected": "Frakoblet", + "Active": "Aktiv", + "Inactive": "Inaktiv", + "Success": "Suksess", + "Failed": "Mislyktes", + "Error": "Feil", + "Warning": "Advarsel", + "Info": "Informasjon", + "Loading": "Laster", + "Search": "Søk", + "Filter": "Filter", + "Sort": "Sorter", + "Export": "Eksporter", + "Import": "Importer", + "Refresh": "Oppdater", + "Back": "Tilbake", + "Next": "Neste", + "Previous": "Forrige", + "Submit": "Send inn", + "Confirm": "Bekreft", + "Yes": "Ja", + "No": "Nei", + "OK": "OK", + "Apply": "Bruk", + "Reset": "Tilbakestill", + "Clear": "Tøm", + "Select": "Velg", + "Upload": "Last opp", + "Download": "Last ned", + "Copy": "Kopier", + "Paste": "Lim inn", + "Cut": "Klipp", + "Undo": "Angre", + "Redo": "Gjør på nytt", + "Name": "Navn", + "Description": "Beskrivelse", + "Status": "Status", + "Type": "Type", + "Date": "Dato", + "Time": "Tid", + "Created": "Opprettet", + "Updated": "Oppdatert", + "Actions": "Handlinger", + "Details": "Detaljer", + "View": "Vis", + "New": "Ny", + "Total": "Total", + "Count": "Antall", + "Price": "Pris", + "Cost": "Kostnad", + "Free": "Gratis", + "Paid": "Betalt", + "Enable": "Aktiver", + "Disable": "Deaktiver", + "Enabled": "Aktivert", + "Disabled": "Deaktivert", + "Online": "Pålogget", + "Offline": "Frakoblet", + "Available": "Tilgjengelig", + "Unavailable": "Utilgjengelig", + "Required": "Obligatorisk", + "Optional": "Valgfritt", + "Default": "Standard", + "Custom": "Tilpasset", + "Advanced": "Avansert", + "Basic": "Grunnleggende", + "Help": "Hjelp", + "Support": "Støtte", + "Documentation": "Dokumentasjon", + "Version": "Versjon", + "Language": "Språk", + "Theme": "Tema", + "Light": "Lys", + "Dark": "Mørk", + "Auto": "Automatisk", + "Endpoint": "Endepunkt", + "Combos": "Kombinasjoner", + "Quota Tracker": "Kvotasporer", + "MITM": "MITM", + "CLI Tools": "Verktøy", + "Console Log": "Konsollogg", + "System": "System", + "Debug": "Feilsøking", + "Shutdown": "Slå av", + "Close Proxy": "Lukk proxy", + "Are you sure you want to close the proxy server?": "Er du sikker på at du vil lukke proxyserveren?", + "Server Disconnected": "Server frakoblet", + "The proxy server has been stopped.": "Proxyserveren har blitt stoppet.", + "Reload Page": "Last inn siden på nytt", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "Tjenesten kjører i terminalen. Du kan lukke denne nettsiden. Slå av vil stoppe tjenesten.", + "Manage your AI provider connections": "Administrer AI-leverandørforbindelsene dine", + "Model combos with fallback": "Modellkombinasjoner med fallback", + "Monitor your API usage, token consumption, and request logs": "Overvåk API-bruken, tokenforbruk og anmodningslogger", + "Intercept CLI tool traffic and route through 9Router": "Avlytting av CLI-verktøy trafikk og rute gjennom 9Router", + "Configure CLI tools": "Konfigurer CLI-verktøy", + "API endpoint configuration": "Konfiguration av API-endepunkt", + "Manage your preferences": "Administrer dine preferanser", + "Debug translation flow between formats": "Feilsøking av oversettelsesflyt mellom formater", + "Live server console output": "Direkte serverkonsolresultat", + "Create model combos with fallback support": "Opprett modellkombinasjoner med fallback-støtte", + "Local Mode": "Lokalt modus", + "Running on your machine": "Kjørende på maskinen din", + "Database Location": "Databaseplassering", + "Download Backup": "Last ned sikkerhetskopi", + "Import Backup": "Importer sikkerhetskopi", + "Database backup downloaded": "Databasesikkerhetskopi lastet ned", + "Database imported successfully": "Database importert med suksess", + "Security": "Sikkerhet", + "Require login": "Krev innlogging", + "When ON, dashboard requires password. When OFF, access without login.": "Når PÅ krever kontrollpanelet passord. Når AV, tilgang uten innlogging.", + "Current Password": "Gjeldende passord", + "Enter current password": "Skriv inn gjeldende passord", + "New Password": "Nytt passord", + "Enter new password": "Skriv inn nytt passord", + "Confirm New Password": "Bekreft nytt passord", + "Confirm new password": "Bekreft nytt passord", + "Update Password": "Oppdater passord", + "Set Password": "Angi passord", + "Password updated successfully": "Passord oppdatert med suksess", + "Passwords do not match": "Passordene samsvarer ikke", + "Routing Strategy": "Rutestrategi", + "Round Robin": "Runderobin", + "Cycle through accounts to distribute load": "Syklus gjennom kontoer for å distribuere belastning", + "Sticky Limit": "Klebrig grense", + "Calls per account before switching": "Anrop per konto før bytte", + "Network": "Nettverk", + "Outbound Proxy": "Utgående proxy", + "Enable proxy for OAuth + provider outbound requests.": "Aktiver proxy for OAuth + leverandør utgående forespørsler.", + "Proxy URL": "Proxy-URL", + "Leave empty to inherit existing env proxy (if any).": "La være tom for å arve eksisterende env-proxy (hvis noen).", + "No Proxy": "Ingen proxy", + "Comma-separated hostnames/domains to bypass the proxy.": "Kommaseparerte vertsnavner/domener for å omgå proxyen.", + "Test proxy URL": "Test proxy-URL", + "Proxy settings applied": "Proxyinnstillinger brukt", + "Proxy enabled": "Proxy aktivert", + "Proxy disabled": "Proxy deaktivert", + "Proxy test OK": "Proxytest OK", + "Proxy test failed": "Proxytest mislyktes", + "Please enter a Proxy URL to test": "Vennligst skriv inn en proxy-URL å teste", + "Observability": "Observerbarhet", + "Enable Observability": "Aktiver observerbarhet", + "Turn request detail recording on/off globally": "Slå detaljregistrering av forespørsel på/av globalt", + "Max Records": "Max-poster", + "Maximum request detail records to keep (older records are auto-deleted)": "Maksimum anmodningsdetaljposter å beholde (eldre poster blir automatisk slettet)", + "Batch Size": "Batch-størrelse", + "Number of items to accumulate before writing to database (higher = better performance)": "Antall elementer som skal akkumuleres før skriving til database (høyere = bedre ytelse)", + "Flush Interval (ms)": "Spylt intervall (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Maksimal ventetid før spyling av buffer (forhindrer tap av data under lavt trafikk)", + "Max JSON Size (KB)": "Maks JSON-størrelse (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Maksimal størrelse for hvert JSON-felt (forespørsel/svar) før avkutting", + "All data stored on your machine": "Alle data lagret på maskinen din", + "MITM Server": "MITM-server", + "Running": "Kjørende", + "Stopped": "Stoppet", + "Cert": "Sertifikat", + "Server": "Server", + "Purpose:": "Formål:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Bruk Antigravity IDE & GitHub Copilot → med ENHVER leverandør/modell fra 9Router", + "How it works:": "Slik fungerer det:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE-forespørsel → DNS-omdirigering til localhost:443 → MITM-proxy avlytt → 9Router → svar til Antigravity/Copilot", + "No API keys — create one in Keys page": "Ingen API-nøkler — lag en på Keys-siden", + "sk_9router (default)": "sk_9router (standard)", + "Server started": "Server startet", + "Failed to start server": "Klarte ikke å starte server", + "Server stopped — all DNS cleared": "Server stoppet — alle DNS ryddet", + "Failed to stop server": "Klarte ikke å stoppe server", + "Sudo password is required": "Sudo-passord er påkrevd", + "Stop Server": "Stopp server", + "Start Server": "Start server", + "Enable DNS per tool below to activate interception": "Aktiver DNS for hvert verktøy nedenfor for å aktivere avlytting", + "Sudo Password Required": "Sudo-passord påkrevd", + "Enter your sudo password to start/stop MITM server": "Skriv inn sudo-passordet ditt for å starte/stoppe MITM-server", + "Sudo Password": "Sudo-passord", + "Click to add, click again to remove. Changes are saved automatically.": "Klikk for å legge til, klikk igjen for å fjerne. Endringer lagres automatisk.", + "⚠️ 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.": "⚠️ Risikovarsel: Denne leverandøren bruker en abonnements-/OAuth-økt som ikke er offisielt lisensiert for proxy-/ruterbruk. Kontoen kan bli begrenset eller utestengt. Bruk på eget ansvar.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM avskjærer HTTPS-trafikk fra IDE-verktøy (Antigravity, GitHub Copilot, Kiro) via lokal CA for å omdirigere forespørsler til dine leverandører. Kan bryte ToS → risiko for kontoutestengelse. Bruk på eget ansvar.", + "Endpoint is exposed without an API key.": "Endepunktet er eksponert uten en API-nøkkel." +} diff --git a/public/i18n/literals/pl.json b/public/i18n/literals/pl.json new file mode 100644 index 0000000000000000000000000000000000000000..f4b62a67fc9eaab2fc44972cea23cdd361eb6cbc --- /dev/null +++ b/public/i18n/literals/pl.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Anuluj", + "Delete": "Usuń", + "Edit": "Edytuj", + "Save": "Zapisz", + "Close": "Zamknij", + "Add": "Dodaj", + "Remove": "Usuń", + "Settings": "Ustawienia", + "Profile": "Profil", + "Dashboard": "Panel kontrolny", + "Logout": "Wyloguj się", + "Login": "Zaloguj się", + "Providers": "Dostawcy", + "Usage": "Statystyka", + "API Key": "Klucz API", + "Connected": "Połączony", + "Disconnected": "Rozłączony", + "Active": "Aktywny", + "Inactive": "Nieaktywny", + "Success": "Sukces", + "Failed": "Niepowodzenie", + "Error": "Błąd", + "Warning": "Ostrzeżenie", + "Info": "Informacja", + "Loading": "Ładowanie", + "Search": "Szukaj", + "Filter": "Filtruj", + "Sort": "Sortuj", + "Export": "Eksportuj", + "Import": "Importuj", + "Refresh": "Odśwież", + "Back": "Wstecz", + "Next": "Dalej", + "Previous": "Wstecz", + "Submit": "Prześlij", + "Confirm": "Potwierdź", + "Yes": "Tak", + "No": "Nie", + "OK": "OK", + "Apply": "Zastosuj", + "Reset": "Resetuj", + "Clear": "Wyczyść", + "Select": "Wybierz", + "Upload": "Prześlij", + "Download": "Pobierz", + "Copy": "Skopiuj", + "Paste": "Wklej", + "Cut": "Wytnij", + "Undo": "Cofnij", + "Redo": "Powtórz", + "Name": "Nazwa", + "Description": "Opis", + "Status": "Stan", + "Type": "Typ", + "Date": "Data", + "Time": "Czas", + "Created": "Utworzono", + "Updated": "Zaktualizowano", + "Actions": "Akcje", + "Details": "Szczegóły", + "View": "Wyświetl", + "New": "Nowy", + "Total": "Razem", + "Count": "Liczba", + "Price": "Cena", + "Cost": "Koszt", + "Free": "Bezpłatny", + "Paid": "Płatny", + "Enable": "Włącz", + "Disable": "Wyłącz", + "Enabled": "Włączony", + "Disabled": "Wyłączony", + "Online": "Online", + "Offline": "Offline", + "Available": "Dostępny", + "Unavailable": "Niedostępny", + "Required": "Wymagane", + "Optional": "Opcjonalne", + "Default": "Domyślnie", + "Custom": "Niestandardowy", + "Advanced": "Zaawansowane", + "Basic": "Podstawowy", + "Help": "Pomoc", + "Support": "Pomoc techniczna", + "Documentation": "Dokumentacja", + "Version": "Wersja", + "Language": "Język", + "Theme": "Motyw", + "Light": "Jasny", + "Dark": "Ciemny", + "Auto": "Automatycznie", + "Endpoint": "Punkt końcowy", + "Combos": "Kombinacje", + "Quota Tracker": "Śledzenie limitów", + "MITM": "MITM", + "CLI Tools": "Narzędzia CLI", + "Console Log": "Dziennik konsoli", + "System": "System", + "Debug": "Debugowanie", + "Shutdown": "Wyłączenie", + "Close Proxy": "Zamknij serwer proxy", + "Are you sure you want to close the proxy server?": "Czy na pewno chcesz zamknąć serwer proxy?", + "Server Disconnected": "Serwer rozłączony", + "The proxy server has been stopped.": "Serwer proxy został zatrzymany.", + "Reload Page": "Odśwież stronę", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "Usługa działa w terminalu. Możesz zamknąć tę stronę internetową. Wyłączenie zatrzyma usługę.", + "Manage your AI provider connections": "Zarządzaj połączeniami dostawcy sztucznej inteligencji", + "Model combos with fallback": "Kombinacje modeli z rezerwą", + "Monitor your API usage, token consumption, and request logs": "Monitoruj użycie API, zużycie tokenów i dzienniki żądań", + "Intercept CLI tool traffic and route through 9Router": "Przechwytuj ruch narzędzi CLI i kieruj przez 9Router", + "Configure CLI tools": "Konfiguruj narzędzia CLI", + "API endpoint configuration": "Konfiguracja punktu końcowego API", + "Manage your preferences": "Zarządzaj swoimi preferencjami", + "Debug translation flow between formats": "Debuguj przepływ tłumaczenia między formatami", + "Live server console output": "Wyjście konsoli serwera na żywo", + "Create model combos with fallback support": "Twórz kombinacje modeli z obsługą rezerwową", + "Local Mode": "Tryb lokalny", + "Running on your machine": "Działające na twoim komputerze", + "Database Location": "Lokalizacja bazy danych", + "Download Backup": "Pobierz kopię zapasową", + "Import Backup": "Importuj kopię zapasową", + "Database backup downloaded": "Kopia zapasowa bazy danych pobrana", + "Database imported successfully": "Baza danych pomyślnie zaimportowana", + "Security": "Bezpieczeństwo", + "Require login": "Wymagaj logowania", + "When ON, dashboard requires password. When OFF, access without login.": "Gdy jest WŁĄCZONY, panel wymaga hasła. Gdy jest WYŁĄCZONY, dostęp bez logowania.", + "Current Password": "Obecne hasło", + "Enter current password": "Wprowadź obecne hasło", + "New Password": "Nowe hasło", + "Enter new password": "Wprowadź nowe hasło", + "Confirm New Password": "Potwierdź nowe hasło", + "Confirm new password": "Potwierdź nowe hasło", + "Update Password": "Aktualizuj hasło", + "Set Password": "Ustaw hasło", + "Password updated successfully": "Hasło zaktualizowane pomyślnie", + "Passwords do not match": "Hasła się nie zgadzają", + "Routing Strategy": "Strategia routingu", + "Round Robin": "Round Robin", + "Cycle through accounts to distribute load": "Obracaj kontem w celu rozłożenia obciążenia", + "Sticky Limit": "Limit lepki", + "Calls per account before switching": "Wywołań na konto przed przełączeniem", + "Network": "Sieć", + "Outbound Proxy": "Serwer proxy wychodzący", + "Enable proxy for OAuth + provider outbound requests.": "Włącz serwer proxy dla żądań wychodzących OAuth + dostawcy.", + "Proxy URL": "URL serwera proxy", + "Leave empty to inherit existing env proxy (if any).": "Zostaw puste, aby odziedziczyć istniejący serwer proxy env (jeśli istnieje).", + "No Proxy": "Bez serwera proxy", + "Comma-separated hostnames/domains to bypass the proxy.": "Nazwy hostów/domeny oddzielone przecinkami do pominięcia serwera proxy.", + "Test proxy URL": "Przetestuj URL serwera proxy", + "Proxy settings applied": "Ustawienia serwera proxy zastosowane", + "Proxy enabled": "Serwer proxy włączony", + "Proxy disabled": "Serwer proxy wyłączony", + "Proxy test OK": "Test serwera proxy OK", + "Proxy test failed": "Test serwera proxy nie powiódł się", + "Please enter a Proxy URL to test": "Proszę wprowadzić URL serwera proxy do testowania", + "Observability": "Obserwacyjność", + "Enable Observability": "Włącz obserwacyjność", + "Turn request detail recording on/off globally": "Włącz/wyłącz globalnie rejestrowanie szczegółów żądania", + "Max Records": "Maksymalna liczba rekordów", + "Maximum request detail records to keep (older records are auto-deleted)": "Maksymalna liczba rekordów szczegółów żądania do przechowywania (starsze rekordy są automatycznie usuwane)", + "Batch Size": "Rozmiar partii", + "Number of items to accumulate before writing to database (higher = better performance)": "Liczba elementów do gromadzenia przed zapisaniem w bazie danych (wyższa = lepsza wydajność)", + "Flush Interval (ms)": "Interwał opróżniania (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Maksymalny czas oczekiwania przed opróżnieniem buforu (zapobiega utracie danych podczas małego ruchu)", + "Max JSON Size (KB)": "Maksymalny rozmiar JSON (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Maksymalny rozmiar każdego pola JSON (żądanie/odpowiedź) przed obcięciem", + "All data stored on your machine": "Wszystkie dane przechowywane na twoim komputerze", + "MITM Server": "Serwer MITM", + "Running": "Działający", + "Stopped": "Zatrzymany", + "Cert": "Certyfikat", + "Server": "Serwer", + "Purpose:": "Cel:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Użyj Antigravity IDE i GitHub Copilot → z DOWOLNYM dostawcą/modelem z 9Router", + "How it works:": "Jak to działa:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Żądanie Antigravity/Copilot IDE → Przekierowanie DNS na localhost:443 → Serwer proxy MITM przechwytuje → 9Router → odpowiedź do Antigravity/Copilot", + "No API keys — create one in Keys page": "Brak kluczy API — utwórz jeden na stronie Klucze", + "sk_9router (default)": "sk_9router (domyślnie)", + "Server started": "Serwer uruchomiony", + "Failed to start server": "Nie udało się uruchomić serwera", + "Server stopped — all DNS cleared": "Serwer zatrzymany — cały DNS usunięty", + "Failed to stop server": "Nie udało się zatrzymać serwera", + "Sudo password is required": "Wymagane hasło sudo", + "Stop Server": "Zatrzymaj serwer", + "Start Server": "Uruchom serwer", + "Enable DNS per tool below to activate interception": "Włącz DNS dla każdego narzędzia poniżej, aby aktywować przechwytywanie", + "Sudo Password Required": "Wymagane hasło Sudo", + "Enter your sudo password to start/stop MITM server": "Wprowadź hasło sudo, aby uruchomić/zatrzymać serwer MITM", + "Sudo Password": "Hasło sudo", + "Click to add, click again to remove. Changes are saved automatically.": "Kliknij, aby dodać, kliknij ponownie, aby usunąć. Zmiany są zapisywane automatycznie.", + "⚠️ 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.": "⚠️ Ostrzeżenie o ryzyku: Ten dostawca używa sesji subskrypcji/OAuth, która nie jest oficjalnie licencjonowana do użytku proxy/routera. Konto może zostać ograniczone lub zbanowane. Używaj na własne ryzyko.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM przechwytuje ruch HTTPS narzędzi IDE (Antigravity, GitHub Copilot, Kiro) przez lokalne CA, aby przekierować żądania do twoich dostawców. Może naruszyć ToS → ryzyko zbanowania konta. Używaj na własne ryzyko.", + "Endpoint is exposed without an API key.": "Punkt końcowy jest dostępny bez klucza API." +} diff --git a/public/i18n/literals/pt-BR.json b/public/i18n/literals/pt-BR.json new file mode 100644 index 0000000000000000000000000000000000000000..6edba2e7c3260dcad863366f0cb0308186338d69 --- /dev/null +++ b/public/i18n/literals/pt-BR.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Cancelar", + "Delete": "Excluir", + "Edit": "Editar", + "Save": "Salvar", + "Close": "Fechar", + "Add": "Adicionar", + "Remove": "Remover", + "Settings": "Configurações", + "Profile": "Perfil", + "Dashboard": "Painel de controle", + "Logout": "Sair", + "Login": "Conectar", + "Providers": "Provedores", + "Usage": "Estatísticas", + "API Key": "Chave API", + "Connected": "Conectado", + "Disconnected": "Desconectado", + "Active": "Ativo", + "Inactive": "Inativo", + "Success": "Sucesso", + "Failed": "Falha", + "Error": "Erro", + "Warning": "Aviso", + "Info": "Informações", + "Loading": "Carregando", + "Search": "Pesquisar", + "Filter": "Filtrar", + "Sort": "Classificar", + "Export": "Exportar", + "Import": "Importar", + "Refresh": "Atualizar", + "Back": "Voltar", + "Next": "Próximo", + "Previous": "Anterior", + "Submit": "Enviar", + "Confirm": "Confirmar", + "Yes": "Sim", + "No": "Não", + "OK": "OK", + "Apply": "Aplicar", + "Reset": "Redefinir", + "Clear": "Limpar", + "Select": "Selecionar", + "Upload": "Enviar", + "Download": "Baixar", + "Copy": "Copiar", + "Paste": "Colar", + "Cut": "Cortar", + "Undo": "Desfazer", + "Redo": "Refazer", + "Name": "Nome", + "Description": "Descrição", + "Status": "Status", + "Type": "Tipo", + "Date": "Data", + "Time": "Hora", + "Created": "Criado", + "Updated": "Atualizado", + "Actions": "Ações", + "Details": "Detalhes", + "View": "Visualizar", + "New": "Novo", + "Total": "Total", + "Count": "Contagem", + "Price": "Preço", + "Cost": "Custo", + "Free": "Gratuito", + "Paid": "Pago", + "Enable": "Ativar", + "Disable": "Desativar", + "Enabled": "Ativado", + "Disabled": "Desativado", + "Online": "Online", + "Offline": "Offline", + "Available": "Disponível", + "Unavailable": "Indisponível", + "Required": "Obrigatório", + "Optional": "Opcional", + "Default": "Padrão", + "Custom": "Personalizado", + "Advanced": "Avançado", + "Basic": "Básico", + "Help": "Ajuda", + "Support": "Suporte", + "Documentation": "Documentação", + "Version": "Versão", + "Language": "Idioma", + "Theme": "Tema", + "Light": "Claro", + "Dark": "Escuro", + "Auto": "Automático", + "Endpoint": "Ponto de extremidade", + "Combos": "Combinações", + "Quota Tracker": "Rastreador de cota", + "MITM": "MITM", + "CLI Tools": "Ferramentas CLI", + "Console Log": "Log do console", + "System": "Sistema", + "Debug": "Depuração", + "Shutdown": "Desligar", + "Close Proxy": "Fechar proxy", + "Are you sure you want to close the proxy server?": "Tem certeza de que deseja fechar o servidor proxy?", + "Server Disconnected": "Servidor desconectado", + "The proxy server has been stopped.": "O servidor proxy foi parado.", + "Reload Page": "Recarregar página", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "O serviço está em execução no terminal. Você pode fechar esta página da web. O desligamento interromperá o serviço.", + "Manage your AI provider connections": "Gerencie suas conexões de provedor de IA", + "Model combos with fallback": "Combinações de modelos com fallback", + "Monitor your API usage, token consumption, and request logs": "Monitore seu uso de API, consumo de tokens e logs de solicitação", + "Intercept CLI tool traffic and route through 9Router": "Intercepte o tráfego da ferramenta CLI e roteie através do 9Router", + "Configure CLI tools": "Configurar ferramentas CLI", + "API endpoint configuration": "Configuração do ponto de extremidade da API", + "Manage your preferences": "Gerenciar suas preferências", + "Debug translation flow between formats": "Depurar fluxo de tradução entre formatos", + "Live server console output": "Saída do console do servidor ao vivo", + "Create model combos with fallback support": "Crie combinações de modelos com suporte a fallback", + "Local Mode": "Modo local", + "Running on your machine": "Executando em sua máquina", + "Database Location": "Localização do banco de dados", + "Download Backup": "Baixar backup", + "Import Backup": "Importar backup", + "Database backup downloaded": "Backup do banco de dados baixado", + "Database imported successfully": "Banco de dados importado com sucesso", + "Security": "Segurança", + "Require login": "Exigir login", + "When ON, dashboard requires password. When OFF, access without login.": "Quando ATIVO, o painel requer senha. Quando DESATIVO, acesso sem login.", + "Current Password": "Senha atual", + "Enter current password": "Digite a senha atual", + "New Password": "Nova senha", + "Enter new password": "Digite a nova senha", + "Confirm New Password": "Confirmar nova senha", + "Confirm new password": "Confirme a nova senha", + "Update Password": "Atualizar senha", + "Set Password": "Definir senha", + "Password updated successfully": "Senha atualizada com sucesso", + "Passwords do not match": "As senhas não correspondem", + "Routing Strategy": "Estratégia de roteamento", + "Round Robin": "Round Robin", + "Cycle through accounts to distribute load": "Percorrer contas para distribuir carga", + "Sticky Limit": "Limite pegajoso", + "Calls per account before switching": "Chamadas por conta antes de alternar", + "Network": "Rede", + "Outbound Proxy": "Proxy de saída", + "Enable proxy for OAuth + provider outbound requests.": "Ativar proxy para OAuth + solicitações de saída do provedor.", + "Proxy URL": "URL do proxy", + "Leave empty to inherit existing env proxy (if any).": "Deixe em branco para herdar o proxy env existente (se houver).", + "No Proxy": "Sem proxy", + "Comma-separated hostnames/domains to bypass the proxy.": "Nomes de host/domínios separados por vírgula para contornar o proxy.", + "Test proxy URL": "Testar URL do proxy", + "Proxy settings applied": "Configurações de proxy aplicadas", + "Proxy enabled": "Proxy ativado", + "Proxy disabled": "Proxy desativado", + "Proxy test OK": "Teste de proxy OK", + "Proxy test failed": "Falha no teste de proxy", + "Please enter a Proxy URL to test": "Por favor, digite uma URL de proxy para testar", + "Observability": "Observabilidade", + "Enable Observability": "Ativar observabilidade", + "Turn request detail recording on/off globally": "Ativar/desativar globalmente o registro de detalhes da solicitação", + "Max Records": "Número máximo de registros", + "Maximum request detail records to keep (older records are auto-deleted)": "Número máximo de registros de detalhes de solicitação a manter (registros antigos são excluídos automaticamente)", + "Batch Size": "Tamanho do lote", + "Number of items to accumulate before writing to database (higher = better performance)": "Número de itens a acumular antes de gravar no banco de dados (maior = melhor desempenho)", + "Flush Interval (ms)": "Intervalo de liberação (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Tempo máximo de espera antes de liberar o buffer (evita perda de dados durante baixo tráfego)", + "Max JSON Size (KB)": "Tamanho máximo de JSON (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Tamanho máximo para cada campo JSON (solicitação/resposta) antes do truncamento", + "All data stored on your machine": "Todos os dados armazenados em sua máquina", + "MITM Server": "Servidor MITM", + "Running": "Executando", + "Stopped": "Parado", + "Cert": "Certificado", + "Server": "Servidor", + "Purpose:": "Propósito:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Use Antigravity IDE e GitHub Copilot → com QUALQUER provedor/modelo do 9Router", + "How it works:": "Como funciona:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Solicitação do Antigravity/Copilot IDE → Redirecionamento DNS para localhost:443 → Proxy MITM intercepta → 9Router → resposta para Antigravity/Copilot", + "No API keys — create one in Keys page": "Sem chaves de API — crie uma na página Chaves", + "sk_9router (default)": "sk_9router (padrão)", + "Server started": "Servidor iniciado", + "Failed to start server": "Falha ao iniciar o servidor", + "Server stopped — all DNS cleared": "Servidor parado — todo DNS foi limpo", + "Failed to stop server": "Falha ao parar o servidor", + "Sudo password is required": "Senha sudo é necessária", + "Stop Server": "Parar servidor", + "Start Server": "Iniciar servidor", + "Enable DNS per tool below to activate interception": "Ativar DNS para cada ferramenta abaixo para ativar a interceptação", + "Sudo Password Required": "Senha Sudo necessária", + "Enter your sudo password to start/stop MITM server": "Digite sua senha sudo para iniciar/parar o servidor MITM", + "Sudo Password": "Senha sudo", + "Click to add, click again to remove. Changes are saved automatically.": "Clique para adicionar, clique novamente para remover. As alterações são salvas automaticamente.", + "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ Aviso de Risco: Este provedor usa uma sessão de assinatura/OAuth não licenciada oficialmente para uso de proxy/roteador. A conta pode ser restrita ou banida. Use por sua conta e risco.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM intercepta tráfego HTTPS de ferramentas IDE (Antigravity, GitHub Copilot, Kiro) via CA local para redirecionar solicitações aos seus provedores. Pode violar ToS → risco de banimento de conta. Use por sua conta e risco.", + "Endpoint is exposed without an API key.": "O endpoint está exposto sem uma chave de API." +} diff --git a/public/i18n/literals/pt-PT.json b/public/i18n/literals/pt-PT.json new file mode 100644 index 0000000000000000000000000000000000000000..c17e932a2942be33fc9ac98f3b939804eb3be017 --- /dev/null +++ b/public/i18n/literals/pt-PT.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Cancelar", + "Delete": "Eliminar", + "Edit": "Editar", + "Save": "Guardar", + "Close": "Fechar", + "Add": "Adicionar", + "Remove": "Remover", + "Settings": "Definições", + "Profile": "Perfil", + "Dashboard": "Painel de controlo", + "Logout": "Terminar sessão", + "Login": "Iniciar sessão", + "Providers": "Fornecedores", + "Usage": "Estatísticas", + "API Key": "Chave API", + "Connected": "Ligado", + "Disconnected": "Desligado", + "Active": "Ativo", + "Inactive": "Inativo", + "Success": "Sucesso", + "Failed": "Falha", + "Error": "Erro", + "Warning": "Aviso", + "Info": "Informações", + "Loading": "A carregar", + "Search": "Pesquisar", + "Filter": "Filtrar", + "Sort": "Ordenar", + "Export": "Exportar", + "Import": "Importar", + "Refresh": "Atualizar", + "Back": "Voltar", + "Next": "Seguinte", + "Previous": "Anterior", + "Submit": "Enviar", + "Confirm": "Confirmar", + "Yes": "Sim", + "No": "Não", + "OK": "OK", + "Apply": "Aplicar", + "Reset": "Repor", + "Clear": "Limpar", + "Select": "Selecionar", + "Upload": "Carregar", + "Download": "Descarregar", + "Copy": "Copiar", + "Paste": "Colar", + "Cut": "Cortar", + "Undo": "Desfazer", + "Redo": "Refazer", + "Name": "Nome", + "Description": "Descrição", + "Status": "Estado", + "Type": "Tipo", + "Date": "Data", + "Time": "Hora", + "Created": "Criado", + "Updated": "Atualizado", + "Actions": "Ações", + "Details": "Detalhes", + "View": "Ver", + "New": "Novo", + "Total": "Total", + "Count": "Contagem", + "Price": "Preço", + "Cost": "Custo", + "Free": "Gratuito", + "Paid": "Pago", + "Enable": "Ativar", + "Disable": "Desativar", + "Enabled": "Ativado", + "Disabled": "Desativado", + "Online": "Online", + "Offline": "Offline", + "Available": "Disponível", + "Unavailable": "Indisponível", + "Required": "Obrigatório", + "Optional": "Opcional", + "Default": "Predefinição", + "Custom": "Personalizado", + "Advanced": "Avançado", + "Basic": "Básico", + "Help": "Ajuda", + "Support": "Suporte", + "Documentation": "Documentação", + "Version": "Versão", + "Language": "Idioma", + "Theme": "Tema", + "Light": "Claro", + "Dark": "Escuro", + "Auto": "Automático", + "Endpoint": "Ponto final", + "Combos": "Combinações", + "Quota Tracker": "Rastreador de quota", + "MITM": "MITM", + "CLI Tools": "Ferramentas CLI", + "Console Log": "Registo da consola", + "System": "Sistema", + "Debug": "Depuração", + "Shutdown": "Encerramento", + "Close Proxy": "Fechar proxy", + "Are you sure you want to close the proxy server?": "Tem a certeza de que deseja fechar o servidor proxy?", + "Server Disconnected": "Servidor desligado", + "The proxy server has been stopped.": "O servidor proxy foi parado.", + "Reload Page": "Recarregar página", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "O serviço está em execução no terminal. Pode fechar esta página da web. O encerramento irá parar o serviço.", + "Manage your AI provider connections": "Gerir as suas ligações de fornecedor de IA", + "Model combos with fallback": "Combinações de modelos com contingência", + "Monitor your API usage, token consumption, and request logs": "Monitorize a sua utilização de API, consumo de fichas e registos de pedidos", + "Intercept CLI tool traffic and route through 9Router": "Intercete o tráfego da ferramenta CLI e encaminhe através do 9Router", + "Configure CLI tools": "Configurar ferramentas CLI", + "API endpoint configuration": "Configuração do ponto final da API", + "Manage your preferences": "Gerir as suas preferências", + "Debug translation flow between formats": "Depurar fluxo de tradução entre formatos", + "Live server console output": "Saída da consola do servidor em direto", + "Create model combos with fallback support": "Criar combinações de modelos com suporte a contingência", + "Local Mode": "Modo local", + "Running on your machine": "Em execução na sua máquina", + "Database Location": "Localização da base de dados", + "Download Backup": "Descarregar cópia de segurança", + "Import Backup": "Importar cópia de segurança", + "Database backup downloaded": "Cópia de segurança da base de dados descarregada", + "Database imported successfully": "Base de dados importada com sucesso", + "Security": "Segurança", + "Require login": "Requer início de sessão", + "When ON, dashboard requires password. When OFF, access without login.": "Quando ATIVO, o painel requer palavra-passe. Quando DESATIVO, acesso sem iniciar sessão.", + "Current Password": "Palavra-passe atual", + "Enter current password": "Introduza a palavra-passe atual", + "New Password": "Nova palavra-passe", + "Enter new password": "Introduza a nova palavra-passe", + "Confirm New Password": "Confirmar nova palavra-passe", + "Confirm new password": "Confirme a nova palavra-passe", + "Update Password": "Atualizar palavra-passe", + "Set Password": "Definir palavra-passe", + "Password updated successfully": "Palavra-passe atualizada com sucesso", + "Passwords do not match": "As palavras-passe não coincidem", + "Routing Strategy": "Estratégia de encaminhamento", + "Round Robin": "Round Robin", + "Cycle through accounts to distribute load": "Passar por contas para distribuir carga", + "Sticky Limit": "Limite aderente", + "Calls per account before switching": "Chamadas por conta antes de mudar", + "Network": "Rede", + "Outbound Proxy": "Proxy de saída", + "Enable proxy for OAuth + provider outbound requests.": "Ativar proxy para OAuth + pedidos de saída do fornecedor.", + "Proxy URL": "URL do proxy", + "Leave empty to inherit existing env proxy (if any).": "Deixe em branco para herdar o proxy env existente (se houver).", + "No Proxy": "Sem proxy", + "Comma-separated hostnames/domains to bypass the proxy.": "Nomes de host/domínios separados por vírgulas para contornar o proxy.", + "Test proxy URL": "Testar URL do proxy", + "Proxy settings applied": "Definições de proxy aplicadas", + "Proxy enabled": "Proxy ativado", + "Proxy disabled": "Proxy desativado", + "Proxy test OK": "Teste de proxy OK", + "Proxy test failed": "Falha no teste de proxy", + "Please enter a Proxy URL to test": "Introduza um URL de proxy para testar", + "Observability": "Observabilidade", + "Enable Observability": "Ativar observabilidade", + "Turn request detail recording on/off globally": "Ativar/desativar globalmente o registo de detalhes de pedidos", + "Max Records": "Número máximo de registos", + "Maximum request detail records to keep (older records are auto-deleted)": "Número máximo de registos de detalhes de pedidos a manter (registos mais antigos são eliminados automaticamente)", + "Batch Size": "Tamanho do lote", + "Number of items to accumulate before writing to database (higher = better performance)": "Número de itens a acumular antes de gravar na base de dados (mais alto = melhor desempenho)", + "Flush Interval (ms)": "Intervalo de limpeza (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Tempo máximo de espera antes de limpar o buffer (evita perda de dados durante tráfego baixo)", + "Max JSON Size (KB)": "Tamanho máximo de JSON (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Tamanho máximo para cada campo JSON (pedido/resposta) antes do truncamento", + "All data stored on your machine": "Todos os dados armazenados na sua máquina", + "MITM Server": "Servidor MITM", + "Running": "Em execução", + "Stopped": "Parado", + "Cert": "Certificado", + "Server": "Servidor", + "Purpose:": "Finalidade:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Utilize o Antigravity IDE e GitHub Copilot → com QUALQUER fornecedor/modelo do 9Router", + "How it works:": "Como funciona:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Pedido do Antigravity/Copilot IDE → Redirecionamento DNS para localhost:443 → Proxy MITM interceta → 9Router → resposta para Antigravity/Copilot", + "No API keys — create one in Keys page": "Sem chaves de API — crie uma na página Chaves", + "sk_9router (default)": "sk_9router (predefinição)", + "Server started": "Servidor iniciado", + "Failed to start server": "Falha ao iniciar o servidor", + "Server stopped — all DNS cleared": "Servidor parado — todo DNS foi limpo", + "Failed to stop server": "Falha ao parar o servidor", + "Sudo password is required": "Palavra-passe sudo é necessária", + "Stop Server": "Parar servidor", + "Start Server": "Iniciar servidor", + "Enable DNS per tool below to activate interception": "Ativar DNS para cada ferramenta abaixo para ativar a interceção", + "Sudo Password Required": "Palavra-passe Sudo necessária", + "Enter your sudo password to start/stop MITM server": "Introduza a sua palavra-passe sudo para iniciar/parar o servidor MITM", + "Sudo Password": "Palavra-passe sudo", + "Click to add, click again to remove. Changes are saved automatically.": "Clique para adicionar, clique novamente para remover. As alterações são guardadas automaticamente.", + "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk.": "⚠️ Aviso de Risco: Este fornecedor utiliza uma sessão de subscrição/OAuth não licenciada oficialmente para uso de proxy/router. A conta pode ser restringida ou banida. Use por sua conta e risco.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM interceta tráfego HTTPS de ferramentas IDE (Antigravity, GitHub Copilot, Kiro) via CA local para redirecionar pedidos para os seus fornecedores. Pode violar ToS → risco de banimento de conta. Use por sua conta e risco.", + "Endpoint is exposed without an API key.": "O endpoint está exposto sem uma chave de API." +} diff --git a/public/i18n/literals/ro.json b/public/i18n/literals/ro.json new file mode 100644 index 0000000000000000000000000000000000000000..03384415b8ef4e6248055711473bb353e8115dc5 --- /dev/null +++ b/public/i18n/literals/ro.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Anulare", + "Delete": "Ștergere", + "Edit": "Editare", + "Save": "Salvare", + "Close": "Închide", + "Add": "Adăugare", + "Remove": "Eliminare", + "Settings": "Setări", + "Profile": "Profil", + "Dashboard": "Tablou de bord", + "Logout": "Ieșire", + "Login": "Conectare", + "Providers": "Furnizori", + "Usage": "Statistici de utilizare", + "API Key": "Cheie API", + "Connected": "Conectat", + "Disconnected": "Deconectat", + "Active": "Activ", + "Inactive": "Inactiv", + "Success": "Succes", + "Failed": "Eşuat", + "Error": "Eroare", + "Warning": "Avertisment", + "Info": "Informații", + "Loading": "Se încarcă", + "Search": "Căutare", + "Filter": "Filtru", + "Sort": "Sortare", + "Export": "Exportare", + "Import": "Importare", + "Refresh": "Reîmprospătare", + "Back": "Înapoi", + "Next": "Următorul", + "Previous": "Anterior", + "Submit": "Trimitere", + "Confirm": "Confirmare", + "Yes": "Da", + "No": "Nu", + "OK": "OK", + "Apply": "Aplicare", + "Reset": "Resetare", + "Clear": "Curățare", + "Select": "Selectare", + "Upload": "Încărcare", + "Download": "Descărcare", + "Copy": "Copiere", + "Paste": "Lipire", + "Cut": "Tăiere", + "Undo": "Anulare", + "Redo": "Refacere", + "Name": "Nume", + "Description": "Descriere", + "Status": "Status", + "Type": "Tip", + "Date": "Data", + "Time": "Oră", + "Created": "Creat", + "Updated": "Actualizat", + "Actions": "Acțiuni", + "Details": "Detalii", + "View": "Vizualizare", + "New": "Nou", + "Total": "Total", + "Count": "Număr", + "Price": "Preț", + "Cost": "Cost", + "Free": "Gratuit", + "Paid": "Plătit", + "Enable": "Activare", + "Disable": "Dezactivare", + "Enabled": "Activat", + "Disabled": "Dezactivat", + "Online": "Online", + "Offline": "Offline", + "Available": "Disponibil", + "Unavailable": "Indisponibil", + "Required": "Necesar", + "Optional": "Opțional", + "Default": "Implicit", + "Custom": "Personalizat", + "Advanced": "Avansat", + "Basic": "De bază", + "Help": "Ajutor", + "Support": "Suport", + "Documentation": "Documentație", + "Version": "Versiune", + "Language": "Limbă", + "Theme": "Temă", + "Light": "Lumină", + "Dark": "Întunecat", + "Auto": "Automat", + "Endpoint": "Punct final", + "Combos": "Combinații", + "Quota Tracker": "Urmăritor cote", + "MITM": "MITM", + "CLI Tools": "Instrumente", + "Console Log": "Jurnal consolă", + "System": "Sistem", + "Debug": "Depanare", + "Shutdown": "Oprire", + "Close Proxy": "Închidere proxy", + "Are you sure you want to close the proxy server?": "Sunteti sigur că doriți să închideți serverul proxy?", + "Server Disconnected": "Server deconectat", + "The proxy server has been stopped.": "Serverul proxy a fost oprit.", + "Reload Page": "Reîncărcare pagină", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "Serviciul rulează în terminal. Puteți închide această pagină web. Oprirea va opri serviciul.", + "Manage your AI provider connections": "Gestionați conexiunile furnizorului dvs. de IA", + "Model combos with fallback": "Combinații de modele cu fallback", + "Monitor your API usage, token consumption, and request logs": "Monitorizați utilizarea API-ului, consumul de token și jurnalele de solicitare", + "Intercept CLI tool traffic and route through 9Router": "Interceptați traficul instrumentului CLI și rutați prin 9Router", + "Configure CLI tools": "Configurați instrumentele CLI", + "API endpoint configuration": "Configurația punctului final API", + "Manage your preferences": "Gestionați preferințele dvs.", + "Debug translation flow between formats": "Depanare fluxului de traducere între formate", + "Live server console output": "Ieșire consolă server în direct", + "Create model combos with fallback support": "Creați combinații de modele cu suport fallback", + "Local Mode": "Mod local", + "Running on your machine": "Rulează pe mașina dvs.", + "Database Location": "Locația bazei de date", + "Download Backup": "Descărcare copie de rezervă", + "Import Backup": "Importare copie de rezervă", + "Database backup downloaded": "Copia de rezervă a bazei de date a fost descărcată", + "Database imported successfully": "Baza de date a fost importată cu succes", + "Security": "Securitate", + "Require login": "Necesită conectare", + "When ON, dashboard requires password. When OFF, access without login.": "Când este PORNIT, tabloul de bord necesită parolă. Când este OPRIT, accesați fără conectare.", + "Current Password": "Parola actuală", + "Enter current password": "Introduceți parola actuală", + "New Password": "Parola nouă", + "Enter new password": "Introduceți parola nouă", + "Confirm New Password": "Confirmați parola nouă", + "Confirm new password": "Confirmați parola nouă", + "Update Password": "Actualizare parolă", + "Set Password": "Setare parolă", + "Password updated successfully": "Parola a fost actualizată cu succes", + "Passwords do not match": "Parolele nu coincid", + "Routing Strategy": "Strategie de rutare", + "Round Robin": "Tur în jurul", + "Cycle through accounts to distribute load": "Ciclați prin conturi pentru a distribui sarcina", + "Sticky Limit": "Limită lipicioasă", + "Calls per account before switching": "Apeluri pe cont înainte de comutare", + "Network": "Rețea", + "Outbound Proxy": "Proxy de ieșire", + "Enable proxy for OAuth + provider outbound requests.": "Activați proxy-ul pentru solicitări de ieșire OAuth + furnizor.", + "Proxy URL": "URL proxy", + "Leave empty to inherit existing env proxy (if any).": "Lăsați gol pentru a moșteni proxy-ul env existent (dacă există).", + "No Proxy": "Nicio delegare", + "Comma-separated hostnames/domains to bypass the proxy.": "Nume de gazdă/domenii separate prin virgulă pentru a ocoli proxy-ul.", + "Test proxy URL": "Testare URL proxy", + "Proxy settings applied": "Setările proxy au fost aplicate", + "Proxy enabled": "Proxy activat", + "Proxy disabled": "Proxy dezactivat", + "Proxy test OK": "Testul proxy OK", + "Proxy test failed": "Testul proxy a eșuat", + "Please enter a Proxy URL to test": "Vă rugăm să introduceți un URL proxy pentru a testa", + "Observability": "Observabilitate", + "Enable Observability": "Activare observabilitate", + "Turn request detail recording on/off globally": "Porniți/opriți înregistrarea detaliilor solicitării la nivel global", + "Max Records": "Înregistrări maxime", + "Maximum request detail records to keep (older records are auto-deleted)": "Înregistrări de detalii de solicitare maxime de păstrat (înregistrările mai vechi sunt șterse automat)", + "Batch Size": "Dimensiune lot", + "Number of items to accumulate before writing to database (higher = better performance)": "Numărul de articole de acumulat înainte de a scrie în baza de date (mai mare = performanță mai bună)", + "Flush Interval (ms)": "Interval de golire (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Timp maxim de așteptare înainte de golirea bufferului (previne pierderea datelor în condiții de trafic redus)", + "Max JSON Size (KB)": "Dimensiune JSON maximă (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Dimensiune maximă pentru fiecare câmp JSON (solicitare/răspuns) înainte de trunchiere", + "All data stored on your machine": "Toate datele sunt stocate pe mașina dvs.", + "MITM Server": "Server MITM", + "Running": "Se execută", + "Stopped": "Oprit", + "Cert": "Certificat", + "Server": "Server", + "Purpose:": "Scop:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Folosiți Antigravity IDE & GitHub Copilot → cu ORICE furnizor/model din 9Router", + "How it works:": "Cum funcționează:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Solicitare Antigravity/Copilot IDE → Redirecționare DNS la localhost:443 → Proxy MITM interceptează → 9Router → răspuns la Antigravity/Copilot", + "No API keys — create one in Keys page": "Nicio cheie API — creați una în pagina Chei", + "sk_9router (default)": "sk_9router (implicit)", + "Server started": "Server pornit", + "Failed to start server": "Nu s-a putut porni serverul", + "Server stopped — all DNS cleared": "Server oprit — toate DNS-urile șterse", + "Failed to stop server": "Nu s-a putut opri serverul", + "Sudo password is required": "Parola sudo este necesară", + "Stop Server": "Oprire server", + "Start Server": "Server de pornire", + "Enable DNS per tool below to activate interception": "Activați DNS pentru fiecare instrument de mai jos pentru a activa interceptarea", + "Sudo Password Required": "Parola Sudo este necesară", + "Enter your sudo password to start/stop MITM server": "Introduceți parola sudo pentru a porni/opri serverul MITM", + "Sudo Password": "Parola Sudo", + "Click to add, click again to remove. Changes are saved automatically.": "Faceți clic pentru a adăuga, faceți clic din nou pentru a elimina. Modificările sunt salvate automat.", + "⚠️ 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.": "⚠️ Notificare de risc: Acest furnizor folosește un abonament/sesiune OAuth care nu este licențiat oficial pentru utilizare proxy/router. Contul poate fi restricționat sau interzis. Utilizați pe propriul risc.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM interceptează traficul HTTPS al instrumentelor IDE (Antigravity, GitHub Copilot, Kiro) prin CA locală pentru a redirecționa cererile către furnizorii dvs. Poate încălca ToS → risc de interzicere a contului. Utilizați pe propriul risc.", + "Endpoint is exposed without an API key.": "Endpointul este expus fără o cheie API." +} diff --git a/public/i18n/literals/ru.json b/public/i18n/literals/ru.json new file mode 100644 index 0000000000000000000000000000000000000000..92b9f0e78de37fad2f232c15aa00810351fcd736 --- /dev/null +++ b/public/i18n/literals/ru.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Отменить", + "Delete": "Удалить", + "Edit": "Редактировать", + "Save": "Сохранить", + "Close": "Закрыть", + "Add": "Добавить", + "Remove": "Удалить", + "Settings": "Настройки", + "Profile": "Профиль", + "Dashboard": "Панель управления", + "Logout": "Выход", + "Login": "Вход", + "Providers": "Провайдеры", + "Usage": "Статистика", + "API Key": "Ключ API", + "Connected": "Подключено", + "Disconnected": "Отключено", + "Active": "Активно", + "Inactive": "Неактивно", + "Success": "Успех", + "Failed": "Ошибка", + "Error": "Ошибка", + "Warning": "Предупреждение", + "Info": "Информация", + "Loading": "Загрузка", + "Search": "Поиск", + "Filter": "Фильтр", + "Sort": "Сортировка", + "Export": "Экспорт", + "Import": "Импорт", + "Refresh": "Обновить", + "Back": "Назад", + "Next": "Далее", + "Previous": "Назад", + "Submit": "Отправить", + "Confirm": "Подтвердить", + "Yes": "Да", + "No": "Нет", + "OK": "OK", + "Apply": "Применить", + "Reset": "Сбросить", + "Clear": "Очистить", + "Select": "Выбрать", + "Upload": "Загрузить", + "Download": "Скачать", + "Copy": "Копировать", + "Paste": "Вставить", + "Cut": "Вырезать", + "Undo": "Отменить", + "Redo": "Повторить", + "Name": "Имя", + "Description": "Описание", + "Status": "Статус", + "Type": "Тип", + "Date": "Дата", + "Time": "Время", + "Created": "Создано", + "Updated": "Обновлено", + "Actions": "Действия", + "Details": "Подробности", + "View": "Просмотр", + "New": "Новый", + "Total": "Всего", + "Count": "Количество", + "Price": "Цена", + "Cost": "Стоимость", + "Free": "Бесплатно", + "Paid": "Платно", + "Enable": "Включить", + "Disable": "Отключить", + "Enabled": "Включено", + "Disabled": "Отключено", + "Online": "Онлайн", + "Offline": "Офлайн", + "Available": "Доступно", + "Unavailable": "Недоступно", + "Required": "Обязательно", + "Optional": "Опционально", + "Default": "По умолчанию", + "Custom": "Пользовательский", + "Advanced": "Дополнительно", + "Basic": "Основной", + "Help": "Справка", + "Support": "Поддержка", + "Documentation": "Документация", + "Version": "Версия", + "Language": "Язык", + "Theme": "Тема", + "Light": "Светлая", + "Dark": "Темная", + "Auto": "Автоматически", + "Endpoint": "Конечная точка", + "Combos": "Комбинации", + "Quota Tracker": "Отслеживание квоты", + "MITM": "MITM", + "CLI Tools": "Инструменты CLI", + "Console Log": "Журнал консоли", + "System": "Система", + "Debug": "Отладка", + "Shutdown": "Завершение", + "Close Proxy": "Закрыть прокси", + "Are you sure you want to close the proxy server?": "Вы уверены, что хотите закрыть прокси-сервер?", + "Server Disconnected": "Сервер отключен", + "The proxy server has been stopped.": "Прокси-сервер был остановлен.", + "Reload Page": "Перезагрузить страницу", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "Сервис работает в терминале. Вы можете закрыть эту веб-страницу. Завершение остановит сервис.", + "Manage your AI provider connections": "Управляйте своими подключениями провайдера ИИ", + "Model combos with fallback": "Комбинации моделей с резервным вариантом", + "Monitor your API usage, token consumption, and request logs": "Мониторьте использование API, потребление токенов и журналы запросов", + "Intercept CLI tool traffic and route through 9Router": "Перехватите трафик инструмента CLI и маршрутизируйте через 9Router", + "Configure CLI tools": "Настройка инструментов CLI", + "API endpoint configuration": "Конфигурация конечной точки API", + "Manage your preferences": "Управляйте своими предпочтениями", + "Debug translation flow between formats": "Отладка потока трансляции между форматами", + "Live server console output": "Вывод консоли сервера в реальном времени", + "Create model combos with fallback support": "Создание комбинаций моделей с поддержкой резервного варианта", + "Local Mode": "Локальный режим", + "Running on your machine": "Работает на вашем компьютере", + "Database Location": "Расположение базы данных", + "Download Backup": "Загрузить резервную копию", + "Import Backup": "Импортировать резервную копию", + "Database backup downloaded": "Резервная копия базы данных загружена", + "Database imported successfully": "База данных успешно импортирована", + "Security": "Безопасность", + "Require login": "Требовать вход", + "When ON, dashboard requires password. When OFF, access without login.": "Когда ВКЛЮЧЕНО, панель управления требует пароль. Когда ОТКЛЮЧЕНО, доступ без входа.", + "Current Password": "Текущий пароль", + "Enter current password": "Введите текущий пароль", + "New Password": "Новый пароль", + "Enter new password": "Введите новый пароль", + "Confirm New Password": "Подтвердить новый пароль", + "Confirm new password": "Подтвердите новый пароль", + "Update Password": "Обновить пароль", + "Set Password": "Установить пароль", + "Password updated successfully": "Пароль успешно обновлен", + "Passwords do not match": "Пароли не совпадают", + "Routing Strategy": "Стратегия маршрутизации", + "Round Robin": "Циклическая выборка", + "Cycle through accounts to distribute load": "Чередование аккаунтов для распределения нагрузки", + "Sticky Limit": "Липкий предел", + "Calls per account before switching": "Вызовов на аккаунт перед переключением", + "Network": "Сеть", + "Outbound Proxy": "Исходящий прокси", + "Enable proxy for OAuth + provider outbound requests.": "Включить прокси для OAuth + исходящих запросов провайдера.", + "Proxy URL": "URL прокси", + "Leave empty to inherit existing env proxy (if any).": "Оставьте пусто, чтобы унаследовать существующий прокси env (если есть).", + "No Proxy": "Без прокси", + "Comma-separated hostnames/domains to bypass the proxy.": "Разделенные запятыми имена хостов/домены для обхода прокси.", + "Test proxy URL": "Протестировать URL прокси", + "Proxy settings applied": "Параметры прокси применены", + "Proxy enabled": "Прокси включен", + "Proxy disabled": "Прокси отключен", + "Proxy test OK": "Тест прокси OK", + "Proxy test failed": "Тест прокси не пройден", + "Please enter a Proxy URL to test": "Пожалуйста, введите URL прокси для тестирования", + "Observability": "Наблюдаемость", + "Enable Observability": "Включить наблюдаемость", + "Turn request detail recording on/off globally": "Включить/отключить запись деталей запроса глобально", + "Max Records": "Максимум записей", + "Maximum request detail records to keep (older records are auto-deleted)": "Максимальное количество записей деталей запроса для сохранения (старые записи автоматически удаляются)", + "Batch Size": "Размер пакета", + "Number of items to accumulate before writing to database (higher = better performance)": "Количество элементов для накопления перед записью в базу данных (выше = лучшая производительность)", + "Flush Interval (ms)": "Интервал очистки (мс)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Максимальное время ожидания перед очисткой буфера (предотвращает потерю данных при низком трафике)", + "Max JSON Size (KB)": "Максимальный размер JSON (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Максимальный размер каждого поля JSON (запрос/ответ) перед усечением", + "All data stored on your machine": "Все данные хранятся на вашем компьютере", + "MITM Server": "Сервер MITM", + "Running": "Работает", + "Stopped": "Остановлено", + "Cert": "Сертификат", + "Server": "Сервер", + "Purpose:": "Назначение:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Используйте Antigravity IDE и GitHub Copilot → с ЛЮБЫМ провайдером/моделью от 9Router", + "How it works:": "Как это работает:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Запрос Antigravity/Copilot IDE → Перенаправление DNS на localhost:443 → Прокси MITM перехватывает → 9Router → ответ для Antigravity/Copilot", + "No API keys — create one in Keys page": "Нет ключей API — создайте один на странице ключей", + "sk_9router (default)": "sk_9router (по умолчанию)", + "Server started": "Сервер запущен", + "Failed to start server": "Ошибка при запуске сервера", + "Server stopped — all DNS cleared": "Сервер остановлен — все DNS очищено", + "Failed to stop server": "Ошибка при остановке сервера", + "Sudo password is required": "Требуется пароль sudo", + "Stop Server": "Остановить сервер", + "Start Server": "Запустить сервер", + "Enable DNS per tool below to activate interception": "Включите DNS для каждого инструмента ниже, чтобы активировать перехват", + "Sudo Password Required": "Требуется пароль Sudo", + "Enter your sudo password to start/stop MITM server": "Введите пароль sudo для запуска/остановки сервера MITM", + "Sudo Password": "Пароль sudo", + "Click to add, click again to remove. Changes are saved automatically.": "Нажмите, чтобы добавить, нажмите ещё раз, чтобы удалить. Изменения сохраняются автоматически.", + "⚠️ 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.": "⚠️ Уведомление о риске: Этот провайдер использует сессию подписки/OAuth, не имеющую официальной лицензии для использования через прокси/маршрутизатор. Аккаунт может быть ограничен или заблокирован. Используйте на свой страх и риск.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM перехватывает HTTPS-трафик IDE-инструментов (Antigravity, GitHub Copilot, Kiro) через локальный CA для перенаправления запросов вашим провайдерам. Может нарушить ToS → риск блокировки аккаунта. Используйте на свой страх и риск.", + "Endpoint is exposed without an API key.": "Эндпоинт открыт без API-ключа." +} diff --git a/public/i18n/literals/sv.json b/public/i18n/literals/sv.json new file mode 100644 index 0000000000000000000000000000000000000000..0e4c3b46be756cd60504f5648c67682d05daa4b8 --- /dev/null +++ b/public/i18n/literals/sv.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Avbryt", + "Delete": "Ta bort", + "Edit": "Redigera", + "Save": "Spara", + "Close": "Stäng", + "Add": "Lägg till", + "Remove": "Ta bort", + "Settings": "Inställningar", + "Profile": "Profil", + "Dashboard": "Instrumentpanel", + "Logout": "Logga ut", + "Login": "Logga in", + "Providers": "Leverantörer", + "Usage": "Användarstatistik", + "API Key": "API-nyckel", + "Connected": "Ansluten", + "Disconnected": "Frånkopplad", + "Active": "Aktiv", + "Inactive": "Inaktiv", + "Success": "Framgång", + "Failed": "Misslyckad", + "Error": "Fel", + "Warning": "Varning", + "Info": "Information", + "Loading": "Laddar", + "Search": "Sök", + "Filter": "Filter", + "Sort": "Sortera", + "Export": "Exportera", + "Import": "Importera", + "Refresh": "Uppdatera", + "Back": "Tillbaka", + "Next": "Nästa", + "Previous": "Föregående", + "Submit": "Skicka", + "Confirm": "Bekräfta", + "Yes": "Ja", + "No": "Nej", + "OK": "OK", + "Apply": "Verkställ", + "Reset": "Återställ", + "Clear": "Rensa", + "Select": "Välj", + "Upload": "Ladda upp", + "Download": "Ladda ner", + "Copy": "Kopiera", + "Paste": "Klistra in", + "Cut": "Klipp ut", + "Undo": "Ångra", + "Redo": "Gör om", + "Name": "Namn", + "Description": "Beskrivning", + "Status": "Status", + "Type": "Typ", + "Date": "Datum", + "Time": "Tid", + "Created": "Skapad", + "Updated": "Uppdaterad", + "Actions": "Åtgärder", + "Details": "Detaljer", + "View": "Visa", + "New": "Ny", + "Total": "Totalt", + "Count": "Antal", + "Price": "Pris", + "Cost": "Kostnad", + "Free": "Gratis", + "Paid": "Betald", + "Enable": "Aktivera", + "Disable": "Inaktivera", + "Enabled": "Aktiverad", + "Disabled": "Inaktiverad", + "Online": "Online", + "Offline": "Offline", + "Available": "Tillgänglig", + "Unavailable": "Inte tillgänglig", + "Required": "Krävs", + "Optional": "Valfritt", + "Default": "Standard", + "Custom": "Anpassad", + "Advanced": "Avancerat", + "Basic": "Grundläggande", + "Help": "Hjälp", + "Support": "Support", + "Documentation": "Dokumentation", + "Version": "Version", + "Language": "Språk", + "Theme": "Tema", + "Light": "Ljus", + "Dark": "Mörk", + "Auto": "Automatisk", + "Endpoint": "Slutpunkt", + "Combos": "Kombinationer", + "Quota Tracker": "Kvotspårare", + "MITM": "MITM", + "CLI Tools": "Verktyg", + "Console Log": "Konsollogg", + "System": "System", + "Debug": "Felsökning", + "Shutdown": "Stänga av", + "Close Proxy": "Stäng proxy", + "Are you sure you want to close the proxy server?": "Är du säker på att du vill stänga proxyservern?", + "Server Disconnected": "Server frånkopplad", + "The proxy server has been stopped.": "Proxyservern har stoppats.", + "Reload Page": "Läs in sidan på nytt", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "Tjänsten körs i terminalen. Du kan stänga denna webbsida. Avstängning stoppar tjänsten.", + "Manage your AI provider connections": "Hantera dina AI-leverantörsanslutningar", + "Model combos with fallback": "Modellkombinationer med fallback", + "Monitor your API usage, token consumption, and request logs": "Övervaka din API-användning, tokenförbrukning och begärandeloggar", + "Intercept CLI tool traffic and route through 9Router": "Avlyssna CLI-verktygstrafik och dirigera genom 9Router", + "Configure CLI tools": "Konfigurera CLI-verktyg", + "API endpoint configuration": "Konfiguration av API-slutpunkt", + "Manage your preferences": "Hantera dina inställningar", + "Debug translation flow between formats": "Felsöka översättningsflöde mellan format", + "Live server console output": "Live-serverkonsoloutdata", + "Create model combos with fallback support": "Skapa modellkombinationer med fallback-stöd", + "Local Mode": "Lokalt läge", + "Running on your machine": "Körs på din maskin", + "Database Location": "Databasplats", + "Download Backup": "Ladda ner säkerhetskopia", + "Import Backup": "Importera säkerhetskopia", + "Database backup downloaded": "Databassäkerhetskopia nedladdad", + "Database imported successfully": "Databasen importerades framgångsrikt", + "Security": "Säkerhet", + "Require login": "Kräv inloggning", + "When ON, dashboard requires password. When OFF, access without login.": "När ON krävs lösenord för instrumentpanelen. När OFF, åtkomst utan inloggning.", + "Current Password": "Aktuellt lösenord", + "Enter current password": "Ange aktuellt lösenord", + "New Password": "Nytt lösenord", + "Enter new password": "Ange nytt lösenord", + "Confirm New Password": "Bekräfta nytt lösenord", + "Confirm new password": "Bekräfta nytt lösenord", + "Update Password": "Uppdatera lösenord", + "Set Password": "Ange lösenord", + "Password updated successfully": "Lösenord uppdaterades framgångsrikt", + "Passwords do not match": "Lösenorden matchar inte", + "Routing Strategy": "Routningsstrategi", + "Round Robin": "Omväxling", + "Cycle through accounts to distribute load": "Cykla genom konton för att distribuera belastningen", + "Sticky Limit": "Klibbig gräns", + "Calls per account before switching": "Anrop per konto innan byte", + "Network": "Nätverk", + "Outbound Proxy": "Utgående proxy", + "Enable proxy for OAuth + provider outbound requests.": "Aktivera proxy för utgående OAuth + leverantörsförfrågningar.", + "Proxy URL": "Proxy-URL", + "Leave empty to inherit existing env proxy (if any).": "Lämna tomt för att ärva befintlig env-proxy (om sådan finns).", + "No Proxy": "Ingen proxy", + "Comma-separated hostnames/domains to bypass the proxy.": "Kommaseparerade värdnamn/domäner för att kringgå proxyn.", + "Test proxy URL": "Testa proxy-URL", + "Proxy settings applied": "Proxyinställningar tillämpade", + "Proxy enabled": "Proxy aktiverad", + "Proxy disabled": "Proxy inaktiverad", + "Proxy test OK": "Proxy-test OK", + "Proxy test failed": "Proxy-test misslyckades", + "Please enter a Proxy URL to test": "Ange en proxy-URL att testa", + "Observability": "Observerbarhet", + "Enable Observability": "Aktivera observerbarhet", + "Turn request detail recording on/off globally": "Slå på/av inspelning av förfrågningsdetaljer globalt", + "Max Records": "Maximala poster", + "Maximum request detail records to keep (older records are auto-deleted)": "Maximala begärandedetaljposter att behålla (äldre poster raderas automatiskt)", + "Batch Size": "Batchstorlek", + "Number of items to accumulate before writing to database (higher = better performance)": "Antal objekt att ackumulera innan skrivning till databas (högre = bättre prestanda)", + "Flush Interval (ms)": "Spölningsintervall (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Maximal väntetid innan buffern spolas (förhindrar dataförlust vid låg trafik)", + "Max JSON Size (KB)": "Maximal JSON-storlek (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Maximal storlek för varje JSON-fält (begäran/svar) före trunkering", + "All data stored on your machine": "Alla data lagras på din maskin", + "MITM Server": "MITM-server", + "Running": "Körs", + "Stopped": "Stoppad", + "Cert": "Certifikat", + "Server": "Server", + "Purpose:": "Syfte:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Använd Antigravity IDE & GitHub Copilot → med VALFRI leverantör/modell från 9Router", + "How it works:": "Hur det fungerar:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE-begäran → DNS-omdirigering till localhost:443 → MITM-proxy avlyssnar → 9Router → svar till Antigravity/Copilot", + "No API keys — create one in Keys page": "Inga API-nycklar — skapa en på nyckelsidan", + "sk_9router (default)": "sk_9router (standard)", + "Server started": "Servern startad", + "Failed to start server": "Misslyckades att starta servern", + "Server stopped — all DNS cleared": "Server stoppad — all DNS rensad", + "Failed to stop server": "Misslyckades att stoppa servern", + "Sudo password is required": "Sudo-lösenord krävs", + "Stop Server": "Stoppa server", + "Start Server": "Starta server", + "Enable DNS per tool below to activate interception": "Aktivera DNS för varje verktyg nedan för att aktivera avlyssning", + "Sudo Password Required": "Sudo-lösenord krävs", + "Enter your sudo password to start/stop MITM server": "Ange ditt sudo-lösenord för att starta/stoppa MITM-servern", + "Sudo Password": "Sudo-lösenord", + "Click to add, click again to remove. Changes are saved automatically.": "Klicka för att lägga till, klicka igen för att ta bort. Ändringar sparas automatiskt.", + "⚠️ 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.": "⚠️ Riskmeddelande: Denna leverantör använder en prenumerations-/OAuth-session som inte är officiellt licensierad för proxy-/routeranvändning. Kontot kan begränsas eller bannlysas. Användning sker på egen risk.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM avlyssnar HTTPS-trafik från IDE-verktyg (Antigravity, GitHub Copilot, Kiro) via lokal CA för att omdirigera förfrågningar till dina leverantörer. Kan bryta mot ToS → risk för kontoavstängning. Användning sker på egen risk.", + "Endpoint is exposed without an API key.": "Slutpunkten är exponerad utan en API-nyckel." +} diff --git a/public/i18n/literals/th.json b/public/i18n/literals/th.json new file mode 100644 index 0000000000000000000000000000000000000000..6169829f7238461416642850082bc09961c107ec --- /dev/null +++ b/public/i18n/literals/th.json @@ -0,0 +1,195 @@ +{ + "Cancel": "ยกเลิก", + "Delete": "ลบ", + "Edit": "แก้ไข", + "Save": "บันทึก", + "Close": "ปิด", + "Add": "เพิ่ม", + "Remove": "นำออก", + "Settings": "การตั้งค่า", + "Profile": "โปรไฟล์", + "Dashboard": "แดชบอร์ด", + "Logout": "ออกจากระบบ", + "Login": "เข้าสู่ระบบ", + "Providers": "ผู้ให้บริการ", + "Usage": "สถิติการใช้งาน", + "API Key": "คีย์ API", + "Connected": "เชื่อมต่อแล้ว", + "Disconnected": "ตัดการเชื่อมต่อ", + "Active": "ใช้งาน", + "Inactive": "ไม่ใช้งาน", + "Success": "สำเร็จ", + "Failed": "ล้มเหลว", + "Error": "ข้อผิดพลาด", + "Warning": "คำเตือน", + "Info": "ข้อมูล", + "Loading": "กำลังโหลด", + "Search": "ค้นหา", + "Filter": "ตัวกรอง", + "Sort": "เรียงลำดับ", + "Export": "ส่งออก", + "Import": "นำเข้า", + "Refresh": "รีเฟรช", + "Back": "ย้อนกลับ", + "Next": "ถัดไป", + "Previous": "ก่อนหน้า", + "Submit": "ส่ง", + "Confirm": "ยืนยัน", + "Yes": "ใช่", + "No": "ไม่", + "OK": "ตกลง", + "Apply": "ใช้", + "Reset": "รีเซ็ต", + "Clear": "ล้าง", + "Select": "เลือก", + "Upload": "อัพโหลด", + "Download": "ดาวน์โหลด", + "Copy": "คัดลอก", + "Paste": "วาง", + "Cut": "ตัด", + "Undo": "ยกเลิก", + "Redo": "ทำซ้ำ", + "Name": "ชื่อ", + "Description": "คำอธิบาย", + "Status": "สถานะ", + "Type": "ประเภท", + "Date": "วันที่", + "Time": "เวลา", + "Created": "สร้างแล้ว", + "Updated": "อัพเดตแล้ว", + "Actions": "การกระทำ", + "Details": "รายละเอียด", + "View": "ดู", + "New": "ใหม่", + "Total": "ทั้งหมด", + "Count": "จำนวน", + "Price": "ราคา", + "Cost": "ต้นทุน", + "Free": "ฟรี", + "Paid": "จ่ายเงิน", + "Enable": "เปิดใช้งาน", + "Disable": "ปิดใช้งาน", + "Enabled": "เปิดใช้งานแล้ว", + "Disabled": "ปิดใช้งานแล้ว", + "Online": "ออนไลน์", + "Offline": "ออฟไลน์", + "Available": "พร้อมใช้งาน", + "Unavailable": "ไม่พร้อมใช้งาน", + "Required": "จำเป็น", + "Optional": "ไม่บังคับ", + "Default": "ค่าเริ่มต้น", + "Custom": "กำหนดเอง", + "Advanced": "ขั้นสูง", + "Basic": "พื้นฐาน", + "Help": "ช่วยเหลือ", + "Support": "สนับสนุน", + "Documentation": "เอกสาร", + "Version": "เวอร์ชัน", + "Language": "ภาษา", + "Theme": "ธีม", + "Light": "สว่าง", + "Dark": "มืด", + "Auto": "อัตโนมัติ", + "Endpoint": "จุดสิ้นสุด", + "Combos": "ชุดรวม", + "Quota Tracker": "ตัวติดตามโควต้า", + "MITM": "MITM", + "CLI Tools": "เครื่องมือ", + "Console Log": "บันทึกคอนโซล", + "System": "ระบบ", + "Debug": "ดีบัก", + "Shutdown": "ปิดระบบ", + "Close Proxy": "ปิด Proxy", + "Are you sure you want to close the proxy server?": "คุณแน่ใจหรือว่าต้องการปิดเซิร์ฟเวอร์ proxy?", + "Server Disconnected": "เซิร์ฟเวอร์ตัดการเชื่อมต่อ", + "The proxy server has been stopped.": "เซิร์ฟเวอร์ proxy ถูกหยุดแล้ว", + "Reload Page": "โหลดหน้าใหม่", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "บริการกำลังทำงานในเทอร์มินัล คุณสามารถปิดหน้าเว็บนี้ได้ การปิดระบบจะหยุดบริการ", + "Manage your AI provider connections": "จัดการการเชื่อมต่อผู้ให้บริการ AI ของคุณ", + "Model combos with fallback": "ชุดรวมโมเดลที่มี fallback", + "Monitor your API usage, token consumption, and request logs": "ติดตามการใช้งาน API การใช้งาน token และบันทึกคำขอของคุณ", + "Intercept CLI tool traffic and route through 9Router": "สกัดปะท่อ CLI และเส้นทางผ่าน 9Router", + "Configure CLI tools": "กำหนดค่าเครื่องมือ CLI", + "API endpoint configuration": "การตั้งค่าจุดสิ้นสุด API", + "Manage your preferences": "จัดการการตั้งค่าของคุณ", + "Debug translation flow between formats": "ดีบักการไหลของการแปลระหว่างรูปแบบ", + "Live server console output": "ผลลัพธ์คอนโซลเซิร์ฟเวอร์สด", + "Create model combos with fallback support": "สร้างชุดรวมโมเดลที่มีการสนับสนุน fallback", + "Local Mode": "โหมดท้องถิ่น", + "Running on your machine": "ทำงานบนเครื่องของคุณ", + "Database Location": "ตำแหน่งของฐานข้อมูล", + "Download Backup": "ดาวน์โหลดการสำรองข้อมูล", + "Import Backup": "นำเข้าการสำรองข้อมูล", + "Database backup downloaded": "ดาวน์โหลดการสำรองข้อมูลฐานข้อมูลแล้ว", + "Database imported successfully": "นำเข้าฐานข้อมูลเสร็จสิ้น", + "Security": "ความปลอดภัย", + "Require login": "ต้องการการเข้าสู่ระบบ", + "When ON, dashboard requires password. When OFF, access without login.": "เมื่อเปิด แดชบอร์ดต้องการรหัสผ่าน เมื่อปิด เข้าถึงโดยไม่ต้องเข้าสู่ระบบ", + "Current Password": "รหัสผ่านปัจจุบัน", + "Enter current password": "ป้อนรหัสผ่านปัจจุบัน", + "New Password": "รหัสผ่านใหม่", + "Enter new password": "ป้อนรหัสผ่านใหม่", + "Confirm New Password": "ยืนยันรหัสผ่านใหม่", + "Confirm new password": "ยืนยันรหัสผ่านใหม่", + "Update Password": "อัพเดตรหัสผ่าน", + "Set Password": "ตั้งรหัสผ่าน", + "Password updated successfully": "อัพเดตรหัสผ่านเสร็จสิ้น", + "Passwords do not match": "รหัสผ่านไม่ตรงกัน", + "Routing Strategy": "กลยุทธ์การเส้นทาง", + "Round Robin": "โรบินรอบ", + "Cycle through accounts to distribute load": "วนรอบบัญชีเพื่อกระจายการโหลด", + "Sticky Limit": "ขีดจำกัดที่เหนียว", + "Calls per account before switching": "การโทรต่อบัญชีก่อนการสลับ", + "Network": "เครือข่าย", + "Outbound Proxy": "Proxy ขาออก", + "Enable proxy for OAuth + provider outbound requests.": "เปิดใช้งาน proxy สำหรับคำขอขาออก OAuth + ผู้ให้บริการ", + "Proxy URL": "URL Proxy", + "Leave empty to inherit existing env proxy (if any).": "ปล่อยว่างไว้เพื่อสืบทอด proxy env ที่มีอยู่ (หากมี)", + "No Proxy": "ไม่มี Proxy", + "Comma-separated hostnames/domains to bypass the proxy.": "ชื่อโฮสต์/โดเมนคั่นด้วยเครื่องหมายจุลภาค เพื่อข้าม proxy", + "Test proxy URL": "ทดสอบ URL Proxy", + "Proxy settings applied": "ใช้การตั้งค่า proxy แล้ว", + "Proxy enabled": "เปิดใช้งาน proxy", + "Proxy disabled": "ปิดใช้งาน proxy", + "Proxy test OK": "ทดสอบ proxy ตกลง", + "Proxy test failed": "ทดสอบ proxy ล้มเหลว", + "Please enter a Proxy URL to test": "กรุณาป้อน URL Proxy เพื่อทดสอบ", + "Observability": "ความสามารถในการสังเกต", + "Enable Observability": "เปิดใช้งานความสามารถในการสังเกต", + "Turn request detail recording on/off globally": "เปิด/ปิดการบันทึกรายละเอียดคำขอทั่วโลก", + "Max Records": "บันทึกสูงสุด", + "Maximum request detail records to keep (older records are auto-deleted)": "บันทึกรายละเอียดคำขอสูงสุดที่จะเก็บ (บันทึกเก่าจะลบโดยอัตโนมัติ)", + "Batch Size": "ขนาดแบตช์", + "Number of items to accumulate before writing to database (higher = better performance)": "จำนวนรายการที่จะรวบรวมก่อนเขียนลงฐานข้อมูล (สูงกว่า = ประสิทธิภาพดีกว่า)", + "Flush Interval (ms)": "ช่วงเวลาล้าง (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "เวลารอสูงสุดก่อนล้างบัฟเฟอร์ (ป้องกันการสูญหายข้อมูลในช่วงจราจรต่ำ)", + "Max JSON Size (KB)": "ขนาด JSON สูงสุด (KB)", + "Maximum size for each JSON field (request/response) before truncation": "ขนาดสูงสุดสำหรับแต่ละช่อง JSON (คำขอ/การตอบสนอง) ก่อนการตัดทอน", + "All data stored on your machine": "ข้อมูลทั้งหมดจัดเก็บไว้บนเครื่องของคุณ", + "MITM Server": "เซิร์ฟเวอร์ MITM", + "Running": "กำลังทำงาน", + "Stopped": "หยุดแล้ว", + "Cert": "ใบรับรอง", + "Server": "เซิร์ฟเวอร์", + "Purpose:": "วัตถุประสงค์:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "ใช้ Antigravity IDE & GitHub Copilot → ที่มีผู้ให้บริการ/โมเดลใด ๆ จาก 9Router", + "How it works:": "วิธีการทำงาน:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "คำขอ Antigravity/Copilot IDE → เปลี่ยนเส้นทาง DNS เป็น localhost:443 → MITM proxy สกัดปะท่อ → 9Router → ตอบสนอง Antigravity/Copilot", + "No API keys — create one in Keys page": "ไม่มีคีย์ API — สร้างคีย์ในหน้า Keys", + "sk_9router (default)": "sk_9router (ค่าเริ่มต้น)", + "Server started": "เซิร์ฟเวอร์เริ่มต้นแล้ว", + "Failed to start server": "ไม่สามารถเริ่มเซิร์ฟเวอร์", + "Server stopped — all DNS cleared": "หยุดเซิร์ฟเวอร์ — ล้าง DNS ทั้งหมด", + "Failed to stop server": "ไม่สามารถหยุดเซิร์ฟเวอร์", + "Sudo password is required": "ต้องการรหัสผ่าน sudo", + "Stop Server": "หยุดเซิร์ฟเวอร์", + "Start Server": "เริ่มเซิร์ฟเวอร์", + "Enable DNS per tool below to activate interception": "เปิดใช้งาน DNS สำหรับแต่ละเครื่องมือด้านล่างเพื่อเปิดใช้งานการสกัดปะท่อ", + "Sudo Password Required": "ต้องการรหัสผ่าน Sudo", + "Enter your sudo password to start/stop MITM server": "ป้อนรหัสผ่าน sudo ของคุณเพื่อเริ่ม/หยุดเซิร์ฟเวอร์ MITM", + "Sudo Password": "รหัสผ่าน Sudo", + "Click to add, click again to remove. Changes are saved automatically.": "คลิกเพื่อเพิ่ม คลิกอีกครั้งเพื่อลบ การเปลี่ยนแปลงจะถูกบันทึกโดยอัตโนมัติ", + "⚠️ 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.": "⚠️ ประกาศความเสี่ยง: ผู้ให้บริการนี้ใช้เซสชันสมัครสมาชิก/OAuth ที่ไม่ได้รับอนุญาตอย่างเป็นทางการสำหรับการใช้งานพร็อกซี/เราเตอร์ บัญชีอาจถูกจำกัดหรือถูกแบน ใช้งานด้วยความเสี่ยงของคุณเอง", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM ดักจับการรับส่งข้อมูล HTTPS ของเครื่องมือ IDE (Antigravity, GitHub Copilot, Kiro) ผ่าน CA ท้องถิ่นเพื่อเปลี่ยนเส้นทางคำขอไปยังผู้ให้บริการของคุณ อาจละเมิด ToS → เสี่ยงถูกแบนบัญชี ใช้งานด้วยความเสี่ยงของคุณเอง", + "Endpoint is exposed without an API key.": "เอนด์พอยต์เปิดให้เข้าถึงโดยไม่มีคีย์ API" +} diff --git a/public/i18n/literals/tl.json b/public/i18n/literals/tl.json new file mode 100644 index 0000000000000000000000000000000000000000..51af4e2412421ac59e69ad58a70686d00430252e --- /dev/null +++ b/public/i18n/literals/tl.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Kanselahin", + "Delete": "Tanggalin", + "Edit": "Baguhin", + "Save": "Salin", + "Close": "Isara", + "Add": "Magdagdag", + "Remove": "Alisin", + "Settings": "Mga Setting", + "Profile": "Propesyal", + "Dashboard": "Dashboard", + "Logout": "Maglog out", + "Login": "Magsimula ng sesyon", + "Providers": "Mga Provider", + "Usage": "Mga Istatistika ng Paggamit", + "API Key": "Susi ng API", + "Connected": "Konektado", + "Disconnected": "Hindi Konektado", + "Active": "Aktibo", + "Inactive": "Hindi Aktibo", + "Success": "Matagumpay", + "Failed": "Nabigo", + "Error": "Kamalian", + "Warning": "Babala", + "Info": "Impormasyon", + "Loading": "Naglo-load", + "Search": "Maghanap", + "Filter": "Salain", + "Sort": "I-sort", + "Export": "I-export", + "Import": "I-import", + "Refresh": "I-refresh", + "Back": "Bumalik", + "Next": "Susunod", + "Previous": "Nakaraan", + "Submit": "Ipadala", + "Confirm": "Kumpirmahin", + "Yes": "Oo", + "No": "Hindi", + "OK": "OK", + "Apply": "Ilapat", + "Reset": "I-reset", + "Clear": "I-clear", + "Select": "Pumili", + "Upload": "Mag-upload", + "Download": "I-download", + "Copy": "Kopyahin", + "Paste": "I-paste", + "Cut": "Gupitin", + "Undo": "Undo", + "Redo": "I-redo", + "Name": "Pangalan", + "Description": "Paglalarawan", + "Status": "Kalagayan", + "Type": "Uri", + "Date": "Petsa", + "Time": "Oras", + "Created": "Ginawa", + "Updated": "Ina-update", + "Actions": "Mga Aksyon", + "Details": "Mga Detalye", + "View": "Tingnan", + "New": "Bago", + "Total": "Kabuuan", + "Count": "Bilang", + "Price": "Presyo", + "Cost": "Gastos", + "Free": "Libre", + "Paid": "Bayad", + "Enable": "Paganahin", + "Disable": "Huwag paganahin", + "Enabled": "Pinagana", + "Disabled": "Hindi pinagana", + "Online": "Online", + "Offline": "Offline", + "Available": "Available", + "Unavailable": "Hindi Available", + "Required": "Kinakailangan", + "Optional": "Opsyonal", + "Default": "Default", + "Custom": "Custom", + "Advanced": "Advanced", + "Basic": "Basic", + "Help": "Tulong", + "Support": "Suporta", + "Documentation": "Dokumentasyon", + "Version": "Bersyon", + "Language": "Wika", + "Theme": "Tema", + "Light": "Liwanag", + "Dark": "Madilim", + "Auto": "Awtomatiko", + "Endpoint": "Endpoint", + "Combos": "Mga Combo", + "Quota Tracker": "Quota Tracker", + "MITM": "MITM", + "CLI Tools": "Mga Tool", + "Console Log": "Console Log", + "System": "Sistema", + "Debug": "I-debug", + "Shutdown": "Patugharin", + "Close Proxy": "Isara ang Proxy", + "Are you sure you want to close the proxy server?": "Sigurado ka ba na gusto mong isara ang proxy server?", + "Server Disconnected": "Ang Server ay Naka-disconnect", + "The proxy server has been stopped.": "Ang proxy server ay tumigil na.", + "Reload Page": "I-reload ang Pahina", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "Ang serbisyo ay tumatakbo sa terminal. Maaari mong isara ang web page na ito. Ang Shutdown ay titigil ng serbisyo.", + "Manage your AI provider connections": "Pamahalaan ang iyong mga koneksyon ng AI provider", + "Model combos with fallback": "Mga model combo na may fallback", + "Monitor your API usage, token consumption, and request logs": "Subaybayan ang iyong paggamit ng API, pagkonsumo ng token, at mga log ng request", + "Intercept CLI tool traffic and route through 9Router": "Harangin ang lalu ng CLI tool at i-route sa pamamagitan ng 9Router", + "Configure CLI tools": "I-configure ang CLI tools", + "API endpoint configuration": "Konfiguration ng API endpoint", + "Manage your preferences": "Pamahalaan ang iyong mga kagustuhan", + "Debug translation flow between formats": "I-debug ang translation flow sa pagitan ng mga format", + "Live server console output": "Live server console output", + "Create model combos with fallback support": "Lumikha ng mga model combo na may fallback support", + "Local Mode": "Local Mode", + "Running on your machine": "Tumatakbo sa iyong machine", + "Database Location": "Lokasyon ng Database", + "Download Backup": "I-download ang Backup", + "Import Backup": "I-import ang Backup", + "Database backup downloaded": "Ang database backup ay na-download", + "Database imported successfully": "Ang database ay matagumpay na nai-import", + "Security": "Seguridad", + "Require login": "Kailangan ng login", + "When ON, dashboard requires password. When OFF, access without login.": "Kapag ON, ang dashboard ay nangangailangan ng password. Kapag OFF, access nang walang login.", + "Current Password": "Kasalukuyang Password", + "Enter current password": "Ipasok ang kasalukuyang password", + "New Password": "Bagong Password", + "Enter new password": "Ipasok ang bagong password", + "Confirm New Password": "Kumpirmahin ang Bagong Password", + "Confirm new password": "Kumpirmahin ang bagong password", + "Update Password": "I-update ang Password", + "Set Password": "I-set ang Password", + "Password updated successfully": "Ang password ay matagumpay na na-update", + "Passwords do not match": "Ang mga password ay hindi tumutugma", + "Routing Strategy": "Routing Strategy", + "Round Robin": "Round Robin", + "Cycle through accounts to distribute load": "Umiikot sa mga account upang ipamahagi ang load", + "Sticky Limit": "Sticky Limit", + "Calls per account before switching": "Mga tawag bawat account bago mag-switch", + "Network": "Network", + "Outbound Proxy": "Outbound Proxy", + "Enable proxy for OAuth + provider outbound requests.": "Paganahin ang proxy para sa OAuth + provider outbound requests.", + "Proxy URL": "Proxy URL", + "Leave empty to inherit existing env proxy (if any).": "Iwanan kung walang laman upang mamanin ang umiiral na env proxy (kung mayroon).", + "No Proxy": "Walang Proxy", + "Comma-separated hostnames/domains to bypass the proxy.": "Mga hostname/domain na pinagseparang komma upang lampasan ang proxy.", + "Test proxy URL": "Subukan ang Proxy URL", + "Proxy settings applied": "Ang mga setting ng proxy ay inilapat", + "Proxy enabled": "Ang proxy ay pinagana", + "Proxy disabled": "Ang proxy ay hindi pinagana", + "Proxy test OK": "Proxy test OK", + "Proxy test failed": "Ang proxy test ay nabigo", + "Please enter a Proxy URL to test": "Pakiusap na ipasok ang Proxy URL upang subukan", + "Observability": "Observability", + "Enable Observability": "Paganahin ang Observability", + "Turn request detail recording on/off globally": "I-turn on/off ang request detail recording nang pandaigdig", + "Max Records": "Max Records", + "Maximum request detail records to keep (older records are auto-deleted)": "Pinakamataas na request detail records na papanatilihin (ang mga lumang record ay awtomatikong tatanggalin)", + "Batch Size": "Batch Size", + "Number of items to accumulate before writing to database (higher = better performance)": "Bilang ng mga item na mag-ipon bago magsulat sa database (mas mataas = mas magandang performance)", + "Flush Interval (ms)": "Flush Interval (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Pinakamataas na oras na paghihintay bago mag-flush ng buffer (pumipigil sa pagkawala ng data sa panahon ng mababang traffic)", + "Max JSON Size (KB)": "Max JSON Size (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Pinakamataas na sukat para sa bawat JSON field (request/response) bago ang truncation", + "All data stored on your machine": "Lahat ng data ay nakaimbak sa iyong machine", + "MITM Server": "MITM Server", + "Running": "Tumatakbo", + "Stopped": "Tumigil", + "Cert": "Cert", + "Server": "Server", + "Purpose:": "Layunin:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Gamitin ang Antigravity IDE & GitHub Copilot → sa ANUMANG provider/model mula sa 9Router", + "How it works:": "Paano ito gumagana:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE request → DNS redirect sa localhost:443 → MITM proxy intercepts → 9Router → response sa Antigravity/Copilot", + "No API keys — create one in Keys page": "Walang API keys — lumikha ng isa sa Keys page", + "sk_9router (default)": "sk_9router (default)", + "Server started": "Ang server ay nagsimula", + "Failed to start server": "Nabigo na magsimula ang server", + "Server stopped — all DNS cleared": "Ang server ay tumigil — lahat ng DNS ay naka-clear", + "Failed to stop server": "Nabigo na ihinto ang server", + "Sudo password is required": "Kailangan ng sudo password", + "Stop Server": "Ihinto ang Server", + "Start Server": "Simulan ang Server", + "Enable DNS per tool below to activate interception": "Paganahin ang DNS para sa bawat tool sa ibaba upang aktivahin ang interception", + "Sudo Password Required": "Kailangan ng Sudo Password", + "Enter your sudo password to start/stop MITM server": "Ipasok ang iyong sudo password upang simulan/ihinto ang MITM server", + "Sudo Password": "Sudo Password", + "Click to add, click again to remove. Changes are saved automatically.": "I-click para idagdag, i-click muli para alisin. Ang mga pagbabago ay awtomatikong nase-save.", + "⚠️ 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.": "⚠️ Babala sa Panganib: Ang provider na ito ay gumagamit ng subscription/OAuth session na hindi opisyal na lisensyado para sa proxy/router na paggamit. Maaaring marestrikta o ma-ban ang account. Gamitin sa sarili mong panganib.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ Hinaharang ng MITM ang HTTPS traffic ng mga IDE tool (Antigravity, GitHub Copilot, Kiro) sa pamamagitan ng lokal na CA upang i-redirect ang mga kahilingan sa iyong mga provider. Maaaring lumabag sa ToS → panganib ng pag-ban sa account. Gamitin sa sarili mong panganib.", + "Endpoint is exposed without an API key.": "Nakalantad ang endpoint nang walang API key." +} diff --git a/public/i18n/literals/tr.json b/public/i18n/literals/tr.json new file mode 100644 index 0000000000000000000000000000000000000000..ac4042aaf3cd630c016513680d3e20ee7f6e253b --- /dev/null +++ b/public/i18n/literals/tr.json @@ -0,0 +1,195 @@ +{ + "Cancel": "İptal", + "Delete": "Sil", + "Edit": "Düzenle", + "Save": "Kaydet", + "Close": "Kapat", + "Add": "Ekle", + "Remove": "Kaldır", + "Settings": "Ayarlar", + "Profile": "Profil", + "Dashboard": "Kontrol Paneli", + "Logout": "Çıkış Yap", + "Login": "Giriş Yap", + "Providers": "Sağlayıcılar", + "Usage": "Kullanım İstatistikleri", + "API Key": "API Anahtarı", + "Connected": "Bağlı", + "Disconnected": "Bağlantısı Kesildi", + "Active": "Etkin", + "Inactive": "Etkin Değil", + "Success": "Başarılı", + "Failed": "Başarısız", + "Error": "Hata", + "Warning": "Uyarı", + "Info": "Bilgi", + "Loading": "Yükleniyor", + "Search": "Ara", + "Filter": "Filtre", + "Sort": "Sırala", + "Export": "Dışa Aktar", + "Import": "İçe Aktar", + "Refresh": "Yenile", + "Back": "Geri", + "Next": "İleri", + "Previous": "Önceki", + "Submit": "Gönder", + "Confirm": "Onayla", + "Yes": "Evet", + "No": "Hayır", + "OK": "Tamam", + "Apply": "Uygula", + "Reset": "Sıfırla", + "Clear": "Temizle", + "Select": "Seç", + "Upload": "Yükle", + "Download": "İndir", + "Copy": "Kopyala", + "Paste": "Yapıştır", + "Cut": "Kes", + "Undo": "Geri Al", + "Redo": "Yinele", + "Name": "Ad", + "Description": "Açıklama", + "Status": "Durum", + "Type": "Tür", + "Date": "Tarih", + "Time": "Saat", + "Created": "Oluşturuldu", + "Updated": "Güncellendi", + "Actions": "İşlemler", + "Details": "Ayrıntılar", + "View": "Görüntüle", + "New": "Yeni", + "Total": "Toplam", + "Count": "Sayı", + "Price": "Fiyat", + "Cost": "Maliyet", + "Free": "Ücretsiz", + "Paid": "Ücretli", + "Enable": "Etkinleştir", + "Disable": "Devre Dışı Bırak", + "Enabled": "Etkinleştirildi", + "Disabled": "Devre Dışı Bırakıldı", + "Online": "Çevrimiçi", + "Offline": "Çevrimdışı", + "Available": "Kullanılabilir", + "Unavailable": "Kullanılamıyor", + "Required": "Gerekli", + "Optional": "İsteğe Bağlı", + "Default": "Varsayılan", + "Custom": "Özel", + "Advanced": "Gelişmiş", + "Basic": "Temel", + "Help": "Yardım", + "Support": "Destek", + "Documentation": "Belgeler", + "Version": "Sürüm", + "Language": "Dil", + "Theme": "Tema", + "Light": "Açık", + "Dark": "Koyu", + "Auto": "Otomatik", + "Endpoint": "Uç Nokta", + "Combos": "Kombinasyonlar", + "Quota Tracker": "Kota İzleyici", + "MITM": "MITM", + "CLI Tools": "Araçlar", + "Console Log": "Konsol Günlüğü", + "System": "Sistem", + "Debug": "Hata Ayıkla", + "Shutdown": "Kapat", + "Close Proxy": "Proxy'yi Kapat", + "Are you sure you want to close the proxy server?": "Proxy sunucusunu kapatmak istediğinizden emin misiniz?", + "Server Disconnected": "Sunucu Bağlantısı Kesildi", + "The proxy server has been stopped.": "Proxy sunucusu durduruldu.", + "Reload Page": "Sayfayı Yenile", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "Hizmet terminalde çalışıyor. Bu web sayfasını kapatabilirsiniz. Kapatma, hizmeti durduracaktır.", + "Manage your AI provider connections": "AI sağlayıcı bağlantılarınızı yönetin", + "Model combos with fallback": "Yedek desteğiyle model kombinasyonları", + "Monitor your API usage, token consumption, and request logs": "API kullanımınızı, token tüketimini ve istek günlüklerini izleyin", + "Intercept CLI tool traffic and route through 9Router": "CLI araç trafiğini yakalayın ve 9Router üzerinden yönlendirin", + "Configure CLI tools": "CLI araçlarını yapılandırın", + "API endpoint configuration": "API uç noktası yapılandırması", + "Manage your preferences": "Tercihlerinizi yönetin", + "Debug translation flow between formats": "Formatlar arasındaki çeviri akışında hata ayıklayın", + "Live server console output": "Canlı sunucu konsol çıkışı", + "Create model combos with fallback support": "Yedek destek ile model kombinasyonları oluşturun", + "Local Mode": "Yerel Mod", + "Running on your machine": "Makinenizde çalışıyor", + "Database Location": "Veritabanı Konumu", + "Download Backup": "Yedeklemeyi İndir", + "Import Backup": "Yedeklemeyi İçe Aktar", + "Database backup downloaded": "Veritabanı yedeklemesi indirildi", + "Database imported successfully": "Veritabanı başarıyla içe aktarıldı", + "Security": "Güvenlik", + "Require login": "Giriş Gerekli", + "When ON, dashboard requires password. When OFF, access without login.": "AÇIK olduğunda, kontrol paneli parola gerektirir. KAPAL olduğunda, oturum açmadan erişin.", + "Current Password": "Mevcut Parola", + "Enter current password": "Mevcut parolayı girin", + "New Password": "Yeni Parola", + "Enter new password": "Yeni parolayı girin", + "Confirm New Password": "Yeni Parolayı Onayla", + "Confirm new password": "Yeni parolayı onayla", + "Update Password": "Parolayı Güncelle", + "Set Password": "Parola Ayarla", + "Password updated successfully": "Parola başarıyla güncellendi", + "Passwords do not match": "Parolalar eşleşmiyor", + "Routing Strategy": "Yönlendirme Stratejisi", + "Round Robin": "Dönerektir", + "Cycle through accounts to distribute load": "Yükü dağıtmak için hesaplar arasında döngü yapın", + "Sticky Limit": "Yapışkan Sınır", + "Calls per account before switching": "Geçiş öncesi hesap başına çağrılar", + "Network": "Ağ", + "Outbound Proxy": "Giden Proxy", + "Enable proxy for OAuth + provider outbound requests.": "OAuth + sağlayıcı giden istekleri için proxy'yi etkinleştirin.", + "Proxy URL": "Proxy URL'si", + "Leave empty to inherit existing env proxy (if any).": "Mevcut ortam proxy'sini (varsa) devralması için boş bırakın.", + "No Proxy": "Proxy Yok", + "Comma-separated hostnames/domains to bypass the proxy.": "Proxy'yi atlamak için virgülle ayrılmış ana bilgisayar adları/etki alanları.", + "Test proxy URL": "Test Proxy URL'si", + "Proxy settings applied": "Proxy ayarları uygulandı", + "Proxy enabled": "Proxy etkinleştirildi", + "Proxy disabled": "Proxy devre dışı bırakıldı", + "Proxy test OK": "Proxy testi Tamam", + "Proxy test failed": "Proxy testi başarısız", + "Please enter a Proxy URL to test": "Test etmek için lütfen bir Proxy URL'si girin", + "Observability": "Gözlemlenebilirlik", + "Enable Observability": "Gözlemlenebilirliği Etkinleştir", + "Turn request detail recording on/off globally": "İstek ayrıntıları kaydını genel olarak aç/kapat", + "Max Records": "Maksimum Kayıtlar", + "Maximum request detail records to keep (older records are auto-deleted)": "Tutulacak maksimum istek ayrıntı kayıtları (eski kayıtlar otomatik olarak silinir)", + "Batch Size": "Toplu İşlem Boyutu", + "Number of items to accumulate before writing to database (higher = better performance)": "Veritabanına yazmadan önce biriktirilecek öğe sayısı (daha yüksek = daha iyi performans)", + "Flush Interval (ms)": "Temizleme Aralığı (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Arabelleği temizlemeden önce beklenecek maksimum süre (düşük trafikte veri kaybını engeller)", + "Max JSON Size (KB)": "Maksimum JSON Boyutu (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Kesilmeden önce her JSON alanı (istek/yanıt) için maksimum boyut", + "All data stored on your machine": "Tüm veriler makinenizde depolanır", + "MITM Server": "MITM Sunucusu", + "Running": "Çalışıyor", + "Stopped": "Durduruldu", + "Cert": "Sertifika", + "Server": "Sunucu", + "Purpose:": "Amaç:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Antigravity IDE & GitHub Copilot kullanın → 9Router'dan HERHANGİ bir sağlayıcı/model ile", + "How it works:": "Nasıl çalışır:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE isteği → DNS'i localhost:443'e yönlendir → MITM proxy yakalar → 9Router → Antigravity/Copilot'a yanıt", + "No API keys — create one in Keys page": "API anahtarı yok — Keys sayfasında bir tane oluşturun", + "sk_9router (default)": "sk_9router (varsayılan)", + "Server started": "Sunucu başlatıldı", + "Failed to start server": "Sunucu başlatılamadı", + "Server stopped — all DNS cleared": "Sunucu durduruldu — tüm DNS temizlendi", + "Failed to stop server": "Sunucu durdurulamadı", + "Sudo password is required": "Sudo parolası gereklidir", + "Stop Server": "Sunucuyu Durdur", + "Start Server": "Sunucuyu Başlat", + "Enable DNS per tool below to activate interception": "Yakalamayı etkinleştirmek için aşağıdaki her araç için DNS'i etkinleştirin", + "Sudo Password Required": "Sudo Parolası Gerekli", + "Enter your sudo password to start/stop MITM server": "MITM sunucusunu başlatmak/durdurmak için sudo parolanızı girin", + "Sudo Password": "Sudo Parolası", + "Click to add, click again to remove. Changes are saved automatically.": "Eklemek için tıklayın, kaldırmak için tekrar tıklayın. Değişiklikler otomatik olarak kaydedilir.", + "⚠️ 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.": "⚠️ Risk Bildirimi: Bu sağlayıcı, proxy/yönlendirici kullanımı için resmi olarak lisanslı olmayan bir abonelik/OAuth oturumu kullanır. Hesap kısıtlanabilir veya yasaklanabilir. Kendi sorumluluğunuzda kullanın.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM, isteklerinizi sağlayıcılarınıza yönlendirmek için yerel CA aracılığıyla IDE araçlarının (Antigravity, GitHub Copilot, Kiro) HTTPS trafiğini engeller. ToS'u ihlal edebilir → hesap yasaklama riski. Kendi sorumluluğunuzda kullanın.", + "Endpoint is exposed without an API key.": "Uç nokta API anahtarı olmadan açıkta." +} diff --git a/public/i18n/literals/uk.json b/public/i18n/literals/uk.json new file mode 100644 index 0000000000000000000000000000000000000000..e238ad2b8fd55bedb743a1482e23845fb0c0894e --- /dev/null +++ b/public/i18n/literals/uk.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Скасувати", + "Delete": "Видалити", + "Edit": "Редагувати", + "Save": "Зберегти", + "Close": "Закрити", + "Add": "Додати", + "Remove": "Видалити", + "Settings": "Налаштування", + "Profile": "Профіль", + "Dashboard": "Панель керування", + "Logout": "Вийти", + "Login": "Увійти", + "Providers": "Постачальники", + "Usage": "Статистика використання", + "API Key": "Ключ API", + "Connected": "Підключено", + "Disconnected": "Відключено", + "Active": "Активний", + "Inactive": "Неактивний", + "Success": "Успіх", + "Failed": "Помилка", + "Error": "Помилка", + "Warning": "Попередження", + "Info": "Інформація", + "Loading": "Завантаження", + "Search": "Пошук", + "Filter": "Фільтр", + "Sort": "Сортування", + "Export": "Експорт", + "Import": "Імпорт", + "Refresh": "Оновити", + "Back": "Назад", + "Next": "Далі", + "Previous": "Попередній", + "Submit": "Надіслати", + "Confirm": "Підтвердити", + "Yes": "Так", + "No": "Ні", + "OK": "ОК", + "Apply": "Застосувати", + "Reset": "Скинути", + "Clear": "Очистити", + "Select": "Вибрати", + "Upload": "Завантажити", + "Download": "Завантажити", + "Copy": "Копіювати", + "Paste": "Вставити", + "Cut": "Вирізати", + "Undo": "Відмінити", + "Redo": "Повторити", + "Name": "Назва", + "Description": "Опис", + "Status": "Статус", + "Type": "Тип", + "Date": "Дата", + "Time": "Час", + "Created": "Створено", + "Updated": "Оновлено", + "Actions": "Дії", + "Details": "Деталі", + "View": "Перегляд", + "New": "Новий", + "Total": "Всього", + "Count": "Кількість", + "Price": "Ціна", + "Cost": "Вартість", + "Free": "Безплатно", + "Paid": "Платно", + "Enable": "Увімкнути", + "Disable": "Вимкнути", + "Enabled": "Увімкнено", + "Disabled": "Вимкнено", + "Online": "Онлайн", + "Offline": "Офлайн", + "Available": "Доступно", + "Unavailable": "Недоступно", + "Required": "Обов'язково", + "Optional": "Опційно", + "Default": "За замовчуванням", + "Custom": "Користувацький", + "Advanced": "Розширені", + "Basic": "Базовий", + "Help": "Допомога", + "Support": "Підтримка", + "Documentation": "Документація", + "Version": "Версія", + "Language": "Мова", + "Theme": "Тема", + "Light": "Світла", + "Dark": "Темна", + "Auto": "Авто", + "Endpoint": "Кінцева точка", + "Combos": "Комбо", + "Quota Tracker": "Відстеження квоти", + "MITM": "MITM", + "CLI Tools": "Інструменти", + "Console Log": "Журнал консолі", + "System": "Система", + "Debug": "Налагодження", + "Shutdown": "Вимкнення", + "Close Proxy": "Закрити проксі", + "Are you sure you want to close the proxy server?": "Ви впевнені, що хочете закрити сервер проксі?", + "Server Disconnected": "Сервер відключено", + "The proxy server has been stopped.": "Сервер проксі було зупинено.", + "Reload Page": "Перезавантажити сторінку", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "Служба працює в терміналі. Ви можете закрити цю веб-сторінку. Вимкнення призупинить службу.", + "Manage your AI provider connections": "Керуйте своїми з'єднаннями постачальника AI", + "Model combos with fallback": "Комбо моделей з резервуванням", + "Monitor your API usage, token consumption, and request logs": "Відстежуйте використання API, споживання токенів та журнали запитів", + "Intercept CLI tool traffic and route through 9Router": "Перехопіть трафік інструменту CLI та маршрутизуйте через 9Router", + "Configure CLI tools": "Налаштуйте інструменти CLI", + "API endpoint configuration": "Конфігурація кінцевої точки API", + "Manage your preferences": "Керуйте своїми уподобаннями", + "Debug translation flow between formats": "Налагодити потік перекладу між форматами", + "Live server console output": "Вихід консолі живого сервера", + "Create model combos with fallback support": "Створіть комбо моделей з підтримкою резервування", + "Local Mode": "Локальний режим", + "Running on your machine": "Запуск на вашій машині", + "Database Location": "Розташування бази даних", + "Download Backup": "Завантажити резервну копію", + "Import Backup": "Імпортувати резервну копію", + "Database backup downloaded": "Резервну копію бази даних завантажено", + "Database imported successfully": "Базу даних успішно імпортовано", + "Security": "Безпека", + "Require login": "Потрібне входження", + "When ON, dashboard requires password. When OFF, access without login.": "Коли ВІД, панель керування потребує пароля. Коли ВИМКНЕНО, доступ без входження.", + "Current Password": "Поточний пароль", + "Enter current password": "Введіть поточний пароль", + "New Password": "Новий пароль", + "Enter new password": "Введіть новий пароль", + "Confirm New Password": "Підтвердіть новий пароль", + "Confirm new password": "Підтвердіть новий пароль", + "Update Password": "Оновити пароль", + "Set Password": "Встановити пароль", + "Password updated successfully": "Пароль успішно оновлено", + "Passwords do not match": "Паролі не збігаються", + "Routing Strategy": "Стратегія маршрутизації", + "Round Robin": "Циклічний розподіл", + "Cycle through accounts to distribute load": "Циклічне переключення облікових записів для розподілу навантаження", + "Sticky Limit": "Обмеження липкості", + "Calls per account before switching": "Виклики на обліковий запис перед перемиканням", + "Network": "Мережа", + "Outbound Proxy": "Вихідний проксі", + "Enable proxy for OAuth + provider outbound requests.": "Увімкніть проксі для запитів OAuth + постачальника на виході.", + "Proxy URL": "URL проксі", + "Leave empty to inherit existing env proxy (if any).": "Залиште порожнім, щоб успадкувати існуючий проксі env (якщо є).", + "No Proxy": "Без проксі", + "Comma-separated hostnames/domains to bypass the proxy.": "Розділені комами імена хостів/домени для обходу проксі.", + "Test proxy URL": "Протестувати URL проксі", + "Proxy settings applied": "Параметри проксі застосовані", + "Proxy enabled": "Проксі увімкнено", + "Proxy disabled": "Проксі вимкнено", + "Proxy test OK": "Тест проксі OK", + "Proxy test failed": "Тест проксі не вдався", + "Please enter a Proxy URL to test": "Будь ласка, введіть URL проксі для тестування", + "Observability": "Спостережуваність", + "Enable Observability": "Увімкнути спостережуваність", + "Turn request detail recording on/off globally": "Увімкніть/вимкніть запис деталей запиту глобально", + "Max Records": "Максимальна кількість записів", + "Maximum request detail records to keep (older records are auto-deleted)": "Максимальна кількість записів деталей запиту для зберігання (старші записи автоматично видаляються)", + "Batch Size": "Розмір пакету", + "Number of items to accumulate before writing to database (higher = better performance)": "Кількість елементів для накопичення перед записом у базу даних (вище = краща продуктивність)", + "Flush Interval (ms)": "Інтервал промивки (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Максимальний час очікування перед промиванням буфера (запобігає втраті даних під час низького трафіку)", + "Max JSON Size (KB)": "Максимальний розмір JSON (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Максимальний розмір для кожного поля JSON (запит/відповідь) перед усіканням", + "All data stored on your machine": "Усі дані зберігаються на вашій машині", + "MITM Server": "MITM сервер", + "Running": "Запущено", + "Stopped": "Зупинено", + "Cert": "Сертифікат", + "Server": "Сервер", + "Purpose:": "Мета:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Використовуйте Antigravity IDE & GitHub Copilot → з БУДЬ-ЯКИМ постачальником/моделлю від 9Router", + "How it works:": "Як це працює:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Запит Antigravity/Copilot IDE → Перенаправлення DNS на localhost:443 → MITM проксі перехопити → 9Router → відповідь на Antigravity/Copilot", + "No API keys — create one in Keys page": "Немає ключів API — створіть один на сторінці ключів", + "sk_9router (default)": "sk_9router (за замовчуванням)", + "Server started": "Сервер запущено", + "Failed to start server": "Не вдалося запустити сервер", + "Server stopped — all DNS cleared": "Сервер зупинено — усі DNS очищено", + "Failed to stop server": "Не вдалося зупинити сервер", + "Sudo password is required": "Потрібен пароль sudo", + "Stop Server": "Зупинити сервер", + "Start Server": "Запустити сервер", + "Enable DNS per tool below to activate interception": "Увімкніть DNS для кожного інструменту нижче, щоб активувати перехоплення", + "Sudo Password Required": "Потрібен пароль Sudo", + "Enter your sudo password to start/stop MITM server": "Введіть пароль sudo для запуску/зупинення MITM сервера", + "Sudo Password": "Пароль Sudo", + "Click to add, click again to remove. Changes are saved automatically.": "Натисніть, щоб додати, натисніть ще раз, щоб видалити. Зміни зберігаються автоматично.", + "⚠️ 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.": "⚠️ Сповіщення про ризик: Цей провайдер використовує сесію підписки/OAuth, яка офіційно не ліцензована для використання через проксі/маршрутизатор. Обліковий запис може бути обмежений або заблокований. Використовуйте на свій страх і ризик.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM перехоплює HTTPS-трафік IDE-інструментів (Antigravity, GitHub Copilot, Kiro) через локальний CA для перенаправлення запитів до ваших провайдерів. Може порушити ToS → ризик блокування облікового запису. Використовуйте на свій страх і ризик.", + "Endpoint is exposed without an API key.": "Кінцеву точку відкрито без API-ключа." +} diff --git a/public/i18n/literals/ur.json b/public/i18n/literals/ur.json new file mode 100644 index 0000000000000000000000000000000000000000..e59217554b6a9460d8bbfd0db610c4ca5b181291 --- /dev/null +++ b/public/i18n/literals/ur.json @@ -0,0 +1,195 @@ +{ + "Cancel": "منسوخ کریں", + "Delete": "حذف کریں", + "Edit": "ترمیم کریں", + "Save": "محفوظ کریں", + "Close": "بند کریں", + "Add": "شامل کریں", + "Remove": "ہٹائیں", + "Settings": "ترتیبات", + "Profile": "پروفائل", + "Dashboard": "ڈیش بورڈ", + "Logout": "لاگ آؤٹ", + "Login": "لاگ ان", + "Providers": "فراہم کنندگان", + "Usage": "استعمال کے اعدادوشمار", + "API Key": "API کلید", + "Connected": "منسلک", + "Disconnected": "منقطع", + "Active": "فعال", + "Inactive": "غیر فعال", + "Success": "کامیاب", + "Failed": "ناکام", + "Error": "خرابی", + "Warning": "انتباہ", + "Info": "معلومات", + "Loading": "لوڈ ہو رہا ہے", + "Search": "تلاش کریں", + "Filter": "فلٹر کریں", + "Sort": "ترتیب دیں", + "Export": "برآمد کریں", + "Import": "درآمد کریں", + "Refresh": "تازہ کریں", + "Back": "واپس", + "Next": "آگے", + "Previous": "پچھلا", + "Submit": "جمع کریں", + "Confirm": "تصدیق کریں", + "Yes": "جی", + "No": "نہیں", + "OK": "ٹھیک ہے", + "Apply": "لاگو کریں", + "Reset": "دوبارہ سیٹ کریں", + "Clear": "صاف کریں", + "Select": "منتخب کریں", + "Upload": "اپ لوڈ کریں", + "Download": "ڈاؤن لوڈ کریں", + "Copy": "کاپی کریں", + "Paste": "پیسٹ کریں", + "Cut": "کاٹ دیں", + "Undo": "واپسی", + "Redo": "دوبارہ کریں", + "Name": "نام", + "Description": "تفصیل", + "Status": "حالت", + "Type": "قسم", + "Date": "تاریخ", + "Time": "وقت", + "Created": "بنایا گیا", + "Updated": "اپڈیٹ شدہ", + "Actions": "اقدامات", + "Details": "تفصیلات", + "View": "دیکھیں", + "New": "نیا", + "Total": "کل", + "Count": "شمار", + "Price": "قیمت", + "Cost": "لاگت", + "Free": "مفت", + "Paid": "ادا شدہ", + "Enable": "فعال کریں", + "Disable": "غیر فعال کریں", + "Enabled": "فعال", + "Disabled": "غیر فعال", + "Online": "آن لائن", + "Offline": "آف لائن", + "Available": "دستیاب", + "Unavailable": "دستیاب نہیں", + "Required": "ضروری", + "Optional": "اختیاری", + "Default": "ڈیفالٹ", + "Custom": "حسب ضرورت", + "Advanced": "اعلیٰ", + "Basic": "بنیادی", + "Help": "مدد", + "Support": "معاونت", + "Documentation": "دستاویزات", + "Version": "ورژن", + "Language": "زبان", + "Theme": "تھیم", + "Light": "روشن", + "Dark": "تاریک", + "Auto": "خودکار", + "Endpoint": "اختتام نقطہ", + "Combos": "امتزاجات", + "Quota Tracker": "کوٹہ ٹریکر", + "MITM": "MITM", + "CLI Tools": "آلات", + "Console Log": "کنسول لاگ", + "System": "نظام", + "Debug": "ڈیبگ", + "Shutdown": "بند کریں", + "Close Proxy": "پروکسی بند کریں", + "Are you sure you want to close the proxy server?": "کیا آپ یقیناً پروکسی سرور کو بند کرنا چاہتے ہیں؟", + "Server Disconnected": "سرور منقطع", + "The proxy server has been stopped.": "پروکسی سرور بند کر دیا گیا ہے۔", + "Reload Page": "صفحہ دوبارہ لوڈ کریں", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "خدمت ٹرمینل میں چل رہی ہے۔ آپ یہ ویب صفحہ بند کر سکتے ہیں۔ شٹ ڈاؤن خدمت کو بند کر دے گا۔", + "Manage your AI provider connections": "اپنے AI فراہم کنندہ کنکشن کو منیج کریں", + "Model combos with fallback": "Fallback کے ساتھ ماڈل امتزاجات", + "Monitor your API usage, token consumption, and request logs": "اپنے API استعمال، ٹوکن کھپت، اور درخواست لاگز کی نگرانی کریں", + "Intercept CLI tool traffic and route through 9Router": "CLI ٹول ٹریفک کو روکیں اور 9Router کے ذریعے منتقل کریں", + "Configure CLI tools": "CLI آلات ترتیب دیں", + "API endpoint configuration": "API اختتام نقطہ کی ترتیب", + "Manage your preferences": "اپنی ترجیحات منیج کریں", + "Debug translation flow between formats": "شکلوں کے درمیان ترجمہ کے بہاؤ کو ڈیبگ کریں", + "Live server console output": "لائیو سرور کنسول آؤٹ پٹ", + "Create model combos with fallback support": "Fallback معاونت کے ساتھ ماڈل امتزاجات بنائیں", + "Local Mode": "مقامی موڈ", + "Running on your machine": "آپ کی مشین پر چل رہا ہے", + "Database Location": "ڈیٹابیس کی جگہ", + "Download Backup": "بیک اپ ڈاؤن لوڈ کریں", + "Import Backup": "بیک اپ درآمد کریں", + "Database backup downloaded": "ڈیٹابیس بیک اپ ڈاؤن لوڈ کیا گیا", + "Database imported successfully": "ڈیٹابیس کامیابی سے درآمد کیا گیا", + "Security": "سیکیورٹی", + "Require login": "لاگ ان کی ضرورت ہے", + "When ON, dashboard requires password. When OFF, access without login.": "جب آن ہو، ڈیش بورڈ کے لیے پاس ورڈ درکار ہے۔ جب آف ہو، بغیر لاگ ان کے رسائی حاصل کریں۔", + "Current Password": "موجودہ پاس ورڈ", + "Enter current password": "موجودہ پاس ورڈ داخل کریں", + "New Password": "نیا پاس ورڈ", + "Enter new password": "نیا پاس ورڈ داخل کریں", + "Confirm New Password": "نئے پاس ورڈ کی تصدیق کریں", + "Confirm new password": "نئے پاس ورڈ کی تصدیق کریں", + "Update Password": "پاس ورڈ اپڈیٹ کریں", + "Set Password": "پاس ورڈ سیٹ کریں", + "Password updated successfully": "پاس ورڈ کامیابی سے اپڈیٹ ہو گیا", + "Passwords do not match": "پاس ورڈ مماثل نہیں ہیں", + "Routing Strategy": "روٹنگ حکمت عملی", + "Round Robin": "دوری رابن", + "Cycle through accounts to distribute load": "بوجھ تقسیم کرنے کے لیے اکاؤنٹس کے ذریعے سائیکل کریں", + "Sticky Limit": "چپکنے والی حد", + "Calls per account before switching": "سوئچنگ سے پہلے فی اکاؤنٹ کالیں", + "Network": "نیٹ ورک", + "Outbound Proxy": "آؤٹ بائونڈ پروکسی", + "Enable proxy for OAuth + provider outbound requests.": "OAuth + فراہم کنندہ آؤٹ بائونڈ درخواستوں کے لیے پروکسی فعال کریں۔", + "Proxy URL": "پروکسی URL", + "Leave empty to inherit existing env proxy (if any).": "موجودہ env پروکسی کو وراثت میں دینے کے لیے خالی رکھیں (اگر کوئی ہو)۔", + "No Proxy": "کوئی پروکسی نہیں", + "Comma-separated hostnames/domains to bypass the proxy.": "پروکسی کو نظر انداز کرنے کے لیے کوما سے الگ شدہ ہوسٹ ناموں/ڈومین۔", + "Test proxy URL": "پروکسی URL کو ٹیسٹ کریں", + "Proxy settings applied": "پروکسی ترتیبات لاگو کی گئیں", + "Proxy enabled": "پروکسی فعال", + "Proxy disabled": "پروکسی غیر فعال", + "Proxy test OK": "پروکسی ٹیسٹ ٹھیک ہے", + "Proxy test failed": "پروکسی ٹیسٹ ناکام", + "Please enter a Proxy URL to test": "براہ کرم ٹیسٹ کے لیے ایک پروکسی URL داخل کریں", + "Observability": "نقطہ نظر کی صلاحیت", + "Enable Observability": "نقطہ نظر کی صلاحیت فعال کریں", + "Turn request detail recording on/off globally": "درخواست کی تفصیلات ریکارڈنگ کو عالمی طور پر آن/آف کریں", + "Max Records": "زیادہ سے زیادہ ریکارڈ", + "Maximum request detail records to keep (older records are auto-deleted)": "رکھنے کے لیے زیادہ سے زیادہ درخواست کی تفصیلات ریکارڈ (پرانے ریکارڈ خود بخود حذف ہو جاتے ہیں)", + "Batch Size": "بیچ سائز", + "Number of items to accumulate before writing to database (higher = better performance)": "ڈیٹابیس میں لکھنے سے پہلے جمع کرنے کے لیے اشیاء کی تعداد (زیادہ = بہتر کارکردگی)", + "Flush Interval (ms)": "فلش وقفہ (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "بفر کو فلش کرنے سے پہلے انتظار کرنے کا زیادہ سے زیادہ وقت (کم ٹریفک کے دوران ڈیٹا کے نقصان سے بچاتا ہے)", + "Max JSON Size (KB)": "زیادہ سے زیادہ JSON سائز (KB)", + "Maximum size for each JSON field (request/response) before truncation": "تشکیل سے پہلے ہر JSON فیلڈ کے لیے زیادہ سے زیادہ سائز (درخواست/جواب)", + "All data stored on your machine": "تمام ڈیٹا آپ کی مشین پر محفوظ ہے", + "MITM Server": "MITM سرور", + "Running": "چل رہا ہے", + "Stopped": "رکا ہوا", + "Cert": "سرٹیفکیٹ", + "Server": "سرور", + "Purpose:": "مقصد:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Antigravity IDE اور GitHub Copilot استعمال کریں → 9Router سے کسی بھی فراہم کنندہ/ماڈل کے ساتھ", + "How it works:": "یہ کیسے کام کرتا ہے:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE درخواست → DNS کو localhost:443 کی طرف ری ڈائریکٹ کریں → MITM پروکسی روکے → 9Router → Antigravity/Copilot کو جواب", + "No API keys — create one in Keys page": "کوئی API کلید نہیں — Keys صفحہ میں ایک بنائیں", + "sk_9router (default)": "sk_9router (ڈیفالٹ)", + "Server started": "سرور شروع ہوگیا", + "Failed to start server": "سرور شروع کرنے میں ناکام", + "Server stopped — all DNS cleared": "سرور بند — تمام DNS صاف کیے گئے", + "Failed to stop server": "سرور بند کرنے میں ناکام", + "Sudo password is required": "Sudo پاس ورڈ درکار ہے", + "Stop Server": "سرور بند کریں", + "Start Server": "سرور شروع کریں", + "Enable DNS per tool below to activate interception": "روک تھام کو فعال کرنے کے لیے نیچے ہر ٹول کے لیے DNS فعال کریں", + "Sudo Password Required": "Sudo پاس ورڈ درکار ہے", + "Enter your sudo password to start/stop MITM server": "MITM سرور شروع/بند کرنے کے لیے اپنا sudo پاس ورڈ داخل کریں", + "Sudo Password": "Sudo پاس ورڈ", + "Click to add, click again to remove. Changes are saved automatically.": "شامل کرنے کے لیے کلک کریں، ہٹانے کے لیے دوبارہ کلک کریں۔ تبدیلیاں خودکار طور پر محفوظ ہو جاتی ہیں۔", + "⚠️ 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.": "⚠️ خطرے کا نوٹس: یہ فراہم کنندہ ایک سبسکرپشن/OAuth سیشن استعمال کرتا ہے جو پراکسی/راؤٹر استعمال کے لیے سرکاری طور پر لائسنس یافتہ نہیں ہے۔ اکاؤنٹ محدود یا پابند ہو سکتا ہے۔ اپنے خطرے پر استعمال کریں۔", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM آپ کے فراہم کنندگان کو درخواستوں کو ری ڈائریکٹ کرنے کے لیے مقامی CA کے ذریعے IDE ٹولز (Antigravity, GitHub Copilot, Kiro) کے HTTPS ٹریفک کو روکتا ہے۔ ToS کی خلاف ورزی کر سکتا ہے → اکاؤنٹ پابندی کا خطرہ۔ اپنے خطرے پر استعمال کریں۔", + "Endpoint is exposed without an API key.": "اینڈ پوائنٹ API کلید کے بغیر بے نقاب ہے۔" +} diff --git a/public/i18n/literals/vi.json b/public/i18n/literals/vi.json new file mode 100644 index 0000000000000000000000000000000000000000..5358d0681764bbda761bf816743c6a310d49c461 --- /dev/null +++ b/public/i18n/literals/vi.json @@ -0,0 +1,195 @@ +{ + "Cancel": "Hủy", + "Delete": "Xóa", + "Edit": "Sửa", + "Save": "Lưu", + "Close": "Đóng", + "Add": "Thêm", + "Remove": "Xóa bỏ", + "Settings": "Cài đặt", + "Profile": "Hồ sơ", + "Dashboard": "Bảng điều khiển", + "Logout": "Đăng xuất", + "Login": "Đăng nhập", + "Providers": "Nhà cung cấp", + "Usage": "Thống kê", + "API Key": "Khóa API", + "Connected": " Đã kết nối", + "Disconnected": "Chưa kết nối", + "Active": "Hoạt động", + "Inactive": "Không hoạt động", + "Success": "Thành công", + "Failed": "Thất bại", + "Error": "Lỗi", + "Warning": "Cảnh báo", + "Info": "Thông tin", + "Loading": "Đang tải", + "Search": "Tìm kiếm", + "Filter": "Lọc", + "Sort": "Sắp xếp", + "Export": "Xuất", + "Import": "Nhập", + "Refresh": "Làm mới", + "Back": "Quay lại", + "Next": "Tiếp theo", + "Previous": "Trước", + "Submit": "Gửi", + "Confirm": "Xác nhận", + "Yes": "Có", + "No": "Không", + "OK": "OK", + "Apply": "Áp dụng", + "Reset": "Đặt lại", + "Clear": "Xóa", + "Select": "Chọn", + "Upload": "Tải lên", + "Download": "Tải xuống", + "Copy": "Sao chép", + "Paste": "Dán", + "Cut": "Cắt", + "Undo": "Hoàn tác", + "Redo": "Làm lại", + "Name": "Tên", + "Description": "Mô tả", + "Status": "Trạng thái", + "Type": "Loại", + "Date": "Ngày", + "Time": "Thời gian", + "Created": "Đã tạo", + "Updated": "Đã cập nhật", + "Actions": "Hành động", + "Details": "Chi tiết", + "View": "Xem", + "New": "Mới", + "Total": "Tổng", + "Count": "Số lượng", + "Price": "Giá", + "Cost": "Chi phí", + "Free": "Miễn phí", + "Paid": "Trả phí", + "Enable": "Bật", + "Disable": "Tắt", + "Enabled": "Đã bật", + "Disabled": "Đã tắt", + "Online": "Trực tuyến", + "Offline": "Ngoại tuyến", + "Available": "Có sẵn", + "Unavailable": "Không có sẵn", + "Required": "Bắt buộc", + "Optional": "Tùy chọn", + "Default": "Mặc định", + "Custom": "Tùy chỉnh", + "Advanced": "Nâng cao", + "Basic": "Cơ bản", + "Help": "Trợ giúp", + "Support": "Hỗ trợ", + "Documentation": "Tài liệu", + "Version": "Phiên bản", + "Language": "Ngôn ngữ", + "Theme": "Giao diện", + "Light": "Sáng", + "Dark": "Tối", + "Auto": "Tự động", + "Endpoint": "Điểm cuối", + "Combos": "Kết hợp", + "Quota Tracker": "Theo dõi hạn mức", + "MITM": "MITM", + "CLI Tools": "Công cụ", + "Console Log": "Nhật ký Console", + "System": "Hệ thống", + "Debug": "Gỡ lỗi", + "Shutdown": "Tắt ứng dụng", + "Close Proxy": "Đóng Proxy", + "Are you sure you want to close the proxy server?": "Bạn có chắc chắn muốn đóng máy chủ proxy không?", + "Server Disconnected": "Máy chủ đã ngắt kết nối", + "The proxy server has been stopped.": "Máy chủ proxy đã bị dừng.", + "Reload Page": "Tải lại trang", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "Dịch vụ đang chạy trong terminal. Bạn có thể đóng trang web này. Tắt ứng dụng sẽ dừng dịch vụ.", + "Manage your AI provider connections": "Quản lý kết nối nhà cung cấp AI của bạn", + "Model combos with fallback": "Kết hợp mô hình với dự phòng", + "Monitor your API usage, token consumption, and request logs": "Theo dõi việc sử dụng API, tiêu thụ token và nhật ký yêu cầu", + "Intercept CLI tool traffic and route through 9Router": "Chặn lưu lượng công cụ CLI và định tuyến qua 9Router", + "Configure CLI tools": "Cấu hình công cụ CLI", + "API endpoint configuration": "Cấu hình điểm cuối API", + "Manage your preferences": "Quản lý tùy chọn của bạn", + "Debug translation flow between formats": "Gỡ lỗi luồng dịch giữa các định dạng", + "Live server console output": "Đầu ra console máy chủ trực tiếp", + "Create model combos with fallback support": "Tạo kết hợp mô hình với hỗ trợ dự phòng", + "Local Mode": "Chế độ cục bộ", + "Running on your machine": "Chạy trên máy của bạn", + "Database Location": "Vị trí cơ sở dữ liệu", + "Download Backup": "Tải xuống bản sao lưu", + "Import Backup": "Nhập bản sao lưu", + "Database backup downloaded": "Đã tải xuống bản sao lưu cơ sở dữ liệu", + "Database imported successfully": "Đã nhập cơ sở dữ liệu thành công", + "Security": "Bảo mật", + "Require login": "Yêu cầu đăng nhập", + "When ON, dashboard requires password. When OFF, access without login.": "Khi BẬT, bảng điều khiển yêu cầu mật khẩu. Khi TẮT, truy cập không cần đăng nhập.", + "Current Password": "Mật khẩu hiện tại", + "Enter current password": "Nhập mật khẩu hiện tại", + "New Password": "Mật khẩu mới", + "Enter new password": "Nhập mật khẩu mới", + "Confirm New Password": "Xác nhận mật khẩu mới", + "Confirm new password": "Xác nhận mật khẩu mới", + "Update Password": "Cập nhật mật khẩu", + "Set Password": "Đặt mật khẩu", + "Password updated successfully": "Đã cập nhật mật khẩu thành công", + "Passwords do not match": "Mật khẩu không khớp", + "Routing Strategy": "Chiến lược định tuyến", + "Round Robin": "Vòng tròn", + "Cycle through accounts to distribute load": "Luân phiên qua các tài khoản để phân phối tải", + "Sticky Limit": "Giới hạn dính", + "Calls per account before switching": "Số lần gọi mỗi tài khoản trước khi chuyển", + "Network": "Mạng", + "Outbound Proxy": "Proxy đi ra", + "Enable proxy for OAuth + provider outbound requests.": "Bật proxy cho OAuth + yêu cầu đi ra của nhà cung cấp.", + "Proxy URL": "URL Proxy", + "Leave empty to inherit existing env proxy (if any).": "Để trống để kế thừa proxy môi trường hiện có (nếu có).", + "No Proxy": "Không có Proxy", + "Comma-separated hostnames/domains to bypass the proxy.": "Tên máy chủ/tên miền được phân tách bằng dấu phẩy để bỏ qua proxy.", + "Test proxy URL": "Kiểm tra URL proxy", + "Proxy settings applied": "Đã áp dụng cài đặt proxy", + "Proxy enabled": "Đã bật proxy", + "Proxy disabled": "Đã tắt proxy", + "Proxy test OK": "Kiểm tra proxy OK", + "Proxy test failed": "Kiểm tra proxy thất bại", + "Please enter a Proxy URL to test": "Vui lòng nhập URL Proxy để kiểm tra", + "Observability": "Khả năng quan sát", + "Enable Observability": "Bật khả năng quan sát", + "Turn request detail recording on/off globally": "Bật/tắt ghi chi tiết yêu cầu toàn cục", + "Max Records": "Số bản ghi tối đa", + "Maximum request detail records to keep (older records are auto-deleted)": "Số bản ghi chi tiết yêu cầu tối đa để giữ (bản ghi cũ hơn sẽ tự động xóa)", + "Batch Size": "Kích thước lô", + "Number of items to accumulate before writing to database (higher = better performance)": "Số mục tích lũy trước khi ghi vào cơ sở dữ liệu (cao hơn = hiệu suất tốt hơn)", + "Flush Interval (ms)": "Khoảng thời gian xả (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "Thời gian tối đa để chờ trước khi xả bộ đệm (ngăn mất dữ liệu trong lưu lượng thấp)", + "Max JSON Size (KB)": "Kích thước JSON tối đa (KB)", + "Maximum size for each JSON field (request/response) before truncation": "Kích thước tối đa cho mỗi trường JSON (yêu cầu/phản hồi) trước khi cắt bớt", + "All data stored on your machine": "Tất cả dữ liệu được lưu trữ trên máy của bạn", + "MITM Server": "Máy chủ MITM", + "Running": "Đang chạy", + "Stopped": "Đã dừng", + "Cert": "Chứng chỉ", + "Server": "Máy chủ", + "Purpose:": "Mục đích:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "Sử dụng Antigravity IDE & GitHub Copilot → với BẤT KỲ nhà cung cấp/mô hình nào từ 9Router", + "How it works:": "Cách hoạt động:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Yêu cầu Antigravity/Copilot IDE → Chuyển hướng DNS đến localhost:443 → MITM proxy chặn → 9Router → phản hồi đến Antigravity/Copilot", + "No API keys — create one in Keys page": "Không có khóa API — tạo một khóa trong trang Keys", + "sk_9router (default)": "sk_9router (mặc định)", + "Server started": "Đã khởi động máy chủ", + "Failed to start server": "Không thể khởi động máy chủ", + "Server stopped — all DNS cleared": "Đã dừng máy chủ — đã xóa tất cả DNS", + "Failed to stop server": "Không thể dừng máy chủ", + "Sudo password is required": "Yêu cầu mật khẩu sudo", + "Stop Server": "Dừng máy chủ", + "Start Server": "Khởi động máy chủ", + "Enable DNS per tool below to activate interception": "Bật DNS cho từng công cụ bên dưới để kích hoạt chặn", + "Sudo Password Required": "Yêu cầu mật khẩu Sudo", + "Enter your sudo password to start/stop MITM server": "Nhập mật khẩu sudo của bạn để khởi động/dừng máy chủ MITM", + "Sudo Password": "Mật khẩu Sudo", + "Click to add, click again to remove. Changes are saved automatically.": "Nhấp để thêm, nhấp lại để xóa. Thay đổi được lưu tự động.", + "⚠️ 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.": "⚠️ Cảnh báo rủi ro: Provider này sử dụng subscription/OAuth không được cấp phép chính thức cho mục đích proxy/router. Tài khoản có thể bị hạn chế hoặc cấm. Người dùng tự chịu trách nhiệm.", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM chặn lưu lượng HTTPS của các IDE tools (Antigravity, GitHub Copilot, Kiro) qua CA cục bộ để chuyển hướng request đến providers của bạn. Có thể vi phạm ToS → rủi ro bị ban tài khoản. Sử dụng với rủi ro của bạn.", + "Endpoint is exposed without an API key.": "Endpoint đang mở mà không có API key." +} diff --git a/public/i18n/literals/zh-CN.json b/public/i18n/literals/zh-CN.json new file mode 100644 index 0000000000000000000000000000000000000000..6e2e2e19a767bc048754c21475c72c84835be2fa --- /dev/null +++ b/public/i18n/literals/zh-CN.json @@ -0,0 +1,1056 @@ +{ + "-compatible models manually or import them from the /models endpoint.": "- 手动兼容模型或从 /models 端点导入它们。", + ". Click \"Apply\" to auto-configure.": "。单击“应用”进行自动配置。", + "($/1M tokens). Example: An input rate of 2.50 means $2.50 per 1,000,000 input tokens.": "($/100 万 Token)。示例:输入费率 2.50 表示每 1,000,000 个输入 Token 需 2.50 美元。", + "($/1M tokens). Example: Input rate of 2.50 means $2.50 per 1,000,000 input tokens.": "($/100 万 Token)。示例:输入费率 2.50 表示每 1,000,000 个输入 Token 需 2.50 美元。", + "1. Client Request (Input)": "1. 客户端请求(输入)", + "1. Generates SSL cert & adds to system keychain": "1. 生成 SSL 证书并添加到系统钥匙串", + "2. Provider Request (Translated)": "2. 提供商请求(已​​翻译)", + "2. Redirects": "2. 重定向", + "24h": "24小时", + "3. Maps Antigravity models to any provider via 9Router": "3. 通过 9Router 将Antigravity模型映射到任何提供商", + "3. Provider Response (Raw)": "3. 提供商响应(原始)", + "4. Client Response (Final)": "4. 客户端响应(最终)", + "About": "关于", + "Access Anywhere": "随处访问", + "Access Token": "访问令牌", + "Access token will be auto-filled...": "访问令牌将自动填充...", + "Account": "账号", + "account has been connected.": "账号已连接。", + "Action": "操作", + "Active": "活跃", + "Add": "添加", + "Add a connection to enable importing models.": "添加连接以启用导入模型。", + "Add Anthropic Compatible": "添加Anthropic兼容", + "Add Connection": "添加连接", + "Add connection using browser cookie": "使用浏览器 cookie 添加连接", + "Add Custom Model": "添加自定义模型", + "Add model": "添加模型", + "Add Model": "添加模型", + "Add Model to Combo": "将模型添加到组合", + "Add New Provider": "添加新提供商", + "Add OpenAI Compatible": "添加 OpenAI 兼容", + "Add your first connection to get started": "添加您的第一个连接以开始使用", + "added)": "已添加)", + "After authorization, copy the full URL from your browser address bar.": "授权后,从浏览器地址栏中复制完整的 URL。", + "After authorization, copy the full URL from your browser.": "授权后,从浏览器复制完整的 URL。", + "After installation, run": "安装后,运行", + "After login, you'll need to copy the callback URL from your browser and paste it back here.": "登录后,您需要从浏览器复制回调 URL 并将其粘贴回此处。", + "All models are responding normally.": "所有模型均响应正常。", + "All Providers": "所有提供商", + "All rates are in": "所有费率均在", + "An error occurred": "发生错误", + "Anthropic Compatible (Prod)": "Anthropic 兼容(生产)", + "API Endpoint": "API端点", + "API Key": "API密钥", + "API Key (for Check)": "API 密钥(用于检查)", + "API Key Compatible Providers": "API 密钥兼容提供商", + "API Key Created": "API 密钥已创建", + "API Key Name": "API 密钥名称", + "API Key Providers": "API 密钥提供商", + "API Keys": "API 密钥", + "API Reference": "API参考", + "API Type": "API类型", + "Apply": "应用", + "Are you sure you want to disable the tunnel?": "您确定要禁用隧道吗?", + "Authenticate": "认证", + "Authentication Method": "认证方式", + "Authentication Successful!": "认证成功!", + "Authorization Successful!": "授权成功!", + "Auto Refresh (3s)": "自动刷新(3秒)", + "Auto-detecting token...": "自动检测令牌...", + "Auto-detecting tokens...": "自动检测令牌...", + "Auto:": "自动:", + "Available": "可用", + "AWS Builder ID": "AWS 构建器 ID", + "AWS IAM Identity Center": "AWS IAM 身份中心", + "AWS Region": "AWS 区域", + "AWS region for your Identity Center (default: us-east-1)": "您的身份中心的 AWS 区域(默认值:us-east-1)", + "Back": "返回", + "Back to Providers": "返回提供商", + "Base URL": "基础 URL", + "Batch Size": "批量大小", + "Blog": "博客", + "Cache Creation": "缓存创建", + "Cache Creation:": "缓存创建:", + "Cached": "缓存", + "Cached input tokens (typically 50% of input rate)": "缓存输入 Token(通常为输入费率的 50%)", + "Cached:": "缓存:", + "Calls per account before switching": "切换前每个账号的调用次数", + "Cancel": "取消", + "Cert": "证书", + "Changelog": "变更日志", + "chars)": "字符)", + "Chat Completions": "聊天完成", + "Checking Claude CLI...": "检查 Claude CLI...", + "Checking Codex CLI...": "正在检查 Codex CLI...", + "Checking Copilot config...": "正在检查Copilot配置...", + "Checking Factory Droid CLI...": "检查 Factory Droid CLI...", + "Checking Open Claw CLI...": "正在检查 Open Claw CLI...", + "Checking OpenCode CLI...": "检查 OpenCode CLI...", + "Choose your authentication method:": "选择您的身份验证方法:", + "Claude": "Claude", + "Claude CLI - Manual Configuration": "Claude CLI - 手动配置", + "Claude CLI not installed": "Claude CLI 未安装", + "Clear": "清除", + "Clear Filters": "清除过滤器", + "Clear search": "清除搜索", + "Click to edit": "点击编辑", + "Close test results": "关闭测试结果", + "Cloudflare Tunnel": "Cloudflare 隧道", + "Codex CLI - Manual Configuration": "Codex CLI - 手动配置", + "Codex CLI not installed": "Codex CLI 未安装", + "Codex uses": "Codex 使用", + "Combo Name": "组合名称", + "Combos": "组合", + "Coming soon...": "即将推出...", + "Comma-separated hostnames/domains to bypass the proxy.": "以逗号分隔的主机名/域以绕过代理。", + "Company": "公司", + "Complete the authorization in the popup window.": "在弹出的窗口中完成授权。", + "Completion/response tokens": "补全/响应 Token", + "Configure a new AI provider to use with your applications.": "配置新的 AI 提供程序以与您的应用程序一起使用。", + "Configure pricing rates for cost tracking and calculations": "配置定价以进行成本跟踪和计算", + "Confirm": "确认", + "Confirm new password": "确认新密码", + "Confirm New Password": "确认新密码", + "Connect": "连接", + "Connect AI tools remotely": "远程连接AI工具", + "Connect Cursor IDE": "连接CursorIDE", + "Connect Kiro": "连接Kiro", + "Connect to providers with OAuth to track your API quota limits and usage.": "使用 OAuth 连接到提供商以跟踪您的 API 配额限制和使用情况。", + "Connect with OAuth2": "使用 OAuth2 连接", + "Connect your account using OAuth2 authentication.": "使用 OAuth2 身份验证连接您的账号。", + "Connected": "已连接", + "Connected Successfully!": "连接成功!", + "Connection Failed": "连接失败", + "Connections": "连接", + "Contact": "联系", + "Content": "内容", + "Continue": "继续", + "Continue with GitHub": "继续使用 GitHub", + "Continue with Google": "使用 Google 继续", + "Cookie": "Cookie", + "Cookie Auth": "Cookie 验证", + "Cookie String": "Cookie 字符串", + "Cooldown": "冷却", + "Copy": "复制", + "Copy combo name": "复制组合名称", + "Copy model": "复制模型", + "Copy the entire cookie string (must include BXAuth)": "复制整个 cookie 字符串(必须包括 BXAuth)", + "Copy This URL": "复制此 URL", + "Cost": "成本", + "Cost Calculation:": "成本计算:", + "Costs are calculated based on token usage and pricing rates. Each request's cost is determined by: (input_tokens × input_rate) + (output_tokens × output_rate) + (cached_tokens × cached_rate)": "成本根据 Token 用量和费率计算。每个请求的成本由以下公式决定:(input_tokens × input_rate) + (output_tokens × output_rate) + (cached_tokens × cached_rate)", + "Could not read Cursor database automatically.": "无法自动读取 Cursor 数据库。", + "Create": "创建", + "Create API Key": "创建 API 密钥", + "Create Combo": "创建组合", + "Create Key": "创建密钥", + "Create model combos with fallback support": "创建具有后备支持的模型组合", + "Create Provider": "创建提供商", + "Create your first API key to get started": "创建您的第一个 API 密钥以开始使用", + "Created": "已创建", + "Current": "当前", + "Current Password": "当前密码", + "Current Pricing Overview": "当前定价概述", + "Current: Keeps": "当前: 保留", + "Cursor IDE not detected. Please paste your tokens manually.": "未检测到Cursor IDE。请手动粘贴您的令牌。", + "Custom Pricing:": "定制定价:", + "Cycle through accounts to distribute load": "循环切换账号以分配负载", + "Database backup downloaded": "数据库备份已下载", + "Database imported successfully": "数据库导入成功", + "Database Location": "数据库位置", + "DateTime": "日期时间", + "Debug translation flow between formats": "调试格式之间的翻译流程", + "Delete": "删除", + "Detail": "详情", + "Disable Tunnel": "禁用隧道", + "Disabled": "已禁用", + "Display Name": "显示名称", + "DNS off": "DNS 关闭", + "Documentation": "文档", + "dollars per million tokens": "美元 / 百万 Token", + "Domain:": "域名:", + "Done": "完成", + "Download Backup": "下载备份", + "e.g. claude-opus-4-5": "例如 claude-opus-4-5", + "e.g., Production API, Dev Environment": "例如,生产 API、开发环境", + "Edit": "编辑", + "Edit Connection": "编辑连接", + "Edit Pricing": "编辑定价", + "Email": "邮箱", + "Enable DNS per tool below to activate interception": "启用下面每个工具的 DNS 以激活拦截", + "Enable Observability": "启用可观察性", + "Enable proxy for OAuth + provider outbound requests.": "为 OAuth + 提供商出站请求启用代理。", + "Enable Tunnel": "启用隧道", + "Encrypted": "已加密", + "End Date": "结束日期", + "End-to-end TLS via Cloudflare": "通过 Cloudflare 的端到端 TLS", + "Endpoint": "端点", + "Enter current password": "输入当前密码", + "Enter new API key": "输入新的 API 密钥", + "Enter new password": "输入新密码", + "Enter sudo password": "输入sudo密码", + "Enter your API key": "输入您的 API 密钥", + "Est. Cost": "预估成本", + "Estimated, not actual billing": "预估费用,非实际账单", + "Expose your local 9Router to the internet. No port forwarding, no static IP needed. Share endpoint URL with your team or use it in Cursor, Cline, and other AI tools from anywhere.": "将您本地的 9Router 暴露到互联网。无需端口转发,无需静态 IP。与您的团队共享端点 URL 或从任何地方在 Cursor、Cline 和其他 AI 工具中使用它。", + "Factory Droid - Manual Configuration": "Factory Droid - 手动配置", + "Factory Droid CLI not installed": "Factory Droid CLI 未安装", + "Fetch Qoder Models": "获取 Qoder 模型", + "Fetching...": "获取中...", + "Failed to load usage statistics.": "无法加载使用情况统计信息。", + "Features": "功能特性", + "Flush Interval (ms)": "刷新间隔(毫秒)", + "For enterprise users with custom AWS IAM Identity Center.": "适用于具有自定义 AWS IAM Identity Center 的企业用户。", + "Free Providers": "免费提供商", + "Fresh API key obtained": "获得新的 API 密钥", + "GitHub Account": "GitHub 账号", + "GitHub Copilot - Manual Configuration": "GitHub Copilot - 手动配置", + "Google Account": "Google 账号", + "has been connected.": "已连接。", + "Help Center": "帮助中心", + "How it works:": "工作原理:", + "How Pricing Works": "定价如何运作", + "How to get cookie:": "如何获取cookie:", + "IDC Start URL": "IDC 起始 URL", + "iFlow AI": "iFlow AI", + "iFlow Cookie Authentication": "iFlow Cookie 身份验证", + "Import Backup": "导入备份", + "Import Token": "导入令牌", + "In": "输入", + "In / Out": "输入/输出", + "Inactive": "未激活", + "Inc. All rights reserved.": "公司。保留所有权利。", + "Input": "输入", + "Input Tokens": "输入 Token", + "Input Tokens:": "输入 Token:", + "Input:": "输入:", + "Installation Guide": "安装指南", + "Interactive diagram visible on desktop": "桌面上可见的交互式图表", + "Intercepts Antigravity traffic via DNS redirect, letting you reroute models through 9Router.": "通过 DNS 重定向拦截Antigravity流量,让您可以通过 9Router 重新路由模型。", + "KB per field": "每个字段的 KB", + "Key Name": "密钥名称", + "Kimi": "Kimi", + "Kiro AI": "Kiro AI", + "Kiro IDE not detected. Please paste your refresh token manually.": "未检测到 Kiro IDE。请手动粘贴您的刷新令牌。", + "Last updated:": "最后更新:", + "Last Used": "最后使用", + "Latency": "延迟", + "Latency:": "延迟:", + "Lean": "Lean", + "Leave empty to inherit existing env proxy (if any).": "留空以继承现有的 env 代理(如果有)。", + "Load": "加载", + "Loading logs...": "正在加载日志...", + "Loading models from provider...": "正在从提供商处加载模型...", + "Loading pricing data...": "正在加载定价数据...", + "Local Mode": "本地模式", + "Local Mode - All data stored on your machine": "本地模式 - 所有数据都存储在您的计算机上", + "Login to your account": "登录您的账号", + "Login with your GitHub account (manual callback).": "使用您的 GitHub 账号登录(手动回调)。", + "Login with your Google account (manual callback).": "使用您的 Google 账号登录(手动回调)。", + "Logs are saved to log.txt in the application data directory.": "日志保存在应用程序数据目录下的log.txt中。", + "Machine ID": "机器ID", + "Machine ID will be auto-filled...": "机器 ID 将自动填充...", + "macOS / Linux / Windows:": "macOS / Linux / Windows:", + "macOS / Linux:": "macOS / Linux:", + "Manual Callback Required": "需要手动回调", + "Manual Config": "手动配置", + "Max JSON Size (KB)": "最大 JSON 大小 (KB)", + "Max Records": "最大记录数", + "Maximum request detail records to keep (older records are auto-deleted)": "要保留的最大请求详细记录(较旧的记录会自动删除)", + "Maximum size for each JSON field (request/response) before truncation": "截断前每个 JSON 字段(请求/响应)的最大大小", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "刷新缓冲区之前等待的最长时间(防止低流量期间数据丢失)", + "Messages": "消息", + "MiniMax": "MiniMax", + "MITM Server": "中间人服务器", + "Model": "模型", + "Model ID": "模型 ID", + "Model ID (from OpenRouter)": "模型 ID(来自 OpenRouter)", + "Model is reachable": "模型可达", + "Model mappings will be available soon.": "模型映射即将推出。", + "Model Status": "模型状态", + "Model:": "模型:", + "Models": "模型", + "more providers": "更多提供商", + "Move down": "下移", + "Move up": "向上移动", + "ms / Total": "毫秒/总计", + "Name": "名称", + "Network": "网络", + "New Password": "新密码", + "No active connections found for this group.": "未找到该组的活跃连接。", + "No active providers": "没有活跃的提供商", + "No API keys yet": "还没有 API 密钥", + "No combos yet": "还没有组合", + "No compatible providers added yet": "尚未添加兼容的提供商", + "No connections": "无连接", + "No connections yet": "还没有连接", + "No console logs yet.": "还没有控制台日志。", + "No data for this period": "此期间没有数据", + "No logs recorded yet.": "尚未记录任何日志。", + "No models": "暂无模型", + "No models added yet": "尚未添加模型", + "No models configured": "未配置模型", + "No models found": "未找到模型", + "No models match your filter.": "没有模型匹配您的筛选条件。", + "No pricing data available": "无可用定价数据", + "No Providers Connected": "没有连接提供商", + "No Proxy": "无代理", + "No quota data available": "无可用配额数据", + "No request details found": "未找到请求详细信息", + "No requests yet.": "暂无请求。", + "Not configured": "未配置", + "Number of items to accumulate before writing to database (higher = better performance)": "写入数据库之前要累积的项目数(越高=性能越好)", + "OAuth Providers": "OAuth 提供商", + "Observability": "可观察性", + "Only letters, numbers, - and _ allowed": "只允许使用字母、数字、- 和 _", + "Only one connection is allowed per compatible node. Add another node if you need more connections.": "每个兼容节点仅允许一个连接。如果需要更多连接,请添加另一个节点。", + "Open Claw - Manual Configuration": "Open Claw - 手动配置", + "Open Claw CLI not installed": "未安装 Open Claw CLI", + "Open DevTools (F12) → Application/Storage → Cookies": "打开 DevTools (F12) → 应用程序/存储 → Cookie", + "Open platform.iflow.cn in your browser": "在浏览器中打开platform.iflow.cn", + "OpenAI Compatible (Prod)": "OpenAI 兼容(生产)", + "OpenCode - Manual Configuration": "OpenCode - 手动配置", + "OpenCode CLI not installed": "未安装 OpenCode CLI", + "OpenRouter": "OpenRouter", + "OpenRouter supports any model. Add models and create aliases for quick access.": "OpenRouter 支持任何模型。添加模型并创建别名以便快速访问。", + "Other": "其他", + "Out": "输出", + "Outbound Proxy": "出站代理", + "Output": "输出", + "Output Tokens": "输出 Token", + "Output Tokens:": "输出 Token:", + "Output:": "输出:", + "Password updated successfully": "密码更新成功", + "Passwords do not match": "密码不匹配", + "Paste it below": "粘贴在下面", + "Paste refresh token from Kiro IDE.": "从 Kiro IDE 粘贴刷新令牌。", + "Paused": "已暂停", + "Please add and connect providers first to configure CLI tools.": "请先添加并连接提供商以配置 CLI 工具。", + "Please add an active Qoder connection first": "请先添加一个活跃的 Qoder 连接", + "Please copy the URL from the address bar and paste it in the application.": "请复制地址栏中的 URL 并将其粘贴到应用程序中。", + "Please enter a Proxy URL to test": "请输入代理 URL 进行测试", + "Please install Claude CLI to use this feature.": "请安装 Claude CLI 才能使用此功能。", + "Please install Codex CLI to use auto-apply feature.": "请安装 Codex CLI 以使用自动应用功能。", + "Please install Factory Droid CLI to use this feature.": "请安装 Factory Droid CLI 才能使用此功能。", + "Please install Open Claw CLI to use this feature.": "请安装 Open Claw CLI 才能使用此功能。", + "Please install OpenCode CLI to use auto-apply feature.": "请安装 OpenCode CLI 以使用自动应用功能。", + "Please wait while we complete the authorization.": "我们正在完成授权,请稍候。", + "Popup blocked? Enter URL manually": "弹出窗口被拦截?请手动输入 URL", + "Prefix": "前缀", + "Pricing": "定价", + "Pricing Configuration": "定价配置", + "Pricing Format:": "定价格式:", + "Pricing Rates Format": "定价格式", + "Pricing Settings": "定价设置", + "Priority": "优先级", + "Privacy Policy": "隐私政策", + "Product": "产品", + "Production Key": "生产密钥", + "Provider": "提供商", + "Provider Limits": "提供商限制", + "Provider not found": "未找到提供商", + "Provider:": "提供商:", + "Providers": "提供商", + "Proxy settings applied": "已应用代理设置", + "Proxy URL": "代理 URL", + "Purpose:": "用途:", + "Qwen": "Qwen", + "Reading from AWS SSO cache": "从 AWS SSO 缓存中读取", + "Reading from Cursor IDE database": "从 Cursor IDE 数据库读取", + "Reasoning": "推理", + "Reasoning:": "推理:", + "Recent Requests": "最近的请求", + "Recommended for most users. Free AWS account required.": "推荐给大多数用户。需要免费 AWS 帐户。", + "records, batches every": "记录,每批", + "Refresh": "刷新", + "Refresh All": "全部刷新", + "Refresh quota": "刷新配额", + "Refresh Token": "刷新令牌", + "Reload VS Code after applying for changes to take effect.": "应用更改后请重新加载 VS Code 以使其生效。", + "Remove": "移除", + "Remove custom model": "删除自定义模型", + "Remove model": "删除模型", + "Request Details": "请求详情", + "Request Logs": "请求日志", + "Requests": "请求", + "Requests without a valid key will be rejected": "没有有效密钥的请求将被拒绝", + "requests, max": "请求数,最大", + "Require API key": "需要 API 密钥", + "Require login": "需要登录", + "Required for SSL certificate and DNS configuration": "SSL 证书和 DNS 配置所需", + "Required for SSL certificate and server startup": "SSL 证书和服务器启动所需", + "Required to modify /etc/hosts and flush DNS cache": "需要修改 /etc/hosts 并刷新 DNS 缓存", + "Requires outbound port 7844 (TCP/UDP). Connection may take 10-30s.": "需要出站端口 7844 (TCP/UDP)。连接可能需要 10-30 秒。", + "Reset": "重置", + "Reset to default": "重置为默认值", + "Reset to Defaults": "重置为默认值", + "Resources": "资源", + "Responses API": "响应API", + "Retry": "重试", + "Round Robin": "轮询", + "Routing Strategy": "路由策略", + "Rows:": "行:", + "Run this command in your terminal, then click": "在终端中运行此命令,然后单击", + "Running": "运行中", + "Running on your machine": "在你的机器上运行", + "s)": ")", + "Save Mappings": "保存映射", + "Save this key now!": "立即保存此密钥!", + "Search model id": "搜索模型 ID", + "Security": "安全", + "Select": "选择", + "Select a provider": "选择提供商", + "Select all": "选择全部", + "Select Model": "选择模型", + "Select Model for Codex": "选择 Codex 模型", + "Select Model for Factory Droid": "选择 Factory Droid 模型", + "Select Model for GitHub Copilot": "选择 GitHub Copilot 模型", + "Select Model for Open Claw": "选择 Open Claw 模型", + "Select Model for OpenCode": "选择 OpenCode 模型", + "Selected only": "仅选定", + "Selected provider": "选定的提供商", + "Send to Provider": "发送给提供商", + "Sent to provider as:": "发送给提供商:", + "Server": "服务器", + "Server off": "服务器关闭", + "Setting up": "设置", + "Share Endpoint": "共享端点", + "Share URL with team members": "与团队成员共享 URL", + "Show only selected models": "仅显示选中的模型", + "Showing": "显示中", + "Special reasoning/thinking tokens (fallback to output rate)": "特殊推理/思考 Token(回退至输出费率)", + "Standard prompt tokens": "标准提示 Token", + "Start Date": "开始日期", + "Start DNS": "启动 DNS", + "Start MITM": "启动中间人", + "Start Server": "启动服务器", + "Start Tunnel": "开始隧道", + "Status": "状态", + "Status:": "状态:", + "Step 1: Open this URL in your browser": "第 1 步:在浏览器中打开此 URL", + "Step 2: Paste the callback URL here": "第 2 步:将回调 URL 粘贴到此处", + "Sticky Limit": "粘性限制", + "Stop DNS": "停止 DNS", + "Stop MITM": "停止中间人", + "Stop Server": "停止服务器", + "Stopped": "已停止", + "Sudo Password Required": "需要 sudo 密码", + "Terms of Service": "服务条款", + "Test": "测试", + "Test all API Key connections": "测试所有 API 密钥连接", + "Test all Compatible connections": "测试所有兼容连接", + "Test all Free connections": "测试所有免费连接", + "Test all Free provider connections": "测试所有免费提供商连接", + "Test all OAuth connections": "测试所有 OAuth 连接", + "Test model": "测试模型", + "Test proxy URL": "测试代理 URL", + "Test Results": "测试结果", + "The tunnel will be disconnected. Remote access will stop working.": "隧道将被断开。远程访问将停止工作。", + "The unified interface for modern AI infrastructure. Secure, observable, and scalable.": "现代人工智能基础设施的统一接口。安全、可观察且可扩展。", + "Thinking Process": "思考过程", + "This is the only time you will see this key. Store it securely.": "这是您唯一一次看到此密钥的机会。请妥善保管。", + "Timestamp": "时间戳", + "Timestamp:": "时间戳:", + "To get a fresh API key, paste your browser cookie from": "要获取新的 API 密钥,请粘贴您的浏览器 cookie", + "to verify.": "来验证。", + "Toggle DNS to redirect": "切换 DNS 重定向", + "Token auto-detected from Kiro IDE successfully!": "已成功从 Kiro IDE 自动检测到令牌!", + "Token Types:": "Token 类型:", + "Token will be auto-filled...": "令牌将自动填充...", + "Tokens": "Token", + "Tokens auto-detected from Cursor IDE successfully!": "已成功从 Cursor IDE 自动检测到令牌!", + "Tokens used to create cache entries (fallback to input rate)": "用于创建缓存条目的 Token(回退至输入费率)", + "Total Input Tokens": "输入 Token 总计", + "Total Models": "模型总数", + "Total Requests": "请求总数", + "Total:": "总计:", + "traffic through 9Router via MITM.": "通过 MITM 通过 9Router 的流量。", + "Translator Debug": "翻译器调试", + "Try Again": "再试一次", + "Tunnel connected!": "隧道连通!", + "Tunnel disabled": "隧道已禁用", + "Turn request detail recording on/off globally": "全局打开/关闭请求详细信息记录", + "Twitter": "Twitter", + "Unavailable": "不可用", + "Unknown": "未知", + "Unselect all": "取消选择全部", + "Update": "更新", + "Usage by Account": "按账号统计", + "Usage by API Key": "按 API 密钥统计", + "Usage by Endpoint": "按端点统计", + "Usage by Model": "按模型统计", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "使用 Antigravity IDE 和 GitHub Copilot → 与 9Router 的任何提供商/模型", + "Use in Cursor/Cline": "在Cursor/Cline中使用", + "Use the buttons above to add OpenAI or Anthropic compatible endpoints": "使用上面的按钮添加 OpenAI 或 Anthropic 兼容端点", + "Use your API from any network": "从任何网络使用您的 API", + "Verification URL": "验证 URL", + "View Full Details": "查看完整详情", + "Visit the URL below and enter the code:": "访问以下网址并输入代码:", + "Waiting for Authorization": "等待授权", + "Waiting for authorization...": "等待授权...", + "Warning": "警告", + "When": "时间", + "When ON, dashboard requires password. When OFF, access without login.": "当打开时,仪表板需要密码。当关闭时,无需登录即可访问。", + "Windows: Run 9Router terminal as Administrator": "Windows:以管理员身份运行 9Router 终端", + "Windows: Run terminal (9Router) as Administrator to enable MITM": "Windows:以管理员身份运行终端 (9Router) 以启用 MITM", + "Writes to": "写入到", + "You can override default pricing for specific models. Reset to defaults anytime to restore standard rates.": "您可以覆盖特定模型的默认定价。随时重置为默认值以恢复标准费率。", + "Your": "你的", + "Your Code": "你的代码", + "Your Kiro account via": "您的 Kiro 帐户通过", + "Your organization's AWS IAM Identity Center URL": "您组织的 AWS IAM Identity Center URL", + "Quota Tracker": "配额跟踪器", + "CLI Tools": "命令行工具", + "Console Log": "控制台日志", + "System": "系统", + "Debug": "调试", + "Settings": "设置", + "Usage": "使用情况", + "Shutdown": "关闭", + "Close Proxy": "关闭代理", + "Are you sure you want to close the proxy server?": "您确定要关闭代理服务器吗?", + "Server Disconnected": "服务器已断开", + "The proxy server has been stopped.": "代理服务器已停止。", + "Reload Page": "重新加载页面", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "服务正在终端中运行。您可以关闭此网页。关闭将停止服务。", + "One Endpoint for": "统一端点,接入", + "All AI Providers": "所有 AI 提供商", + "Route AI requests through subscription, cheap, and free tiers with auto-fallback. One endpoint for Claude, GPT, Gemini, and more.": "通过订阅、低价和免费层级路由 AI 请求并自动回退。一个端点接入 Claude、GPT、Gemini 等。", + "Get Started": "开始使用", + "View on GitHub": "在 GitHub 上查看", + "How 9Router Works": "9Router 工作原理", + "Data flows seamlessly through our intelligent routing system": "数据通过我们的智能路由系统无缝流转", + "1. CLI & SDKs": "1. CLI 和 SDK", + "Your requests start from your favorite tools — Cursor, Claude, Copilot, or any OpenAI-compatible SDK.": "请求从您常用的工具发起——Cursor、Claude、Copilot 或任何 OpenAI 兼容的 SDK。", + "2. 9Router Hub": "2. 9Router 枢纽", + "Our engine analyzes the prompt and routes through your subscription, cheap, and free provider tiers with automatic fallback.": "我们的引擎分析提示词并通过您的订阅、低价和免费提供商层级路由,自动回退。", + "3. AI Providers": "3. AI 提供商", + "The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly.": "请求由 OpenAI、Anthropic、Gemini 或其他提供商即时响应。", + "Powerful Features": "强大功能", + "Everything you need to manage your AI infrastructure efficiently.": "高效管理 AI 基础设施所需的一切。", + "Unified Endpoint": "统一端点", + "Single API endpoint for all major AI providers. Simplify your integration.": "一个 API 端点接入所有主要 AI 提供商。简化集成。", + "Easy Setup": "简单设置", + "Get started in seconds. Just install, open, and route.": "几秒钟即可上手。安装、打开、路由。", + "Model Fallback": "模型回退", + "Automatically switch between providers when limits are hit.": "当达到限额时自动切换提供商。", + "Usage Tracking": "使用量跟踪", + "Track token usage, costs, and performance across all providers.": "跟踪所有提供商的 Token 使用量、成本和性能。", + "OAuth & API Keys": "OAuth 和 API 密钥", + "Connect via OAuth or API keys. Securely manage credentials.": "通过 OAuth 或 API 密钥连接。安全管理凭据。", + "Cloud Sync": "云端同步", + "Sync settings across devices with optional cloud storage.": "通过可选的云存储在设备间同步设置。", + "CLI Support": "CLI 支持", + "Native CLI tool support for Cursor, Claude, Copilot, and more.": "原生支持 Cursor、Claude、Copilot 等 CLI 工具。", + "Dashboard": "仪表盘", + "Beautiful web dashboard for managing providers and monitoring usage.": "精美的 Web 仪表盘,用于管理提供商和监控使用情况。", + "Get Started in 30 Seconds": "30 秒快速上手", + "Install 9Router": "安装 9Router", + "Open Dashboard": "打开仪表盘", + "Route Requests": "路由请求", + "npm install -g 9router": "npm install -g 9router", + "open http://localhost:9099": "open http://localhost:9099", + "Ready! Requests route automatically through your configured providers.": "就绪!请求将自动通过您配置的提供商路由。", + "How it Works": "工作原理", + "Docs": "文档", + "GitHub": "GitHub", + "Legal": "法律", + "Manage your AI provider connections": "管理您的 AI 提供商连接", + "Model combos with fallback": "模型组合及回退", + "Monitor your API usage, token consumption, and request logs": "监控您的 API 使用量、Token 消耗和请求日志", + "Track and manage your API quota limits": "跟踪和管理您的 API 配额限制", + "Intercept CLI tool traffic and route through 9Router": "拦截 CLI 工具流量并通过 9Router 路由", + "Configure CLI tools": "配置 CLI 工具", + "Manage your proxy pool configurations": "管理您的代理池配置", + "API endpoint configuration": "API 端点配置", + "Manage your preferences": "管理您的偏好设置", + "Live server console output": "服务器实时控制台输出", + "Usage & Analytics": "使用量和分析", + "MITM Proxy": "MITM 代理", + "Translator": "翻译器", + "Media Providers": "媒体提供商", + "Theme": "主题", + "Remote": "远程", + "Logout": "退出登录", + "Change Log": "更新日志", + "Proxy Pools": "代理池", + "MITM": "MITM", + "Loading...": "加载中...", + "Enter your password to access the dashboard": "输入密码以访问仪表盘", + "Password": "密码", + "Enter password": "输入密码", + "Login": "登录", + "Default password is 123456": "默认密码为 123456", + "Invalid password": "密码错误", + "An error occurred. Please try again.": "发生错误,请重试。", + "light": "浅色", + "dark": "深色", + "system": "跟随系统", + "Combo Round Robin": "组合轮询", + "Cycle through providers in combos instead of always starting with first": "在组合中循环使用提供商,而不是总是从第一个开始", + "Currently using accounts in priority order (Fill First).": "当前按优先级顺序使用账号(优先填满)。", + "Record request details for inspection in the logs view": "记录请求详情以在日志视图中查看", + "Update Password": "修改密码", + "Set Password": "设置密码", + "Overview": "概览", + "Details": "详情", + "Search...": "搜索...", + "Saving...": "保存中...", + "Save": "保存", + "Save Changes": "保存更改", + "Saving": "保存中", + "Importing...": "导入中...", + "Import": "导入", + "Deploying... (may take ~1 min)": "部署中...(可能需要约 1 分钟)", + "Deploy": "部署", + "Enable": "启用", + "Disable": "禁用", + "Token Saver": "Token 节省器", + "Experimental": "实验性", + "Compress tool output to reduce token usage.": "压缩工具输出以减少 Token 使用量。", + "sk_9router (default)": "sk_9router(默认)", + "Install Tailscale": "安装 Tailscale", + "Installing Tailscale...": "正在安装 Tailscale...", + "Tailscale installed": "Tailscale 已安装", + "Tailscale Funnel": "Tailscale Funnel", + "Allow dashboard access via tunnel": "允许通过隧道访问仪表盘", + "Disconnected from server": "与服务器断开连接", + "Attempting to reconnect...": "正在尝试重新连接...", + "Click to retry": "点击重试", + "Failed to load changelog:": "加载更新日志失败:", + "Copied!": "已复制!", + "Manage reusable per-connection proxies and bind them to provider connections.": "管理可复用的连接代理并绑定到提供商连接。", + "Vercel Relay": "Vercel Relay", + "Batch Import": "批量导入", + "Add Proxy Pool": "添加代理池", + "No proxy pool entries yet": "暂无代理池条目", + "Create a proxy pool entry, then assign it to connections.": "创建代理池条目,然后分配到连接。", + "Batch Import Proxies": "批量导入代理", + "Paste Proxy List (One per line)": "粘贴代理列表(每行一个)", + "Supported formats: protocol://user:pass@host:port, host:port:user:pass": "支持的格式:protocol://user:pass@host:port, host:port:user:pass", + "Deploy Vercel Relay": "部署 Vercel Relay", + "What is Vercel Relay?": "什么是 Vercel Relay?", + "Deploys an edge relay function to Vercel that proxies requests through Vercel's network.": "将边缘中继函数部署到 Vercel,通过 Vercel 的网络代理请求。", + "Vercel API Token": "Vercel API Token", + "Project Name": "项目名称", + "Edit Proxy Pool": "编辑代理池", + "Strict Proxy": "严格代理", + "Fail request if proxy is unreachable instead of falling back to direct.": "当代理不可达时直接失败,而不是回退到直连。", + "Inactive pools are ignored by runtime resolution.": "未激活的代理池将被运行时解析忽略。", + "active": "活跃", + "inactive": "未激活", + "unknown": "未知", + "bound": "已绑定", + "Last tested:": "上次测试:", + "No proxy:": "无代理:", + "Proxy pool updated": "代理池已更新", + "Proxy pool created": "代理池已创建", + "Proxy pool deleted": "代理池已删除", + "Proxy test passed": "代理测试通过", + "Proxy test failed": "代理测试失败", + "Replay request flow — matches log files": "重放请求流程——匹配日志文件", + "Client Request": "客户端请求", + "Source Body": "源请求体", + "OpenAI Intermediate": "OpenAI 中间格式", + "Target Request": "目标请求", + "Provider Response": "提供商响应", + "OpenAI Response": "OpenAI 响应", + "Client Response": "客户端响应", + "Format": "格式化", + "Send": "发送", + "→ OpenAI": "→ OpenAI", + "→ Target": "→ 目标", + "Terminal": "终端", + "Full shell access": "完整 Shell 访问", + "Desktop": "桌面", + "Screen sharing": "屏幕共享", + "Files": "文件", + "Browse & edit files": "浏览和编辑文件", + "Scan QR to connect instantly": "扫描二维码即刻连接", + "No port forwarding needed": "无需端口转发", + "Works on any device": "适用于任何设备", + "Access your terminal, desktop & files from anywhere": "从任何地方访问您的终端、桌面和文件", + "Get 9Remote": "获取 9Remote", + "Manual configuration is still available if 9router is deployed on a remote server.": "如果 9router 部署在远程服务器上,仍可使用手动配置。", + "How to Install": "如何安装", + "Hide": "隐藏", + "Filter naming": "过滤命名", + "Filter naming requests": "过滤命名请求", + "Intercepts Claude Code's topic-naming requests and returns a fake response locally, saving API tokens.": "拦截 Claude Code 的主题命名请求并在本地返回伪响应,节省 API Token。", + "Settings applied successfully!": "设置已成功应用!", + "Failed to apply settings": "应用设置失败", + "Settings reset successfully!": "设置已成功重置!", + "Failed to reset settings": "重置设置失败", + "No API keys - Create one in Keys page": "暂无 API 密钥 - 请在密钥页面创建", + "Subagent Model": "子代理模型", + "Select Subagent Model for Codex": "选择 Codex 子代理模型", + "Select Subagent Model for OpenCode": "选择 OpenCode 子代理模型", + "No models selected": "未选择模型", + "Click a model to set/clear active": "点击模型以设置/取消活跃状态", + "Select models to add": "选择要添加的模型", + "Add Model for OpenCode": "为 OpenCode 添加模型", + "Default Model": "默认模型", + "9Router Base URL": "9Router 基础 URL", + "Trust Cert": "信任证书", + "Trusted": "已信任", + "not detected locally": "未在本地检测到", + "Select to pre-fill, then edit model ID in the input": "选择以预填充,然后在输入框中编辑模型 ID", + "Free & Free Tier Providers": "免费及免费额度提供商", + "Testing...": "测试中...", + "Test All": "全部测试", + "Ready": "就绪", + "Valid": "有效", + "Invalid": "无效", + "Checking...": "检查中...", + "Check": "检查", + "Creating...": "创建中...", + "Network error": "网络错误", + "Provider test failed": "提供商测试失败", + "Enable provider": "启用提供商", + "Disable provider": "禁用提供商", + "Chat": "对话", + "Responses": "响应", + "passed": "通过", + "failed": "失败", + "tested": "已测试", + "Required. A friendly label for this node.": "必填。为此节点设置一个友好的显示名称。", + "Required. Used as the provider prefix for model IDs.": "必填。用作模型 ID 的提供商前缀。", + "Model ID (optional)": "模型 ID(可选)", + "If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead.": "如果提供商不支持 /models 端点,请输入模型 ID 通过 chat/completions 进行验证。", + "(via inference test)": "(通过推理测试)", + "Delete this combo?": "删除此组合?", + "Name is required": "名称为必填项", + "Failed to create combo": "创建组合失败", + "Failed to update combo": "更新组合失败", + "Only letters, numbers, -, _ and . allowed": "仅允许使用字母、数字、-、_ 和 .", + "Input Cost": "输入成本", + "Output Cost": "输出成本", + "Total Cost": "总成本", + "Total Tokens": "总 Token", + "Never": "从未", + "Just now": "刚刚", + "m ago": "分钟前", + "h ago": "小时前", + "None": "无", + "disabled": "已禁用", + "OAuth Account": "OAuth 账号", + "no_proxy:": "无代理:", + "Pool:": "代理池:", + "Legacy:": "旧版:", + "Error": "错误", + "more": "更多", + "Proxy": "代理", + "No authentication required": "无需身份验证", + "This provider is ready to use.": "此提供商已准备就绪。", + "Available Models": "可用模型", + "Model not reachable": "模型不可达", + "Failed to set alias": "设置别名失败", + "Delete this connection?": "删除此连接?", + "Proxy Pool": "代理池", + "Proxy Action": "代理操作", + "Selecting None will unbind selected connections from proxy pool.": "选择「无」将解除所选连接与代理池的绑定。", + "Applying...": "应用中...", + "Select one or more connections, then click Proxy Action.": "选择一个或多个连接,然后点击代理操作。", + "All selected currently unbound": "所有选中项当前未绑定", + "Selected connections have mixed proxy bindings": "所选连接的代理绑定状态不一致", + "Anthropic Compatible Details": "Anthropic 兼容详情", + "OpenAI Compatible Details": "OpenAI 兼容详情", + "Messages API": "消息 API", + "Sticky:": "粘滞:", + "connection": "个连接", + "connections": "个连接", + "Suggested free models (≥200k context):": "推荐的免费模型(≥200k 上下文):", + "Get API Key →": "获取 API 密钥 →", + "OAuth": "OAuth", + "Click to add, click again to remove. Changes are saved automatically.": "点击添加,再次点击删除。更改将自动保存。", + "Close": "关闭", + "⚠️ 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.": "⚠️ 风险提示:此提供商使用的订阅/OAuth 会话未获官方授权用于代理/路由器使用。账户可能被限制或封禁。使用风险自负。", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM 通过本地 CA 拦截 IDE 工具(Antigravity、GitHub Copilot、Kiro)的 HTTPS 流量,将请求重定向到您的提供商。可能违反 ToS → 账户封禁风险。使用风险自负。", + "Endpoint is exposed without an API key.": "端点未设置 API 密钥即对外暴露。", + "↑ New version available: v{version}": "↑ 新版本可用:v{version}", + "Update now": "立即更新", + "Copy install command": "复制安装命令", + "✓ copied!": "✓ 已复制!", + "Update 9Router": "更新 9Router", + "Show install command for v{version}? You can copy it and shutdown to install manually.": "显示 v{version} 的安装命令?您可以复制它并关闭服务器以手动安装。", + "Show Command": "显示命令", + "Server stopped. Paste the command into a terminal to install.": "服务器已停止。将命令粘贴到终端中进行安装。", + "Command copied. Server will stop in {countdown}s...": "命令已复制。服务器将在 {countdown}s 后停止...", + "Click the button below to copy the install command and shutdown.": "点击下方按钮复制安装命令并关闭服务器。", + "Install command:": "安装命令:", + "Click Copy & Shutdown below.": "点击下方「复制并关闭」。", + "Paste the command into your terminal and press Enter.": "将命令粘贴到终端并按回车。", + "Run 9router again after install.": "安装后重新运行 9router。", + "✓ Copied — shutting down...": "✓ 已复制 — 正在关闭...", + "Shutting down in {countdown}s": "将在 {countdown}s 后关闭", + "Copy & Shutdown": "复制并关闭", + "Local": "本地", + "Tunnel reconnecting...": "隧道正在重新连接...", + "Tunnel checking...": "隧道检查中...", + "Tunnel process stopped unexpectedly.": "隧道进程意外停止。", + "Tunnel created but not reachable. Please try again.": "隧道已创建但无法访问,请重试。", + "Waiting for tunnel ready...": "等待隧道就绪...", + "Downloading cloudflared... {progress}%": "正在下载 cloudflared... {progress}%", + "Failed to enable tunnel": "无法启用隧道", + "No tunnel URL returned": "未返回隧道 URL", + "Security required: {reason}": "安全要求:{reason}", + "Security required: Enable \"Require API key\" before activating the tunnel.": "安全要求:启用「需要 API 密钥」后再激活隧道。", + "Require API key is disabled — your endpoint is publicly accessible without authentication.": "「需要 API 密钥」已禁用 — 您的端点无需身份验证即可公开访问。", + "Require login is disabled — anyone can access your dashboard via tunnel.": "「需要登录」已禁用 — 任何人都可以通过隧道访问您的仪表盘。", + "Dashboard uses the default password — change it in Profile settings.": "仪表盘使用默认密码 — 请在个人设置中更改。", + "Change password": "更改密码", + "When enabled, the dashboard can be accessed through your tunnel or Tailscale URL (login still required). When disabled, dashboard access via tunnel/Tailscale is completely blocked.": "启用后,可通过隧道或 Tailscale URL 访问仪表盘(仍需登录)。禁用后,通过隧道/Tailscale 访问仪表盘将被完全阻止。", + "Compress tool output": "压缩工具输出", + "git/grep/ls/tree/logs → 60-90% fewer input tokens": "git/grep/ls/tree/logs → 减少 60-90% 输入 Token", + "Compress LLM output": "压缩 LLM 输出", + "Terse-style system prompt → ~65% fewer output tokens (up to 87%)": "精简风格系统提示 → 减少约 65% 输出 Token(最高 87%)", + "Lite": "轻量", + "Full": "完整", + "Ultra": "极致", + "文 Lite": "文 Lite", + "文 Full": "文 Full", + "文 Ultra": "文 Ultra", + "Drop filler, keep grammar": "去除填充词,保留语法", + "Drop articles, fragments OK": "去除冠词,允许片段", + "Telegraphic, max compression": "电报体,最大压缩", + "Classical Chinese, light compression": "文言文,轻度压缩", + "Maximum 文言文, 80-90% reduction": "最大文言文,减少 80-90%", + "Extreme classical compression": "极致文言压缩", + "Delete API Key": "删除 API 密钥", + "Pause API Key": "暂停 API 密钥", + "Pause API key \"{name}\"?\n\nThis key will stop working immediately but can be resumed later.": "暂停 API 密钥「{name}」?\n\n此密钥将立即停止工作,但可以稍后恢复。", + "Hide key": "隐藏密钥", + "Show key": "显示密钥", + "Pause key": "暂停密钥", + "Resume key": "恢复密钥", + "The Cloudflare tunnel will be disconnected. Remote access via tunnel URL will stop working.": "Cloudflare 隧道将被断开。通过隧道 URL 的远程访问将停止工作。", + "Disabling...": "正在禁用...", + "Tailscale is not installed. Install it to enable Funnel.": "Tailscale 未安装。请安装以启用 Funnel。", + "Tailscale Funnel will be stopped. Remote access via Tailscale URL will stop working.": "Tailscale Funnel 将被停止。通过 Tailscale URL 的远程访问将停止工作。", + "Tailscale disabled": "Tailscale 已禁用", + "Waiting for Tailscale ready...": "等待 Tailscale 就绪...", + "Connected but not reachable yet.": "已连接但尚不可达。", + "Open Login Page": "打开登录页面", + "Login required — click \"Open Login Page\" to continue": "需要登录 — 点击「打开登录页面」继续", + "Starting funnel...": "正在启动 Funnel...", + "Failed to start funnel": "启动 Funnel 失败", + "Open Funnel Settings": "打开 Funnel 设置", + "Click \"Open Funnel Settings\" to enable Funnel...": "点击「打开 Funnel 设置」以启用 Funnel...", + "Timed out waiting for Funnel to be enabled.": "等待 Funnel 启用超时。", + "Login timed out. Please try again.": "登录超时,请重试。", + "Failed to connect": "连接失败", + "Web Fetch & Search": "网页抓取与搜索", + "Agent Skills": "代理技能", + "Copy a link and paste to your AI to use 9Router — no install needed": "复制链接并粘贴到您的 AI 中即可使用 9Router — 无需安装", + "Auth Files": "授权文件", + "Map provider credentials stored in the local database": "映射存储在本地数据库中的提供商凭据", + "Donate": "捐赠", + "Search providers...": "搜索提供商...", + "No providers match your search": "没有匹配您搜索的提供商", + "Custom Providers (OpenAI/Anthropic Compatible)": "自定义提供商(OpenAI/Anthropic 兼容)", + "No custom providers — use buttons above to add OpenAI/Anthropic compatible endpoints": "暂无自定义提供商 — 使用上方按钮添加 OpenAI/Anthropic 兼容端点", + "Free Tier Providers": "免费额度提供商", + "Show all {count} providers": "显示全部 {count} 个提供商", + "Compatible": "兼容", + "OK": "正常", + "ERROR": "错误", + "Group models under one name, then pick a strategy per combo:": "将模型归组到一个名称下,然后为每个组合选择策略:", + "Fallback — tries models in order (next on failure)": "回退 — 按顺序尝试模型(失败时切换到下一个)", + "Round Robin — rotate": "轮询 — 循环切换", + "Fusion — panel + judge": "融合 — 面板 + 裁判", + "Capacity auto-switch": "容量自动切换", + "sends image/PDF/audio requests to a model that supports them first": "将图片/PDF/音频请求发送到支持它们的模型", + "Judge": "裁判", + "Pick the model that fuses panel answers": "选择融合面板回答的模型", + "Auto — {model}": "自动 — {model}", + "first model": "第一个模型", + "Reset judge to Auto": "重置裁判为自动", + "Select Judge Model": "选择裁判模型", + "Edit Combo": "编辑组合", + "Delete Combo": "删除组合", + "Drag to reorder": "拖拽以重新排序", + "No providers support {label} yet.": "暂无提供商支持 {label}。", + "Add Custom Embedding": "添加自定义嵌入", + "Custom": "自定义", + "Added": "已添加", + "Today": "今天", + "7D": "7天", + "30D": "30天", + "60D": "60天", + "Processing...": "处理中...", + "This window will close automatically...": "此窗口将自动关闭...", + "You can close this tab now.": "您现在可以关闭此标签页。", + "Sign in with your OIDC provider to access the dashboard": "使用您的 OIDC 提供商登录以访问仪表盘", + "Set a new password before accessing the dashboard remotely.": "在远程访问仪表盘之前设置新密码。", + "New password": "新密码", + "Set password": "设置密码", + "OIDC login is enabled, but the issuer/client fields are not configured yet. Password login is still available for recovery.": "OIDC 登录已启用,但发行者/客户端字段尚未配置。密码登录仍可用于恢复。", + "Password and OIDC login are both enabled.": "密码和 OIDC 登录均已启用。", + "Locked. Retry in {retryAfter}s": "已锁定。{retryAfter}s 后重试", + "Forgot password? Open 9router CLI on the host → Settings → Reset Password to Default.": "忘记密码?在主机上打开 9router CLI → 设置 → 将密码重置为默认值。", + "Security risk: no password set. You will be asked to set one when logging in remotely.": "安全风险:未设置密码。远程登录时将要求您设置一个。", + "Wait {retryAfter}s": "等待 {retryAfter}s", + "Failed to set password": "设置密码失败", + "Language": "语言", + "Display language": "显示语言", + "OIDC Dashboard Login": "OIDC 仪表盘登录", + "OIDC active": "OIDC 已激活", + "Password + OIDC active": "密码 + OIDC 已激活", + "Optional SSO via Authentik/Keycloak/Google": "可选的 SSO,通过 Authentik/Keycloak/Google", + "Use Authentik or any OIDC provider to sign in to the dashboard. You can enable password-only, OIDC-only, or both for the dashboard; model API access still uses API keys.": "使用 Authentik 或任何 OIDC 提供商登录仪表盘。您可以启用仅密码、仅 OIDC 或两者兼有;模型 API 访问仍使用 API 密钥。", + "Auth Mode": "认证模式", + "Password only": "仅密码", + "Keep the legacy password login.": "保留传统密码登录。", + "OIDC only": "仅 OIDC", + "Require OIDC for dashboard access.": "要求 OIDC 才能访问仪表盘。", + "Both": "两者", + "Allow either password or OIDC.": "允许密码或 OIDC。", + "Issuer URL": "发行者 URL", + "Client ID": "客户端 ID", + "Client Secret": "客户端密钥", + "Leave blank to keep existing secret": "留空以保留现有密钥", + "This value is write-only after saving.": "此值保存后为只写。", + "Scopes": "作用域", + "Login Button Label": "登录按钮标签", + "Redirect URI": "重定向 URI", + "Save auth mode": "保存认证模式", + "Test connection": "测试连接", + "OIDC login is currently active. Password login is disabled until you switch back.": "OIDC 登录当前已激活。密码登录已禁用,直到您切换回来。", + "Password and OIDC login are both active.": "密码和 OIDC 登录均已激活。", + "Issuer URL, client ID, and client secret are required to enable OIDC.": "启用 OIDC 需要发行者 URL、客户端 ID 和客户端密钥。", + "OIDC login enabled": "OIDC 登录已启用", + "Password and OIDC login enabled": "密码和 OIDC 登录已启用", + "OIDC settings saved": "OIDC 设置已保存", + "Failed to save OIDC settings": "保存 OIDC 设置失败", + "Issuer URL and client ID are required to test the connection.": "测试连接需要发行者 URL 和客户端 ID。", + "Failed to save OIDC settings before testing": "测试前保存 OIDC 设置失败", + "Connection OK. Discovery loaded from {issuerUrl}. Client secret validated too.": "连接成功。已从 {issuerUrl} 加载发现信息。客户端密钥也已验证。", + "Connection OK. Discovery loaded from {issuerUrl}. Client secret was not checked.": "连接成功。已从 {issuerUrl} 加载发现信息。客户端密钥未检查。", + "Connection OK. Discovery loaded from {issuerUrl}.": "连接成功。已从 {issuerUrl} 加载发现信息。", + "OIDC connection test failed": "OIDC 连接测试失败", + "Currently distributing requests across all available accounts with {count} calls per account.": "当前在所有可用账号间分配请求,每个账号 {count} 次调用。", + "Combos rotate after {count} call{plural} per model.": "组合在每个模型 {count} 次调用后轮换。", + "Combos always start with their first model.": "组合始终从第一个模型开始。", + "Proxy enabled": "代理已启用", + "Proxy disabled": "代理已禁用", + "Failed to update proxy settings": "更新代理设置失败", + "Proxy test OK ({status}) in {elapsedMs}ms": "代理测试通过({status}),耗时 {elapsedMs}ms", + "Enter your current password to {action} the database.": "输入当前密码以{action}数据库。", + "Failed to export database": "导出数据库失败", + "Invalid backup file": "无效的备份文件", + "Deploy Relay": "部署中继", + "Cloudflare Relay": "Cloudflare 中继", + "Deno Relay": "Deno 中继", + "Delete Proxy Pools": "删除代理池", + "Delete {count} proxy pool(s)?": "删除 {count} 个代理池?", + "Disable Dead Proxies": "禁用死亡代理", + "Alive: {alive}, Dead: {dead}.\n\nDisable {dead} dead proxies?": "存活:{alive},死亡:{dead}。\n\n禁用 {dead} 个死亡代理?", + "Health check done. Alive: {alive}, Dead: {dead}": "健康检查完成。存活:{alive},死亡:{dead}", + "Checking {current}/{total}": "检查中 {current}/{total}", + "Health Check": "健康检查", + "Activate": "激活", + "Deactivate": "停用", + "All pools": "所有代理池", + "{count} selected": "已选择 {count} 项", + "Active:": "活跃:", + "Test proxy": "测试代理", + "Deploy Cloudflare Relay": "部署 Cloudflare 中继", + "What is Cloudflare Relay?": "什么是 Cloudflare 中继?", + "Deploys a Cloudflare Worker as a proxy relay. All AI provider requests will be forwarded through Cloudflare's global edge network.": "将 Cloudflare Worker 部署为代理中继。所有 AI 提供商请求将通过 Cloudflare 的全球边缘网络转发。", + "High performance global routing and IP masking via Cloudflare Workers": "通过 Cloudflare Workers 实现高性能全球路由和 IP 掩码", + "Free tier: 100,000 requests per day": "免费额度:每天 100,000 次请求", + "Requires Cloudflare Account ID and a Workers API Token (Edit Workers permission)": "需要 Cloudflare 账户 ID 和 Workers API Token(编辑 Workers 权限)", + "How to generate your API Token:": "如何生成您的 API Token:", + "Go to My Profile → API Tokens → Create Token": "前往 我的个人资料 → API Token → 创建 Token", + "Scroll down to Custom Token and click Get started": "向下滚动到 自定义 Token 并点击 开始使用", + "Under Permissions: Account | Workers Scripts | Edit": "在 权限 下:账户 | Workers 脚本 | 编辑", + "Under Account Resources: Include | Account | Your Account Name": "在 账户资源 下:包含 | 账户 | 您的账户名称", + "Click Continue to summary → Create Token": "点击 继续到摘要 → 创建 Token", + "Account ID": "账户 ID", + "Found on the right side of the Cloudflare dashboard overview page.": "位于 Cloudflare 仪表盘概览页面的右侧。", + "Worker Name": "Worker 名称", + "Unique name for your Cloudflare Worker. Leave empty for auto-generated name.": "您的 Cloudflare Worker 的唯一名称。留空则自动生成名称。", + "Deploy Worker": "部署 Worker", + "Deploy Deno Relay": "部署 Deno 中继", + "What is Deno Relay?": "什么是 Deno 中继?", + "Deploys a relay worker to Deno Deploy's global edge network. All AI provider requests are forwarded through Deno's edge, masking your real IP.": "将中继 Worker 部署到 Deno Deploy 的全球边缘网络。所有 AI 提供商请求通过 Deno 边缘转发,隐藏您的真实 IP。", + "Deno Deploy v2 runs on a high-performance global edge network": "Deno Deploy v2 运行在高性能全球边缘网络上", + "Free tier: 1M requests & 100GiB outbound traffic per month": "免费额度:每月 100 万次请求和 100GiB 出站流量", + "No per-request CPU time limits (unlike Vercel/Cloudflare)": "无单次请求 CPU 时间限制(不同于 Vercel/Cloudflare)", + "Support up to 20 active apps & 50 custom domains": "支持最多 20 个活跃应用和 50 个自定义域名", + "Deploy multiple relays for maximum IP diversity": "部署多个中继以获得最大 IP 多样性", + "How to generate API token:": "如何生成 API Token:", + "Go to console.deno.com": "前往 console.deno.com", + "Select your Organization → Settings → Organization Tokens": "选择您的 组织 → 设置 → 组织 Token", + "Create a Organization Token (prefix ddo_)": "创建一个 组织 Token(前缀 ddo_)", + "Deno Deploy API Token": "Deno Deploy API Token", + "Token is used once for deployment, not stored. Found in Organization Settings.": "Token 仅在部署时使用一次,不存储。可在组织设置中找到。", + "Organization Domain": "组织域名", + "Organization's default domain. Your relay URL will be in the format: https://my-relay.your-org.deno.net": "组织的默认域名。您的中继 URL 格式为:https://my-relay.your-org.deno.net", + "App Name": "应用名称", + "Unique app name. Leave empty for auto-generated name.": "唯一应用名称。留空则自动生成名称。", + "Deploys an edge relay function to Vercel. All AI provider requests will be forwarded through Vercel's edge network, masking your real IP from providers.": "将边缘中继函数部署到 Vercel。所有 AI 提供商请求将通过 Vercel 的边缘网络转发,向提供商隐藏您的真实 IP。", + "Your IP is replaced by Vercel's dynamic edge IPs (hundreds of IPs across 20+ global regions)": "您的 IP 被 Vercel 的动态边缘 IP 替换(覆盖 20 多个全球区域的数百个 IP)", + "Vercel serves millions of apps — providers can't block Vercel IPs without affecting legitimate traffic": "Vercel 服务数百万应用 — 提供商无法在不影响正常流量的情况下封锁 Vercel IP", + "Free tier: 100GB bandwidth/month, 500K edge invocations": "免费额度:每月 100GB 带宽、50 万次边缘调用", + "Deploy multiple relays on different accounts for more IP diversity": "在不同账户上部署多个中继以获得更多 IP 多样性", + "Token is used once for deployment and not stored.": "Token 仅在部署时使用一次,不存储。", + "Unique name for your Vercel project. Leave empty for auto-generated name.": "您的 Vercel 项目的唯一名称。留空则自动生成名称。", + "Paste this to your AI:": "将此粘贴到您的 AI 中:", + "Read this skill and use it: {url}": "阅读此技能并使用:{url}", + "START HERE": "从这里开始", + "Copy link": "复制链接", + "More on GitHub": "GitHub 上更多内容", + "Browse source, README, and examples.": "浏览源代码、README 和示例。", + "v1.0 is now live": "v1.0 现已上线", + "AI endpoint proxy with web dashboard - A JavaScript port of CLIProxyAPI. Works seamlessly with Claude Code, OpenAI Codex, Cline, RooCode, and other CLI tools.": "AI 端点代理及 Web 仪表盘 — CLIProxyAPI 的 JavaScript 移植版。与 Claude Code、OpenAI Codex、Cline、RooCode 及其他 CLI 工具无缝配合。", + "Ready to Simplify Your AI Infrastructure?": "准备好简化您的 AI 基础设施了吗?", + "Join developers who are streamlining their AI integrations with 9Router. Open source and free to start.": "加入使用 9Router 简化 AI 集成的开发者行列。开源且免费开始使用。", + "Start Free": "免费开始", + "Read Documentation": "阅读文档", + "Data flows seamlessly from your application through our intelligent routing layer to the best provider for the job.": "数据从您的应用程序通过我们的智能路由层无缝流转到最适合的提供商。", + "Your requests start from your favorite tools or our unified SDK. Just change the base URL.": "请求从您常用的工具或我们的统一 SDK 发起。只需更改基础 URL。", + "Our engine analyzes the prompt, checks provider health, and routes for lowest latency or cost.": "我们的引擎分析提示词,检查提供商健康状态,并以最低延迟或成本路由。", + "Access all providers via a single standard API URL.": "通过单一标准 API URL 访问所有提供商。", + "Get up and running in minutes with npx command.": "使用 npx 命令几分钟内即可运行。", + "Automatically switch providers on failure or high latency.": "在失败或高延迟时自动切换提供商。", + "Detailed analytics and cost monitoring across all models.": "所有模型的详细分析和成本监控。", + "Securely manage credentials in one vault.": "在一个保险库中安全管理凭据。", + "Sync your configurations across devices instantly.": "在设备间即时同步配置。", + "Works with Claude Code, Codex, Cline, Cursor, and more.": "支持 Claude Code、Codex、Cline、Cursor 等。", + "Visual dashboard for real-time traffic analysis.": "可视化仪表盘,用于实时流量分析。", + "Everything you need to manage your AI infrastructure in one place, built for scale.": "在一个地方管理 AI 基础设施所需的一切,为规模而生。", + "Install 9Router, configure your providers via web dashboard, and start routing AI requests.": "安装 9Router,通过 Web 仪表盘配置提供商,开始路由 AI 请求。", + "Run npx command to start the server instantly": "运行 npx 命令立即启动服务器", + "Configure providers and API keys via web interface": "通过 Web 界面配置提供商和 API 密钥", + "Point your CLI tools to http://localhost:20128": "将您的 CLI 工具指向 http://localhost:20128", + "Starting 9Router...": "正在启动 9Router...", + "Server running on http://localhost:20128": "服务器运行在 http://localhost:20128", + "Dashboard: http://localhost:20128/dashboard": "仪表盘:http://localhost:20128/dashboard", + "Ready to route! ✓": "准备就绪!✓", + "Configure providers in dashboard or use environment variables": "在仪表盘中配置提供商或使用环境变量", + "Data Location:": "数据位置:", + "The unified endpoint for AI generation. Connect, route, and manage your AI providers with ease.": "AI 生成的统一端点。轻松连接、路由和管理您的 AI 提供商。", + "NPM": "NPM", + "MIT License": "MIT 许可证", + "Comma-separated hosts/domains to bypass proxy": "以逗号分隔的主机/域名以绕过代理", + "Please paste at least one proxy line.": "请至少粘贴一行代理。", + "Invalid proxy format:": "无效的代理格式:", + "Batch import completed: Created {created}, Skipped {skipped}, Failed {failed}": "批量导入完成:已创建 {created},已跳过 {skipped},已失败 {failed}", + "Batch import failed": "批量导入失败", + "Cannot delete: {count} connection(s) are still using this pool.": "无法删除:{count} 个连接仍在使用此代理池。", + "Imported {host}": "已导入 {host}", + "Deploy failed": "部署失败", + "Deployed: {url}": "已部署:{url}", + "Combo Sticky Limit": "组合粘性限制", + "Calls per combo model before switching": "切换前每个组合模型的调用次数", + "Open": "打开", + "No providers match your filter.": "没有提供商匹配您的筛选条件。", + "All {total} tests passed": "全部 {total} 个测试通过", + "{passed}/{total} passed, {failed} failed": "{passed}/{total} 通过,{failed} 失败", + "Test request failed": "测试请求失败", + "✓ Copied": "✓ 已复制", + "Open settings": "打开设置" +} \ No newline at end of file diff --git a/public/i18n/literals/zh-TW.json b/public/i18n/literals/zh-TW.json new file mode 100644 index 0000000000000000000000000000000000000000..ea9183ebdf4a6cfd3cdf92313ecf629873034e7d --- /dev/null +++ b/public/i18n/literals/zh-TW.json @@ -0,0 +1,195 @@ +{ + "Cancel": "取消", + "Delete": "刪除", + "Edit": "編輯", + "Save": "保存", + "Close": "關閉", + "Add": "添加", + "Remove": "移除", + "Settings": "設置", + "Profile": "個人資料", + "Dashboard": "儀表板", + "Logout": "登出", + "Login": "登錄", + "Providers": "提供者", + "Usage": "統計", + "API Key": "API 金鑰", + "Connected": "已連接", + "Disconnected": "未連接", + "Active": "活躍", + "Inactive": "非活躍", + "Success": "成功", + "Failed": "失敗", + "Error": "錯誤", + "Warning": "警告", + "Info": "信息", + "Loading": "載入中", + "Search": "搜尋", + "Filter": "篩選", + "Sort": "排序", + "Export": "導出", + "Import": "導入", + "Refresh": "刷新", + "Back": "返回", + "Next": "下一個", + "Previous": "上一個", + "Submit": "提交", + "Confirm": "確認", + "Yes": "是", + "No": "否", + "OK": "確定", + "Apply": "應用", + "Reset": "重置", + "Clear": "清除", + "Select": "選擇", + "Upload": "上傳", + "Download": "下載", + "Copy": "複製", + "Paste": "粘貼", + "Cut": "剪切", + "Undo": "撤銷", + "Redo": "重做", + "Name": "名稱", + "Description": "描述", + "Status": "狀態", + "Type": "類型", + "Date": "日期", + "Time": "時間", + "Created": "已建立", + "Updated": "已更新", + "Actions": "操作", + "Details": "詳細信息", + "View": "查看", + "New": "新建", + "Total": "總計", + "Count": "計數", + "Price": "價格", + "Cost": "成本", + "Free": "免費", + "Paid": "付費", + "Enable": "啟用", + "Disable": "禁用", + "Enabled": "已啟用", + "Disabled": "已禁用", + "Online": "在線", + "Offline": "離線", + "Available": "可用", + "Unavailable": "不可用", + "Required": "必需", + "Optional": "可選", + "Default": "默認", + "Custom": "自定義", + "Advanced": "進階", + "Basic": "基本", + "Help": "幫助", + "Support": "支持", + "Documentation": "文檔", + "Version": "版本", + "Language": "語言", + "Theme": "主題", + "Light": "淺色", + "Dark": "深色", + "Auto": "自動", + "Endpoint": "端點", + "Combos": "組合", + "Quota Tracker": "配額跟蹤", + "MITM": "MITM", + "CLI Tools": "CLI 工具", + "Console Log": "控制台日誌", + "System": "系統", + "Debug": "調試", + "Shutdown": "關機", + "Close Proxy": "關閉代理", + "Are you sure you want to close the proxy server?": "您確定要關閉代理服務器嗎?", + "Server Disconnected": "服務器已斷開連接", + "The proxy server has been stopped.": "代理服務器已停止。", + "Reload Page": "重新加載頁面", + "Service is running in terminal. You can close this web page. Shutdown will stop the service.": "服務在終端中運行。您可以關閉此網頁。關機將停止服務。", + "Manage your AI provider connections": "管理您的 AI 提供者連接", + "Model combos with fallback": "具有備用的模型組合", + "Monitor your API usage, token consumption, and request logs": "監控您的 API 使用、令牌消耗和請求日誌", + "Intercept CLI tool traffic and route through 9Router": "攔截 CLI 工具流量並通過 9Router 路由", + "Configure CLI tools": "配置 CLI 工具", + "API endpoint configuration": "API 端點配置", + "Manage your preferences": "管理您的首選項", + "Debug translation flow between formats": "調試格式之間的轉換流", + "Live server console output": "實時服務器控制台輸出", + "Create model combos with fallback support": "創建具有備用支持的模型組合", + "Local Mode": "本地模式", + "Running on your machine": "在您的機器上運行", + "Database Location": "數據庫位置", + "Download Backup": "下載備份", + "Import Backup": "導入備份", + "Database backup downloaded": "已下載數據庫備份", + "Database imported successfully": "已成功導入數據庫", + "Security": "安全性", + "Require login": "需要登錄", + "When ON, dashboard requires password. When OFF, access without login.": "打開時,儀表板需要密碼。關閉時,無需登錄即可訪問。", + "Current Password": "當前密碼", + "Enter current password": "輸入當前密碼", + "New Password": "新密碼", + "Enter new password": "輸入新密碼", + "Confirm New Password": "確認新密碼", + "Confirm new password": "確認新密碼", + "Update Password": "更新密碼", + "Set Password": "設置密碼", + "Password updated successfully": "已成功更新密碼", + "Passwords do not match": "密碼不匹配", + "Routing Strategy": "路由策略", + "Round Robin": "輪詢", + "Cycle through accounts to distribute load": "循環循環帳戶以分配負載", + "Sticky Limit": "粘性限制", + "Calls per account before switching": "切換前每個帳戶的調用次數", + "Network": "網絡", + "Outbound Proxy": "出站代理", + "Enable proxy for OAuth + provider outbound requests.": "為 OAuth + 提供者出站請求啟用代理。", + "Proxy URL": "代理 URL", + "Leave empty to inherit existing env proxy (if any).": "留空以繼承現有的環境代理(如果有)。", + "No Proxy": "無代理", + "Comma-separated hostnames/domains to bypass the proxy.": "逗號分隔的主機名/域以繞過代理。", + "Test proxy URL": "測試代理 URL", + "Proxy settings applied": "已應用代理設置", + "Proxy enabled": "已啟用代理", + "Proxy disabled": "已禁用代理", + "Proxy test OK": "代理測試成功", + "Proxy test failed": "代理測試失敗", + "Please enter a Proxy URL to test": "請輸入要測試的代理 URL", + "Observability": "可觀測性", + "Enable Observability": "啟用可觀測性", + "Turn request detail recording on/off globally": "全局打開/關閉請求詳細記錄", + "Max Records": "最大記錄數", + "Maximum request detail records to keep (older records are auto-deleted)": "要保留的最大請求詳細記錄數(舊記錄自動刪除)", + "Batch Size": "批量大小", + "Number of items to accumulate before writing to database (higher = better performance)": "寫入數據庫前要積累的項目數(更高 = 更好的性能)", + "Flush Interval (ms)": "刷新間隔 (ms)", + "Maximum time to wait before flushing buffer (prevents data loss during low traffic)": "刷新緩衝區之前的最大等待時間(防止低流量期間的數據丟失)", + "Max JSON Size (KB)": "最大 JSON 大小 (KB)", + "Maximum size for each JSON field (request/response) before truncation": "截斷前每個 JSON 字段(請求/響應)的最大大小", + "All data stored on your machine": "所有數據存儲在您的機器上", + "MITM Server": "MITM 服務器", + "Running": "運行中", + "Stopped": "已停止", + "Cert": "證書", + "Server": "服務器", + "Purpose:": "目的:", + "Use Antigravity IDE & GitHub Copilot → with ANY provider/model from 9Router": "使用 Antigravity IDE 和 GitHub Copilot → 與 9Router 中的任何提供者/模型一起", + "How it works:": "工作原理:", + "Antigravity/Copilot IDE request → DNS redirect to localhost:443 → MITM proxy intercepts → 9Router → response to Antigravity/Copilot": "Antigravity/Copilot IDE 請求 → DNS 重定向到 localhost:443 → MITM 代理攔截 → 9Router → 响應到 Antigravity/Copilot", + "No API keys — create one in Keys page": "沒有 API 金鑰 — 在金鑰頁面中創建一個", + "sk_9router (default)": "sk_9router(默認)", + "Server started": "服務器已啟動", + "Failed to start server": "啟動服務器失敗", + "Server stopped — all DNS cleared": "服務器已停止 — 已清除所有 DNS", + "Failed to stop server": "停止服務器失敗", + "Sudo password is required": "需要 Sudo 密碼", + "Stop Server": "停止服務器", + "Start Server": "啟動服務器", + "Enable DNS per tool below to activate interception": "為下面的每個工具啟用 DNS 以激活攔截", + "Sudo Password Required": "需要 Sudo 密碼", + "Enter your sudo password to start/stop MITM server": "輸入您的 Sudo 密碼以啟動/停止 MITM 服務器", + "Sudo Password": "Sudo 密碼", + "Click to add, click again to remove. Changes are saved automatically.": "點擊新增,再次點擊移除。變更將自動儲存。", + "⚠️ 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.": "⚠️ 風險提示:此提供商使用的訂閱/OAuth 工作階段未獲官方授權用於代理/路由器使用。帳戶可能被限制或封禁。使用風險自負。", + "⚠️ MITM intercepts HTTPS traffic of IDE tools (Antigravity, GitHub Copilot, Kiro) via local CA to redirect requests to your providers. May violate ToS → account ban. Use at your own risk.": "⚠️ MITM 透過本地 CA 攔截 IDE 工具(Antigravity、GitHub Copilot、Kiro)的 HTTPS 流量,將請求重新導向到您的提供商。可能違反 ToS → 帳戶封禁風險。使用風險自負。", + "Endpoint is exposed without an API key.": "端點未設定 API 金鑰即對外暴露。" +} diff --git a/public/icons/icon-192.svg b/public/icons/icon-192.svg new file mode 100644 index 0000000000000000000000000000000000000000..e797891090732d71292217ee0481297c0b9062ba --- /dev/null +++ b/public/icons/icon-192.svg @@ -0,0 +1,4 @@ + + + 9R + diff --git a/public/icons/icon-512.svg b/public/icons/icon-512.svg new file mode 100644 index 0000000000000000000000000000000000000000..6fef45af493d939728e38b49fd980f5863fdefae --- /dev/null +++ b/public/icons/icon-512.svg @@ -0,0 +1,4 @@ + + + 9R + diff --git a/public/next.svg b/public/next.svg new file mode 100644 index 0000000000000000000000000000000000000000..5174b28c565c285e3e312ec5178be64fbeca8398 --- /dev/null +++ b/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/alicode-intl.png b/public/providers/alicode-intl.png new file mode 100644 index 0000000000000000000000000000000000000000..9bee7d5b4d42f85a5ff6302b4fb6a9f14a40387c Binary files /dev/null and b/public/providers/alicode-intl.png differ diff --git a/public/providers/alicode.png b/public/providers/alicode.png new file mode 100644 index 0000000000000000000000000000000000000000..9bee7d5b4d42f85a5ff6302b4fb6a9f14a40387c Binary files /dev/null and b/public/providers/alicode.png differ diff --git a/public/providers/amp.png b/public/providers/amp.png new file mode 100644 index 0000000000000000000000000000000000000000..6bacd059371367aa6d494059d2d275785d256ac0 Binary files /dev/null and b/public/providers/amp.png differ diff --git a/public/providers/anthropic-m.png b/public/providers/anthropic-m.png new file mode 100644 index 0000000000000000000000000000000000000000..feea1edd1afad2f996744aea6eefa47ff85906f2 Binary files /dev/null and b/public/providers/anthropic-m.png differ diff --git a/public/providers/anthropic.png b/public/providers/anthropic.png new file mode 100644 index 0000000000000000000000000000000000000000..dc58142bd80af59ed94e51da6a1055d81d160646 Binary files /dev/null and b/public/providers/anthropic.png differ diff --git a/public/providers/antigravity.png b/public/providers/antigravity.png new file mode 100644 index 0000000000000000000000000000000000000000..6fe0feaee7ae4fb09679f470df7e89553469e8b0 Binary files /dev/null and b/public/providers/antigravity.png differ diff --git a/public/providers/assemblyai.png b/public/providers/assemblyai.png new file mode 100644 index 0000000000000000000000000000000000000000..d4367af077d01c68ef65af01c2089236583ffc19 Binary files /dev/null and b/public/providers/assemblyai.png differ diff --git a/public/providers/aws-polly.png b/public/providers/aws-polly.png new file mode 100644 index 0000000000000000000000000000000000000000..eef2d60102db72c22139d63f3c09596586a3c961 Binary files /dev/null and b/public/providers/aws-polly.png differ diff --git a/public/providers/azure.png b/public/providers/azure.png new file mode 100644 index 0000000000000000000000000000000000000000..9cbd38d0fc9252adfe3e90a406850b3300f715a6 Binary files /dev/null and b/public/providers/azure.png differ diff --git a/public/providers/black-forest-labs.png b/public/providers/black-forest-labs.png new file mode 100644 index 0000000000000000000000000000000000000000..c42a3134e648b0960fd7ab37f69b7900bd96f957 Binary files /dev/null and b/public/providers/black-forest-labs.png differ diff --git a/public/providers/blackbox.png b/public/providers/blackbox.png new file mode 100644 index 0000000000000000000000000000000000000000..3dc5d903ac823021e3b659b35e7b6d52025459c8 Binary files /dev/null and b/public/providers/blackbox.png differ diff --git a/public/providers/brave-search.png b/public/providers/brave-search.png new file mode 100644 index 0000000000000000000000000000000000000000..a70acda1c87c4b611feb50edae2738faa9a88804 Binary files /dev/null and b/public/providers/brave-search.png differ diff --git a/public/providers/byteplus.png b/public/providers/byteplus.png new file mode 100644 index 0000000000000000000000000000000000000000..cafbf6cd83acd0b75ead4efba2a43b749d0456f9 Binary files /dev/null and b/public/providers/byteplus.png differ diff --git a/public/providers/cartesia.png b/public/providers/cartesia.png new file mode 100644 index 0000000000000000000000000000000000000000..45177c0902c7fba413be9b7326aa86ed4b792ca7 Binary files /dev/null and b/public/providers/cartesia.png differ diff --git a/public/providers/cerebras.png b/public/providers/cerebras.png new file mode 100644 index 0000000000000000000000000000000000000000..0fbe159302fe8b51dcf96684b6286dfb4759275e Binary files /dev/null and b/public/providers/cerebras.png differ diff --git a/public/providers/chutes.png b/public/providers/chutes.png new file mode 100644 index 0000000000000000000000000000000000000000..f39a6c21c2181413d41b3ecc67b09690aa545bff Binary files /dev/null and b/public/providers/chutes.png differ diff --git a/public/providers/claude.png b/public/providers/claude.png new file mode 100644 index 0000000000000000000000000000000000000000..c223ad464bd3837ce7758ee54bbb035f7a00aa08 Binary files /dev/null and b/public/providers/claude.png differ diff --git a/public/providers/cline.png b/public/providers/cline.png new file mode 100644 index 0000000000000000000000000000000000000000..be2418999f3d06e90ce66918013bd768247ada7e Binary files /dev/null and b/public/providers/cline.png differ diff --git a/public/providers/cloudflare-ai.png b/public/providers/cloudflare-ai.png new file mode 100644 index 0000000000000000000000000000000000000000..26cda8cb822d12244d37097e54edd197f9a45a01 Binary files /dev/null and b/public/providers/cloudflare-ai.png differ diff --git a/public/providers/codex.png b/public/providers/codex.png new file mode 100644 index 0000000000000000000000000000000000000000..41a9dd775e7363fd2f4e63939b96904334b2090b Binary files /dev/null and b/public/providers/codex.png differ diff --git a/public/providers/cohere.png b/public/providers/cohere.png new file mode 100644 index 0000000000000000000000000000000000000000..47e7ab49ac562b415fb1a02478e21ccf0ddaab5e Binary files /dev/null and b/public/providers/cohere.png differ diff --git a/public/providers/comfyui.png b/public/providers/comfyui.png new file mode 100644 index 0000000000000000000000000000000000000000..df8116bca480566ce7d16e648e3447177de1121c Binary files /dev/null and b/public/providers/comfyui.png differ diff --git a/public/providers/commandcode.png b/public/providers/commandcode.png new file mode 100644 index 0000000000000000000000000000000000000000..ed7c8c99b9b72c21aced2b4a1435d32552b228dc Binary files /dev/null and b/public/providers/commandcode.png differ diff --git a/public/providers/continue.png b/public/providers/continue.png new file mode 100644 index 0000000000000000000000000000000000000000..f54685be8de738c2df92cd94cb4b8a2cacea4304 Binary files /dev/null and b/public/providers/continue.png differ diff --git a/public/providers/copilot.png b/public/providers/copilot.png new file mode 100644 index 0000000000000000000000000000000000000000..9907963e408e15a0d4c96018f692001bfd4f7e6f Binary files /dev/null and b/public/providers/copilot.png differ diff --git a/public/providers/coqui.png b/public/providers/coqui.png new file mode 100644 index 0000000000000000000000000000000000000000..6e2471ec45356ea032b5ef647c49ea1eb1ec1a0a Binary files /dev/null and b/public/providers/coqui.png differ diff --git a/public/providers/cursor.png b/public/providers/cursor.png new file mode 100644 index 0000000000000000000000000000000000000000..ec02b070bad87b6412334450a80ae435ffbff049 Binary files /dev/null and b/public/providers/cursor.png differ diff --git a/public/providers/deepgram.png b/public/providers/deepgram.png new file mode 100644 index 0000000000000000000000000000000000000000..dc58142bd80af59ed94e51da6a1055d81d160646 Binary files /dev/null and b/public/providers/deepgram.png differ diff --git a/public/providers/deepseek-tui.png b/public/providers/deepseek-tui.png new file mode 100644 index 0000000000000000000000000000000000000000..fe5667113cfb899fceaa685217589380092f7fbb Binary files /dev/null and b/public/providers/deepseek-tui.png differ diff --git a/public/providers/deepseek.png b/public/providers/deepseek.png new file mode 100644 index 0000000000000000000000000000000000000000..06036213eb2753062ef9d8c6110bf641d7022c6f Binary files /dev/null and b/public/providers/deepseek.png differ diff --git a/public/providers/droid.png b/public/providers/droid.png new file mode 100644 index 0000000000000000000000000000000000000000..28b8350a78a73f17a3949cb29e280aa688e9ae37 Binary files /dev/null and b/public/providers/droid.png differ diff --git a/public/providers/edge-tts.png b/public/providers/edge-tts.png new file mode 100644 index 0000000000000000000000000000000000000000..8b1b333f4d09dda73510e5cf07e207e1aced6f32 Binary files /dev/null and b/public/providers/edge-tts.png differ diff --git a/public/providers/elevenlabs.png b/public/providers/elevenlabs.png new file mode 100644 index 0000000000000000000000000000000000000000..c36dcfa193ded0398056ff3175687f615ebcad50 Binary files /dev/null and b/public/providers/elevenlabs.png differ diff --git a/public/providers/exa.png b/public/providers/exa.png new file mode 100644 index 0000000000000000000000000000000000000000..03eff2af67f372d2112da426d92dc604e2e4357b Binary files /dev/null and b/public/providers/exa.png differ diff --git a/public/providers/fal-ai.png b/public/providers/fal-ai.png new file mode 100644 index 0000000000000000000000000000000000000000..871855e344c43a4b201b81a7123fe303c3ea349b Binary files /dev/null and b/public/providers/fal-ai.png differ diff --git a/public/providers/firecrawl.png b/public/providers/firecrawl.png new file mode 100644 index 0000000000000000000000000000000000000000..623235e9e4d98b98b98f64f16f2f0dd35298e5bd Binary files /dev/null and b/public/providers/firecrawl.png differ diff --git a/public/providers/fireworks.png b/public/providers/fireworks.png new file mode 100644 index 0000000000000000000000000000000000000000..2295c0c7d3575399c2ea4b379c658ee89b1f81e8 Binary files /dev/null and b/public/providers/fireworks.png differ diff --git a/public/providers/gemini-cli.png b/public/providers/gemini-cli.png new file mode 100644 index 0000000000000000000000000000000000000000..5b72946d3a86cd98c981595222167b633004099f Binary files /dev/null and b/public/providers/gemini-cli.png differ diff --git a/public/providers/gemini.png b/public/providers/gemini.png new file mode 100644 index 0000000000000000000000000000000000000000..9df2d30179bf7d321ef16aed745c1af7959c361c Binary files /dev/null and b/public/providers/gemini.png differ diff --git a/public/providers/github.png b/public/providers/github.png new file mode 100644 index 0000000000000000000000000000000000000000..9907963e408e15a0d4c96018f692001bfd4f7e6f Binary files /dev/null and b/public/providers/github.png differ diff --git a/public/providers/glm-cn.png b/public/providers/glm-cn.png new file mode 100644 index 0000000000000000000000000000000000000000..cee2b24be2c9f03697cdf0cc2fed04b7116a7cb5 Binary files /dev/null and b/public/providers/glm-cn.png differ diff --git a/public/providers/glm.png b/public/providers/glm.png new file mode 100644 index 0000000000000000000000000000000000000000..cee2b24be2c9f03697cdf0cc2fed04b7116a7cb5 Binary files /dev/null and b/public/providers/glm.png differ diff --git a/public/providers/google-pse.png b/public/providers/google-pse.png new file mode 100644 index 0000000000000000000000000000000000000000..465357f2340eec971dad9ee5eba60259d71ca908 Binary files /dev/null and b/public/providers/google-pse.png differ diff --git a/public/providers/google-tts.png b/public/providers/google-tts.png new file mode 100644 index 0000000000000000000000000000000000000000..68b77439c1945cac968f0f10cb5e9a2ab28b8081 Binary files /dev/null and b/public/providers/google-tts.png differ diff --git a/public/providers/grok-web.png b/public/providers/grok-web.png new file mode 100644 index 0000000000000000000000000000000000000000..ef9d7abcffb964099a927fba9e10954e3c6d8aac Binary files /dev/null and b/public/providers/grok-web.png differ diff --git a/public/providers/groq.png b/public/providers/groq.png new file mode 100644 index 0000000000000000000000000000000000000000..1773fb66066553550648f125a6c4197ce02c8af4 Binary files /dev/null and b/public/providers/groq.png differ diff --git a/public/providers/hermes.png b/public/providers/hermes.png new file mode 100644 index 0000000000000000000000000000000000000000..d108d0c38d5b197feb906e0a0c6b8bdd377f106c Binary files /dev/null and b/public/providers/hermes.png differ diff --git a/public/providers/huggingface.png b/public/providers/huggingface.png new file mode 100644 index 0000000000000000000000000000000000000000..9a36b8f6a346c1b244cb95294d76454875cfa7b7 Binary files /dev/null and b/public/providers/huggingface.png differ diff --git a/public/providers/hyperbolic.png b/public/providers/hyperbolic.png new file mode 100644 index 0000000000000000000000000000000000000000..0b4802d1d47791aa518c362f60192741ffcc7955 Binary files /dev/null and b/public/providers/hyperbolic.png differ diff --git a/public/providers/iflow.png b/public/providers/iflow.png new file mode 100644 index 0000000000000000000000000000000000000000..1bddeae625daf5f1a972f3ebe2a0fb736804dc91 Binary files /dev/null and b/public/providers/iflow.png differ diff --git a/public/providers/inworld.png b/public/providers/inworld.png new file mode 100644 index 0000000000000000000000000000000000000000..579a6c244a9e13401ebda83743e59ee3d3d8e1bb Binary files /dev/null and b/public/providers/inworld.png differ diff --git a/public/providers/jcode.png b/public/providers/jcode.png new file mode 100644 index 0000000000000000000000000000000000000000..27e75a99cff0bc130bfc3b82de33e5c39f3f3dce Binary files /dev/null and b/public/providers/jcode.png differ diff --git a/public/providers/jina-ai.png b/public/providers/jina-ai.png new file mode 100644 index 0000000000000000000000000000000000000000..bb8ee31a2f4de3eaf9481b099f821f25f4c93fb9 Binary files /dev/null and b/public/providers/jina-ai.png differ diff --git a/public/providers/jina-reader.png b/public/providers/jina-reader.png new file mode 100644 index 0000000000000000000000000000000000000000..388d65deec8bd4ef329e36170607bf8a9e9e651b Binary files /dev/null and b/public/providers/jina-reader.png differ diff --git a/public/providers/kilocode.png b/public/providers/kilocode.png new file mode 100644 index 0000000000000000000000000000000000000000..b1272cfee4db1a99a3523541230a98b6a7f3527d Binary files /dev/null and b/public/providers/kilocode.png differ diff --git a/public/providers/kimi-coding.png b/public/providers/kimi-coding.png new file mode 100644 index 0000000000000000000000000000000000000000..422b7f96289368f94a5e85a25dd31e36288acaed Binary files /dev/null and b/public/providers/kimi-coding.png differ diff --git a/public/providers/kimi.png b/public/providers/kimi.png new file mode 100644 index 0000000000000000000000000000000000000000..422b7f96289368f94a5e85a25dd31e36288acaed Binary files /dev/null and b/public/providers/kimi.png differ diff --git a/public/providers/kiro.png b/public/providers/kiro.png new file mode 100644 index 0000000000000000000000000000000000000000..166c7c72c9a680992ce46ff35f616469fb19ee8d Binary files /dev/null and b/public/providers/kiro.png differ diff --git a/public/providers/linkup.png b/public/providers/linkup.png new file mode 100644 index 0000000000000000000000000000000000000000..166b1a5454b5d1782fa807346cf67bea3d4dfec4 Binary files /dev/null and b/public/providers/linkup.png differ diff --git a/public/providers/local-device.png b/public/providers/local-device.png new file mode 100644 index 0000000000000000000000000000000000000000..201cb10e16f9660411f37227e7dd115f466a16f5 Binary files /dev/null and b/public/providers/local-device.png differ diff --git a/public/providers/mimo-free.png b/public/providers/mimo-free.png new file mode 100644 index 0000000000000000000000000000000000000000..3fbdd81d7a7eaf4fc72a0306a49bed5887049aef Binary files /dev/null and b/public/providers/mimo-free.png differ diff --git a/public/providers/minimax-cn.png b/public/providers/minimax-cn.png new file mode 100644 index 0000000000000000000000000000000000000000..a8b9bf7ea6080912467af2906da7026b08f84649 Binary files /dev/null and b/public/providers/minimax-cn.png differ diff --git a/public/providers/minimax.png b/public/providers/minimax.png new file mode 100644 index 0000000000000000000000000000000000000000..a8b9bf7ea6080912467af2906da7026b08f84649 Binary files /dev/null and b/public/providers/minimax.png differ diff --git a/public/providers/mistral.png b/public/providers/mistral.png new file mode 100644 index 0000000000000000000000000000000000000000..4fcbb0c52aad8a739045aba562f947041a458ab5 Binary files /dev/null and b/public/providers/mistral.png differ diff --git a/public/providers/nanobanana.png b/public/providers/nanobanana.png new file mode 100644 index 0000000000000000000000000000000000000000..9df2d30179bf7d321ef16aed745c1af7959c361c Binary files /dev/null and b/public/providers/nanobanana.png differ diff --git a/public/providers/nebius.png b/public/providers/nebius.png new file mode 100644 index 0000000000000000000000000000000000000000..c4ebd3cb327118fa46569613172f23787c8d658c Binary files /dev/null and b/public/providers/nebius.png differ diff --git a/public/providers/nvidia.png b/public/providers/nvidia.png new file mode 100644 index 0000000000000000000000000000000000000000..d115e366634bf0d1a8a497d30d5f444d3d5725ae Binary files /dev/null and b/public/providers/nvidia.png differ diff --git a/public/providers/oai-cc.png b/public/providers/oai-cc.png new file mode 100644 index 0000000000000000000000000000000000000000..56a7a3e618c83a9887597e2c3ea45f75d5e7ea5f Binary files /dev/null and b/public/providers/oai-cc.png differ diff --git a/public/providers/oai-r.png b/public/providers/oai-r.png new file mode 100644 index 0000000000000000000000000000000000000000..0dbd61c734a2cc0d8812d03195debc25defc4237 Binary files /dev/null and b/public/providers/oai-r.png differ diff --git a/public/providers/ollama-local.png b/public/providers/ollama-local.png new file mode 100644 index 0000000000000000000000000000000000000000..302b1b182cd991ad5fa562d34483c095e189c573 Binary files /dev/null and b/public/providers/ollama-local.png differ diff --git a/public/providers/ollama.png b/public/providers/ollama.png new file mode 100644 index 0000000000000000000000000000000000000000..302b1b182cd991ad5fa562d34483c095e189c573 Binary files /dev/null and b/public/providers/ollama.png differ diff --git a/public/providers/openai.png b/public/providers/openai.png new file mode 100644 index 0000000000000000000000000000000000000000..d4367af077d01c68ef65af01c2089236583ffc19 Binary files /dev/null and b/public/providers/openai.png differ diff --git a/public/providers/openclaw.png b/public/providers/openclaw.png new file mode 100644 index 0000000000000000000000000000000000000000..7ef77ac754a1a396eb2d0655ba73a4b1c10cd663 Binary files /dev/null and b/public/providers/openclaw.png differ diff --git a/public/providers/opencode-go.png b/public/providers/opencode-go.png new file mode 100644 index 0000000000000000000000000000000000000000..2e709e1c49db5064be4dc445a3d6e3fb4407c1da Binary files /dev/null and b/public/providers/opencode-go.png differ diff --git a/public/providers/opencode.png b/public/providers/opencode.png new file mode 100644 index 0000000000000000000000000000000000000000..2e709e1c49db5064be4dc445a3d6e3fb4407c1da Binary files /dev/null and b/public/providers/opencode.png differ diff --git a/public/providers/openrouter.png b/public/providers/openrouter.png new file mode 100644 index 0000000000000000000000000000000000000000..0b4802d1d47791aa518c362f60192741ffcc7955 Binary files /dev/null and b/public/providers/openrouter.png differ diff --git a/public/providers/perplexity-web.png b/public/providers/perplexity-web.png new file mode 100644 index 0000000000000000000000000000000000000000..0b5851e5f6d089656ee1130741fc834197a368f8 Binary files /dev/null and b/public/providers/perplexity-web.png differ diff --git a/public/providers/perplexity.png b/public/providers/perplexity.png new file mode 100644 index 0000000000000000000000000000000000000000..0b5851e5f6d089656ee1130741fc834197a368f8 Binary files /dev/null and b/public/providers/perplexity.png differ diff --git a/public/providers/playht.png b/public/providers/playht.png new file mode 100644 index 0000000000000000000000000000000000000000..1807e9a2b627f7d078e23f2ef94abf74caba170b Binary files /dev/null and b/public/providers/playht.png differ diff --git a/public/providers/qoder.png b/public/providers/qoder.png new file mode 100644 index 0000000000000000000000000000000000000000..41e81c1d3760fa47678a9a99d7e105b010298163 Binary files /dev/null and b/public/providers/qoder.png differ diff --git a/public/providers/qwen.png b/public/providers/qwen.png new file mode 100644 index 0000000000000000000000000000000000000000..0fc2d1b308466c5da00cea81da89b3d86dfedcea Binary files /dev/null and b/public/providers/qwen.png differ diff --git a/public/providers/recraft.png b/public/providers/recraft.png new file mode 100644 index 0000000000000000000000000000000000000000..3ed12856322ff968314e1e0848502ab4e1c063ea Binary files /dev/null and b/public/providers/recraft.png differ diff --git a/public/providers/roo.png b/public/providers/roo.png new file mode 100644 index 0000000000000000000000000000000000000000..0503577502bc572ffc7e55874aeda3e9174dbc15 Binary files /dev/null and b/public/providers/roo.png differ diff --git a/public/providers/runwayml.png b/public/providers/runwayml.png new file mode 100644 index 0000000000000000000000000000000000000000..8f53a141075d47cd4e04b0989022fe520fb7f9e7 Binary files /dev/null and b/public/providers/runwayml.png differ diff --git a/public/providers/sdwebui.png b/public/providers/sdwebui.png new file mode 100644 index 0000000000000000000000000000000000000000..89e9ae2d51c9111571f06843d24bf50a8caa5ee1 Binary files /dev/null and b/public/providers/sdwebui.png differ diff --git a/public/providers/searchapi.png b/public/providers/searchapi.png new file mode 100644 index 0000000000000000000000000000000000000000..23630ec3efbf1d878fdf3e928afb326507f37319 Binary files /dev/null and b/public/providers/searchapi.png differ diff --git a/public/providers/searxng.png b/public/providers/searxng.png new file mode 100644 index 0000000000000000000000000000000000000000..3487f02317e6afe44afea28c9359c392e84ea5a7 Binary files /dev/null and b/public/providers/searxng.png differ diff --git a/public/providers/serper.png b/public/providers/serper.png new file mode 100644 index 0000000000000000000000000000000000000000..6a8063c9393c02258fdeee2eccb5d06a63b309bc Binary files /dev/null and b/public/providers/serper.png differ diff --git a/public/providers/siliconflow.png b/public/providers/siliconflow.png new file mode 100644 index 0000000000000000000000000000000000000000..d599df2e7a45d4d5c3d2d51b46993e8c7c064664 Binary files /dev/null and b/public/providers/siliconflow.png differ diff --git a/public/providers/stability-ai.png b/public/providers/stability-ai.png new file mode 100644 index 0000000000000000000000000000000000000000..31cf71c7724396fde773d1f8fb98897d33e39867 Binary files /dev/null and b/public/providers/stability-ai.png differ diff --git a/public/providers/tavily.png b/public/providers/tavily.png new file mode 100644 index 0000000000000000000000000000000000000000..b51f3c5faf6b0ee2b60d177785370ab5261ad6fa Binary files /dev/null and b/public/providers/tavily.png differ diff --git a/public/providers/together.png b/public/providers/together.png new file mode 100644 index 0000000000000000000000000000000000000000..9acc9017b1304be99d2c8805c7e832668178ab90 Binary files /dev/null and b/public/providers/together.png differ diff --git a/public/providers/topaz.png b/public/providers/topaz.png new file mode 100644 index 0000000000000000000000000000000000000000..3f86008e4d05603a81c10cd06c6e1f0d8a80d652 Binary files /dev/null and b/public/providers/topaz.png differ diff --git a/public/providers/tortoise.png b/public/providers/tortoise.png new file mode 100644 index 0000000000000000000000000000000000000000..70e93fcc50a71c5f032dc69afbdfa0b09c676ef6 Binary files /dev/null and b/public/providers/tortoise.png differ diff --git a/public/providers/vertex-partner.png b/public/providers/vertex-partner.png new file mode 100644 index 0000000000000000000000000000000000000000..892af458a9ca8de4bfc51d661f882b3599e51d11 Binary files /dev/null and b/public/providers/vertex-partner.png differ diff --git a/public/providers/vertex.png b/public/providers/vertex.png new file mode 100644 index 0000000000000000000000000000000000000000..892af458a9ca8de4bfc51d661f882b3599e51d11 Binary files /dev/null and b/public/providers/vertex.png differ diff --git a/public/providers/volcengine-ark.png b/public/providers/volcengine-ark.png new file mode 100644 index 0000000000000000000000000000000000000000..e452a09bfa6ef9e25c98755e5eb84a4a2c8ec53c Binary files /dev/null and b/public/providers/volcengine-ark.png differ diff --git a/public/providers/voyage-ai.png b/public/providers/voyage-ai.png new file mode 100644 index 0000000000000000000000000000000000000000..72c41963e5ed62ef7c68ed6e0148b5ed877319cb Binary files /dev/null and b/public/providers/voyage-ai.png differ diff --git a/public/providers/xai.png b/public/providers/xai.png new file mode 100644 index 0000000000000000000000000000000000000000..ef9d7abcffb964099a927fba9e10954e3c6d8aac Binary files /dev/null and b/public/providers/xai.png differ diff --git a/public/providers/xiaomi-mimo.png b/public/providers/xiaomi-mimo.png new file mode 100644 index 0000000000000000000000000000000000000000..1752aa9a19973a076e9814f8e6f5785ae3d1fe44 Binary files /dev/null and b/public/providers/xiaomi-mimo.png differ diff --git a/public/providers/xiaomi-tokenplan.png b/public/providers/xiaomi-tokenplan.png new file mode 100644 index 0000000000000000000000000000000000000000..1752aa9a19973a076e9814f8e6f5785ae3d1fe44 Binary files /dev/null and b/public/providers/xiaomi-tokenplan.png differ diff --git a/public/providers/youcom.png b/public/providers/youcom.png new file mode 100644 index 0000000000000000000000000000000000000000..4f7d4296a0fb80c94af7e6f25e1c040ec54d2305 Binary files /dev/null and b/public/providers/youcom.png differ diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000000000000000000000000000000000000..4c4de800af87b76fd266fcd489d920f2ee75b452 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,22 @@ +self.addEventListener('push', function (event) { + if (event.data) { + const data = event.data.json() + const options = { + body: data.body, + icon: data.icon || '/icons/icon-192.svg', + badge: '/icons/icon-192.svg', + vibrate: [100, 50, 100], + data: { + dateOfArrival: Date.now(), + primaryKey: '2', + }, + } + event.waitUntil(self.registration.showNotification(data.title, options)) + } +}) + +self.addEventListener('notificationclick', function (event) { + console.log('Notification click received.') + event.notification.close() + event.waitUntil(clients.openWindow('/')) +}) diff --git a/public/vercel.svg b/public/vercel.svg new file mode 100644 index 0000000000000000000000000000000000000000..77053960334e2e34dc584dea8019925c3b4ccca9 --- /dev/null +++ b/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/window.svg b/public/window.svg new file mode 100644 index 0000000000000000000000000000000000000000..b2b2a44f6ebc70c450043c05a002e7a93ba5d651 --- /dev/null +++ b/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/injectDisplayToRegistry.mjs b/scripts/injectDisplayToRegistry.mjs new file mode 100644 index 0000000000000000000000000000000000000000..1da6be551692dd9b20a57c53efed6686f208e74e --- /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 0000000000000000000000000000000000000000..ba1d0e48afa180d25e865e47926078110fd3c0df --- /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 0000000000000000000000000000000000000000..358ebcf97c9188afe3cb1120739538d62dd6a615 --- /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/scripts/translate-readme.js b/scripts/translate-readme.js new file mode 100755 index 0000000000000000000000000000000000000000..af6a11a6e17c078cbd1e5f6a82be9172d9f4e17e --- /dev/null +++ b/scripts/translate-readme.js @@ -0,0 +1,201 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +// ============ CONFIGURATION ============ +const API_ENDPOINT = process.env.GLM_API_ENDPOINT || 'https://api.z.ai/api/anthropic/v1/messages'; +const API_MODEL = process.env.GLM_API_MODEL || 'glm-5'; +const API_KEY = process.env.GLM_API_KEY; +const MAX_TOKENS = parseInt(process.env.GLM_MAX_TOKENS || '32000'); +const TEMPERATURE = parseFloat(process.env.GLM_TEMPERATURE || '0.3'); +const BATCH_SIZE = parseInt(process.env.TRANSLATE_BATCH_SIZE || '2'); // Number of languages to translate in parallel + +const SUPPORTED_LANGUAGES = { + vi: 'Vietnamese', + 'zh-CN': 'Simplified Chinese' +}; + +// ============ VALIDATION ============ +if (!API_KEY) { + console.error('Error: GLM_API_KEY environment variable not set'); + process.exit(1); +} + +const targetLangs = process.argv.slice(2); +if (targetLangs.length === 0) { + console.error('Usage: node translate-readme.js [lang2] ...'); + console.error(`Supported languages: ${Object.keys(SUPPORTED_LANGUAGES).join(', ')}`); + process.exit(1); +} + +for (const lang of targetLangs) { + if (!SUPPORTED_LANGUAGES[lang]) { + console.error(`Unsupported language: ${lang}`); + process.exit(1); + } +} + +// ============ TRANSLATION FUNCTION ============ +async function translateToLanguage(readmeContent, targetLang) { + const langName = SUPPORTED_LANGUAGES[targetLang]; + console.log(`\n[${targetLang}] Translating to ${langName}...`); + console.log(`[${targetLang}] README size: ${readmeContent.length} characters`); + + const prompt = `Translate this entire Markdown document to ${langName}. + +CRITICAL RULES: +- Keep ALL markdown syntax EXACTLY as is (##, \`\`\`, -, *, |, tables, etc.) +- Do NOT modify code blocks, ASCII diagrams, or code fences +- Only translate human-readable text content +- Keep all URLs, links, and technical terms unchanged + +${readmeContent}`; + + const response = await fetch(API_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': API_KEY, + 'anthropic-version': '2023-06-01' + }, + body: JSON.stringify({ + model: API_MODEL, + messages: [{ role: 'user', content: prompt }], + temperature: TEMPERATURE, + max_tokens: MAX_TOKENS, + stream: true + }) + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`[${targetLang}] API Error: ${response.status} ${error}`); + } + + console.log(`[${targetLang}] Receiving translation stream...`); + + let translatedContent = ''; + let chunkCount = 0; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6); + if (data === '[DONE]') continue; + + try { + const parsed = JSON.parse(data); + if (parsed.type === 'content_block_delta' && parsed.delta?.text) { + translatedContent += parsed.delta.text; + chunkCount++; + if (chunkCount % 100 === 0) { + process.stdout.write(`\r[${targetLang}] Received ${translatedContent.length} chars...`); + } + } + } catch (e) { + // Skip invalid JSON + } + } + } + } + + process.stdout.write('\n'); + + console.log(`\n[${targetLang}] Stream complete, received ${translatedContent.length} characters`); + + if (!translatedContent) { + throw new Error(`[${targetLang}] No translation received`); + } + + console.log(`[${targetLang}] Fixing image paths...`); + + // Fix image paths + translatedContent = translatedContent + .replace(/!\[([^\]]*)\]\(\.\/images\//g, '![$1](../images/') + .replace(/!\[([^\]]*)\]\(\.\/public\//g, '![$1](../public/') + .replace(/ translateToLanguage(readmeContent, lang)); + + // Wait for all to complete + const batchResults = await Promise.allSettled(batchPromises); + + results.push(...batchResults); + + // Wait between batches to avoid rate limit + if (i + BATCH_SIZE < targetLangs.length) { + console.log('\nWaiting 3s before next batch...'); + await new Promise(resolve => setTimeout(resolve, 3000)); + } + } + + console.log('\n' + '='.repeat(60)); + console.log('SUMMARY'); + console.log('='.repeat(60)); + + results.forEach((result) => { + if (result.status === 'fulfilled') { + console.log(`✅ ${result.value.lang}: ${result.value.path}`); + } else { + console.log(`❌ ${result.lang}: ${result.reason.message}`); + } + }); + + const failed = results.filter(r => r.status === 'rejected').length; + if (failed > 0) { + console.log(`\n⚠️ ${failed} translation(s) failed`); + process.exit(1); + } + + console.log('\n✅ All translations completed successfully!'); +} + +main().catch(err => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/skills/9router-chat/SKILL.md b/skills/9router-chat/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..d109ac7fc521e1d232a0bfa3ef7d2929023cc991 --- /dev/null +++ b/skills/9router-chat/SKILL.md @@ -0,0 +1,73 @@ +--- +name: 9router-chat +description: Chat / code generation via 9Router using OpenAI /v1/chat/completions or Anthropic /v1/messages format with streaming + auto-fallback combos. Use when the user wants to ask an LLM, generate code, summarize text, or run prompts through 9Router. +--- + +# 9Router — Chat + +Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup. + +## Endpoints + +- `POST $NINEROUTER_URL/v1/chat/completions` — OpenAI format +- `POST $NINEROUTER_URL/v1/messages` — Anthropic format + +## Discover + +```bash +curl $NINEROUTER_URL/v1/models | jq '.data[].id' +# Per-model metadata (contextWindow, params) +curl "$NINEROUTER_URL/v1/models/info?id=openai/gpt-4o" +``` + +Combos (e.g. `vip`, `mycodex`) auto-fallback through multiple providers. + +## OpenAI format + +```bash +curl -X POST $NINEROUTER_URL/v1/chat/completions \ + -H "Authorization: Bearer $NINEROUTER_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"openai/gpt-5","messages":[{"role":"user","content":"Hi"}],"stream":false}' +``` + +JS (OpenAI SDK): + +```js +import OpenAI from "openai"; +const client = new OpenAI({ baseURL: `${process.env.NINEROUTER_URL}/v1`, apiKey: process.env.NINEROUTER_KEY }); +const res = await client.chat.completions.create({ + model: "openai/gpt-5", + messages: [{ role: "user", content: "Hi" }], + stream: true, +}); +for await (const chunk of res) process.stdout.write(chunk.choices[0]?.delta?.content || ""); +``` + +## Anthropic format + +```bash +curl -X POST $NINEROUTER_URL/v1/messages \ + -H "Authorization: Bearer $NINEROUTER_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -H "Content-Type: application/json" \ + -d '{"model":"cc/claude-opus-4-7","max_tokens":1024,"messages":[{"role":"user","content":"Hi"}]}' +``` + +## Response shape + +OpenAI (`/v1/chat/completions`): +```json +{ "id": "chatcmpl-...", "object": "chat.completion", "model": "openai/gpt-5", + "choices": [{ "index": 0, "message": { "role": "assistant", "content": "Hello!" }, "finish_reason": "stop" }], + "usage": { "prompt_tokens": 8, "completion_tokens": 2, "total_tokens": 10 } } +``` + +Streaming (`stream:true`) emits SSE: `data: {choices:[{delta:{content:"..."}}]}\n\n` ... `data: [DONE]\n\n`. + +Anthropic (`/v1/messages`): +```json +{ "id": "msg_...", "type": "message", "role": "assistant", "model": "cc/claude-opus-4-7", + "content": [{ "type": "text", "text": "Hello!" }], + "stop_reason": "end_turn", "usage": { "input_tokens": 8, "output_tokens": 2 } } +``` diff --git a/skills/9router-embeddings/SKILL.md b/skills/9router-embeddings/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..648e4ca7a9e5dc3b18fc4e82688a43792bdd75ca --- /dev/null +++ b/skills/9router-embeddings/SKILL.md @@ -0,0 +1,69 @@ +--- +name: 9router-embeddings +description: Generate vector embeddings via 9Router /v1/embeddings using OpenAI / Gemini / Mistral / Voyage / Nvidia / GitHub embedding models for RAG, semantic search, similarity. Use when the user wants embeddings, vectors, RAG, semantic search, or to embed text. +--- + +# 9Router — Embeddings + +Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup. + +## Discover + +```bash +curl $NINEROUTER_URL/v1/models/embedding | jq '.data[].id' +# Per-model dimensions +curl "$NINEROUTER_URL/v1/models/info?id=openai/text-embedding-3-small" +``` + +## Endpoint + +`POST $NINEROUTER_URL/v1/embeddings` + +| Field | Required | Notes | +|---|---|---| +| `model` | yes | from `/v1/models/embedding` | +| `input` | yes | string OR array of strings | +| `encoding_format` | no | `float` (default) / `base64` | +| `dimensions` | no | OpenAI v3 only | + +## Examples + +```bash +curl -X POST $NINEROUTER_URL/v1/embeddings \ + -H "Authorization: Bearer $NINEROUTER_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"openai/text-embedding-3-small","input":["hello","world"]}' +``` + +JS: + +```js +const r = await fetch(`${process.env.NINEROUTER_URL}/v1/embeddings`, { + method: "POST", + headers: { "Authorization": `Bearer ${process.env.NINEROUTER_KEY}`, "Content-Type": "application/json" }, + body: JSON.stringify({ model: "gemini/text-embedding-004", input: "RAG chunk text" }), +}); +const { data } = await r.json(); +console.log(data[0].embedding.length); // dimension +``` + +## Response shape + +```json +{ "object": "list", "model": "openai/text-embedding-3-small", + "data": [ + { "object": "embedding", "index": 0, "embedding": [0.0123, -0.045, ...] }, + { "object": "embedding", "index": 1, "embedding": [...] } + ], + "usage": { "prompt_tokens": 5, "total_tokens": 5 } } +``` + +## Provider quirks + +| Provider | Notes | +|---|---| +| `openai`, `openrouter`, `mistral`, `voyage-ai`, `fireworks`, `together`, `nebius`, `github`, `nvidia`, `jina-ai` | Native OpenAI shape — `dimensions` works only on OpenAI v3 (`text-embedding-3-*`) | +| `gemini`, `google_ai_studio` | Server auto-converts to `embedContent`/`batchEmbedContents` — send OpenAI shape | +| `openai-compatible-*`, `custom-embedding-*` | Custom `baseUrl` from credentials | + +Batch (`input` as array) is faster; some providers cap batch size. diff --git a/skills/9router-image/SKILL.md b/skills/9router-image/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..f5bbfad14505810f0f95d583ab6b219c1c7e9e15 --- /dev/null +++ b/skills/9router-image/SKILL.md @@ -0,0 +1,86 @@ +--- +name: 9router-image +description: Generate images via 9Router /v1/images/generations using OpenAI / Gemini Imagen / DALL-E / FLUX / MiniMax / SDWebUI / ComfyUI / Codex models. Use when the user wants to create, generate, draw, or render an image, picture, or text-to-image (txt2img). +--- + +# 9Router — Image Generation + +Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup. + +## Discover + +```bash +curl $NINEROUTER_URL/v1/models/image | jq '.data[].id' +# Per-model params/options (size enum, quality enum, capabilities like edit) +curl "$NINEROUTER_URL/v1/models/info?id=openai/dall-e-3" +``` + +## Endpoint + +`POST $NINEROUTER_URL/v1/images/generations` + +| Field | Required | Notes | +|---|---|---| +| `model` | yes | from `/v1/models/image` | +| `prompt` | yes | image description | +| `n` | no | count (provider-dependent) | +| `size` | no | `1024x1024`, `1792x1024`, ... | +| `quality` | no | `standard` / `hd` (OpenAI) | +| `response_format` | no | `url` (default) or `b64_json` | + +Add query `?response_format=binary` to receive raw image bytes (handy for saving file). + +## Examples + +Save to file (binary): + +```bash +curl -X POST "$NINEROUTER_URL/v1/images/generations?response_format=binary" \ + -H "Authorization: Bearer $NINEROUTER_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"gemini/gemini-3-pro-image-preview","prompt":"watercolor mountains at sunrise","size":"1024x1024"}' \ + --output out.png +``` + +JS (URL response): + +```js +const r = await fetch(`${process.env.NINEROUTER_URL}/v1/images/generations`, { + method: "POST", + headers: { "Authorization": `Bearer ${process.env.NINEROUTER_KEY}`, "Content-Type": "application/json" }, + body: JSON.stringify({ model: "gemini/gemini-3-pro-image-preview", prompt: "neon city", size: "1024x1024" }), +}); +const { data } = await r.json(); +console.log(data[0].url || data[0].b64_json.slice(0, 40)); +``` + +## Response shape + +JSON (default `response_format=url`): +```json +{ "created": 1735000000, "data": [{ "url": "https://..." }] } +``` + +`response_format=b64_json`: +```json +{ "created": 1735000000, "data": [{ "b64_json": "iVBORw0KGgo..." }] } +``` + +Query `?response_format=binary` returns raw image bytes (Content-Type `image/png` or `image/jpeg`). + +## Provider quirks + +Common fields above work everywhere. These add/override: + +| Provider | Extra/changed fields | Notes | +|---|---|---| +| `openai`, `minimax`, `openrouter`, `recraft` | `quality`, `style`, `response_format` | Standard OpenAI shape | +| `gemini` (nano-banana) | — | Only `prompt`; ignores `size`/`n` | +| `codex` (gpt-5.4-image) | `image`, `images[]`, `image_detail`, `output_format`, `background` | SSE stream; **ChatGPT Plus/Pro required** | +| `huggingface` | — | Only `prompt`; returns single image | +| `nanobanana` | `image`, `images[]` (edit mode) | `size` → aspect ratio; async polling | +| `fal-ai` | `image` (img2img) | `n` → `num_images`; `size` → ratio; async | +| `stability-ai` | `style` (preset), `output_format` | `size` → `aspect_ratio` | +| `black-forest-labs` (FLUX) | `image` (ref) | `size` → exact `width`/`height`; async | +| `runwayml` | `image` (ref) | `size` → ratio; async; video models exist | +| `sdwebui`, `comfyui` | — | Localhost noAuth (`:7860` / `:8188`) | diff --git a/skills/9router-stt/SKILL.md b/skills/9router-stt/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..1561c9dc1fd28de61885f8519cadaf24a6c7b5f4 --- /dev/null +++ b/skills/9router-stt/SKILL.md @@ -0,0 +1,79 @@ +--- +name: 9router-stt +description: Speech-to-text via 9Router /v1/audio/transcriptions using OpenAI Whisper / Groq / Gemini / Deepgram / AssemblyAI / NVIDIA / HuggingFace models. Use when the user wants to transcribe audio, convert speech to text, or get subtitles from audio files. +--- + +# 9Router — Speech-to-Text + +Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup. + +## Discover + +```bash +curl $NINEROUTER_URL/v1/models/stt | jq '.data[].id' +# Per-model params (language, response_format, prompt, temperature support) +curl "$NINEROUTER_URL/v1/models/info?id=openai/whisper-1" +``` + +`model` = STT model ID (e.g. `openai/whisper-1`, `groq/whisper-large-v3`, `deepgram/nova-3`, `gemini/gemini-2.5-flash`). + +## Endpoint + +`POST $NINEROUTER_URL/v1/audio/transcriptions` (OpenAI Whisper compatible, `multipart/form-data`) + +| Field | Required | Notes | +|---|---|---| +| `model` | yes | from `/v1/models/stt` | +| `file` | yes | audio file (mp3, wav, m4a, webm, ogg, flac) | +| `language` | no | ISO-639-1 (e.g. `en`, `vi`) | +| `prompt` | no | hint text to guide transcription | +| `response_format` | no | `json` (default) / `text` / `verbose_json` / `srt` / `vtt` | +| `temperature` | no | 0–1 | + +## Examples + +```bash +curl -X POST "$NINEROUTER_URL/v1/audio/transcriptions" \ + -H "Authorization: Bearer $NINEROUTER_KEY" \ + -F "model=openai/whisper-1" \ + -F "file=@audio.mp3" \ + -F "language=vi" +``` + +JS (Node): + +```js +import { createReadStream } from "node:fs"; +const form = new FormData(); +form.append("model", "groq/whisper-large-v3-turbo"); +form.append("file", new Blob([await (await import("node:fs/promises")).readFile("audio.mp3")]), "audio.mp3"); +const r = await fetch(`${process.env.NINEROUTER_URL}/v1/audio/transcriptions`, { + method: "POST", + headers: { "Authorization": `Bearer ${process.env.NINEROUTER_KEY}` }, + body: form, +}); +const { text } = await r.json(); +console.log(text); +``` + +## Response shape + +Default (`response_format=json`): +```json +{ "text": "Xin chào, đây là bản ghi âm." } +``` + +`verbose_json` adds `language`, `duration`, `segments[]` with timestamps. +`srt` / `vtt` return subtitle text. + +## Provider quirks + +| Provider | `model` format | Notes | +|---|---|---| +| `openai` | `whisper-1`, `gpt-4o-transcribe`, `gpt-4o-mini-transcribe` | Native OpenAI shape | +| `groq` | `whisper-large-v3`, `whisper-large-v3-turbo`, `distil-whisper-large-v3-en` | Fastest; OpenAI shape | +| `gemini` | `gemini-2.5-flash`, `gemini-2.5-pro`, `gemini-2.5-flash-lite` | Server converts to `generateContent` with audio inline | +| `deepgram` | `nova-3`, `nova-2`, `whisper-large` | Token auth; server adapts response | +| `assemblyai` | `universal-3-pro`, `universal-2` | Async upload+poll handled server-side | +| `nvidia` | `nvidia/parakeet-ctc-1.1b-asr` | NIM endpoint | +| `huggingface` | `openai/whisper-large-v3`, `openai/whisper-small` | HF Inference API | diff --git a/skills/9router-tts/SKILL.md b/skills/9router-tts/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..4a9536cb81cd46ba471ed8563633cd0a7b09a6a9 --- /dev/null +++ b/skills/9router-tts/SKILL.md @@ -0,0 +1,80 @@ +--- +name: 9router-tts +description: Text-to-speech via 9Router /v1/audio/speech using OpenAI / ElevenLabs / Deepgram / Edge TTS / Google TTS / Hyperbolic / Inworld voices. Use when the user wants to convert text to speech, generate audio, voiceover, narrate, or read text aloud. +--- + +# 9Router — Text-to-Speech + +Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup. + +## Discover + +```bash +# 1) List models +curl $NINEROUTER_URL/v1/models/tts | jq '.data[].id' +# 2) Per-model metadata (params, voicesUrl if voice-by-id) +curl "$NINEROUTER_URL/v1/models/info?id=el/eleven_multilingual_v2" +# 3) List voices (elevenlabs, edge-tts, deepgram, inworld, local-device). Optional ?lang=vi +curl "$NINEROUTER_URL/v1/audio/voices?provider=edge-tts&lang=vi" | jq '.data[].model' +``` + +`model` field in `/v1/audio/speech` = voice ID directly (e.g. `edge-tts/vi-VN-HoaiMyNeural`, `el/`, or `openai/tts-1` model+default voice). + +## Endpoint + +`POST $NINEROUTER_URL/v1/audio/speech` + +| Field | Required | Notes | +|---|---|---| +| `model` | yes | voice ID from `/v1/models/tts` | +| `input` | yes | text to speak | + +Query `?response_format=mp3` (default, raw bytes) or `?response_format=json` (`{audio: base64, format}`). + +## Examples + +Save MP3: + +```bash +curl -X POST "$NINEROUTER_URL/v1/audio/speech" \ + -H "Authorization: Bearer $NINEROUTER_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"openai/tts-1","input":"Hello world"}' \ + --output speech.mp3 +``` + +JS (save file): + +```js +import { writeFile } from "node:fs/promises"; +const r = await fetch(`${process.env.NINEROUTER_URL}/v1/audio/speech`, { + method: "POST", + headers: { "Authorization": `Bearer ${process.env.NINEROUTER_KEY}`, "Content-Type": "application/json" }, + body: JSON.stringify({ model: "el/eleven_multilingual_v2", input: "Xin chào" }), +}); +await writeFile("speech.mp3", Buffer.from(await r.arrayBuffer())); +``` + +## Response shape + +Default → raw audio bytes (Content-Type `audio/mp3`). + +`?response_format=json`: +```json +{ "audio": "SUQzBAAAA...", "format": "mp3" } +``` + +## Provider quirks (model format) + +| Provider | `model` format | Notes | +|---|---|---| +| `openai` | `tts-1/alloy` (model/voice) or just voice | Default model `gpt-4o-mini-tts` | +| `elevenlabs` | `/` or `` | Default model `eleven_flash_v2_5`; list voices in Dashboard | +| `openrouter` | `openai/gpt-4o-mini-tts/alloy` | Streamed via chat-completions audio modality | +| `edge-tts` | voice id e.g. `vi-VN-HoaiMyNeural` | **noAuth**; default `vi-VN-HoaiMyNeural` | +| `google-tts` | language code e.g. `en`, `vi` | **noAuth** | +| `local-device` | OS voice name (`say -v ?` / SAPI) | **noAuth**; needs `ffmpeg` | +| `deepgram` | `aura-asteria-en` etc | Token auth | +| `nvidia`, `inworld`, `cartesia`, `playht` | `model/voice` | Provider-specific auth header | +| `coqui`, `tortoise` | speaker / voice id | Localhost noAuth | +| `hyperbolic` | model id | Body = `{text}` only | diff --git a/skills/9router-web-fetch/SKILL.md b/skills/9router-web-fetch/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..69c41ac3f7e4fb15dcd42eebb21cce93f7461842 --- /dev/null +++ b/skills/9router-web-fetch/SKILL.md @@ -0,0 +1,99 @@ +--- +name: 9router-web-fetch +description: Fetch URL → markdown / text / HTML via 9Router /v1/web/fetch using Firecrawl / Jina Reader / Tavily Extract / Exa Contents. Use when the user wants to scrape a webpage, extract URL content, read article, or convert a URL to markdown. +--- + +# 9Router — Web Fetch + +Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup. + +## Discover + +```bash +curl $NINEROUTER_URL/v1/models/web | jq '.data[] | select(.kind=="webFetch") | .id' +# Per-provider params +curl "$NINEROUTER_URL/v1/models/info?id=firecrawl/fetch" +``` + +IDs end in `/fetch` (e.g. `firecrawl/fetch`, `jina/fetch`). `fetch-combo` chains providers with auto-fallback. + +## Endpoint + +`POST $NINEROUTER_URL/v1/web/fetch` + +| Field | Required | Notes | +|---|---|---| +| `model` (or `provider`) | yes | from `/v1/models/web` (e.g. `firecrawl` or `jina-reader`) | +| `url` | yes | URL to extract | +| `format` | no | `markdown` (default) / `text` / `html` | +| `max_characters` | no | truncate output | + +## Examples + +### Jina Reader +```bash +curl -X POST $NINEROUTER_URL/v1/web/fetch \ + -H "Authorization: Bearer $NINEROUTER_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"jina-reader","url":"https://9router.com","format":"markdown"}' +``` + +### Exa +```bash +curl -X POST $NINEROUTER_URL/v1/web/fetch \ + -H "Authorization: Bearer $NINEROUTER_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"exa","url":"https://example.com","format":"markdown","max_characters":0}' +``` + +### Firecrawl +```bash +curl -X POST $NINEROUTER_URL/v1/web/fetch \ + -H "Authorization: Bearer $NINEROUTER_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"firecrawl","url":"https://example.com","format":"markdown","max_characters":0}' +``` + +### Tavily +```bash +curl -X POST $NINEROUTER_URL/v1/web/fetch \ + -H "Authorization: Bearer $NINEROUTER_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"tavily","url":"https://example.com","format":"markdown","max_characters":0}' +``` + + +JS: + +```js +const r = await fetch(`${process.env.NINEROUTER_URL}/v1/web/fetch`, { + method: "POST", + headers: { "Authorization": `Bearer ${process.env.NINEROUTER_KEY}`, "Content-Type": "application/json" }, + body: JSON.stringify({ model: "fetch-combo", url: "https://example.com", format: "markdown", max_characters: 5000 }), +}); +const { data } = await r.json(); +console.log(data.title, data.content.length); +``` + +## Response shape + +```json +{ + "provider": "jina-reader", + "url": "...", + "title": "...", + "content": { "format": "markdown", "text": "...", "length": 1234 }, + "metadata": { "author": null, "published_at": null, "language": null }, + "usage": { "fetch_cost_usd": 0 }, + "metrics": { "response_time_ms": 850, "upstream_latency_ms": 700 } +} +``` + +## Provider quirks + +| Provider | Auth | Best for | +|---|---|---| +| `firecrawl` | Bearer | JS-rendered pages, `format=markdown/html` | +| `jina-reader` | Bearer (optional) | Free tier (~1M chars/mo); fastest plain markdown | +| `tavily` | Bearer | Bulk extract; returns `raw_content` | +| `exa` | `x-api-key` | Pre-indexed pages; fast text extraction | diff --git a/skills/9router-web-search/SKILL.md b/skills/9router-web-search/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..ebd3003d421d0140b5db1fc15edfee2cfa123dd8 --- /dev/null +++ b/skills/9router-web-search/SKILL.md @@ -0,0 +1,91 @@ +--- +name: 9router-web-search +description: Web search via 9Router /v1/search using Tavily / Exa / Brave / Serper / SearXNG / Google PSE / Linkup / SearchAPI / You.com / Perplexity. Use when the user wants to search the web, look up information, find articles, or query a search engine. +--- + +# 9Router — Web Search + +Requires `NINEROUTER_URL` (and `NINEROUTER_KEY` if auth enabled). See https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md for setup. + +## Discover + +```bash +curl $NINEROUTER_URL/v1/models/web | jq '.data[] | select(.kind=="webSearch") | .id' +# Per-provider params (searchTypes, maxResults, required options like cx for google-pse) +curl "$NINEROUTER_URL/v1/models/info?id=tavily/search" +``` + +IDs end in `/search` (e.g. `tavily/search`). Combos (`owned_by:"combo"`) chain providers with auto-fallback. + +## Endpoint + +`POST $NINEROUTER_URL/v1/search` + +| Field | Required | Notes | +|---|---|---| +| `model` (or `provider`) | yes | from `/v1/models/web` (e.g. `tavily` or `brave`) | +| `query` | yes | search query | +| `max_results` | no | default 5 | +| `search_type` | no | `web` (default) / `news` | +| `country`, `language`, `time_range`, `domain_filter` | no | provider-dependent | + +## Examples + +```bash +curl -X POST $NINEROUTER_URL/v1/search \ + -H "Authorization: Bearer $NINEROUTER_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"tavily","query":"9Router open source","max_results":5}' +``` + +JS: + +```js +const r = await fetch(`${process.env.NINEROUTER_URL}/v1/search`, { + method: "POST", + headers: { "Authorization": `Bearer ${process.env.NINEROUTER_KEY}`, "Content-Type": "application/json" }, + body: JSON.stringify({ model: "search-combo", query: "latest LLM benchmarks", max_results: 10 }), +}); +console.log(await r.json()); +``` + +## Response shape + +```json +{ + "provider": "tavily", + "query": "9Router open source", + "results": [ + { + "title": "...", "url": "https://...", "display_url": "github.com/...", + "snippet": "...", "position": 1, "score": 0.92, + "published_at": null, "favicon_url": null, "content": null, + "metadata": { "author": null, "language": null, "source_type": null, "image_url": null }, + "citation": { "provider": "tavily", "retrieved_at": "2026-...", "rank": 1 } + } + ], + "answer": null, + "usage": { "queries_used": 1, "search_cost_usd": 0.008 }, + "metrics": { "response_time_ms": 850, "upstream_latency_ms": 700, "total_results_available": 12 }, + "errors": [] +} +``` + +## Provider quirks + +All accept `query` + `max_results`. Optional fields vary: + +| Provider | Supports | Required extras | +|---|---|---| +| `tavily` | country, domain_filter, news topic | — | +| `exa` | domain_filter (incl/excl), news category | — | +| `brave-search` | country, language | — | +| `serper` | country, language, news endpoint | — | +| `perplexity` | country, language, domain_filter | — | +| `linkup` | domain_filter, time_range | `depth: fast/standard/deep` (option) | +| `google-pse` | country, language, time_range, offset | **`cx` required** (providerOptions) | +| `searchapi` | country, language, pagination | — | +| `youcom` | country, language, time_range, domain_filter, full_page | — | +| `searxng` | language, time_range | Self-hosted, **noAuth** | + +Provider IS the model — `"provider":"tavily" ≡ "model":"tavily"`. diff --git a/skills/9router/SKILL.md b/skills/9router/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..ea4c511d653540aad3f1274f4fbfa3b4398921ab --- /dev/null +++ b/skills/9router/SKILL.md @@ -0,0 +1,61 @@ +--- +name: 9router +description: Entry point for 9Router — local/remote AI gateway with OpenAI-compatible REST for chat, image, TTS, embeddings, web search, web fetch. Use when the user mentions 9Router, NINEROUTER_URL, or wants AI without writing provider boilerplate. This skill covers setup + indexes capability skills; fetch the relevant capability SKILL.md from the URLs below when needed. +--- + +# 9Router + +Local/remote AI gateway exposing OpenAI-compatible REST. One key, many providers, auto-fallback. + +## Setup + +```bash +export NINEROUTER_URL="http://localhost:20128" # or VPS / tunnel URL +export NINEROUTER_KEY="sk-..." # from Dashboard → Keys (only if requireApiKey=true) +``` + +All requests: `${NINEROUTER_URL}/v1/...` with header `Authorization: Bearer ${NINEROUTER_KEY}` (omit if auth disabled). + +Verify: `curl $NINEROUTER_URL/api/health` → `{"ok":true}` + +## Discover models + +```bash +curl $NINEROUTER_URL/v1/models # chat/LLM (default) +curl $NINEROUTER_URL/v1/models/image # image-gen +curl $NINEROUTER_URL/v1/models/tts # text-to-speech +curl $NINEROUTER_URL/v1/models/embedding # embeddings +curl $NINEROUTER_URL/v1/models/web # web search + fetch (entries have `kind` field) +curl $NINEROUTER_URL/v1/models/stt # speech-to-text +curl $NINEROUTER_URL/v1/models/image-to-text # vision +``` + +Use `data[].id` as `model` field in requests. Combos appear with `owned_by:"combo"`. + +Response shape: +```json +{ "object": "list", "data": [ + { "id": "openai/gpt-5", "object": "model", "owned_by": "openai", "created": 1735000000 }, + { "id": "tavily/search", "object": "model", "kind": "webSearch", "owned_by": "tavily", "created": 1735000000 } +]} +``` + +## Capability skills + +When the user needs a specific capability, fetch that skill's `SKILL.md` from its raw URL: + +| Capability | Raw URL | +|---|---| +| Chat / code-gen | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-chat/SKILL.md | +| Image generation | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-image/SKILL.md | +| Text-to-speech | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-tts/SKILL.md | +| Speech-to-text | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-stt/SKILL.md | +| Embeddings | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-embeddings/SKILL.md | +| Web search | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-web-search/SKILL.md | +| Web fetch (URL → markdown) | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-web-fetch/SKILL.md | + +## Errors + +- 401 → set/refresh `NINEROUTER_KEY` (Dashboard → Keys) +- 400 `Invalid model format` → check `model` exists in `/v1/models/` +- 503 `All accounts unavailable` → wait `retry-after` or add another provider account diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 0000000000000000000000000000000000000000..f9f06b90b5beba3776845b570fd04638c9517510 --- /dev/null +++ b/skills/README.md @@ -0,0 +1,42 @@ +# 9Router — Agent Skills + +Drop-in skills for any AI agent (Claude, Cursor, ChatGPT, custom SDK). Just **copy a link** below and paste it to your AI — it will fetch the skill and use 9Router for you. + +> Tip: start with the **9router** entry skill — it covers setup and links to all capability skills. + +## Skills + +| Capability | Copy link below and paste to your AI | +|---|---| +| **Entry / Setup** (start here) | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md | +| Chat / code-gen | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-chat/SKILL.md | +| Image generation | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-image/SKILL.md | +| Text-to-speech | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-tts/SKILL.md | +| Speech-to-text | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-stt/SKILL.md | +| Embeddings | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-embeddings/SKILL.md | +| Web search | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-web-search/SKILL.md | +| Web fetch (URL → markdown) | https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router-web-fetch/SKILL.md | + +## How to use + +Paste to your AI (Claude, Cursor, ChatGPT, …): + +``` +Read this skill and use it: https://raw.githubusercontent.com/decolua/9router/refs/heads/master/skills/9router/SKILL.md +``` + +Then ask normally — *"generate an image of a cat"*, *"transcribe this URL"*, etc. + +## Configure your shell once + +```bash +export NINEROUTER_URL="http://localhost:20128" # local default, or your VPS / tunnel URL +export NINEROUTER_KEY="sk-..." # from Dashboard → Keys (only if requireApiKey=true) +``` + +Verify: `curl $NINEROUTER_URL/api/health` → `{"ok":true}`. + +## Links + +- Source: https://github.com/decolua/9router +- Dashboard: https://9router.com diff --git a/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js b/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js new file mode 100644 index 0000000000000000000000000000000000000000..a97d0a5ea1cf9ae6943830fc3d2ec1e34ae25e1a --- /dev/null +++ b/src/app/(dashboard)/dashboard/basic-chat/BasicChatPageClient.js @@ -0,0 +1,967 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Badge, Button } from "@/shared/components"; +import { getModelsByProviderId } from "@/shared/constants/models"; +import { isAnthropicCompatibleProvider, isOpenAICompatibleProvider } from "@/shared/constants/providers"; + +const STORAGE_KEYS = { + sessions: "basic-chat.sessions", + activeSessionId: "basic-chat.activeSessionId", + activeProviderId: "basic-chat.activeProviderId", + draft: "basic-chat.draft", +}; + +function createId() { + if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID(); + return `chat_${Date.now()}_${Math.random().toString(16).slice(2)}`; +} + +function safeParse(value, fallback) { + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function textValue(value) { + if (typeof value === "string") return value; + if (value == null) return ""; + if (Array.isArray(value)) return value.map(textValue).filter(Boolean).join(" "); + if (typeof value === "object") { + if (typeof value.message === "string") return value.message; + if (typeof value.error === "string") return value.error; + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); +} + +function humanize(value = "") { + return String(value) + .replace(/[-_]/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()) + .trim() || "Unknown"; +} + +function formatRelativeTime(value) { + if (!value) return "Now"; + const time = new Date(value).getTime(); + if (Number.isNaN(time)) return "Now"; + const diffMinutes = Math.max(1, Math.round((Date.now() - time) / 60000)); + if (diffMinutes < 60) return `${diffMinutes}m`; + const diffHours = Math.round(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h`; + return `${Math.round(diffHours / 24)}d`; +} + +function makeSessionTitle(text = "") { + const normalized = textValue(text).replace(/\s+/g, " ").trim(); + if (!normalized) return "New chat"; + return normalized.length > 52 ? `${normalized.slice(0, 52).trimEnd()}…` : normalized; +} + +function buildUserContent(message) { + const text = textValue(message.content).trim(); + const attachments = Array.isArray(message.attachments) ? message.attachments : []; + + if (attachments.length === 0) return text; + + const content = []; + if (text) content.push({ type: "text", text }); + + for (const attachment of attachments) { + if (attachment?.dataUrl) { + content.push({ type: "image_url", image_url: { url: attachment.dataUrl } }); + } + } + + return content.length > 0 ? content : text; +} + +function readAssistantText(chunk) { + if (!chunk || typeof chunk !== "object") return ""; + const choice = chunk.choices?.[0]; + const delta = choice?.delta || {}; + const pieces = [delta.content, choice?.message?.content, chunk.output_text, chunk.text] + .map(textValue) + .filter(Boolean); + return pieces[0] || ""; +} + +async function fileToDataUrl(file) { + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("Failed to read file")); + reader.readAsDataURL(file); + }); +} + +function cloneSession(session) { + return { + ...session, + messages: Array.isArray(session.messages) ? session.messages.map((message) => ({ ...message })) : [], + }; +} + +function getProviderLabel(connection) { + return connection?.name || humanize(connection?.provider || connection?.id || "provider"); +} + +function normalizeStaticModel(model, connection) { + if (!model?.id) return null; + return { + id: `${connection.provider}/${model.id}`, + requestModel: `${connection.provider}/${model.id}`, + name: model.name || model.id, + providerId: connection.provider, + providerName: getProviderLabel(connection), + source: "static", + }; +} + +function normalizeLiveModel(model, connection) { + const rawId = typeof model === "string" ? model : model?.id || model?.name || model?.model || ""; + if (!rawId) return null; + + const displayName = typeof model === "string" + ? model + : model?.name || model?.displayName || rawId; + + let requestModel = rawId; + const isCompatible = isOpenAICompatibleProvider(connection.provider) || isAnthropicCompatibleProvider(connection.provider); + if (isCompatible && !rawId.includes("/")) { + requestModel = `${connection.provider}/${rawId}`; + } + + return { + id: requestModel, + requestModel, + name: displayName, + providerId: connection.provider, + providerName: getProviderLabel(connection), + source: "live", + }; +} + +function parseProviderModelsPayload(data) { + if (Array.isArray(data?.models)) return data.models; + if (Array.isArray(data?.data)) return data.data; + if (Array.isArray(data?.results)) return data.results; + if (Array.isArray(data)) return data; + return []; +} + +function dedupeModels(models) { + const map = new Map(); + for (const model of models) { + if (!model?.id) continue; + if (!map.has(model.id)) map.set(model.id, model); + } + return Array.from(map.values()); +} + +export default function BasicChatPageClient() { + const [providerGroups, setProviderGroups] = useState([]); + const [loadingData, setLoadingData] = useState(true); + const [loadError, setLoadError] = useState(""); + const [sessions, setSessions] = useState(() => { + if (typeof window === "undefined") return []; + try { + const saved = safeParse(globalThis.localStorage.getItem(STORAGE_KEYS.sessions), []); + return Array.isArray(saved) ? saved.map((session) => ({ + ...session, + messages: Array.isArray(session.messages) ? session.messages : [], + })) : []; + } catch { return []; } + }); + const [activeSessionId, setActiveSessionId] = useState(() => { + if (typeof window === "undefined") return ""; + return globalThis.localStorage.getItem(STORAGE_KEYS.activeSessionId) || ""; + }); + const [activeProviderId, setActiveProviderId] = useState(() => { + if (typeof window === "undefined") return ""; + return globalThis.localStorage.getItem(STORAGE_KEYS.activeProviderId) || ""; + }); + const [activeModelId, setActiveModelId] = useState(""); + const [draft, setDraft] = useState(() => { + if (typeof window === "undefined") return ""; + return globalThis.localStorage.getItem(STORAGE_KEYS.draft) || ""; + }); + const [attachments, setAttachments] = useState([]); + const [isSending, setIsSending] = useState(false); + const [streamingMessageId, setStreamingMessageId] = useState(""); + const [streamingText, setStreamingText] = useState(""); + const [isHydrated, setIsHydrated] = useState(false); + const [modelMenuOpen, setModelMenuOpen] = useState(false); + const [historyOpen, setHistoryOpen] = useState(false); + const fileInputRef = useRef(null); + const abortRef = useRef(null); + const initializedRef = useRef(false); + const modelMenuRef = useRef(null); + const historyMenuRef = useRef(null); + + useEffect(() => { + setIsHydrated(true); + }, []); + + useEffect(() => { + let cancelled = false; + + async function loadData() { + setLoadingData(true); + setLoadError(""); + + try { + const providersRes = await fetch("/api/providers", { cache: "no-store" }); + const providersData = await providersRes.json().catch(() => ({})); + const connections = Array.isArray(providersData.connections) + ? providersData.connections.filter((connection) => connection?.isActive !== false) + : []; + + if (connections.length === 0) { + if (!cancelled) { + setProviderGroups([]); + setLoadError("No providers connected yet."); + } + return; + } + + const providerMap = new Map(); + + for (const connection of connections) { + const providerId = connection.provider || connection.id; + const providerName = getProviderLabel(connection); + const providerType = isOpenAICompatibleProvider(providerId) + ? "openai-compatible" + : isAnthropicCompatibleProvider(providerId) + ? "anthropic-compatible" + : providerId; + + if (!providerMap.has(providerId)) { + providerMap.set(providerId, { + providerId, + providerName, + providerType, + connections: [], + models: [], + }); + } + + const group = providerMap.get(providerId); + group.providerName = group.providerName || providerName; + group.providerType = group.providerType || providerType; + group.connections.push(connection); + + const staticModels = getModelsByProviderId(providerId) + .map((model) => normalizeStaticModel(model, connection)) + .filter(Boolean); + group.models.push(...staticModels); + } + + const liveResults = await Promise.all( + connections.map(async (connection) => { + try { + const response = await fetch(`/api/providers/${connection.id}/models`, { cache: "no-store" }); + const data = await response.json().catch(() => ({})); + if (!response.ok) return { connection, models: [] }; + const models = parseProviderModelsPayload(data) + .map((model) => normalizeLiveModel(model, connection)) + .filter(Boolean); + return { connection, models }; + } catch { + return { connection, models: [] }; + } + }) + ); + + for (const result of liveResults) { + const providerId = result.connection.provider || result.connection.id; + const group = providerMap.get(providerId); + if (!group) continue; + group.models.push(...result.models); + } + + const normalized = Array.from(providerMap.values()) + .map((group) => ({ + ...group, + models: dedupeModels(group.models).sort((a, b) => a.name.localeCompare(b.name)), + })) + .filter((group) => group.models.length > 0) + .sort((a, b) => a.providerName.localeCompare(b.providerName)); + + if (!cancelled) { + setProviderGroups(normalized); + if (normalized.length === 0) { + setLoadError("Providers connected but no models available."); + } + } + } catch (error) { + if (!cancelled) { + setLoadError(textValue(error?.message) || "Failed to load providers/models."); + setProviderGroups([]); + } + } finally { + if (!cancelled) setLoadingData(false); + } + } + + loadData(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + const handleClickOutside = (event) => { + if (modelMenuRef.current && !modelMenuRef.current.contains(event.target)) { + setModelMenuOpen(false); + } + if (historyMenuRef.current && !historyMenuRef.current.contains(event.target)) { + setHistoryOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const modelIndex = useMemo(() => { + const map = new Map(); + for (const group of providerGroups) { + for (const model of group.models) { + map.set(model.id, { + ...model, + providerId: group.providerId, + providerName: group.providerName, + }); + } + } + return map; + }, [providerGroups]); + + const activeProviderGroup = useMemo(() => { + return providerGroups.find((group) => group.providerId === activeProviderId) || providerGroups[0] || null; + }, [providerGroups, activeProviderId]); + + const activeModel = useMemo(() => { + if (activeModelId && modelIndex.has(activeModelId)) return modelIndex.get(activeModelId); + if (activeSessionId) { + const session = sessions.find((item) => item.id === activeSessionId); + if (session?.modelId && modelIndex.has(session.modelId)) return modelIndex.get(session.modelId); + } + return activeProviderGroup?.models?.[0] || null; + }, [activeModelId, modelIndex, activeProviderGroup, sessions, activeSessionId]); + + const currentSession = useMemo(() => sessions.find((session) => session.id === activeSessionId) || null, [sessions, activeSessionId]); + const currentMessages = currentSession?.messages || []; + const sessionItems = useMemo(() => [...sessions].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()), [sessions]); + const canSend = !isSending && !!activeModel && (draft.trim().length > 0 || attachments.length > 0); + + useEffect(() => { + if (!isHydrated) return; + try { + globalThis.localStorage.setItem(STORAGE_KEYS.sessions, JSON.stringify(sessions)); + globalThis.localStorage.setItem(STORAGE_KEYS.activeSessionId, activeSessionId); + globalThis.localStorage.setItem(STORAGE_KEYS.activeProviderId, activeProviderId); + globalThis.localStorage.setItem(STORAGE_KEYS.draft, draft); + } catch { + // Ignore storage errors. + } + }, [isHydrated, sessions, activeSessionId, activeProviderId, draft]); + + useEffect(() => { + if (!isHydrated || loadingData || initializedRef.current) return; + if (providerGroups.length === 0) return; + + const savedProvider = providerGroups.find((group) => group.providerId === activeProviderId) || providerGroups[0]; + const savedModel = activeModelId && modelIndex.has(activeModelId) + ? modelIndex.get(activeModelId) + : savedProvider.models[0]; + + if (sessions.length > 0) { + const session = sessions.find((item) => item.id === activeSessionId) || sessions[0]; + const sessionModel = session?.modelId && modelIndex.has(session.modelId) + ? modelIndex.get(session.modelId) + : savedModel; + initializedRef.current = true; + setActiveSessionId(session.id); + setActiveProviderId(sessionModel?.providerId || savedProvider.providerId); + setActiveModelId(sessionModel?.id || savedModel.id); + return; + } + + const session = { + id: createId(), + title: "New chat", + providerId: savedProvider.providerId, + providerName: savedProvider.providerName, + modelId: savedModel.id, + modelName: savedModel.name, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messages: [], + }; + + initializedRef.current = true; + setSessions([session]); + setActiveSessionId(session.id); + setActiveProviderId(savedProvider.providerId); + setActiveModelId(savedModel.id); + }, [isHydrated, loadingData, providerGroups, modelIndex, sessions, activeSessionId, activeProviderId, activeModelId]); + + const updateSession = (sessionId, updater) => { + setSessions((prev) => prev.map((session) => (session.id === sessionId ? updater(cloneSession(session)) : session))); + }; + + const ensureSessionForModel = (model) => { + if (!model) return null; + return { + id: createId(), + title: "New chat", + providerId: model.providerId, + providerName: model.providerName, + modelId: model.id, + modelName: model.name, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messages: [], + }; + }; + + const handleNewChat = () => { + if (!activeModel) return; + const session = ensureSessionForModel(activeModel); + if (!session) return; + setSessions((prev) => [session, ...prev]); + setActiveSessionId(session.id); + setActiveProviderId(session.providerId); + setActiveModelId(session.modelId); + setDraft(""); + setAttachments([]); + setStreamingMessageId(""); + setStreamingText(""); + }; + + const handleSelectSession = (sessionId) => { + const session = sessions.find((item) => item.id === sessionId); + if (!session) return; + setActiveSessionId(sessionId); + setActiveProviderId(session.providerId || activeProviderId); + setActiveModelId(session.modelId || activeModelId); + setHistoryOpen(false); + }; + + const handleDeleteCurrentChat = () => { + if (!activeSessionId) return; + const nextSessions = sessions.filter((session) => session.id !== activeSessionId); + const fallback = nextSessions[0] || null; + setSessions(nextSessions); + if (fallback) { + setActiveSessionId(fallback.id); + setActiveProviderId(fallback.providerId); + setActiveModelId(fallback.modelId); + } else { + setActiveSessionId(""); + setActiveProviderId(""); + setActiveModelId(""); + } + }; + + const handleSelectProvider = (providerId) => { + const group = providerGroups.find((item) => item.providerId === providerId); + if (!group || group.models.length === 0) return; + const nextModel = group.models[0]; + + const current = sessions.find((session) => session.id === activeSessionId); + if (current && current.messages.length > 0) { + const session = ensureSessionForModel(nextModel); + if (!session) return; + setSessions((prev) => [session, ...prev]); + setActiveSessionId(session.id); + } else if (current) { + setSessions((prev) => prev.map((item) => (item.id === current.id ? { + ...item, + providerId: group.providerId, + providerName: group.providerName, + modelId: nextModel.id, + modelName: nextModel.name, + } : item))); + setActiveSessionId(current.id); + } + + setActiveProviderId(group.providerId); + setActiveModelId(nextModel.id); + setModelMenuOpen(false); + }; + + const handleSelectModel = (modelId) => { + const model = modelIndex.get(modelId); + if (!model) return; + + const current = sessions.find((session) => session.id === activeSessionId); + if (current && current.messages.length > 0) { + const session = ensureSessionForModel(model); + if (!session) return; + setSessions((prev) => [session, ...prev]); + setActiveSessionId(session.id); + } else if (current) { + setSessions((prev) => prev.map((item) => (item.id === current.id ? { + ...item, + providerId: model.providerId, + providerName: model.providerName, + modelId: model.id, + modelName: model.name, + } : item))); + setActiveSessionId(current.id); + } else { + const session = ensureSessionForModel(model); + if (!session) return; + setSessions((prev) => [session, ...prev]); + setActiveSessionId(session.id); + } + + setActiveProviderId(model.providerId); + setActiveModelId(model.id); + setModelMenuOpen(false); + }; + + const handleAttachFiles = async (event) => { + const files = Array.from(event.target.files || []); + if (files.length === 0) return; + + const images = files.filter((file) => file.type.startsWith("image/")); + if (images.length === 0) { + event.target.value = ""; + return; + } + + const converted = await Promise.all(images.map(async (file) => ({ + id: createId(), + name: file.name, + type: file.type, + size: file.size, + dataUrl: await fileToDataUrl(file), + }))); + + setAttachments((prev) => [...prev, ...converted]); + event.target.value = ""; + }; + + const removeAttachment = (attachmentId) => { + setAttachments((prev) => prev.filter((attachment) => attachment.id !== attachmentId)); + }; + + const handleStop = () => { + abortRef.current?.abort(); + }; + + const finalizeSessionTitle = (sessionId, titleSeed) => { + const title = makeSessionTitle(titleSeed); + updateSession(sessionId, (session) => ({ + ...session, + title: session.title === "New chat" ? title : session.title, + updatedAt: new Date().toISOString(), + })); + }; + + const sendMessage = async () => { + const model = activeModel || activeProviderGroup?.models?.[0] || null; + if (!model) return; + + const userText = draft.trim(); + if (!userText && attachments.length === 0) return; + + let sessionId = activeSessionId; + let session = sessions.find((item) => item.id === sessionId); + if (!session) { + session = ensureSessionForModel(model); + if (!session) return; + sessionId = session.id; + setSessions((prev) => [session, ...prev]); + setActiveSessionId(sessionId); + } + + const userMessage = { + id: createId(), + role: "user", + content: userText, + attachments: attachments.map((attachment) => ({ + id: attachment.id, + name: attachment.name, + type: attachment.type, + dataUrl: attachment.dataUrl, + })), + createdAt: new Date().toISOString(), + }; + + const assistantMessageId = createId(); + const assistantMessage = { + id: assistantMessageId, + role: "assistant", + content: "", + createdAt: new Date().toISOString(), + status: "streaming", + }; + + const nextMessages = [...(session.messages || []), userMessage, assistantMessage]; + setSessions((prev) => prev.map((item) => (item.id === sessionId ? { + ...item, + providerId: model.providerId, + providerName: model.providerName, + modelId: model.id, + modelName: model.name, + messages: nextMessages, + updatedAt: new Date().toISOString(), + title: item.title === "New chat" ? makeSessionTitle(userText) : item.title, + } : item))); + setDraft(""); + setAttachments([]); + setIsSending(true); + setStreamingMessageId(assistantMessageId); + setStreamingText(""); + abortRef.current?.abort(); + abortRef.current = new AbortController(); + + const requestMessages = nextMessages + .filter((message) => !(message.role === "assistant" && message.id === assistantMessageId)) + .map((message) => ({ + role: message.role, + content: message.role === "user" ? buildUserContent(message) : message.content, + })); + + try { + const response = await fetch("/api/dashboard/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + }, + body: JSON.stringify({ + model: model.requestModel || model.id, + messages: requestMessages, + stream: true, + }), + signal: abortRef.current.signal, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(textValue(errorData.error || errorData.message || `Request failed (${response.status})`)); + } + + const reader = response.body?.getReader(); + if (!reader) { + const data = await response.json().catch(() => ({})); + const fallbackText = textValue(data?.choices?.[0]?.message?.content || data?.output_text || data?.error || data?.message || ""); + updateSession(sessionId, (currentSession) => ({ + ...currentSession, + messages: currentSession.messages.map((message) => (message.id === assistantMessageId ? { ...message, content: fallbackText, status: "done" } : message)), + updatedAt: new Date().toISOString(), + })); + return; + } + + const decoder = new TextDecoder(); + let buffer = ""; + let assistantText = ""; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() || ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) continue; + + const payload = trimmed.slice(5).trim(); + if (!payload || payload === "[DONE]") continue; + + try { + const chunk = JSON.parse(payload); + const text = readAssistantText(chunk); + if (!text) continue; + + assistantText += text; + setStreamingText(assistantText); + updateSession(sessionId, (currentSession) => ({ + ...currentSession, + messages: currentSession.messages.map((message) => (message.id === assistantMessageId ? { ...message, content: assistantText, status: "streaming" } : message)), + updatedAt: new Date().toISOString(), + })); + } catch { + // Ignore malformed chunks. + } + } + } + + updateSession(sessionId, (currentSession) => ({ + ...currentSession, + messages: currentSession.messages.map((message) => (message.id === assistantMessageId ? { ...message, content: assistantText || message.content, status: "done" } : message)), + updatedAt: new Date().toISOString(), + })); + finalizeSessionTitle(sessionId, userText); + } catch (error) { + if (error.name !== "AbortError") { + const errorText = textValue(error?.message || error); + updateSession(sessionId, (currentSession) => ({ + ...currentSession, + messages: currentSession.messages.map((message) => (message.id === assistantMessageId ? { ...message, content: message.content || `Error: ${errorText}`, status: "error" } : message)), + updatedAt: new Date().toISOString(), + })); + setLoadError(errorText || "Failed to send message."); + } + } finally { + setIsSending(false); + setStreamingMessageId(""); + setStreamingText(""); + abortRef.current = null; + } + }; + + const handleKeyDown = (event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + if (canSend) sendMessage(); + } + }; + + const modelLabel = activeModel ? `${activeModel.name}` : "Select model"; + const modelSubLabel = activeModel ? activeModel.requestModel : "Choose from connected providers"; + + return ( +
+
+
+
+ + + {modelMenuOpen ? ( +
+
+

Models

+

Only from connected providers

+
+
+ {providerGroups.map((group) => ( +
+
+

{group.providerName}

+ {group.models.length} +
+
+ {group.models.map((model) => { + const isActive = model.id === activeModelId; + return ( + + ); + })} +
+
+ ))} +
+
+ ) : null} +
+ +
+ + +
+
+ + {historyOpen ? ( +
+
+

Recent chats

+
+
+ {sessionItems.length === 0 ? ( +
+ No conversations yet. +
+ ) : sessionItems.map((session) => { + const isActive = session.id === activeSessionId; + const latestMessage = [...(session.messages || [])].reverse().find((message) => message.role === "user") || session.messages?.[0]; + return ( + + ); + })} +
+
+ ) : null} + + {loadError ? ( +
+
+ error +

{loadError}

+
+
+ ) : null} + +
+
+ {currentMessages.length === 0 ? ( +
+
+
+ chat +
+
+

Start a conversation

+

+ Simple chat interface to interact with any AI model from connected providers. Select a model and start chatting! +

+
+
+
+ ) : null} + +
+ {currentMessages.map((message) => { + const isUser = message.role === "user"; + const isAssistant = message.role === "assistant"; + const isStreaming = isAssistant && message.id === streamingMessageId && message.status === "streaming"; + const content = textValue(message.content) || (isAssistant ? streamingText : ""); + + return ( +
+
+
+ {isUser ? "You" : activeModel?.name || "Assistant"} +
+ + {message.attachments?.length ? ( +
+ {message.attachments.map((attachment) => ( + + {attachment.name} + + ))} +
+ ) : null} + +
+ {content} + {isAssistant && isStreaming && !streamingText ? : null} +
+
+
+ ); + })} +
+
+ +
+ {attachments.length > 0 ? ( +
+ {attachments.map((attachment) => ( +
+ {attachment.name} + +
+ ))} +
+ ) : null} + +
+
+