mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
- Updated markAccountUnavailable function to accept resetsAtMs for precise cooldown management.
- Added email backfill functionality for Codex OAuth connections to improve account information accuracy.
This commit is contained in:
@@ -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";
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "9router-app",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.3",
|
||||
"description": "9Router web dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -490,11 +490,13 @@ export default function ProviderLimits() {
|
||||
<h3 className="text-sm font-semibold text-text-primary capitalize truncate">
|
||||
{conn.provider}
|
||||
</h3>
|
||||
{conn.name && (
|
||||
<p className="text-xs text-text-muted truncate">
|
||||
{conn.name}
|
||||
</p>
|
||||
)}
|
||||
{(() => {
|
||||
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 ? (
|
||||
<p className="text-xs text-text-muted truncate">{label}</p>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
187
src/lib/updater/updater.js
Normal file
187
src/lib/updater/updater.js
Normal file
@@ -0,0 +1,187 @@
|
||||
// Standalone detached updater process.
|
||||
// Spawns `npm i -g <pkg>@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);
|
||||
}
|
||||
@@ -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"];
|
||||
|
||||
@@ -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 && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||
<div className="text-center p-8">
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<div className="flex items-center justify-center size-16 rounded-full bg-green-500/20 text-green-500 mx-auto mb-4">
|
||||
<span className="material-symbols-outlined text-[32px]">download</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Updating 9Router</h2>
|
||||
<p className="text-text-muted mb-6">
|
||||
A new terminal window is installing the update. Once finished, run <code className="text-green-400">9router</code> again.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-center size-16 rounded-full bg-red-500/20 text-red-500 mx-auto mb-4">
|
||||
<span className="material-symbols-outlined text-[32px]">power_off</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Server Disconnected</h2>
|
||||
<p className="text-text-muted mb-6">The proxy server has been stopped.</p>
|
||||
<Button variant="secondary" onClick={() => globalThis.location.reload()}>
|
||||
Reload Page
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-6">
|
||||
{isUpdating ? (
|
||||
<UpdateProgress
|
||||
status={updateStatus}
|
||||
latestVersion={updateInfo?.latestVersion}
|
||||
installCmd={INSTALL_CMD}
|
||||
copied={copied}
|
||||
onCopy={() => copy(INSTALL_CMD)}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center p-8">
|
||||
<div className="flex items-center justify-center size-16 rounded-full bg-red-500/20 text-red-500 mx-auto mb-4">
|
||||
<span className="material-symbols-outlined text-[32px]">power_off</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Server Disconnected</h2>
|
||||
<p className="text-text-muted mb-6">The proxy server has been stopped.</p>
|
||||
<Button variant="secondary" onClick={() => globalThis.location.reload()}>
|
||||
Reload Page
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -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 (
|
||||
<div className="w-full max-w-lg rounded-xl bg-neutral-900/95 border border-white/10 p-6 text-white">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={cn(
|
||||
"flex items-center justify-center size-11 rounded-full",
|
||||
done && success ? "bg-green-500/20 text-green-400" :
|
||||
done && !success ? "bg-red-500/20 text-red-400" :
|
||||
"bg-blue-500/20 text-blue-400"
|
||||
)}>
|
||||
<span className={cn(
|
||||
"material-symbols-outlined text-[24px]",
|
||||
!done && "animate-spin"
|
||||
)}>
|
||||
{done && success ? "check_circle" : done && !success ? "error" : "progress_activity"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{done && success ? "Update Completed" : done && !success ? "Update Failed" : "Updating 9Router"}
|
||||
</h2>
|
||||
<p className="text-xs text-white/60">
|
||||
{done && success
|
||||
? `Installed v${latestVersion || "latest"} successfully`
|
||||
: done && !success
|
||||
? (errorMsg || "Installation failed")
|
||||
: `Installing v${latestVersion || "latest"} from npm...`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<ul className="space-y-2 mb-4">
|
||||
{steps.map((s) => (
|
||||
<li key={s.key} className="flex items-center gap-3 text-sm">
|
||||
<span className={cn(
|
||||
"material-symbols-outlined text-[18px] shrink-0",
|
||||
s.state === "done" && "text-green-400",
|
||||
s.state === "active" && "text-blue-400 animate-pulse",
|
||||
s.state === "error" && "text-red-400",
|
||||
s.state === "pending" && "text-white/30"
|
||||
)}>
|
||||
{s.state === "done" ? "check_circle" :
|
||||
s.state === "error" ? "cancel" :
|
||||
s.state === "active" ? "radio_button_checked" : "radio_button_unchecked"}
|
||||
</span>
|
||||
<span className={cn(
|
||||
s.state === "pending" ? "text-white/40" : "text-white/90"
|
||||
)}>{s.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Log tail */}
|
||||
{logTail.length > 0 && (
|
||||
<div className="rounded-md bg-black/50 border border-white/5 p-3 mb-4 max-h-40 overflow-auto">
|
||||
<pre className="text-[11px] font-mono text-white/70 whitespace-pre-wrap break-all">
|
||||
{logTail.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{done && success ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-white/80">
|
||||
Run <code className="px-1.5 py-0.5 rounded bg-white/10 text-green-400">9router</code> in your terminal to start the new version.
|
||||
</p>
|
||||
<Button variant="secondary" fullWidth onClick={() => globalThis.location.reload()}>
|
||||
Reload Page
|
||||
</Button>
|
||||
</div>
|
||||
) : done && !success ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-white/80">Run the install command manually:</p>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
className="w-full text-left px-3 py-2 rounded bg-white/5 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<code className="text-xs font-mono text-amber-400">
|
||||
{copied ? "✓ copied!" : installCmd}
|
||||
</code>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-white/50 text-center">
|
||||
This may take 30-60 seconds. Please don't close this window.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
UpdateProgress.propTypes = {
|
||||
status: PropTypes.object,
|
||||
latestVersion: PropTypes.string,
|
||||
installCmd: PropTypes.string.isRequired,
|
||||
copied: PropTypes.bool,
|
||||
onCopy: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user