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.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
- A new terminal window is installing the update. Once finished, run 9router again.
-
The proxy server has been stopped.
- - > - )} -The proxy server has been stopped.
+ ++ {done && success + ? `Installed v${latestVersion || "latest"} successfully` + : done && !success + ? (errorMsg || "Installation failed") + : `Installing v${latestVersion || "latest"} from npm...`} +
+
+ {logTail.join("\n")}
+
+
+ Run 9router in your terminal to start the new version.
+
Run the install command manually:
+ ++ This may take 30-60 seconds. Please don't close this window. +
+ )} +