diff --git a/cloud/src/handlers/chat.js b/cloud/src/handlers/chat.js index a6b8e4d5..0bcb9c7e 100644 --- a/cloud/src/handlers/chat.js +++ b/cloud/src/handlers/chat.js @@ -149,7 +149,7 @@ async function handleSingleModelChat(body, modelStr, machineId, env) { if (shouldFallback) { log.warn("FALLBACK", `${provider.toUpperCase()} | ${credentials.id} | ${result.status}`); - await markAccountUnavailable(machineId, credentials.id, result.status, result.error, env); + await markAccountUnavailable(machineId, credentials.id, result.status, result.error, env, result.resetsAtMs); excludeConnectionId = credentials.id; lastError = result.error; lastStatus = result.status; @@ -244,13 +244,20 @@ async function getProviderCredentials(machineId, provider, env, excludeConnectio }; } -async function markAccountUnavailable(machineId, connectionId, status, errorText, env) { +async function markAccountUnavailable(machineId, connectionId, status, errorText, env, resetsAtMs = null) { const data = await getMachineData(machineId, env); if (!data?.providers?.[connectionId]) return; const conn = data.providers[connectionId]; const backoffLevel = conn.backoffLevel || 0; - const { cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel); + // Provider-specific precise cooldown (e.g. codex usage_limit_reached) overrides backoff + let cooldownMs, newBackoffLevel; + if (resetsAtMs && resetsAtMs > Date.now()) { + cooldownMs = resetsAtMs - Date.now(); + newBackoffLevel = 0; + } else { + ({ cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel)); + } const rateLimitedUntil = getUnavailableUntil(cooldownMs); const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error"; diff --git a/open-sse/executors/codex.js b/open-sse/executors/codex.js index daf9e712..f7a62120 100644 --- a/open-sse/executors/codex.js +++ b/open-sse/executors/codex.js @@ -121,6 +121,31 @@ export class CodexExecutor extends BaseExecutor { return super.execute(args); } + // Parse Codex usage_limit_reached to extract precise resetsAtMs; fallback to default otherwise + parseError(response, bodyText) { + if (response.status === 429 && bodyText) { + try { + const json = JSON.parse(bodyText); + const err = json?.error; + if (err?.type === "usage_limit_reached") { + const now = Date.now(); + let resetsAtMs = null; + if (typeof err.resets_at === "number" && err.resets_at > 0) { + const ms = err.resets_at * 1000; + if (ms > now) resetsAtMs = ms; + } + if (!resetsAtMs && typeof err.resets_in_seconds === "number" && err.resets_in_seconds > 0) { + resetsAtMs = now + err.resets_in_seconds * 1000; + } + if (resetsAtMs) { + return { status: 429, message: err.message || bodyText, resetsAtMs }; + } + } + } catch { /* fall through to default */ } + } + return super.parseError(response, bodyText); + } + /** * Transform request before sending - inject default instructions if missing. * Image fetching is handled separately in prefetchImages() so this stays sync. diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 9e95d01c..63f43d12 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -197,7 +197,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Provider returned error if (!providerResponse.ok) { trackPendingRequest(model, provider, connectionId, false, true); - const { statusCode, message } = await parseUpstreamError(providerResponse); + const { statusCode, message, resetsAtMs } = await parseUpstreamError(providerResponse, executor); appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => {}); saveRequestDetail(buildRequestDetail({ provider, model, connectionId, @@ -212,7 +212,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred const errMsg = formatProviderError(new Error(message), provider, model, statusCode); console.log(`${COLORS.red}[ERROR] ${errMsg}${COLORS.reset}`); reqLogger.logError(new Error(message), finalBody || translatedBody); - return createErrorResult(statusCode, errMsg); + return createErrorResult(statusCode, errMsg, resetsAtMs); } const sharedCtx = { provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess }; diff --git a/open-sse/utils/error.js b/open-sse/utils/error.js index df9afc98..315723e3 100644 --- a/open-sse/utils/error.js +++ b/open-sse/utils/error.js @@ -52,44 +52,55 @@ export async function writeStreamError(writer, statusCode, message) { /** * Parse upstream provider error response * @param {Response} response - Fetch response from provider - * @returns {Promise<{statusCode: number, message: string}>} + * @param {object} [executor] - Optional executor with parseError() override for provider-specific parsing + * @returns {Promise<{statusCode: number, message: string, resetsAtMs?: number}>} */ -export async function parseUpstreamError(response) { - let message = ""; - +export async function parseUpstreamError(response, executor = null) { + let bodyText = ""; try { - const text = await response.text(); - - try { - const json = JSON.parse(text); - message = json.error?.message || json.message || json.error || text; - } catch { - message = text; - } + bodyText = await response.text(); } catch { - message = `Upstream error: ${response.status}`; + bodyText = ""; + } + + // Let executor-specific parser extract provider-specific fields (e.g. codex resetsAtMs) + if (executor && typeof executor.parseError === "function") { + try { + const parsed = executor.parseError(response, bodyText); + if (parsed && typeof parsed === "object") { + const msg = parsed.message || DEFAULT_ERROR_MESSAGES[response.status] || `Upstream error: ${response.status}`; + return { statusCode: parsed.status || response.status, message: msg, resetsAtMs: parsed.resetsAtMs }; + } + } catch { /* fall through to default parsing */ } + } + + let message = ""; + try { + const json = JSON.parse(bodyText); + message = json.error?.message || json.message || json.error || bodyText; + } catch { + message = bodyText; } const messageStr = typeof message === "string" ? message : JSON.stringify(message); const finalMessage = messageStr || DEFAULT_ERROR_MESSAGES[response.status] || `Upstream error: ${response.status}`; - return { - statusCode: response.status, - message: finalMessage - }; + return { statusCode: response.status, message: finalMessage }; } /** * Create error result for chatCore handler * @param {number} statusCode - HTTP status code * @param {string} message - Error message - * @returns {{ success: false, status: number, error: string, response: Response }} + * @param {number} [resetsAtMs] - Optional precise cooldown expiry (ms epoch) for provider-specific quota errors + * @returns {{ success: false, status: number, error: string, response: Response, resetsAtMs?: number }} */ -export function createErrorResult(statusCode, message) { +export function createErrorResult(statusCode, message, resetsAtMs) { return { success: false, status: statusCode, error: message, + resetsAtMs, response: errorResponse(statusCode, message) }; } diff --git a/package.json b/package.json index e181a7e6..caa9a568 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.4.2", + "version": "0.4.3", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index 3f0dc59c..8fdd7f8e 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -490,11 +490,13 @@ export default function ProviderLimits() {

{conn.provider}

- {conn.name && ( -

- {conn.name} -

- )} + {(() => { + const isEmail = (v) => typeof v === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); + const label = isEmail(conn.email) ? conn.email : (isEmail(conn.name) ? conn.name : conn.name); + return label ? ( +

{label}

+ ) : null; + })()} diff --git a/src/app/api/providers/client/route.js b/src/app/api/providers/client/route.js index 5428cd81..33a31127 100644 --- a/src/app/api/providers/client/route.js +++ b/src/app/api/providers/client/route.js @@ -1,9 +1,11 @@ import { NextResponse } from "next/server"; import { getProviderConnections } from "@/lib/localDb"; +import { backfillCodexEmails } from "@/lib/oauth/providers"; // GET /api/providers/client - List all connections for client (includes sensitive fields for sync) export async function GET() { try { + await backfillCodexEmails(); const connections = await getProviderConnections(); // Include sensitive fields for sync to cloud (only accessible from same origin) diff --git a/src/lib/appUpdater.js b/src/lib/appUpdater.js index 959af8cc..b9cf04c2 100644 --- a/src/lib/appUpdater.js +++ b/src/lib/appUpdater.js @@ -78,48 +78,44 @@ function collectAppPids() { return pids; } -// Build the .bat content for Windows update flow -function buildWindowsScript(packageName) { - return `@echo off -timeout /t 3 /nobreak >nul -echo Installing new version... -npm install -g ${packageName}@latest --prefer-online -if %ERRORLEVEL% EQU 0 ( - echo. - echo Update completed. Run "${packageName}" to start. -) else ( - echo. - echo Update failed. Try manually: npm install -g ${packageName}@latest -) -pause -`; +// Copy updater.js into DATA_DIR so npm -g can overwrite node_modules safely +function getDataDir() { + if (process.env.DATA_DIR) return process.env.DATA_DIR; + if (process.platform === "win32") { + return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "9router"); + } + return path.join(os.homedir(), ".9router"); } -// Build the .sh content for macOS/Linux update flow -function buildUnixScript(packageName) { - return `#!/bin/bash -echo "Installing new version..." -sleep 2 +function resolveBundledUpdaterPath() { + if (process.env.UPDATER_SCRIPT_PATH && fs.existsSync(process.env.UPDATER_SCRIPT_PATH)) { + return process.env.UPDATER_SCRIPT_PATH; + } + // Production standalone: cwd is binAppDir (see bin/cli.js) + // Dev: cwd is app/ + const fromCwd = path.join(process.cwd(), "src", "lib", "updater", "updater.js"); + if (fs.existsSync(fromCwd)) return fromCwd; + const fromParent = path.join(process.cwd(), "..", "src", "lib", "updater", "updater.js"); + if (fs.existsSync(fromParent)) return fromParent; + return fromCwd; +} -npm cache clean --force 2>/dev/null -EXIT_CODE=1 -for i in 1 2 3; do - npm install -g ${packageName}@latest --prefer-online 2>&1 - EXIT_CODE=$? - [ $EXIT_CODE -eq 0 ] && break - echo "Retry $i/3..." - sleep 5 -done - -if [ $EXIT_CODE -eq 0 ]; then - echo "" - echo "Update completed. Run \\"${packageName}\\" to start." -else - echo "" - echo "Update failed (exit code: $EXIT_CODE)" - echo "Try manually: npm install -g ${packageName}@latest" -fi -`; +function ensureRuntimeUpdater(bundledPath) { + try { + if (!bundledPath || !fs.existsSync(bundledPath)) return bundledPath; + const runtimeDir = path.join(getDataDir(), "runtime", "updater"); + const runtimePath = path.join(runtimeDir, "updater.js"); + if (fs.existsSync(runtimePath)) { + try { + if (fs.statSync(bundledPath).size === fs.statSync(runtimePath).size) return runtimePath; + } catch { /* recopy */ } + } + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.copyFileSync(bundledPath, runtimePath); + return runtimePath; + } catch { + return bundledPath; + } } // Kill all app-related processes to release file locks (esp. on Windows) @@ -143,26 +139,27 @@ export async function killAppProcesses() { } } -// Spawn detached updater script and schedule current process to exit +// Spawn detached headless updater (Node process) then exit current server export function spawnUpdaterAndExit(packageName = UPDATER_CONFIG.npmPackageName) { - const platform = process.platform; - - if (platform === "win32") { - const scriptPath = path.join(os.tmpdir(), `${packageName}-update.bat`); - fs.writeFileSync(scriptPath, buildWindowsScript(packageName)); - spawn("cmd", ["/c", "start", "", "cmd", "/c", scriptPath], { - detached: true, - stdio: "ignore", - windowsHide: false, - }).unref(); - } else { - const scriptPath = path.join(os.tmpdir(), `${packageName}-update.sh`); - fs.writeFileSync(scriptPath, buildUnixScript(packageName), { mode: 0o755 }); - spawn("sh", [scriptPath], { - detached: true, - stdio: "inherit", - }).unref(); - } + const updaterPath = ensureRuntimeUpdater(resolveBundledUpdaterPath()); + spawn(process.execPath, [updaterPath], { + detached: true, + stdio: "ignore", + windowsHide: true, + env: { + ...process.env, + UPDATER_PKG_NAME: packageName, + UPDATER_PORT: String(UPDATER_CONFIG.statusPort), + UPDATER_TAIL_LINES: String(UPDATER_CONFIG.statusLogTailLines), + UPDATER_RETRIES: String(UPDATER_CONFIG.installRetries), + UPDATER_RETRY_DELAY_MS: String(UPDATER_CONFIG.installRetryDelayMs), + UPDATER_LINGER_MS: String(UPDATER_CONFIG.lingerAfterDoneMs), + UPDATER_WAIT_MIN_MS: String(UPDATER_CONFIG.waitForExitMinMs), + UPDATER_WAIT_MAX_MS: String(UPDATER_CONFIG.waitForExitMaxMs), + UPDATER_WAIT_CHECK_MS: String(UPDATER_CONFIG.waitForExitCheckMs), + UPDATER_APP_PORT: String(UPDATER_CONFIG.appPort), + }, + }).unref(); setTimeout(() => process.exit(0), UPDATER_CONFIG.exitDelayMs); } diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index ffb7e2a1..6055d70b 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -32,21 +32,38 @@ const BASE64_BLOCK_SIZE = 4; * @param {string} accessToken * @returns {string|undefined} */ -function extractEmailFromAccessToken(accessToken) { +function decodeJwtPayload(jwt) { try { - if (!accessToken || typeof accessToken !== "string") return undefined; - const parts = accessToken.split("."); - if (parts.length !== 3) return undefined; + if (!jwt || typeof jwt !== "string") return null; + const parts = jwt.split("."); + if (parts.length !== 3) return null; const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); const missingPadding = (BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE; const padded = base64 + "=".repeat(missingPadding); - const payload = JSON.parse(Buffer.from(padded, "base64").toString("utf8")); - return payload.email || payload.preferred_username || payload.sub || undefined; + return JSON.parse(Buffer.from(padded, "base64").toString("utf8")); } catch { - return undefined; + return null; } } +function extractEmailFromAccessToken(accessToken) { + const payload = decodeJwtPayload(accessToken); + if (!payload) return undefined; + return payload.email || payload.preferred_username || payload.sub || undefined; +} + +// Extract codex account info from id_token +export function extractCodexAccountInfo(idToken) { + const payload = decodeJwtPayload(idToken); + if (!payload) return {}; + const chatgpt = payload["https://api.openai.com/auth"] || {}; + return { + email: payload.email, + chatgptAccountId: chatgpt.chatgpt_account_id, + chatgptPlanType: chatgpt.chatgpt_plan_type, + }; +} + // Provider configurations const PROVIDERS = { claude: { @@ -150,12 +167,23 @@ const PROVIDERS = { return await response.json(); }, - mapTokens: (tokens) => ({ - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - idToken: tokens.id_token, - expiresIn: tokens.expires_in, - }), + mapTokens: (tokens) => { + const info = extractCodexAccountInfo(tokens.id_token); + const mapped = { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + idToken: tokens.id_token, + expiresIn: tokens.expires_in, + }; + if (info.email) mapped.email = info.email; + if (info.chatgptAccountId || info.chatgptPlanType) { + mapped.providerSpecificData = { + chatgptAccountId: info.chatgptAccountId, + chatgptPlanType: info.chatgptPlanType, + }; + } + return mapped; + }, }, "gemini-cli": { @@ -1256,3 +1284,41 @@ export async function pollForToken(providerName, deviceCode, codeVerifier, extra return { success: false, error: result.data.error, errorDescription: result.data.error_description }; } + +// Run-once guard across the process lifetime +let codexBackfillDone = false; + +// Backfill email + chatgpt account info for existing codex OAuth connections missing them +export async function backfillCodexEmails() { + if (codexBackfillDone) return; + codexBackfillDone = true; + try { + const { getProviderConnections, updateProviderConnection } = await import("@/lib/localDb"); + const connections = await getProviderConnections(); + const targets = connections.filter((c) => { + if (c.provider !== "codex" || c.authType !== "oauth" || !c.idToken) return false; + const hasEmail = !!c.email; + const hasAccountInfo = !!c.providerSpecificData?.chatgptAccountId; + return !hasEmail || !hasAccountInfo; + }); + for (const conn of targets) { + const info = extractCodexAccountInfo(conn.idToken); + if (!info.email && !info.chatgptAccountId) continue; + const patch = {}; + if (!conn.email && info.email) patch.email = info.email; + if (info.chatgptAccountId || info.chatgptPlanType) { + patch.providerSpecificData = { + ...(conn.providerSpecificData || {}), + chatgptAccountId: info.chatgptAccountId, + chatgptPlanType: info.chatgptPlanType, + }; + } + if (Object.keys(patch).length) { + await updateProviderConnection(conn.id, patch); + } + } + } catch (err) { + codexBackfillDone = false; + console.log("backfillCodexEmails failed:", err?.message || err); + } +} diff --git a/src/lib/updater/updater.js b/src/lib/updater/updater.js new file mode 100644 index 00000000..6c69dc93 --- /dev/null +++ b/src/lib/updater/updater.js @@ -0,0 +1,187 @@ +// Standalone detached updater process. +// Spawns `npm i -g @latest`, exposes progress via tiny HTTP server. +// Survives after parent Next server exits (detached + unref by spawner). + +const { spawn } = require("child_process"); +const http = require("http"); +const net = require("net"); +const path = require("path"); +const fs = require("fs"); +const os = require("os"); + +const packageName = process.env.UPDATER_PKG_NAME || "9router"; +const port = parseInt(process.env.UPDATER_PORT || "20129", 10); +const tailLines = parseInt(process.env.UPDATER_TAIL_LINES || "8", 10); +const maxRetries = parseInt(process.env.UPDATER_RETRIES || "3", 10); +const retryDelayMs = parseInt(process.env.UPDATER_RETRY_DELAY_MS || "5000", 10); +const lingerMs = parseInt(process.env.UPDATER_LINGER_MS || "30000", 10); +const waitMinMs = parseInt(process.env.UPDATER_WAIT_MIN_MS || "3000", 10); +const waitMaxMs = parseInt(process.env.UPDATER_WAIT_MAX_MS || "15000", 10); +const waitCheckMs = parseInt(process.env.UPDATER_WAIT_CHECK_MS || "500", 10); +const appPort = parseInt(process.env.UPDATER_APP_PORT || "20128", 10); + +// Data directory (match mitm/paths.js logic) +function getDataDir() { + if (process.env.DATA_DIR) return process.env.DATA_DIR; + if (process.platform === "win32") { + return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "9router"); + } + return path.join(os.homedir(), ".9router"); +} +const updateDir = path.join(getDataDir(), "update"); +try { fs.mkdirSync(updateDir, { recursive: true }); } catch { /* best effort */ } +const statusFile = path.join(updateDir, "status.json"); +const logFile = path.join(updateDir, "install.log"); + +const state = { + phase: "starting", + packageName, + startedAt: Date.now(), + finishedAt: null, + attempt: 0, + maxRetries, + done: false, + success: false, + exitCode: null, + error: null, + logTail: [], +}; + +function pushLog(line) { + const trimmed = line.replace(/\r?\n$/, ""); + if (!trimmed) return; + state.logTail.push(trimmed); + if (state.logTail.length > tailLines) state.logTail = state.logTail.slice(-tailLines); + try { fs.appendFileSync(logFile, `${trimmed}\n`); } catch { /* best effort */ } +} + +function persistStatus() { + try { fs.writeFileSync(statusFile, JSON.stringify(state, null, 2)); } catch { /* best effort */ } +} + +function setPhase(phase) { + state.phase = phase; + persistStatus(); +} + +// HTTP server exposing status (browser polls this while Next server is dead) +const server = http.createServer((req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Cache-Control", "no-store"); + if (req.url === "/update/status" || req.url === "/") { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(state)); + return; + } + res.statusCode = 404; + res.end("not found"); +}); + +server.on("error", (e) => { + state.error = `status server error: ${e.message}`; + persistStatus(); +}); + +server.listen(port, "127.0.0.1", () => { + persistStatus(); + waitForAppExit().then(runInstall); +}); + +// Check if app port is still being listened on (= app server still alive) +function isAppPortBusy() { + return new Promise((resolve) => { + const socket = new net.Socket(); + const done = (busy) => { + socket.destroy(); + resolve(busy); + }; + socket.setTimeout(300); + socket.once("connect", () => done(true)); + socket.once("timeout", () => done(false)); + socket.once("error", () => done(false)); + socket.connect(appPort, "127.0.0.1"); + }); +} + +// Wait for app process to fully exit before running npm (avoids Windows file-lock) +async function waitForAppExit() { + setPhase("waitingForExit"); + pushLog(`[updater] waiting for app to exit (min ${Math.round(waitMinMs / 1000)}s)...`); + + // Hard minimum delay: OS needs time to release file handles + await sleep(waitMinMs); + + // Poll app port until free or max timeout + const deadline = Date.now() + (waitMaxMs - waitMinMs); + while (Date.now() < deadline) { + const busy = await isAppPortBusy(); + if (!busy) { + pushLog(`[updater] app port :${appPort} is free, proceeding`); + return; + } + await sleep(waitCheckMs); + } + pushLog(`[updater] timeout waiting for app, proceeding anyway`); +} + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +function runInstall() { + state.attempt += 1; + setPhase("installing"); + pushLog(`[updater] attempt ${state.attempt}/${maxRetries} — npm i -g ${packageName}`); + + const isWin = process.platform === "win32"; + const cmd = isWin ? "npm.cmd" : "npm"; + const args = ["i", "-g", packageName]; + + const child = spawn(cmd, args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + shell: isWin, + }); + + child.stdout.on("data", (buf) => { + buf.toString().split(/\r?\n/).forEach(pushLog); + persistStatus(); + }); + child.stderr.on("data", (buf) => { + buf.toString().split(/\r?\n/).forEach(pushLog); + persistStatus(); + }); + + child.on("error", (e) => { + pushLog(`[updater] spawn error: ${e.message}`); + finalize(false, null, e.message); + }); + + child.on("close", (code) => { + pushLog(`[updater] npm exited with code ${code}`); + if (code === 0) { + finalize(true, code, null); + return; + } + if (state.attempt < maxRetries) { + pushLog(`[updater] retrying in ${Math.round(retryDelayMs / 1000)}s...`); + setTimeout(runInstall, retryDelayMs); + return; + } + finalize(false, code, `Install failed after ${maxRetries} attempts`); + }); +} + +function finalize(success, exitCode, error) { + state.done = true; + state.success = success; + state.exitCode = exitCode; + state.error = error; + state.finishedAt = Date.now(); + setPhase(success ? "done" : "error"); + // Linger so browser can poll final status, then exit & close the port + setTimeout(() => { + try { server.close(); } catch { /* ignore */ } + process.exit(success ? 0 : 1); + }, lingerMs); +} diff --git a/src/lib/usageDb.js b/src/lib/usageDb.js index 6d9bf034..489c1112 100644 --- a/src/lib/usageDb.js +++ b/src/lib/usageDb.js @@ -703,6 +703,39 @@ export async function getUsageStats(period = "all") { if (dateKey > (stats.byEndpoint[epKey].lastUsed || "")) stats.byEndpoint[epKey].lastUsed = dateKey; } } + + // Overlay lastUsed with precise ISO timestamps from live history (dailySummary only has YYYY-MM-DD) + const overlayCutoff = maxDays ? Date.now() - maxDays * 86400000 : 0; + for (const entry of history) { + const ts = entry.timestamp; + if (!ts || new Date(ts).getTime() < overlayCutoff) continue; + + const modelKey = entry.provider ? `${entry.model} (${entry.provider})` : entry.model; + if (stats.byModel[modelKey] && new Date(ts) > new Date(stats.byModel[modelKey].lastUsed)) { + stats.byModel[modelKey].lastUsed = ts; + } + + if (entry.connectionId) { + const accountName = connectionMap[entry.connectionId] || `Account ${entry.connectionId.slice(0, 8)}...`; + const accountKey = `${entry.model} (${entry.provider} - ${accountName})`; + if (stats.byAccount[accountKey] && new Date(ts) > new Date(stats.byAccount[accountKey].lastUsed)) { + stats.byAccount[accountKey].lastUsed = ts; + } + } + + const apiKeyKey = (entry.apiKey && typeof entry.apiKey === "string") + ? `${entry.apiKey}|${entry.model}|${entry.provider || "unknown"}` + : "local-no-key"; + if (stats.byApiKey[apiKeyKey] && new Date(ts) > new Date(stats.byApiKey[apiKeyKey].lastUsed)) { + stats.byApiKey[apiKeyKey].lastUsed = ts; + } + + const endpoint = entry.endpoint || "Unknown"; + const endpointKey = `${endpoint}|${entry.model}|${entry.provider || "unknown"}`; + if (stats.byEndpoint[endpointKey] && new Date(ts) > new Date(stats.byEndpoint[endpointKey].lastUsed)) { + stats.byEndpoint[endpointKey].lastUsed = ts; + } + } } else { // 24h: use live history (original logic) const cutoff = Date.now() - PERIOD_MS["24h"]; diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index 35cb481b..fa78072b 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -43,10 +43,12 @@ export default function Sidebar({ onClose }) { const [updateInfo, setUpdateInfo] = useState(null); const [showUpdateModal, setShowUpdateModal] = useState(false); const [isUpdating, setIsUpdating] = useState(false); + const [updateStatus, setUpdateStatus] = useState(null); const [enableTranslator, setEnableTranslator] = useState(false); const { copied, copy } = useCopyToClipboard(2000); const INSTALL_CMD = UPDATER_CONFIG.installCmd; + const STATUS_URL = `http://localhost:${UPDATER_CONFIG.statusPort}/update/status`; useEffect(() => { fetch("/api/settings") @@ -81,14 +83,30 @@ export default function Sidebar({ onClose }) { setIsUpdating(false); return; } - // Server will exit shortly; show disconnected overlay setIsDisconnected(true); } catch (e) { - // Expected once the server exits; treat as disconnected setIsDisconnected(true); } }; + // Poll updater status server while updating (Next server is dead, updater.js is alive) + useEffect(() => { + if (!isUpdating || !isDisconnected) return; + let stopped = false; + const tick = async () => { + try { + const res = await fetch(STATUS_URL, { cache: "no-store" }); + if (res.ok) { + const data = await res.json(); + if (!stopped) setUpdateStatus(data); + } + } catch { /* updater not ready yet or finished */ } + }; + tick(); + const id = setInterval(tick, UPDATER_CONFIG.statusPollIntervalMs); + return () => { stopped = true; clearInterval(id); }; + }, [isUpdating, isDisconnected, STATUS_URL]); + const handleShutdown = async () => { setIsShuttingDown(true); try { @@ -338,31 +356,27 @@ export default function Sidebar({ onClose }) { {/* Disconnected Overlay */} {isDisconnected && ( -
-
- {isUpdating ? ( - <> -
- download -
-

Updating 9Router

-

- A new terminal window is installing the update. Once finished, run 9router again. -

- - ) : ( - <> -
- power_off -
-

Server Disconnected

-

The proxy server has been stopped.

- - - )} -
+
+ {isUpdating ? ( + copy(INSTALL_CMD)} + /> + ) : ( +
+
+ power_off +
+

Server Disconnected

+

The proxy server has been stopped.

+ +
+ )}
)} @@ -372,3 +386,137 @@ export default function Sidebar({ onClose }) { Sidebar.propTypes = { onClose: PropTypes.func, }; + +function UpdateProgress({ status, latestVersion, installCmd, copied, onCopy }) { + const phase = status?.phase || "connecting"; + const done = status?.done === true; + const success = status?.success === true; + const attempt = status?.attempt || 0; + const maxRetries = status?.maxRetries || 0; + const logTail = status?.logTail || []; + const errorMsg = status?.error; + + const steps = [ + { key: "stopped", label: "Stopped 9Router server", state: "done" }, + { + key: "launched", + label: "Launched background installer", + state: status ? "done" : "active", + }, + { + key: "waiting", + label: "Waiting for app processes to exit", + state: phase === "waitingForExit" ? "active" : + (status && phase !== "starting" ? "done" : "pending"), + }, + { + key: "installing", + label: attempt > 1 ? `Installing v${latestVersion || "latest"} (attempt ${attempt}/${maxRetries})` : `Installing v${latestVersion || "latest"}`, + state: done ? (success ? "done" : "error") : (phase === "installing" ? "active" : "pending"), + }, + { + key: "finished", + label: done && success ? "Installed — ready to restart" : "Waiting to finish", + state: done && success ? "done" : (done && !success ? "error" : "pending"), + }, + ]; + + return ( +
+
+
+ + {done && success ? "check_circle" : done && !success ? "error" : "progress_activity"} + +
+
+

+ {done && success ? "Update Completed" : done && !success ? "Update Failed" : "Updating 9Router"} +

+

+ {done && success + ? `Installed v${latestVersion || "latest"} successfully` + : done && !success + ? (errorMsg || "Installation failed") + : `Installing v${latestVersion || "latest"} from npm...`} +

+
+
+ + {/* Timeline */} +
    + {steps.map((s) => ( +
  • + + {s.state === "done" ? "check_circle" : + s.state === "error" ? "cancel" : + s.state === "active" ? "radio_button_checked" : "radio_button_unchecked"} + + {s.label} +
  • + ))} +
+ + {/* Log tail */} + {logTail.length > 0 && ( +
+
+            {logTail.join("\n")}
+          
+
+ )} + + {/* Actions */} + {done && success ? ( +
+

+ Run 9router in your terminal to start the new version. +

+ +
+ ) : done && !success ? ( +
+

Run the install command manually:

+ +
+ ) : ( +

+ This may take 30-60 seconds. Please don't close this window. +

+ )} +
+ ); +} + +UpdateProgress.propTypes = { + status: PropTypes.object, + latestVersion: PropTypes.string, + installCmd: PropTypes.string.isRequired, + copied: PropTypes.bool, + onCopy: PropTypes.func.isRequired, +}; diff --git a/src/shared/constants/config.js b/src/shared/constants/config.js index 73f334ad..5b52cb7e 100644 --- a/src/shared/constants/config.js +++ b/src/shared/constants/config.js @@ -17,6 +17,16 @@ export const UPDATER_CONFIG = { npmPackageName: "9router", installCmd: "npm i -g 9router", exitDelayMs: 500, + statusPort: 20129, + statusPollIntervalMs: 1000, + statusLogTailLines: 8, + installRetries: 3, + installRetryDelayMs: 5000, + lingerAfterDoneMs: 30000, + waitForExitMinMs: 3000, + waitForExitMaxMs: 15000, + waitForExitCheckMs: 500, + appPort: 20128, }; // Theme configuration diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index aa6cff7a..3bb40e2c 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -224,8 +224,8 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re if (result.success) return result.response; - // Mark account unavailable (auto-calculates cooldown with exponential backoff) - const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model); + // Mark account unavailable (auto-calculates cooldown with exponential backoff, or precise resetsAtMs) + const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model, result.resetsAtMs); if (shouldFallback) { log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`); diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index 9b1bc4cb..ff009636 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -169,13 +169,21 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu * @param {string|null} model - The specific model that triggered the error * @returns {{ shouldFallback: boolean, cooldownMs: number }} */ -export async function markAccountUnavailable(connectionId, status, errorText, provider = null, model = null) { +export async function markAccountUnavailable(connectionId, status, errorText, provider = null, model = null, resetsAtMs = null) { if (!connectionId || connectionId === "noauth") return { shouldFallback: false, cooldownMs: 0 }; const connections = await getProviderConnections({ provider }); const conn = connections.find(c => c.id === connectionId); const backoffLevel = conn?.backoffLevel || 0; - const { shouldFallback, cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel); + // Provider-specific precise cooldown (e.g. codex usage_limit_reached resets_at) overrides backoff + let shouldFallback, cooldownMs, newBackoffLevel; + if (resetsAtMs && resetsAtMs > Date.now()) { + shouldFallback = true; + cooldownMs = resetsAtMs - Date.now(); + newBackoffLevel = 0; + } else { + ({ shouldFallback, cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel)); + } if (!shouldFallback) return { shouldFallback: false, cooldownMs: 0 }; const reason = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";