diff --git a/open-sse/config/providers.js b/open-sse/config/providers.js index 91a3132..b3a077f 100644 --- a/open-sse/config/providers.js +++ b/open-sse/config/providers.js @@ -33,6 +33,7 @@ export const PROVIDERS = { claude: { baseUrl: "https://api.anthropic.com/v1/messages", format: "claude", + retry: { 429: 0 }, headers: { "Anthropic-Version": "2023-06-01", "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05", diff --git a/open-sse/config/runtimeConfig.js b/open-sse/config/runtimeConfig.js index 19ac15a..b72ce83 100644 --- a/open-sse/config/runtimeConfig.js +++ b/open-sse/config/runtimeConfig.js @@ -60,12 +60,19 @@ export const MEMORY_CONFIG = { export const DEFAULT_MAX_TOKENS = 64000; export const DEFAULT_MIN_TOKENS = 32000; -// Retry config for 429 responses +// Retry config for 429 responses (legacy - kept for backward compatibility) export const RETRY_CONFIG = { maxAttempts: 2, delayMs: 2000 }; +// Default retry config by status code (number of retry attempts) +export const DEFAULT_RETRY_CONFIG = { + 429: 2, // Rate limit - retry 2 times + 503: 0, // Service unavailable - no retry + 502: 0 // Bad gateway - no retry +}; + // Exponential backoff config for rate limits export const BACKOFF_CONFIG = { base: 1000, diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index 61fb0f6..74ca14d 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -1,4 +1,4 @@ -import { HTTP_STATUS, RETRY_CONFIG } from "../config/runtimeConfig.js"; +import { HTTP_STATUS, RETRY_CONFIG, DEFAULT_RETRY_CONFIG } from "../config/runtimeConfig.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; /** @@ -81,6 +81,9 @@ export class BaseExecutor { let lastError = null; let lastStatus = 0; const retryAttemptsByUrl = {}; + + // Merge default retry config with provider-specific config + const retryConfig = { ...DEFAULT_RETRY_CONFIG, ...this.config.retry }; for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const url = this.buildUrl(model, stream, urlIndex, credentials); @@ -97,10 +100,11 @@ export class BaseExecutor { signal }, proxyOptions); - // Retry 429 with fixed delay before falling back to next URL - if (response.status === HTTP_STATUS.RATE_LIMITED && retryAttemptsByUrl[urlIndex] < RETRY_CONFIG.maxAttempts) { + // Retry based on status code config + const maxRetries = retryConfig[response.status] || 0; + if (maxRetries > 0 && retryAttemptsByUrl[urlIndex] < maxRetries) { retryAttemptsByUrl[urlIndex]++; - log?.debug?.("RETRY", `429 retry ${retryAttemptsByUrl[urlIndex]}/${RETRY_CONFIG.maxAttempts} after ${RETRY_CONFIG.delayMs / 1000}s`); + log?.debug?.("RETRY", `${response.status} retry ${retryAttemptsByUrl[urlIndex]}/${maxRetries} after ${RETRY_CONFIG.delayMs / 1000}s`); await new Promise(resolve => setTimeout(resolve, RETRY_CONFIG.delayMs)); urlIndex--; continue; diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index cc9ebdc..aeb612e 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -3,6 +3,7 @@ import { PROVIDERS } from "../config/providers.js"; import { v4 as uuidv4 } from "uuid"; import { refreshKiroToken } from "../services/tokenRefresh.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { HTTP_STATUS, RETRY_CONFIG, DEFAULT_RETRY_CONFIG } from "../config/runtimeConfig.js"; /** * KiroExecutor - Executor for Kiro AI (AWS CodeWhisperer) @@ -32,29 +33,45 @@ export class KiroExecutor extends BaseExecutor { } /** - * Custom execute for Kiro - handles AWS EventStream binary response + * Custom execute for Kiro - handles AWS EventStream binary response with retry support */ async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { const url = this.buildUrl(model, stream, 0); - const headers = this.buildHeaders(credentials, stream); const transformedBody = this.transformRequest(model, body, stream, credentials); + + // Merge default retry config with provider-specific config + const retryConfig = { ...DEFAULT_RETRY_CONFIG, ...this.config.retry }; + let retryAttempts = 0; - const response = await proxyAwareFetch(url, { - method: "POST", - headers, - body: JSON.stringify(transformedBody), - signal - }, proxyOptions); + while (true) { + const headers = this.buildHeaders(credentials, stream); + + const response = await proxyAwareFetch(url, { + method: "POST", + headers, + body: JSON.stringify(transformedBody), + signal + }, proxyOptions); - if (!response.ok) { - return { response, url, headers, transformedBody }; + // Check if should retry based on status code + const maxRetries = retryConfig[response.status] || 0; + if (!response.ok && maxRetries > 0 && retryAttempts < maxRetries) { + retryAttempts++; + log?.debug?.("RETRY", `${response.status} retry ${retryAttempts}/${maxRetries} after ${RETRY_CONFIG.delayMs / 1000}s`); + await new Promise(resolve => setTimeout(resolve, RETRY_CONFIG.delayMs)); + continue; + } + + if (!response.ok) { + return { response, url, headers, transformedBody }; + } + + // Success - transform and return + // For Kiro, we need to transform the binary EventStream to SSE + // Create a TransformStream to convert binary to SSE text + const transformedResponse = this.transformEventStreamToSSE(response, model); + return { response: transformedResponse, url, headers, transformedBody }; } - - // For Kiro, we need to transform the binary EventStream to SSE - // Create a TransformStream to convert binary to SSE text - const transformedResponse = this.transformEventStreamToSSE(response, model); - - return { response: transformedResponse, url, headers, transformedBody }; } /** diff --git a/package.json b/package.json index fe034c3..6849f7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.3.61", + "version": "0.3.62", "description": "9Router web dashboard", "private": true, "scripts": { @@ -27,6 +27,7 @@ "node-machine-id": "^1.1.12", "open": "^11.0.0", "ora": "^9.1.0", + "proper-lockfile": "^4.1.2", "react": "19.2.4", "react-dom": "19.2.4", "react-is": "^16.13.1", diff --git a/src/app/api/9remote/install/route.js b/src/app/api/9remote/install/route.js new file mode 100644 index 0000000..2332687 --- /dev/null +++ b/src/app/api/9remote/install/route.js @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { exec } from "child_process"; +import { join, dirname } from "path"; + +// Use npm from the same Node.js that runs Next.js — ensures 9remote +// lands in the correct global bin (nvm or system, whichever is active) +const npmBin = join(dirname(process.execPath), "npm"); + +function installPackage() { + return new Promise((resolve, reject) => { + exec(`"${npmBin}" install -g 9remote`, { windowsHide: true }, (err, stdout, stderr) => { + if (err) reject(new Error(stderr || err.message)); + else resolve(stdout); + }); + }); +} + +export async function POST() { + try { + await installPackage(); + return NextResponse.json({ ok: true }); + } catch (error) { + return NextResponse.json({ ok: false, error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/9remote/start/route.js b/src/app/api/9remote/start/route.js new file mode 100644 index 0000000..67b60f9 --- /dev/null +++ b/src/app/api/9remote/start/route.js @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server"; +import { spawn } from "child_process"; +import { join, dirname } from "path"; +import os from "os"; +import { setRemoteProcess } from "@/lib/9remoteManager"; + +const bin9remote = join(dirname(process.execPath), "9remote"); + +export async function POST() { + try { + const nodeDir = dirname(process.execPath); + const existingPath = process.env.PATH || ""; + const path = existingPath.includes(nodeDir) + ? existingPath + : `${nodeDir}:${existingPath}`; + + const env = { + HOME: os.homedir(), + PATH: path, + USER: process.env.USER || process.env.LOGNAME, + LANG: process.env.LANG || "en_US.UTF-8", + TERM: process.env.TERM || "xterm-256color", + TMPDIR: process.env.TMPDIR || os.tmpdir(), + SHELL: process.env.SHELL, + }; + const home = os.homedir(); + + // Spawn without detached - process will be child of Next.js and receive SIGTERM + const child = spawn(bin9remote, ["ui", "--start"], { + cwd: home, + stdio: "ignore", + env, + windowsHide: process.platform === "win32", + }); + + // Store child process for manual cleanup if needed + setRemoteProcess(child); + + return NextResponse.json({ ok: true }); + } catch (error) { + return NextResponse.json({ ok: false, error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/9remote/status/route.js b/src/app/api/9remote/status/route.js new file mode 100644 index 0000000..735b22b --- /dev/null +++ b/src/app/api/9remote/status/route.js @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { existsSync } from "fs"; +import { join, dirname } from "path"; + +const bin9remote = join(dirname(process.execPath), "9remote"); +const AGENT_URL = "http://localhost:2208"; + +async function isRunning() { + try { + const res = await fetch(`${AGENT_URL}/api/health`, { + signal: AbortSignal.timeout(1500), + }); + return res.ok; + } catch { + return false; + } +} + +export async function GET() { + const running = await isRunning(); + if (running) return NextResponse.json({ installed: true, running: true }); + + const installed = existsSync(bin9remote); + return NextResponse.json({ installed, running: false }); +} diff --git a/src/lib/9remoteManager.js b/src/lib/9remoteManager.js new file mode 100644 index 0000000..0b608ee --- /dev/null +++ b/src/lib/9remoteManager.js @@ -0,0 +1,35 @@ +// 9remote process lifecycle manager +let remoteProcess = null; + +export function setRemoteProcess(child) { + remoteProcess = child; +} + +export function getRemoteProcess() { + return remoteProcess; +} + +export function killRemote() { + if (!remoteProcess) return; + + try { + remoteProcess.kill("SIGTERM"); + console.log(`[9remote] Killed process ${remoteProcess.pid}`); + remoteProcess = null; + } catch (err) { + console.log(`[9remote] Failed to kill:`, err.message); + remoteProcess = null; + } +} + +// Register cleanup handlers +if (typeof process !== "undefined") { + const cleanup = () => { + killRemote(); + process.exit(0); + }; + + process.on("SIGTERM", cleanup); + process.on("SIGINT", cleanup); + process.on("beforeExit", killRemote); +} diff --git a/src/lib/localDb.js b/src/lib/localDb.js index 5d634be..de44066 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -4,6 +4,7 @@ import { v4 as uuidv4 } from "uuid"; import path from "node:path"; import os from "node:os"; import fs from "node:fs"; +import lockfile from "proper-lockfile"; const isCloud = typeof caches !== 'undefined' || typeof caches === 'object'; @@ -161,6 +162,78 @@ function ensureDbShape(data) { // Singleton instance let dbInstance = null; +// Lock options for proper-lockfile +const LOCK_OPTIONS = { + retries: { + retries: 5, + minTimeout: 100, + maxTimeout: 2000, + }, + stale: 10000, // Consider lock stale after 10s +}; + +/** + * Safely read database with file locking + */ +async function safeRead(db) { + if (isCloud) { + await db.read(); + return; + } + + let release = null; + try { + // Acquire lock before reading + release = await lockfile.lock(DB_FILE, LOCK_OPTIONS); + await db.read(); + } catch (error) { + if (error.code === "ELOCKED") { + console.warn("[DB] File is locked, retrying read..."); + throw error; + } + throw error; + } finally { + if (release) { + try { + await release(); + } catch (err) { + // Ignore unlock errors + } + } + } +} + +/** + * Safely write database with file locking + */ +async function safeWrite(db) { + if (isCloud) { + await db.write(); + return; + } + + let release = null; + try { + // Acquire lock before writing + release = await lockfile.lock(DB_FILE, LOCK_OPTIONS); + await db.write(); + } catch (error) { + if (error.code === "ELOCKED") { + console.warn("[DB] File is locked, retrying write..."); + throw error; + } + throw error; + } finally { + if (release) { + try { + await release(); + } catch (err) { + // Ignore unlock errors + } + } + } +} + /** * Get database instance (singleton) */ @@ -182,12 +255,12 @@ export async function getDb() { // Always read latest disk state to avoid stale singleton data across route workers. try { - await dbInstance.read(); + await safeRead(dbInstance); } catch (error) { if (error instanceof SyntaxError) { console.warn('[DB] Corrupt JSON detected, resetting to defaults...'); dbInstance.data = cloneDefaultData(); - await dbInstance.write(); + await safeWrite(dbInstance); } else { throw error; } @@ -196,12 +269,12 @@ export async function getDb() { // Initialize/migrate missing keys for older DB schema versions. if (!dbInstance.data) { dbInstance.data = cloneDefaultData(); - await dbInstance.write(); + await safeWrite(dbInstance); } else { const { data, changed } = ensureDbShape(dbInstance.data); dbInstance.data = data; if (changed) { - await dbInstance.write(); + await safeWrite(dbInstance); } } @@ -279,7 +352,7 @@ export async function createProviderNode(data) { }; db.data.providerNodes.push(node); - await db.write(); + await safeWrite(db); return node; } @@ -303,7 +376,7 @@ export async function updateProviderNode(id, data) { updatedAt: new Date().toISOString(), }; - await db.write(); + await safeWrite(db); return db.data.providerNodes[index]; } @@ -322,7 +395,7 @@ export async function deleteProviderNode(id) { if (index === -1) return null; const [removed] = db.data.providerNodes.splice(index, 1); - await db.write(); + await safeWrite(db); return removed; } @@ -380,7 +453,7 @@ export async function createProxyPool(data) { }; db.data.proxyPools.push(pool); - await db.write(); + await safeWrite(db); return pool; } @@ -403,7 +476,7 @@ export async function updateProxyPool(id, data) { updatedAt: new Date().toISOString(), }; - await db.write(); + await safeWrite(db); return db.data.proxyPools[index]; } @@ -420,7 +493,7 @@ export async function deleteProxyPool(id) { if (index === -1) return null; const [removed] = db.data.proxyPools.splice(index, 1); - await db.write(); + await safeWrite(db); return removed; } @@ -435,7 +508,7 @@ export async function deleteProviderConnectionsByProvider(providerId) { (connection) => connection.provider !== providerId ); const deletedCount = beforeCount - db.data.providerConnections.length; - await db.write(); + await safeWrite(db); return deletedCount; } @@ -474,7 +547,7 @@ export async function createProviderConnection(data) { ...data, updatedAt: now, }; - await db.write(); + await safeWrite(db); return db.data.providerConnections[existingIndex]; } @@ -535,7 +608,7 @@ export async function createProviderConnection(data) { } db.data.providerConnections.push(connection); - await db.write(); + await safeWrite(db); // Reorder to ensure consistency await reorderProviderConnections(data.provider); @@ -560,7 +633,7 @@ export async function updateProviderConnection(id, data) { updatedAt: new Date().toISOString(), }; - await db.write(); + await safeWrite(db); // Reorder if priority was changed if (data.priority !== undefined) { @@ -582,7 +655,7 @@ export async function deleteProviderConnection(id) { const providerId = db.data.providerConnections[index].provider; db.data.providerConnections.splice(index, 1); - await db.write(); + await safeWrite(db); // Reorder to fill gaps await reorderProviderConnections(providerId); @@ -612,7 +685,7 @@ export async function reorderProviderConnections(providerId) { conn.priority = index + 1; }); - await db.write(); + await safeWrite(db); } // ============ Model Aliases ============ @@ -631,7 +704,7 @@ export async function getModelAliases() { export async function setModelAlias(alias, model) { const db = await getDb(); db.data.modelAliases[alias] = model; - await db.write(); + await safeWrite(db); } /** @@ -640,7 +713,7 @@ export async function setModelAlias(alias, model) { export async function deleteModelAlias(alias) { const db = await getDb(); delete db.data.modelAliases[alias]; - await db.write(); + await safeWrite(db); } // ============ MITM Alias ============ @@ -656,7 +729,7 @@ export async function setMitmAliasAll(toolName, mappings) { const db = await getDb(); if (!db.data.mitmAlias) db.data.mitmAlias = {}; db.data.mitmAlias[toolName] = mappings || {}; - await db.write(); + await safeWrite(db); } // ============ Combos ============ @@ -702,7 +775,7 @@ export async function createCombo(data) { }; db.data.combos.push(combo); - await db.write(); + await safeWrite(db); return combo; } @@ -722,7 +795,7 @@ export async function updateCombo(id, data) { updatedAt: new Date().toISOString(), }; - await db.write(); + await safeWrite(db); return db.data.combos[index]; } @@ -737,7 +810,7 @@ export async function deleteCombo(id) { if (index === -1) return false; db.data.combos.splice(index, 1); - await db.write(); + await safeWrite(db); return true; } @@ -790,7 +863,7 @@ export async function createApiKey(name, machineId) { }; db.data.apiKeys.push(apiKey); - await db.write(); + await safeWrite(db); return apiKey; } @@ -805,7 +878,7 @@ export async function deleteApiKey(id) { if (index === -1) return false; db.data.apiKeys.splice(index, 1); - await db.write(); + await safeWrite(db); return true; } @@ -829,7 +902,7 @@ export async function updateApiKey(id, data) { ...db.data.apiKeys[index], ...data, }; - await db.write(); + await safeWrite(db); return db.data.apiKeys[index]; } @@ -873,7 +946,7 @@ export async function cleanupProviderConnections() { } if (cleaned > 0) { - await db.write(); + await safeWrite(db); } return cleaned; } @@ -897,7 +970,7 @@ export async function updateSettings(updates) { ...db.data.settings, ...updates }; - await db.write(); + await safeWrite(db); return db.data.settings; } @@ -931,7 +1004,7 @@ export async function importDb(payload) { const { data: normalized } = ensureDbShape(nextData); const db = await getDb(); db.data = normalized; - await db.write(); + await safeWrite(db); return db.data; } @@ -1071,7 +1144,7 @@ export async function updatePricing(pricingData) { } } - await db.write(); + await safeWrite(db); return db.data.pricing; } @@ -1101,7 +1174,7 @@ export async function resetPricing(provider, model) { delete db.data.pricing[provider]; } - await db.write(); + await safeWrite(db); return db.data.pricing; } @@ -1111,6 +1184,6 @@ export async function resetPricing(provider, model) { export async function resetAllPricing() { const db = await getDb(); db.data.pricing = {}; - await db.write(); + await safeWrite(db); return db.data.pricing; } diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index 66eccce..380c565 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -6,6 +6,7 @@ import Link from "next/link"; import PropTypes from "prop-types"; import ProviderIcon from "@/shared/components/ProviderIcon"; import { ThemeToggle, LanguageSwitcher } from "@/shared/components"; +import NineRemoteButton from "@/shared/components/NineRemoteButton"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; import { translate } from "@/i18n/runtime"; @@ -187,6 +188,9 @@ export default function Header({ onMenuClick, showMenuButton = true }) { {/* Right actions */}
Something went wrong
+{errorMsg}
++ Access your terminal, desktop & files from anywhere +
+{label}
+{desc}
+