diff --git a/.gitignore b/.gitignore index b4b1d852..68f9949a 100644 --- a/.gitignore +++ b/.gitignore @@ -48,8 +48,8 @@ source/* .cursor/* docs/* !docs/ARCHITECTURE.md +!docs/AMP_CLI_IMPLEMENTATION_REPORT.md test/* -bin/* open-sse/test/* RM.vn.md RM.md diff --git a/.npmignore b/.npmignore index 9bec212d..834d1271 100644 --- a/.npmignore +++ b/.npmignore @@ -5,6 +5,7 @@ data/ # Development src/ +!src/cli/ docs/ test/ agents/ diff --git a/README.md b/README.md index 7fadf096..c44c7491 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,25 @@ npm run build PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start ``` +**System Tray Mode (Desktop App):** + +Run 9Router with a system tray icon for easy access: + +```bash +npm run build +npm run start:tray +``` + +The system tray provides: +- Quick access to dashboard +- Real-time model and usage information +- Context statistics (input/output/total tokens) +- Quota tracker for all providers +- MITM server toggle +- Autostart option + +See [bin/README.md](bin/README.md) for more details. + Default URLs: - Dashboard: `http://localhost:20128/dashboard` - OpenAI-compatible API: `http://localhost:20128/v1` diff --git a/bin/cli-menu.cjs b/bin/cli-menu.cjs new file mode 100755 index 00000000..9247aa51 --- /dev/null +++ b/bin/cli-menu.cjs @@ -0,0 +1,236 @@ +#!/usr/bin/env node + +const { spawn } = require('child_process'); +const path = require('path'); +const readline = require('readline'); + +// Configuration +const PORT = process.env.PORT || 20127; +const HOSTNAME = process.env.HOSTNAME || '0.0.0.0'; + +let serverProcess = null; + +/** + * Start the Next.js server + */ +function startServer() { + return new Promise((resolve, reject) => { + const serverPath = path.join(__dirname, '..', '.next', 'standalone', 'server.js'); + + // Check if built server exists + const fs = require('fs'); + if (!fs.existsSync(serverPath)) { + console.error('Server not built. Please run: npm run build'); + reject(new Error('Server not built')); + return; + } + + console.log('Starting 9Router server...'); + + serverProcess = spawn('node', [serverPath], { + env: { + ...process.env, + PORT, + HOSTNAME, + NODE_ENV: 'production' + }, + stdio: 'inherit' + }); + + serverProcess.on('error', (err) => { + console.error('Failed to start server:', err); + reject(err); + }); + + // Wait for server to be ready + setTimeout(() => { + console.log('\n✓ Server started successfully'); + console.log(`✓ Dashboard: http://localhost:${PORT}/dashboard\n`); + resolve(); + }, 3000); + }); +} + +/** + * Stop the Next.js server + */ +function stopServer() { + if (serverProcess) { + console.log('\nStopping server...'); + serverProcess.kill(); + serverProcess = null; + } +} + +/** + * Display menu + */ +function displayMenu() { + console.log('\n╔════════════════════════════════════════╗'); + console.log('║ 9Router Control Menu ║'); + console.log('╠════════════════════════════════════════╣'); + console.log('║ [1] Open Dashboard in Browser ║'); + console.log('║ [2] Show Server Status ║'); + console.log('║ [3] Restart Server ║'); + console.log('║ [4] View Logs ║'); + console.log('║ [q] Quit ║'); + console.log('╚════════════════════════════════════════╝\n'); + process.stdout.write('Select an option: '); +} + +/** + * Open dashboard in browser + */ +function openDashboard() { + const open = require('child_process').exec; + const url = `http://localhost:${PORT}/dashboard`; + + const command = process.platform === 'darwin' ? 'open' : + process.platform === 'win32' ? 'start' : 'xdg-open'; + + open(`${command} ${url}`, (error) => { + if (error) { + console.log(`\n✗ Failed to open browser. Please visit: ${url}\n`); + } else { + console.log(`\n✓ Opening dashboard in browser...\n`); + } + }); +} + +/** + * Show server status + */ +function showStatus() { + const http = require('http'); + + const options = { + hostname: 'localhost', + port: PORT, + path: '/api/health', + method: 'GET', + timeout: 2000 + }; + + const req = http.request(options, (res) => { + console.log(`\n✓ Server Status: Running`); + console.log(`✓ Port: ${PORT}`); + console.log(`✓ URL: http://localhost:${PORT}/dashboard\n`); + }); + + req.on('error', () => { + console.log(`\n✗ Server Status: Not responding\n`); + }); + + req.on('timeout', () => { + console.log(`\n✗ Server Status: Timeout\n`); + req.destroy(); + }); + + req.end(); +} + +/** + * Restart server + */ +async function restartServer() { + console.log('\nRestarting server...'); + stopServer(); + await new Promise(resolve => setTimeout(resolve, 2000)); + await startServer(); +} + +/** + * View logs + */ +function viewLogs() { + console.log('\n[Logs are displayed in the main console output]\n'); +} + +/** + * Handle menu input + */ +function handleInput(input, rl) { + const choice = input.trim().toLowerCase(); + + switch (choice) { + case '1': + openDashboard(); + displayMenu(); + break; + case '2': + showStatus(); + displayMenu(); + break; + case '3': + restartServer().then(() => displayMenu()); + break; + case '4': + viewLogs(); + displayMenu(); + break; + case 'q': + case 'quit': + case 'exit': + console.log('\nShutting down 9Router...'); + stopServer(); + rl.close(); + process.exit(0); + break; + default: + console.log('\n✗ Invalid option. Please try again.\n'); + displayMenu(); + break; + } +} + +/** + * Initialize the CLI + */ +async function initialize() { + try { + // Start the server + await startServer(); + + // Create readline interface + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + // Display initial menu + displayMenu(); + + // Handle user input + rl.on('line', (input) => { + handleInput(input, rl); + }); + + // Handle Ctrl+C + rl.on('SIGINT', () => { + console.log('\n\nReceived SIGINT. Shutting down...'); + stopServer(); + rl.close(); + process.exit(0); + }); + + } catch (error) { + console.error('Failed to initialize:', error); + process.exit(1); + } +} + +// Handle uncaught exceptions +process.on('uncaughtException', (error) => { + console.error('Uncaught exception:', error); + stopServer(); + process.exit(1); +}); + +process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled rejection at:', promise, 'reason:', reason); + stopServer(); + process.exit(1); +}); + +// Start the CLI +initialize(); diff --git a/bin/cli.cjs b/bin/cli.cjs new file mode 100755 index 00000000..d1e4b932 --- /dev/null +++ b/bin/cli.cjs @@ -0,0 +1,592 @@ +#!/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 args = process.argv.slice(2); + +// Pre-load CLI modules at the top level +let selectMenu, clearScreen, getEndpoint, startTerminalUI, initTray, killTray; +try { + ({ selectMenu } = require("../src/cli/utils/input.cjs")); + ({ clearScreen } = require("../src/cli/utils/display.cjs")); + ({ getEndpoint } = require("../src/cli/utils/endpoint.cjs")); + ({ startTerminalUI } = require("../src/cli/terminalUI.cjs")); + ({ initTray, killTray } = require("../src/cli/tray/tray.cjs")); +} catch (e) { + // Modules will be loaded when needed +} + +// Configuration constants +const APP_NAME = pkg.name; +const DEFAULT_PORT = 20127; +const DEFAULT_HOST = "0.0.0.0"; +const MAX_PORT_ATTEMPTS = 10; +const PROCESS_IDENTIFIERS = ['9router-fdk']; + +// 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; + } 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); + } +} + +// Always use Node.js runtime with absolute path +const RUNTIME = process.execPath; + +// Compare semver versions +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; +} + +// Kill all app processes +function killAllAppProcesses() { + return new Promise((resolve) => { + try { + const platform = process.platform; + let pids = []; + + if (platform === "win32") { + try { + const output = execSync('tasklist /FO CSV /V 2>/dev/null | findstr /I "node"', { + encoding: 'utf8', + shell: true, + windowsHide: true, + timeout: 5000 + }); + const lines = output.split('\n').filter(l => l.trim()); + + lines.forEach(line => { + const isAppProcess = line.toLowerCase().includes("9router-fdk") || + line.toLowerCase().includes("next-server"); + if (isAppProcess) { + const match = line.match(/"node\.exe","(\d+)"/i); + if (match && match[1] && match[1] !== process.pid.toString()) { + pids.push(match[1]); + } + } + }); + } catch (e) {} + } else { + try { + const output = execSync('ps aux 2>/dev/null', { + encoding: 'utf8', + timeout: 5000 + }); + const lines = output.split('\n'); + + lines.forEach(line => { + const isAppProcess = line.includes("9router-fdk") || line.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) {} + } + + 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) {} + }); + setTimeout(() => resolve(), 1000); + } else { + resolve(); + } + } catch (err) { + resolve(); + } + }); +} + +// Kill process on 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) {} + } else { + 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) {} + } + + setTimeout(() => resolve(), 500); + } catch (err) { + resolve(); + } + }); +} + +// Detect restricted environment +function isRestrictedEnvironment() { + if (process.env.CODESPACES === "true" || process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN) { + return "GitHub Codespaces"; + } + if (fs.existsSync("/.dockerenv") || (fs.existsSync("/proc/1/cgroup") && fs.readFileSync("/proc/1/cgroup", "utf8").includes("docker"))) { + return "Docker"; + } + return null; +} + +// Check for updates +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); }); + }); +} + +// Perform update +function performUpdate() { + console.log(`\n🔄 Updating ${pkg.name}...\n`); + + try { + const platform = process.platform; + let updateScript, scriptPath, shellCmd; + + if (platform === "win32") { + updateScript = ` +Write-Host "📥 Installing new version..." +npm cache clean --force 2>$null +npm install -g ${pkg.name}@latest --prefer-online 2>&1 | Out-Host +if ($LASTEXITCODE -eq 0) { + Write-Host "" + Write-Host "✅ Update completed. Run '${pkg.name}' to start." +} else { + Write-Host "" + Write-Host "❌ Update failed. Try manually: npm install -g ${pkg.name}@latest" +} +Read-Host "Press Enter to continue" +`; + scriptPath = path.join(os.tmpdir(), `${APP_NAME}-update.ps1`); + fs.writeFileSync(scriptPath, updateScript); + shellCmd = ["powershell.exe", ["-WindowStyle", "Normal", "-ExecutionPolicy", "Bypass", "-File", scriptPath]]; + } else { + updateScript = `#!/bin/bash +echo "📥 Installing new version..." +sleep 1 + +pkill -f "${pkg.name}" 2>/dev/null || true +sleep 1 + +npm cache clean --force 2>/dev/null +npm install -g ${pkg.name}@latest --prefer-online 2>&1 +EXIT_CODE=$? + +if [ $EXIT_CODE -eq 0 ]; then + echo "" + echo "✅ Update completed. Run \\"${pkg.name}\\" to start." +else + echo "" + echo "❌ Update failed (exit code: $EXIT_CODE)" + echo "💡 Try manually: npm install -g ${pkg.name}@latest" +fi +`; + scriptPath = path.join(os.tmpdir(), `${APP_NAME}-update.sh`); + fs.writeFileSync(scriptPath, updateScript, { mode: 0o755 }); + shellCmd = ["sh", [scriptPath]]; + } + + const child = spawn(shellCmd[0], shellCmd[1], { + detached: true, + stdio: "inherit", + windowsHide: false + }); + child.unref(); + process.exit(0); + } catch (err) { + console.error(`⚠️ Update failed: ${err.message}`); + console.log(` Run manually: npm install -g ${pkg.name}@latest\n`); + } +} + +// 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, (err) => { + if (err) { + console.log(`Open browser manually: ${url}`); + } + }); +} + +// Check if Next.js build exists (support both dev and standalone builds) +const nextDir = path.join(__dirname, "..", ".next"); +const standaloneNextDir = path.join(__dirname, "..", ".next", "standalone", ".next"); +if (!fs.existsSync(nextDir) && !fs.existsSync(standaloneNextDir)) { + console.error("Error: Next.js build not found."); + console.error("Please run 'npm run build' first."); + process.exit(1); +} + +// Determine the correct working directory for Next.js +const isStandalone = fs.existsSync(standaloneNextDir); +const workingDir = isStandalone ? path.join(__dirname, "..", ".next", "standalone") : path.join(__dirname, ".."); + +// Show interface selection menu +async function showInterfaceMenu(latestVersion) { + clearScreen(); + + const displayHost = host === DEFAULT_HOST ? "localhost" : host; + + 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"; +} + +// Start server +function startServer(latestVersion) { + const displayHost = host === DEFAULT_HOST ? "localhost" : host; + const url = `http://${displayHost}:${port}/dashboard`; + + const nextBin = path.join(__dirname, "..", "node_modules", ".bin", "next"); + const server = spawn(RUNTIME, [nextBin, "start", "-p", port, "-H", host], { + cwd: workingDir, + stdio: showLog ? "inherit" : "ignore", + detached: true, + env: { + ...process.env, + PORT: port.toString(), + HOSTNAME: host + } + }); + + // Cleanup function + let isCleaningUp = false; + function cleanup() { + if (isCleaningUp) return; + isCleaningUp = true; + try { + try { + if (killTray) killTray(); + } catch (e) {} + if (server.pid) { + process.kill(server.pid, "SIGKILL"); + } + process.kill(-server.pid, "SIGKILL"); + } catch (e) {} + } + + // Suppress errors during shutdown + let isShuttingDown = false; + process.on("uncaughtException", (err) => { + if (isShuttingDown) return; + console.error("Error:", err.message); + }); + + process.on("SIGINT", () => { + isShuttingDown = true; + cleanup(); + process.exit(0); + }); + + process.on("SIGTERM", () => { + isShuttingDown = true; + cleanup(); + process.exit(0); + }); + + server.on("error", (err) => { + console.error("Server error:", err.message); + process.exit(1); + }); + + server.on("exit", (code) => { + if (!isShuttingDown) { + console.log(`Server exited with code ${code}`); + process.exit(code || 0); + } + }); + + // Wait for server to start + setTimeout(async () => { + console.log(`\n✅ Server started on port ${port}`); + + // Handle tray mode directly + if (trayMode) { + console.log("Starting in tray mode...\n"); + try { + const tray = await initTray({ + port, + onQuit: () => { + isShuttingDown = true; + cleanup(); + process.exit(0); + }, + onOpenDashboard: () => { + openBrowser(url); + } + }); + + if (tray) { + console.log("✅ Running in system tray"); + console.log(" Click tray icon to access menu\n"); + } else { + console.log("⚠️ System tray not supported on this platform"); + console.log(` Server running at: ${url}\n`); + } + } catch (err) { + console.log("⚠️ Failed to initialize tray:", err.message); + console.log(` Server running at: ${url}\n`); + } + return; + } + + // Show interface menu + try { + const choice = await showInterfaceMenu(latestVersion); + + if (choice === "update") { + performUpdate(); + } else if (choice === "web") { + console.log(`\n🌐 Opening dashboard in browser...\n`); + openBrowser(url); + console.log(`Server running at: ${url}`); + console.log("Press Ctrl+C to stop\n"); + } else if (choice === "terminal") { + await startTerminalUI(port); + } else if (choice === "hide") { + console.log("\n🔔 Hiding to system tray...\n"); + try { + const tray = await initTray({ + port, + onQuit: () => { + isShuttingDown = true; + cleanup(); + process.exit(0); + }, + onOpenDashboard: () => { + openBrowser(url); + } + }); + + if (tray) { + console.log("✅ Running in system tray"); + console.log(" Click tray icon to access menu\n"); + } else { + console.log("⚠️ System tray not supported on this platform"); + console.log(` Server running at: ${url}`); + console.log(" Press Ctrl+C to stop\n"); + } + } catch (err) { + console.log("⚠️ Failed to initialize tray:", err.message); + console.log(` Server running at: ${url}`); + console.log(" Press Ctrl+C to stop\n"); + } + } else { + console.log("\n👋 Exiting...\n"); + isShuttingDown = true; + cleanup(); + process.exit(0); + } + } catch (err) { + console.error("Error showing menu:", err.message); + console.log(`\nServer running at: ${url}`); + console.log("Press Ctrl+C to stop\n"); + } + }, 3000); +} + +// Main execution +checkForUpdate().then((latestVersion) => { + killAllAppProcesses().then(() => { + return killProcessOnPort(port); + }).then(() => { + startServer(latestVersion); + }); +}); diff --git a/next.config.mjs b/next.config.mjs index 36505ea0..b88b70ae 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -5,6 +5,7 @@ const nextConfig = { unoptimized: true }, env: {}, + turbopack: {}, webpack: (config, { isServer }) => { // Ignore fs/path modules in browser bundle if (!isServer) { diff --git a/open-sse/config/constants.js b/open-sse/config/constants.js index c9bd203a..2067c125 100644 --- a/open-sse/config/constants.js +++ b/open-sse/config/constants.js @@ -19,6 +19,34 @@ function mapStainlessArch() { } } +// === Gemini CLI Version Constants === +export const GEMINI_CLI_VERSION = "0.31.0"; +export const GEMINI_CLI_API_CLIENT = "google-genai-sdk/1.41.0 gl-node/v22.19.0"; + +function mapGeminiCLIOs() { + switch (platform()) { + case "darwin": return "darwin"; + case "win32": return "windows"; + case "linux": return "linux"; + case "freebsd": return "freebsd"; + default: return platform(); + } +} + +function mapGeminiCLIArch() { + switch (arch()) { + case "x64": return "x64"; + case "arm64": return "arm64"; + case "ia32": return "x86"; + default: return arch(); + } +} + +/** Returns User-Agent matching native Gemini CLI format: GeminiCLI// (; ) */ +export function geminiCLIUserAgent(model = "unknown") { + return `GeminiCLI/${GEMINI_CLI_VERSION}/${model || "unknown"} (${mapGeminiCLIOs()}; ${mapGeminiCLIArch()})`; +} + // === GitHub Copilot Version Constants === export const GITHUB_COPILOT = { VSCODE_VERSION: "1.110.0", @@ -262,6 +290,11 @@ export const PROVIDERS = { format: "openai", headers: {} }, + "alicode-intl": { + baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions", + format: "openai", + headers: {} + }, github: { baseUrl: "https://api.githubcopilot.com/chat/completions", // GitHub Copilot API endpoint for chat responsesUrl: "https://api.githubcopilot.com/responses", @@ -331,6 +364,11 @@ export const PROVIDERS = { tokenUrl: "https://api.cline.bot/api/v1/auth/token", refreshUrl: "https://api.cline.bot/api/v1/auth/refresh" }, + ramclouds: { + baseUrl: "https://ramclouds.me/v1/chat/completions", + format: "openai", + headers: {} + }, nvidia: { baseUrl: "https://integrate.api.nvidia.com/v1/chat/completions", format: "openai" diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index b1676859..c32ff49f 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -12,6 +12,7 @@ export const PROVIDER_MODELS = { { id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" }, ], cx: [ // OpenAI Codex + { id: "gpt-5.4", name: "GPT 5.4" }, // GPT 5.3 Codex - all thinking levels { id: "gpt-5.3-codex", name: "GPT 5.3 Codex" }, { id: "gpt-5.3-codex-xhigh", name: "GPT 5.3 Codex (xHigh)" }, @@ -80,6 +81,7 @@ export const PROVIDER_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" }, // GitHub Copilot - Anthropic models { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, { id: "claude-opus-4.1", name: "Claude Opus 4.1" }, @@ -202,6 +204,15 @@ export const PROVIDER_MODELS = { { id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" }, { id: "glm-4.7", name: "GLM 4.7" }, ], + "alicode-intl": [ + { id: "qwen3.5-plus", name: "Qwen3.5 Plus" }, + { id: "kimi-k2.5", name: "Kimi K2.5" }, + { id: "glm-5", name: "GLM 5" }, + { id: "MiniMax-M2.5", name: "MiniMax M2.5" }, + { id: "qwen3-coder-next", name: "Qwen3 Coder Next" }, + { id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" }, + { id: "glm-4.7", name: "GLM 4.7" }, + ], deepseek: [ { id: "deepseek-chat", name: "DeepSeek V3.2 Chat" }, { id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" }, @@ -284,6 +295,9 @@ export const PROVIDER_MODELS = { { 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" }, ], + ramclouds: [ + { id: "auto", name: "Auto (Best Available)" }, + ], }; // Helper functions @@ -341,6 +355,7 @@ export const PROVIDER_ID_TO_ALIAS = { minimax: "minimax", "minimax-cn": "minimax-cn", alicode: "alicode", + "alicode-intl": "alicode-intl", deepseek: "deepseek", groq: "groq", xai: "xai", @@ -354,6 +369,7 @@ export const PROVIDER_ID_TO_ALIAS = { nebius: "nebius", siliconflow: "siliconflow", hyperbolic: "hyperbolic", + ramclouds: "ramclouds", }; export function getModelsByProviderId(providerId) { diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index 0cbcdd3c..5c16ad2e 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -82,8 +82,8 @@ export class BaseExecutor { for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const url = this.buildUrl(model, stream, urlIndex, credentials); - const headers = this.buildHeaders(credentials, stream); const transformedBody = this.transformRequest(model, body, stream, credentials); + const headers = this.buildHeaders(credentials, stream); try { const response = await fetch(url, { diff --git a/open-sse/executors/gemini-cli.js b/open-sse/executors/gemini-cli.js index 0fbe2c66..e6fe3d35 100644 --- a/open-sse/executors/gemini-cli.js +++ b/open-sse/executors/gemini-cli.js @@ -1,5 +1,5 @@ import { BaseExecutor } from "./base.js"; -import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.js"; +import { PROVIDERS, OAUTH_ENDPOINTS, GEMINI_CLI_API_CLIENT, geminiCLIUserAgent } from "../config/constants.js"; export class GeminiCLIExecutor extends BaseExecutor { constructor() { @@ -15,11 +15,15 @@ export class GeminiCLIExecutor extends BaseExecutor { return { "Content-Type": "application/json", "Authorization": `Bearer ${credentials.accessToken}`, - ...(stream && { "Accept": "text/event-stream" }) + "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; if (!body.project && credentials?.projectId) { body.project = credentials.projectId; } diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 83e10fe5..08796d11 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -15,6 +15,7 @@ import { buildRequestDetail, extractRequestConfig } from "./chatCore/requestDeta import { handleForcedSSEToJson } from "./chatCore/sseToJsonHandler.js"; import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js"; import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.js"; +import { handleJsonToSSE } from "./chatCore/jsonToSseHandler.js"; /** * Core chat handler - shared between SSE and Worker @@ -39,7 +40,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred const clientRequestedStreaming = body.stream === true || sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI; const providerRequiresStreaming = provider === "openai" || provider === "codex"; - const stream = providerRequiresStreaming ? true : (body.stream !== false); + const stream = providerRequiresStreaming ? true : (body.stream === true); const reqLogger = await createRequestLogger(sourceFormat, targetFormat, model); if (clientRawRequest) reqLogger.logClientRawRequest(clientRawRequest.endpoint, clientRawRequest.body, clientRawRequest.headers); @@ -153,6 +154,16 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred return handleNonStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, reqLogger, trackDone, appendLog }); } + // Streaming response - validate upstream content-type + const contentType = providerResponse.headers.get("content-type") || ""; + const isSSEResponse = contentType.includes("text/event-stream"); + + // If stream mode is true but upstream is not SSE, convert JSON to SSE + if (stream && !isSSEResponse) { + log?.warn?.("STREAM", `Expected SSE but got ${contentType}, converting JSON to SSE`); + return handleJsonToSSE({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, reqLogger, trackDone, appendLog }); + } + // Streaming response const { onStreamComplete } = buildOnStreamComplete({ ...sharedCtx }); return handleStreamingResponse({ ...sharedCtx, providerResponse, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, streamController, onStreamComplete }); diff --git a/open-sse/handlers/chatCore/jsonToSseHandler.js b/open-sse/handlers/chatCore/jsonToSseHandler.js new file mode 100644 index 00000000..e6daf009 --- /dev/null +++ b/open-sse/handlers/chatCore/jsonToSseHandler.js @@ -0,0 +1,154 @@ +import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats } from "./requestDetail.js"; +import { saveRequestDetail } from "@/lib/usageDb.js"; +import { createErrorResult } from "../../utils/error.js"; +import { HTTP_STATUS } from "../../config/constants.js"; + +const SSE_HEADERS = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Access-Control-Allow-Origin": "*" +}; + +/** + * Convert a JSON chat completion response to SSE format as a ReadableStream. + * Used when provider returns JSON but client expects streaming. + */ +function convertJsonToSSEStream(jsonResponse) { + const encoder = new TextEncoder(); + const choice = jsonResponse.choices?.[0]; + + if (!choice) { + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + } + }); + } + + const message = choice.message || {}; + const content = message.content || ""; + const reasoningContent = message.reasoning_content || ""; + + return new ReadableStream({ + start(controller) { + // Send reasoning content first if present + if (reasoningContent) { + const chunk = `data: ${JSON.stringify({ + id: jsonResponse.id || `chatcmpl-${Date.now()}`, + object: "chat.completion.chunk", + created: jsonResponse.created || Math.floor(Date.now() / 1000), + model: jsonResponse.model || "unknown", + choices: [{ + index: 0, + delta: { reasoning_content: reasoningContent }, + finish_reason: null + }] + })}\n\n`; + controller.enqueue(encoder.encode(chunk)); + } + + // Send content + if (content) { + const chunk = `data: ${JSON.stringify({ + id: jsonResponse.id || `chatcmpl-${Date.now()}`, + object: "chat.completion.chunk", + created: jsonResponse.created || Math.floor(Date.now() / 1000), + model: jsonResponse.model || "unknown", + choices: [{ + index: 0, + delta: { content }, + finish_reason: null + }] + })}\n\n`; + controller.enqueue(encoder.encode(chunk)); + } + + // Send tool calls if present + if (message.tool_calls) { + const chunk = `data: ${JSON.stringify({ + id: jsonResponse.id || `chatcmpl-${Date.now()}`, + object: "chat.completion.chunk", + created: jsonResponse.created || Math.floor(Date.now() / 1000), + model: jsonResponse.model || "unknown", + choices: [{ + index: 0, + delta: { tool_calls: message.tool_calls }, + finish_reason: null + }] + })}\n\n`; + controller.enqueue(encoder.encode(chunk)); + } + + // Send finish chunk with usage + const finishChunk = `data: ${JSON.stringify({ + id: jsonResponse.id || `chatcmpl-${Date.now()}`, + object: "chat.completion.chunk", + created: jsonResponse.created || Math.floor(Date.now() / 1000), + model: jsonResponse.model || "unknown", + choices: [{ + index: 0, + delta: {}, + finish_reason: choice.finish_reason || "stop" + }], + usage: jsonResponse.usage || {} + })}\n\n`; + controller.enqueue(encoder.encode(finishChunk)); + + // Send [DONE] + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + } + }); +} + +/** + * Handle case: provider returns JSON but client expects SSE streaming. + */ +export async function handleJsonToSSE({ providerResponse, provider, model, sourceFormat, targetFormat, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, trackDone, appendLog }) { + trackDone(); + + let responseBody; + 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(); + + const usage = extractUsageFromResponse(responseBody); + appendLog({ tokens: usage, status: "200 OK" }); + saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint }); + + 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: responseBody?.choices?.[0]?.message?.content || null, + thinking: responseBody?.choices?.[0]?.message?.reasoning_content || null, + finish_reason: responseBody?.choices?.[0]?.finish_reason || "unknown" + }, + status: "success" + }, { endpoint: clientRawRequest?.endpoint || null })).catch(err => { + console.error("[RequestDetail] Failed to save:", err.message); + }); + + // Convert JSON to SSE format stream + const sseStream = convertJsonToSSEStream(responseBody); + reqLogger.logConvertedResponse({ format: "SSE", originalFormat: "JSON" }); + + return { + success: true, + response: new Response(sseStream, { headers: SSE_HEADERS }) + }; +} diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 733768a9..94a00ab1 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -37,25 +37,30 @@ const CLAUDE_CONFIG = { * @returns {Object} Usage data with quotas */ export async function getUsageForProvider(connection) { - const { provider, accessToken, providerSpecificData } = connection; + const { provider, accessToken, apiKey, providerSpecificData } = connection; + + // Support both accessToken and apiKey fields + const token = accessToken || apiKey; switch (provider) { case "github": - return await getGitHubUsage(accessToken, providerSpecificData); + return await getGitHubUsage(token, providerSpecificData); case "gemini-cli": - return await getGeminiUsage(accessToken); + return await getGeminiUsage(token); case "antigravity": - return await getAntigravityUsage(accessToken); + return await getAntigravityUsage(token); case "claude": - return await getClaudeUsage(accessToken); + return await getClaudeUsage(token); case "codex": - return await getCodexUsage(accessToken); + return await getCodexUsage(token); case "kiro": - return await getKiroUsage(accessToken, providerSpecificData); + return await getKiroUsage(token, providerSpecificData); case "qwen": - return await getQwenUsage(accessToken, providerSpecificData); + return await getQwenUsage(token, providerSpecificData); case "iflow": - return await getIflowUsage(accessToken); + return await getIflowUsage(token); + case "ramclouds": + return await getRamcloudsUsage(token); default: return { message: `Usage API not implemented for ${provider}` }; } @@ -643,3 +648,127 @@ async function getIflowUsage(accessToken) { } } +// Ramclouds reset time constants +const RAMCLOUDS_RESET_HOUR_UTC = 17; // Midnight ICT (UTC+7) = 17:00 UTC +const RAMCLOUDS_CACHE_TTL = 3600000; // Cache for 1 hour (resetAt only changes once per day) + +// Cache for resetAt calculation +let cachedResetAt = null; +let resetAtCacheTime = 0; + +/** + * Calculate next reset time for Ramclouds (daily at 0h ICT / 17:00 UTC) + * Cached for 1 hour since value only changes once per day + */ +function calculateRamcloudsResetTime() { + const now = Date.now(); + + // Return cached value if still valid + if (cachedResetAt && (now - resetAtCacheTime) < RAMCLOUDS_CACHE_TTL) { + return cachedResetAt; + } + + try { + const nowDate = new Date(now); + const year = nowDate.getUTCFullYear(); + const month = nowDate.getUTCMonth(); + const date = nowDate.getUTCDate(); + const hours = nowDate.getUTCHours(); + + // Today's midnight ICT in UTC (17:00 UTC) + let nextResetUTC = new Date(Date.UTC(year, month, date, RAMCLOUDS_RESET_HOUR_UTC, 0, 0, 0)); + + // If current UTC time is >= 17:00, next reset is tomorrow + if (hours >= RAMCLOUDS_RESET_HOUR_UTC) { + nextResetUTC = new Date(nextResetUTC.getTime() + 86400000); // 24 hours in ms + } + + cachedResetAt = nextResetUTC.toISOString(); + resetAtCacheTime = now; + + return cachedResetAt; + } catch (err) { + console.error('[Ramclouds] Error calculating resetAt:', err); + return null; + } +} + +/** + * Ramclouds Usage (New-API based) + * Fetches quota information from the log/token endpoint + */ +async function getRamcloudsUsage(apiKey) { + try { + const response = await fetch("https://ramclouds.me/api/log/token?p=0&page_size=1", { + method: "GET", + headers: { + "Authorization": `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + if (response.status === 403 || response.status === 401) { + return { + message: "Ramclouds API key invalid or expired", + quotas: {} + }; + } + throw new Error(`Ramclouds API error: ${response.status}`); + } + + const data = await response.json(); + + if (!data.data || data.data.length === 0) { + return { + message: "No usage data available yet", + plan: "Active", + quotas: { + tokens: { + used: 0, + total: 0, + remaining: 0, + unlimited: false, + } + } + }; + } + + // Parse subscription info from the latest log entry + const latestLog = data.data[0]; + const other = JSON.parse(latestLog.other || "{}"); + + const subscriptionTotal = other.subscription_total || 0; + const subscriptionUsed = other.subscription_used || 0; + const subscriptionRemain = other.subscription_remain || 0; + + // Convert to USD (500,000 tokens = $1 USD based on quota_per_unit from status API) + const RAMCLOUDS_QUOTA_PER_UNIT = 500000; + const usedUSD = (subscriptionUsed / RAMCLOUDS_QUOTA_PER_UNIT).toFixed(2); + const totalUSD = (subscriptionTotal / RAMCLOUDS_QUOTA_PER_UNIT).toFixed(2); + const remainingUSD = (subscriptionRemain / RAMCLOUDS_QUOTA_PER_UNIT).toFixed(2); + + // Calculate next reset time with caching (value only changes once per day) + const resetAt = calculateRamcloudsResetTime(); + + const result = { + plan: other.subscription_plan_title || "Unknown", + quotas: { + tokens: { + used: subscriptionUsed, + total: subscriptionTotal, + remaining: subscriptionRemain, + unlimited: false, + usedUSD, + totalUSD, + remainingUSD, + resetAt, + } + } + }; + + return result; + } catch (error) { + return { message: `Ramclouds error: ${error.message}` }; + } +} + diff --git a/open-sse/transformer/responsesTransformer.js b/open-sse/transformer/responsesTransformer.js index ac84db20..ba92a8bf 100644 --- a/open-sse/transformer/responsesTransformer.js +++ b/open-sse/transformer/responsesTransformer.js @@ -225,6 +225,35 @@ export function createResponsesApiTransformStream(logger = null) { const sendCompleted = (controller) => { if (!state.completedSent) { state.completedSent = true; + + // Build output array from accumulated state so clients can iterate response.output + const output = []; + if (state.reasoningId) { + output.push({ + id: state.reasoningId, + type: "reasoning", + summary: [{ type: "summary_text", text: state.reasoningBuf || "" }] + }); + } + for (const idx in state.msgItemAdded) { + output.push({ + id: `msg_${state.responseId}_${idx}`, + type: "message", + role: "assistant", + content: [{ type: "output_text", annotations: [], text: state.msgTextBuf[idx] || "" }] + }); + } + for (const idx in state.funcCallIds) { + const callId = state.funcCallIds[idx]; + output.push({ + id: `fc_${callId}`, + type: "function_call", + call_id: callId, + name: state.funcNames[idx] || "", + arguments: state.funcArgsBuf[idx] || "{}" + }); + } + emit(controller, "response.completed", { type: "response.completed", response: { @@ -233,7 +262,8 @@ export function createResponsesApiTransformStream(logger = null) { created_at: state.created, status: "completed", background: false, - error: null + error: null, + output } }); } diff --git a/open-sse/translator/helpers/claudeHelper.js b/open-sse/translator/helpers/claudeHelper.js index a06bab00..5fc9c37d 100644 --- a/open-sse/translator/helpers/claudeHelper.js +++ b/open-sse/translator/helpers/claudeHelper.js @@ -173,14 +173,22 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null) { body.tools = body.tools.filter(tool => !tool.type || tool.type === "function"); } - body.tools = body.tools.map((tool, i) => { + body.tools = body.tools.map(tool => { const { cache_control, ...rest } = tool; - if (i === body.tools.length - 1) { - return { ...rest, cache_control: { type: "ephemeral", ttl: "1h" } }; + if (rest.defer_loading) { + return rest; } return rest; }); + for (let i = body.tools.length - 1; i >= 0; i--) { + const tool = body.tools[i]; + if (!tool?.defer_loading) { + body.tools[i] = { ...tool, cache_control: { type: "ephemeral", ttl: "1h" } }; + break; + } + } + // Remove tools array and tool_choice if empty after filtering if (body.tools.length === 0) { delete body.tools; diff --git a/open-sse/translator/helpers/openaiHelper.js b/open-sse/translator/helpers/openaiHelper.js index 90879b93..333d3cec 100644 --- a/open-sse/translator/helpers/openaiHelper.js +++ b/open-sse/translator/helpers/openaiHelper.js @@ -8,25 +8,25 @@ export const VALID_OPENAI_MESSAGE_TYPES = ["text", "image_url", "image", "tool_c // 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 => { // Keep tool messages as-is (OpenAI format) if (msg.role === "tool") return msg; - + // Keep assistant messages with tool_calls as-is if (msg.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 === "thinking" || block.type === "redacted_thinking") continue; - + // Only keep valid OpenAI content types if (VALID_OPENAI_CONTENT_TYPES.includes(block.type)) { // Remove signature field if exists @@ -41,15 +41,23 @@ export function filterToOpenAIFormat(body) { filteredContent.push(cleanBlock); } } - + // If all content was filtered, add empty text if (filteredContent.length === 0) { filteredContent.push({ type: "text", text: "" }); } - + + // Normalize: if content is array with only text blocks, convert to string + // This handles cases where clients send multimodal format for simple text + const hasOnlyText = filteredContent.every(block => block.type === "text"); + if (hasOnlyText && filteredContent.length > 0) { + const textContent = filteredContent.map(block => block.text || "").join(""); + return { ...msg, content: textContent }; + } + return { ...msg, content: filteredContent }; } - + return msg; }); diff --git a/open-sse/translator/request/openai-to-claude.js b/open-sse/translator/request/openai-to-claude.js index e2e96630..6a7e810e 100644 --- a/open-sse/translator/request/openai-to-claude.js +++ b/open-sse/translator/request/openai-to-claude.js @@ -120,7 +120,11 @@ export function openaiToClaudeRequest(model, body, stream) { // Pass-through built-in tools (e.g. web_search_20250305) without prefix or conversion const toolType = tool.type; if (toolType && toolType !== "function") { - result.tools.push(tool); + const builtinTool = { ...tool }; + if (builtinTool.defer_loading && builtinTool.cache_control) { + delete builtinTool.cache_control; + } + result.tools.push(builtinTool); continue; } @@ -141,7 +145,13 @@ export function openaiToClaudeRequest(model, body, stream) { } if (result.tools.length > 0) { - result.tools[result.tools.length - 1].cache_control = { type: "ephemeral", ttl: "1h" }; + for (let i = result.tools.length - 1; i >= 0; i--) { + const tool = result.tools[i]; + if (!tool?.defer_loading) { + tool.cache_control = { type: "ephemeral", ttl: "1h" }; + break; + } + } } } diff --git a/open-sse/translator/response/openai-responses.js b/open-sse/translator/response/openai-responses.js index 671de332..ff20b952 100644 --- a/open-sse/translator/response/openai-responses.js +++ b/open-sse/translator/response/openai-responses.js @@ -323,6 +323,35 @@ function closeToolCall(state, emit, idx) { function sendCompleted(state, emit) { if (!state.completedSent) { state.completedSent = true; + + // Build output array from accumulated state so clients can iterate response.output + const output = []; + if (state.reasoningId) { + output.push({ + id: state.reasoningId, + type: "reasoning", + summary: [{ type: "summary_text", text: state.reasoningBuf || "" }] + }); + } + for (const idx in state.msgItemAdded) { + output.push({ + id: `msg_${state.responseId}_${idx}`, + type: "message", + role: "assistant", + content: [{ type: "output_text", annotations: [], text: state.msgTextBuf[idx] || "" }] + }); + } + for (const idx in state.funcCallIds) { + const callId = state.funcCallIds[idx]; + output.push({ + id: `fc_${callId}`, + type: "function_call", + call_id: callId, + name: state.funcNames[idx] || "", + arguments: state.funcArgsBuf[idx] || "{}" + }); + } + emit("response.completed", { type: "response.completed", response: { @@ -331,7 +360,8 @@ function sendCompleted(state, emit) { created_at: state.created, status: "completed", background: false, - error: null + error: null, + output } }); } diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index 4538035f..1b54952a 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -55,6 +55,7 @@ export function createSSEStream(options = {}) { let accumulatedContent = ""; let accumulatedThinking = ""; let ttftAt = null; + let hasSeenSSEData = false; // Track if we've seen valid SSE data events return new TransformStream({ transform(chunk, controller) { @@ -77,6 +78,7 @@ export function createSSEStream(options = {}) { let injectedUsage = false; if (trimmed.startsWith("data:") && trimmed.slice(5).trim() !== "[DONE]") { + hasSeenSSEData = true; // Mark that we've seen valid SSE data try { const parsed = JSON.parse(trimmed.slice(5).trim()); @@ -163,9 +165,12 @@ export function createSSEStream(options = {}) { if (!parsed) continue; if (parsed && parsed.done) { - const output = "data: [DONE]\n\n"; - reqLogger?.appendConvertedChunk?.(output); - controller.enqueue(sharedEncoder.encode(output)); + // Responses API SSE does not use [DONE] sentinel — stream ends after response.completed + if (sourceFormat !== FORMATS.OPENAI_RESPONSES) { + const output = "data: [DONE]\n\n"; + reqLogger?.appendConvertedChunk?.(output); + controller.enqueue(sharedEncoder.encode(output)); + } continue; } @@ -273,14 +278,15 @@ export function createSSEStream(options = {}) { } 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)); + + // IMPORTANT: Only append [DONE] sentinel if we've seen valid SSE data events. + // This prevents mixing JSON responses with SSE terminators. + // Some clients (e.g. OpenClaw) expect the OpenAI-style sentinel for true SSE streams. + if (hasSeenSSEData) { + const doneOutput = "data: [DONE]\n\n"; + reqLogger?.appendConvertedChunk?.(doneOutput); + controller.enqueue(sharedEncoder.encode(doneOutput)); + } if (onStreamComplete) { onStreamComplete({ @@ -330,9 +336,12 @@ export function createSSEStream(options = {}) { } } - const doneOutput = "data: [DONE]\n\n"; - reqLogger?.appendConvertedChunk?.(doneOutput); - controller.enqueue(sharedEncoder.encode(doneOutput)); + // Responses API SSE does not use [DONE] sentinel — stream ends after response.completed + if (sourceFormat !== FORMATS.OPENAI_RESPONSES) { + 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); diff --git a/package.json b/package.json index f0fc941a..efc3fd02 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,31 @@ { - "name": "9router-app", - "version": "0.3.33", - "description": "9Router web dashboard", - "private": true, + "name": "9router-fdk", + "version": "0.3.43", + "description": "9Router web dashboard (modded by FDKGenie, add support for Amp CLI, fixed MITM server for Antigravity and Github Copilot. Track quota right from the system tray icon)", + "bin": { + "9router-fdk": "bin/cli.cjs" + }, + "files": [ + ".next/standalone", + "bin", + "src/cli" + ], "scripts": { - "dev": "next dev --webpack --port 20128", - "build": "NODE_ENV=production next build --webpack", - "start": "NODE_ENV=production next start", - "dev:bun": "bun --bun next dev --webpack --port 20128", - "build:bun": "NODE_ENV=production bun --bun next build --webpack", - "start:bun": "NODE_ENV=production bun ./.next/standalone/server.js" + "dev": "next dev -p 20127", + "dev:alt": "next dev -p 20126", + "build": "NODE_ENV=production next build", + "start": "node ./bin/cli.cjs", + "start:tray": "node ./bin/cli.cjs --tray", + "dev:bun": "bun --bun next dev", + "build:bun": "NODE_ENV=production bun --bun next build", + "start:bun": "NODE_ENV=production bun ./.next/standalone/server.js", + "prepack": "npm run build", + "prepublishOnly": "npm run build" }, "dependencies": { "@monaco-editor/react": "^4.7.0", "@xyflow/react": "^12.10.1", + "9router-fdk": "^0.3.39", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.6.2", "express": "^5.2.1", @@ -32,6 +44,7 @@ "recharts": "^3.7.0", "selfsigned": "^5.5.0", "socks-proxy-agent": "^8.0.5", + "systray": "^1.0.5", "undici": "^7.19.2", "uuid": "^13.0.0", "zustand": "^5.0.10" @@ -42,5 +55,22 @@ "eslint-config-next": "16.1.6", "postcss": "^8.5.6", "tailwindcss": "^4" - } + }, + "main": "index.js", + "directories": { + "doc": "docs", + "test": "tests" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/fdkgenie/9router.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "module", + "bugs": { + "url": "https://github.com/fdkgenie/9router/issues" + }, + "homepage": "https://github.com/fdkgenie/9router#readme" } diff --git a/public/providers/alicode-intl.png b/public/providers/alicode-intl.png new file mode 100644 index 00000000..21c3ef3d 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 00000000..21c3ef3d Binary files /dev/null and b/public/providers/alicode.png differ diff --git a/public/providers/amp.svg b/public/providers/amp.svg new file mode 100644 index 00000000..c2dfe36a --- /dev/null +++ b/public/providers/amp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/ramclouds.png b/public/providers/ramclouds.png new file mode 100644 index 00000000..c921d49e Binary files /dev/null and b/public/providers/ramclouds.png differ diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js index dc07aa80..4c296767 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js @@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react"; import { Card, CardSkeleton } from "@/shared/components"; import { CLI_TOOLS } from "@/shared/constants/cliTools"; import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; -import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, DefaultToolCard, OpenCodeToolCard } from "./components"; +import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, DefaultToolCard, OpenCodeToolCard, AmpToolCard } from "./components"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -17,6 +17,7 @@ const STATUS_ENDPOINTS = { opencode: "/api/cli-tools/opencode-settings", droid: "/api/cli-tools/droid-settings", openclaw: "/api/cli-tools/openclaw-settings", + amp: "/api/cli-tools/amp-settings", }; export default function CLIToolsPageClient({ machineId }) { @@ -110,7 +111,12 @@ export default function CLIToolsPageClient({ machineId }) { const seenModels = new Set(); activeProviders.forEach(conn => { const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider; - const providerModels = getModelsByProviderId(conn.provider); + + // Use dynamically fetched models if available, otherwise fall back to static models + const dynamicModels = conn.providerSpecificData?.models || []; + const staticModels = getModelsByProviderId(conn.provider); + const providerModels = dynamicModels.length > 0 ? dynamicModels : staticModels; + providerModels.forEach(m => { const modelValue = `${alias}/${m.id}`; if (!seenModels.has(modelValue)) { @@ -180,6 +186,19 @@ export default function CLIToolsPageClient({ machineId }) { return ; case "openclaw": return ; + case "amp": + return ( + handleModelMappingChange(toolId, alias, target)} + hasActiveProviders={hasActiveProviders} + cloudEnabled={cloudEnabled} + initialStatus={toolStatuses.amp} + /> + ); default: return ; } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/AmpToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/AmpToolCard.js new file mode 100644 index 00000000..f5c6fb5e --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/AmpToolCard.js @@ -0,0 +1,448 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, Button, ManualConfigModal, ModelSelectModal } from "@/shared/components"; +import Image from "next/image"; + +const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; + +export default function AmpToolCard({ + tool, + isExpanded, + onToggle, + baseUrl, + apiKeys, + cloudEnabled, + initialStatus, + activeProviders, + modelMappings, + onModelMappingChange, + hasActiveProviders, +}) { + const [ampStatus, setAmpStatus] = useState(initialStatus || null); + const [checkingAmp, setCheckingAmp] = useState(false); + const [applying, setApplying] = useState(false); + const [restoring, setRestoring] = useState(false); + const [loggingIn, setLoggingIn] = useState(false); + const [message, setMessage] = useState(null); + const [showInstallGuide, setShowInstallGuide] = useState(false); + const [selectedApiKey, setSelectedApiKey] = useState(""); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); + const [customBaseUrl, setCustomBaseUrl] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [currentEditingAlias, setCurrentEditingAlias] = useState(null); + const [modelAliases, setModelAliases] = useState({}); + + const getConfigStatus = () => { + if (!ampStatus?.installed) return null; + const currentUrl = ampStatus.settings?.["amp.url"]; + if (!currentUrl) return "not_configured"; + const localMatch = currentUrl.includes("localhost") || currentUrl.includes("127.0.0.1"); + const cloudMatch = cloudEnabled && CLOUD_URL && currentUrl.startsWith(CLOUD_URL); + const tunnelMatch = baseUrl && currentUrl.startsWith(baseUrl); + if (localMatch || cloudMatch || tunnelMatch) return "configured"; + return "other"; + }; + + const configStatus = getConfigStatus(); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) { + setSelectedApiKey(apiKeys[0].key); + } + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (initialStatus) setAmpStatus(initialStatus); + }, [initialStatus]); + + useEffect(() => { + if (isExpanded && !ampStatus) { + checkAmpStatus(); + fetchModelAliases(); + loadModelMappings(); + } + if (isExpanded) { + fetchModelAliases(); + loadModelMappings(); + } + }, [isExpanded]); + + const fetchModelAliases = async () => { + try { + const res = await fetch("/api/models/alias"); + const data = await res.json(); + if (res.ok) setModelAliases(data.aliases || {}); + } catch (error) { + console.log("Error fetching model aliases:", error); + } + }; + + const loadModelMappings = async () => { + try { + const res = await fetch("/api/settings"); + const data = await res.json(); + if (res.ok && data.ampModelMappings) { + // Load saved model mappings into the component state + Object.entries(data.ampModelMappings).forEach(([alias, model]) => { + if (model) { + onModelMappingChange?.(alias, model); + } + }); + } + } catch (error) { + console.log("Error loading model mappings:", error); + } + }; + + const checkAmpStatus = async () => { + setCheckingAmp(true); + try { + const res = await fetch("/api/cli-tools/amp-settings"); + const data = await res.json(); + setAmpStatus(data); + } catch (error) { + setAmpStatus({ installed: false, error: error.message }); + } finally { + setCheckingAmp(false); + } + }; + + const getEffectiveBaseUrl = () => { + return customBaseUrl || baseUrl; + }; + + const getDisplayUrl = () => { + return customBaseUrl || baseUrl; + }; + + const handleApplySettings = async () => { + setApplying(true); + setMessage(null); + try { + const url = getEffectiveBaseUrl(); + + // Get key from dropdown, fallback to first key or sk_9router for localhost + const keyToUse = selectedApiKey?.trim() + || (apiKeys?.length > 0 ? apiKeys[0].key : null) + || (!cloudEnabled ? "sk_9router" : null); + + const res = await fetch("/api/cli-tools/amp-settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url, + apiKey: keyToUse, + modelMappings: modelMappings || {} + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings applied successfully!" }); + setAmpStatus(prev => ({ + ...prev, + has9Router: true, + settings: { ...prev?.settings, "amp.url": url } + })); + } else { + setMessage({ type: "error", text: data.error || "Failed to apply settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setApplying(false); + } + }; + + const handleResetSettings = async () => { + setRestoring(true); + setMessage(null); + try { + const res = await fetch("/api/cli-tools/amp-settings", { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings reset successfully!" }); + setSelectedApiKey(""); + tool.defaultModels?.forEach((model) => onModelMappingChange?.(model.alias, "")); + } else { + setMessage({ type: "error", text: data.error || "Failed to reset settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoring(false); + } + }; + + const handleAmpLogin = async () => { + setLoggingIn(true); + setMessage(null); + try { + // Get key from dropdown or use default + const keyToUse = selectedApiKey?.trim() + || (apiKeys?.length > 0 ? apiKeys[0].key : null); + + if (!keyToUse) { + setMessage({ type: "error", text: "Please select or create an API key first" }); + return; + } + + // Request login from Amp + const res = await fetch("/api/amp-cli-login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey: keyToUse }), + }); + + const data = await res.json(); + + if (res.ok) { + // Open auth URL in new window + if (data.authUrl) { + window.open(data.authUrl, "_blank"); + setMessage({ + type: "success", + text: `Login initiated! Verification code: ${data.verificationCode}. Complete authentication in the opened window.` + }); + } + } else { + setMessage({ type: "error", text: data.error || "Failed to initiate login" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setLoggingIn(false); + } + }; + + const openModelSelector = (alias) => { + setCurrentEditingAlias(alias); + setModalOpen(true); + }; + + const handleModelSelect = (model) => { + if (currentEditingAlias) onModelMappingChange?.(currentEditingAlias, model.value); + }; + + // Generate config files content for manual copy + const getManualConfigs = () => { + const url = getEffectiveBaseUrl(); + const keyToUse = (selectedApiKey && selectedApiKey.trim()) + ? selectedApiKey + : (!cloudEnabled ? "sk_9router" : ""); + + return [ + { + filename: "~/.config/amp/settings.json", + content: JSON.stringify({ "amp.url": url }, null, 2), + }, + { + filename: "~/.local/share/amp/secrets.json", + content: JSON.stringify({ [`apiKey@${url}`]: keyToUse }, null, 2), + }, + { + filename: "Environment Variables (Alternative)", + content: `export AMP_URL="${url}"\nexport AMP_API_KEY="${keyToUse}"`, + }, + ]; + }; + + return ( + +
+
+
+ {tool.name} { e.target.style.display = "none"; }} + /> +
+
+
+

{tool.name}

+ {configStatus === "configured" && Connected} + {configStatus === "not_configured" && Not configured} + {configStatus === "other" && Other} +
+

{tool.description}

+
+
+ expand_more +
+ + {isExpanded && ( +
+ {checkingAmp && ( +
+ progress_activity + Checking Amp CLI... +
+ )} + + {!checkingAmp && ampStatus && !ampStatus.installed && ( +
+
+ warning +
+

Amp CLI not installed

+

Please install Amp CLI to use this feature.

+
+ +
+ {showInstallGuide && ( +
+

Installation Guide

+
+
+

macOS / Linux:

+ npm install -g @amp/cli +
+

After installation, run amp to verify.

+

Visit ampcode.com for more information.

+
+
+ )} +
+ )} + + {!checkingAmp && ampStatus?.installed && ( + <> +
+ {/* Current URL */} + {ampStatus?.settings?.["amp.url"] && ( +
+ Current + arrow_forward + + {ampStatus.settings["amp.url"]} + +
+ )} + + {/* Base URL */} +
+ Base URL + arrow_forward + setCustomBaseUrl(e.target.value)} + placeholder="http://localhost:20128" + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + {customBaseUrl && customBaseUrl !== baseUrl && ( + + )} +
+ + {/* API Key */} +
+ API Key + arrow_forward + {apiKeys.length > 0 ? ( + + ) : ( + + {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} + + )} +
+ + {/* Model Mappings for Amp Modes */} + {tool.defaultModels && tool.defaultModels.map((model) => ( +
+
+ {model.name} + arrow_forward + onModelMappingChange?.(model.alias, e.target.value)} + placeholder="provider/model-id" + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + + {modelMappings?.[model.alias] && ( + + )} +
+ {model.description && ( +

{model.description}

+ )} +
+ ))} +
+ + {message && ( +
+ {message.type === "success" ? "check_circle" : "error"} + {message.text} +
+ )} + +
+ + + + +
+ + )} +
+ )} + + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={currentEditingAlias ? modelMappings?.[currentEditingAlias] : null} + activeProviders={activeProviders} + modelAliases={modelAliases} + title={`Select model for ${currentEditingAlias}`} + /> + + setShowManualConfigModal(false)} + title="Amp CLI - Manual Configuration" + configs={getManualConfigs()} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js index bc8be224..69fafb58 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js @@ -32,6 +32,8 @@ export default function MitmToolCard({ const [sudoPassword, setSudoPassword] = useState(""); const [pendingDnsAction, setPendingDnsAction] = useState(null); const [modelMappings, setModelMappings] = useState({}); + const [alwaysFallbackEnabled, setAlwaysFallbackEnabled] = useState(false); + const [alwaysFallbackModel, setAlwaysFallbackModel] = useState(""); const [modalOpen, setModalOpen] = useState(false); const [currentEditingAlias, setCurrentEditingAlias] = useState(null); @@ -47,19 +49,26 @@ export default function MitmToolCard({ if (res.ok) { const data = await res.json(); if (Object.keys(data.aliases || {}).length > 0) setModelMappings(data.aliases); + setAlwaysFallbackEnabled(!!data.alwaysFallbackEnabled); + setAlwaysFallbackModel(data.alwaysFallbackModel || ""); } } catch { /* ignore */ } }; - const saveMappings = useCallback(async (mappings) => { + const saveMappings = useCallback(async (mappings, fallbackEnabled = alwaysFallbackEnabled, fallbackModel = alwaysFallbackModel) => { try { await fetch("/api/cli-tools/antigravity-mitm/alias", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tool: tool.id, mappings }), + body: JSON.stringify({ + tool: tool.id, + mappings, + alwaysFallbackEnabled: !!fallbackEnabled, + alwaysFallbackModel: fallbackModel || "", + }), }); } catch { /* ignore */ } - }, [tool.id]); + }, [tool.id, alwaysFallbackEnabled, alwaysFallbackModel]); const handleMappingBlur = (alias, value) => { saveMappings({ ...modelMappings, [alias]: value }); @@ -76,11 +85,27 @@ export default function MitmToolCard({ const handleModelSelect = (model) => { if (!currentEditingAlias || model.isPlaceholder) return; + + if (currentEditingAlias === "__always_fallback__") { + setAlwaysFallbackModel(model.value); + saveMappings(modelMappings, alwaysFallbackEnabled, model.value); + return; + } + const updated = { ...modelMappings, [currentEditingAlias]: model.value }; setModelMappings(updated); saveMappings(updated); }; + const handleAlwaysFallbackToggle = (checked) => { + setAlwaysFallbackEnabled(checked); + saveMappings(modelMappings, checked, alwaysFallbackModel); + }; + + const handleAlwaysFallbackModelBlur = (value) => { + saveMappings(modelMappings, alwaysFallbackEnabled, value); + }; + // DNS toggle logic const handleDnsToggle = () => { if (!serverRunning) return; @@ -191,6 +216,40 @@ export default function MitmToolCard({ )} + {/* Always fallback model */} +
+ +
+ setAlwaysFallbackModel(e.target.value)} + onBlur={(e) => handleAlwaysFallbackModelBlur(e.target.value)} + placeholder="provider/model-id" + disabled={!dnsActive || !alwaysFallbackEnabled} + className={`flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 ${(!dnsActive || !alwaysFallbackEnabled) ? "opacity-50 cursor-not-allowed" : ""}`} + /> + +
+

+ Checked: unmapped model will use fallback model. Unchecked: unmapped model passthrough. +

+
+ {/* Model Mappings */} {tool.defaultModels?.length > 0 && (
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js index ee0ecc13..535f7fb3 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -8,4 +8,5 @@ export { default as OpenCodeToolCard } from "./OpenCodeToolCard"; export { default as CopilotToolCard } from "./CopilotToolCard"; export { default as MitmServerCard } from "./MitmServerCard"; export { default as MitmToolCard } from "./MitmToolCard"; +export { default as AmpToolCard } from "./AmpToolCard"; diff --git a/src/app/(dashboard)/dashboard/mitm/MitmPageClient.js b/src/app/(dashboard)/dashboard/mitm/MitmPageClient.js index 25b6bbc4..7e541996 100644 --- a/src/app/(dashboard)/dashboard/mitm/MitmPageClient.js +++ b/src/app/(dashboard)/dashboard/mitm/MitmPageClient.js @@ -67,11 +67,15 @@ export default function MitmPageClient() { const hasActiveProviders = () => { const active = getActiveProviders(); - return active.some(conn => - getModelsByProviderId(conn.provider).length > 0 || - isOpenAICompatibleProvider(conn.provider) || - isAnthropicCompatibleProvider(conn.provider) - ); + return active.some(conn => { + // Use dynamically fetched models if available, otherwise fall back to static models + const dynamicModels = conn.providerSpecificData?.models || []; + const staticModels = getModelsByProviderId(conn.provider); + return dynamicModels.length > 0 || + staticModels.length > 0 || + isOpenAICompatibleProvider(conn.provider) || + isAnthropicCompatibleProvider(conn.provider); + }); }; const mitmTools = Object.entries(CLI_TOOLS).filter(([id]) => MITM_TOOL_IDS.includes(id)); diff --git a/src/app/(dashboard)/dashboard/playground/page.js b/src/app/(dashboard)/dashboard/playground/page.js new file mode 100644 index 00000000..38cbc453 --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/page.js @@ -0,0 +1,776 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Card, Button, Select, Input, Toggle } from "@/shared/components"; + +const PLAYGROUND_HISTORY_KEY = "playground_history_v1"; +const PLAYGROUND_PREFS_KEY = "playground_prefs_v1"; + +const PROMPT_TEMPLATES = [ + { + value: "", + label: "Custom", + prompt: "", + }, + { + value: "summary", + label: "Summary", + prompt: "Summarize the following in 3 short bullet points:", + }, + { + value: "debug", + label: "Debug assistant", + prompt: "Given this error log, explain root cause and propose a fix with minimal code changes:", + }, + { + value: "rewrite", + label: "Rewrite", + prompt: "Rewrite this text to be clear, concise, and professional:", + }, +]; + +function extractContent(data) { + const content = data?.choices?.[0]?.message?.content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((item) => { + if (typeof item === "string") return item; + if (item?.type === "text") return item.text || ""; + return ""; + }) + .join("\n") + .trim(); + } + return ""; +} + +function parseSSEChunk(chunk, onDelta, onPacket) { + const lines = chunk.split("\n"); + const remaining = lines.pop() || ""; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line.startsWith("data:")) continue; + + const payload = line.slice(5).trim(); + if (!payload || payload === "[DONE]") continue; + + try { + const packet = JSON.parse(payload); + onPacket(packet); + const delta = packet?.choices?.[0]?.delta?.content; + if (typeof delta === "string" && delta) onDelta(delta); + } catch { + // ignore malformed packet + } + } + + return remaining; +} + +export default function PlaygroundPage() { + const [models, setModels] = useState([]); + const [selectedModel, setSelectedModel] = useState(""); + const [prompt, setPrompt] = useState("Write a 3-line summary about why routing fallback is useful."); + const [template, setTemplate] = useState(""); + const [loadingModels, setLoadingModels] = useState(true); + const [running, setRunning] = useState(false); + const [streaming, setStreaming] = useState(false); + const [resultText, setResultText] = useState(""); + const [rawResponse, setRawResponse] = useState(""); + const [error, setError] = useState(""); + const [latencyMs, setLatencyMs] = useState(null); + const [streamChars, setStreamChars] = useState(0); + const [authHeader, setAuthHeader] = useState(""); + const [history, setHistory] = useState([]); + const [systemPrompt, setSystemPrompt] = useState(""); + const [temperature, setTemperature] = useState("0.7"); + const [topP, setTopP] = useState("1"); + const [maxTokens, setMaxTokens] = useState("512"); + const [chatSession, setChatSession] = useState([]); + const [debugInfo, setDebugInfo] = useState(null); + const [showDebug, setShowDebug] = useState(false); + const [pricing, setPricing] = useState(null); + const [batchMode, setBatchMode] = useState(false); + const [batchPrompts, setBatchPrompts] = useState(""); + const [batchResults, setBatchResults] = useState([]); + const [batchRunning, setBatchRunning] = useState(false); + + useEffect(() => { + const load = async () => { + try { + const [modelsRes, settingsRes, pricingRes] = await Promise.all([ + fetch("/api/v1/models"), + fetch("/api/settings"), + fetch("/api/pricing").catch(() => null), + ]); + + const modelsJson = await modelsRes.json(); + const modelList = Array.isArray(modelsJson?.data) + ? modelsJson.data.map((item) => item.id).filter(Boolean) + : []; + + setModels(modelList); + if (modelList.length > 0) setSelectedModel(modelList[0]); + + if (settingsRes.ok) { + const settings = await settingsRes.json(); + if (settings.requireApiKey) { + const keysRes = await fetch("/api/keys"); + const keysJson = await keysRes.json(); + const activeKey = (keysJson?.keys || []).find((key) => key.isActive !== false)?.key || ""; + setAuthHeader(activeKey ? `Bearer ${activeKey}` : ""); + } + } + + if (pricingRes?.ok) { + const pricingData = await pricingRes.json(); + setPricing(pricingData); + } + } catch { + setError("Failed to load models."); + } finally { + setLoadingModels(false); + } + }; + + load(); + + try { + const saved = globalThis.localStorage?.getItem(PLAYGROUND_HISTORY_KEY); + if (saved) { + const parsed = JSON.parse(saved); + if (Array.isArray(parsed)) setHistory(parsed); + } + + const prefsRaw = globalThis.localStorage?.getItem(PLAYGROUND_PREFS_KEY); + if (prefsRaw) { + const prefs = JSON.parse(prefsRaw); + if (typeof prefs.model === "string" && prefs.model) { + setSelectedModel(prefs.model); + } + if (typeof prefs.systemPrompt === "string") setSystemPrompt(prefs.systemPrompt); + if (typeof prefs.temperature === "string") setTemperature(prefs.temperature); + if (typeof prefs.topP === "string") setTopP(prefs.topP); + if (typeof prefs.maxTokens === "string") setMaxTokens(prefs.maxTokens); + } + } catch { + // ignore + } + }, []); + + const modelOptions = useMemo( + () => models.map((id) => ({ value: id, label: id })), + [models], + ); + + const templateOptions = useMemo( + () => PROMPT_TEMPLATES.map((t) => ({ value: t.value, label: t.label })), + [], + ); + + useEffect(() => { + try { + globalThis.localStorage?.setItem( + PLAYGROUND_PREFS_KEY, + JSON.stringify({ + model: selectedModel, + systemPrompt, + temperature, + topP, + maxTokens, + }), + ); + } catch { + // ignore + } + }, [selectedModel, systemPrompt, temperature, topP, maxTokens]); + + const saveHistory = (next) => { + setHistory(next); + try { + globalThis.localStorage?.setItem(PLAYGROUND_HISTORY_KEY, JSON.stringify(next)); + } catch { + // ignore + } + }; + + const appendHistory = (item) => { + setHistory((prev) => { + const next = [item, ...prev].slice(0, 20); + try { + globalThis.localStorage?.setItem(PLAYGROUND_HISTORY_KEY, JSON.stringify(next)); + } catch { + // ignore + } + return next; + }); + }; + + const buildHeaders = () => { + const headers = { "Content-Type": "application/json" }; + const authValue = authHeader.trim(); + if (authValue) { + headers.Authorization = authValue.toLowerCase().startsWith("bearer ") + ? authValue + : `Bearer ${authValue}`; + } + return headers; + }; + + const estimateCost = (model, inputTokens, outputTokens) => { + if (!pricing) return null; + + const parts = model.split("/"); + const provider = parts[0]; + const modelName = parts.slice(1).join("/"); + + const providerPricing = pricing[provider]; + if (!providerPricing) return null; + + const modelPricing = providerPricing[modelName]; + if (!modelPricing) return null; + + const inputCost = (inputTokens / 1_000_000) * (modelPricing.input || 0); + const outputCost = (outputTokens / 1_000_000) * (modelPricing.output || 0); + + return { + inputCost, + outputCost, + totalCost: inputCost + outputCost, + currency: "USD", + }; + }; + + const handleRun = async () => { + if (!selectedModel || !prompt.trim() || running) return; + + setRunning(true); + setError(""); + setLatencyMs(null); + setStreamChars(0); + setResultText(""); + setRawResponse(""); + setDebugInfo(null); + + const parsedTemperature = Number.parseFloat(temperature); + const parsedTopP = Number.parseFloat(topP); + const parsedMaxTokens = Number.parseInt(maxTokens, 10); + + const messages = [ + ...(systemPrompt.trim() ? [{ role: "system", content: systemPrompt.trim() }] : []), + ...chatSession, + { role: "user", content: prompt.trim() }, + ]; + + const requestBody = { + model: selectedModel, + stream: streaming, + max_tokens: Number.isFinite(parsedMaxTokens) && parsedMaxTokens > 0 ? parsedMaxTokens : 512, + temperature: Number.isFinite(parsedTemperature) ? parsedTemperature : 0.7, + top_p: Number.isFinite(parsedTopP) ? parsedTopP : 1, + messages, + }; + + const headers = buildHeaders(); + + try { + const start = performance.now(); + const res = await fetch("/api/v1/chat/completions", { + method: "POST", + headers, + body: JSON.stringify(requestBody), + }); + + const debug = { + requestHeaders: headers, + requestBody, + responseStatus: res.status, + responseHeaders: Object.fromEntries(res.headers.entries()), + }; + + if (!streaming) { + const data = await res.json().catch(() => ({})); + const elapsed = Math.round(performance.now() - start); + setLatencyMs(elapsed); + setRawResponse(JSON.stringify(data, null, 2)); + + debug.responseBody = data; + const cost = estimateCost( + selectedModel, + data?.usage?.prompt_tokens || 0, + data?.usage?.completion_tokens || 0 + ); + if (cost) debug.estimatedCost = cost; + setDebugInfo(debug); + + if (!res.ok) { + const message = data?.error?.message || data?.error || data?.message || `HTTP ${res.status}`; + setError(String(message)); + appendHistory({ + id: Date.now(), + model: selectedModel, + prompt: prompt.trim(), + resultText: "", + error: String(message), + latencyMs: elapsed, + createdAt: new Date().toISOString(), + }); + return; + } + + const extracted = extractContent(data) || "No text content returned."; + setResultText(extracted); + setChatSession((prev) => [ + ...prev, + { role: "user", content: prompt.trim() }, + { role: "assistant", content: extracted }, + ]); + appendHistory({ + id: Date.now(), + model: selectedModel, + prompt: prompt.trim(), + resultText: extracted, + error: "", + latencyMs: elapsed, + createdAt: new Date().toISOString(), + }); + return; + } + + if (!res.ok || !res.body) { + const fallback = await res.text().catch(() => ""); + const message = fallback || `HTTP ${res.status}`; + setError(message); + setRawResponse(fallback); + return; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let lastPacket = null; + let streamedText = ""; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + buffer = parseSSEChunk( + buffer, + (delta) => { + streamedText += delta; + setResultText((prev) => prev + delta); + setStreamChars((prev) => prev + delta.length); + }, + (packet) => { + lastPacket = packet; + }, + ); + } + + const elapsed = Math.round(performance.now() - start); + setLatencyMs(elapsed); + setRawResponse(lastPacket ? JSON.stringify(lastPacket, null, 2) : "[No terminal packet]"); + + debug.responseBody = lastPacket; + const cost = estimateCost( + selectedModel, + lastPacket?.usage?.prompt_tokens || 0, + lastPacket?.usage?.completion_tokens || 0 + ); + if (cost) debug.estimatedCost = cost; + setDebugInfo(debug); + + setChatSession((prev) => [ + ...prev, + { role: "user", content: prompt.trim() }, + { role: "assistant", content: streamedText }, + ]); + appendHistory({ + id: Date.now(), + model: selectedModel, + prompt: prompt.trim(), + resultText: streamedText, + error: "", + latencyMs: elapsed, + createdAt: new Date().toISOString(), + }); + } catch (err) { + setError(err?.message || "Request failed."); + setResultText(""); + setRawResponse(""); + } finally { + setRunning(false); + } + }; + + const handleBatchRun = async () => { + if (!selectedModel || !batchPrompts.trim() || batchRunning) return; + + const prompts = batchPrompts.split("\n").map(p => p.trim()).filter(Boolean); + if (prompts.length === 0) return; + + setBatchRunning(true); + setBatchResults([]); + + const results = []; + for (let i = 0; i < prompts.length; i++) { + const promptText = prompts[i]; + const start = performance.now(); + + try { + const res = await fetch("/api/v1/chat/completions", { + method: "POST", + headers: buildHeaders(), + body: JSON.stringify({ + model: selectedModel, + stream: false, + max_tokens: 512, + messages: [{ role: "user", content: promptText }], + }), + }); + + const data = await res.json().catch(() => ({})); + const elapsed = Math.round(performance.now() - start); + const extracted = extractContent(data) || "No content"; + + results.push({ + id: Date.now() + i, + prompt: promptText, + result: extracted, + latencyMs: elapsed, + error: res.ok ? "" : (data?.error?.message || `HTTP ${res.status}`), + tokens: data?.usage || {}, + }); + } catch (err) { + results.push({ + id: Date.now() + i, + prompt: promptText, + result: "", + latencyMs: Math.round(performance.now() - start), + error: err?.message || "Request failed", + tokens: {}, + }); + } + + setBatchResults([...results]); + } + + setBatchRunning(false); + }; + + const handleApplyTemplate = () => { + const selected = PROMPT_TEMPLATES.find((item) => item.value === template); + if (selected?.prompt) setPrompt(selected.prompt); + }; + + const handleCopyCurl = async () => { + const auth = authHeader.trim() ? `-H 'Authorization: ${authHeader.trim()}' \\\n ` : ""; + const curl = `curl ${globalThis.location.origin}/api/v1/chat/completions \\ + -X POST \\ + -H 'Content-Type: application/json' \\ + ${auth}-d '${JSON.stringify({ model: selectedModel, stream: streaming, messages: [{ role: "user", content: prompt.trim() }] })}'`; + await navigator.clipboard.writeText(curl); + }; + + const handleCopyJs = async () => { + const js = `const res = await fetch("${globalThis.location.origin}/api/v1/chat/completions", {\n method: "POST",\n headers: {\n "Content-Type": "application/json",\n Authorization: "${authHeader.trim()}"\n },\n body: JSON.stringify({\n model: "${selectedModel}",\n stream: ${streaming},\n messages: [{ role: "user", content: ${JSON.stringify(prompt.trim())} }]\n })\n});\nconst data = await res.json();\nconsole.log(data);`; + await navigator.clipboard.writeText(js); + }; + + return ( +
+ {/* Left Column - Input Controls */} +
+ +
+ setTemplate(e.target.value)} + placeholder="Choose a template" + disabled={running} + /> + +
+ +
+ +