feat: persist rotation state across restarts, fix dashboard alignment
- Save currentIndex + rateLimits to JSON file in DATA_DIR - Restore on startup so rotation survives service restarts - Replace emoji indicators with text (*/!) for monospace alignment - Dynamic column width based on longest name - Show Codex accounts with plan type on dashboard
This commit is contained in:
@@ -14,15 +14,32 @@ import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from './config.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
const STATE_FILE = path.join(DATA_DIR, 'codex-rotation-state.json');
|
||||
|
||||
interface CodexAccount {
|
||||
index: number;
|
||||
authPath: string;
|
||||
accountId: string;
|
||||
planType: string;
|
||||
rateLimitedUntil: number | null;
|
||||
}
|
||||
|
||||
function parsePlanFromJwt(idToken: string): string {
|
||||
try {
|
||||
const parts = idToken.split('.');
|
||||
if (parts.length < 2) return '?';
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(parts[1], 'base64url').toString('utf-8'),
|
||||
);
|
||||
return payload?.['https://api.openai.com/auth']?.chatgpt_plan_type || '?';
|
||||
} catch {
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
const accounts: CodexAccount[] = [];
|
||||
let currentIndex = 0;
|
||||
let initialized = false;
|
||||
@@ -34,11 +51,15 @@ export function initCodexTokenRotation(): void {
|
||||
initialized = true;
|
||||
|
||||
if (!fs.existsSync(ACCOUNTS_DIR)) {
|
||||
logger.info({ dir: ACCOUNTS_DIR }, 'Codex accounts dir not found, skipping');
|
||||
logger.info(
|
||||
{ dir: ACCOUNTS_DIR },
|
||||
'Codex accounts dir not found, skipping',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const dirs = fs.readdirSync(ACCOUNTS_DIR)
|
||||
const dirs = fs
|
||||
.readdirSync(ACCOUNTS_DIR)
|
||||
.filter((d) => /^\d+$/.test(d))
|
||||
.sort((a, b) => parseInt(a) - parseInt(b));
|
||||
|
||||
@@ -49,10 +70,12 @@ export function initCodexTokenRotation(): void {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
|
||||
const accountId = data?.tokens?.account_id || `account-${dir}`;
|
||||
const planType = parsePlanFromJwt(data?.tokens?.id_token || '');
|
||||
accounts.push({
|
||||
index: accounts.length,
|
||||
authPath,
|
||||
accountId,
|
||||
planType,
|
||||
rateLimitedUntil: null,
|
||||
});
|
||||
} catch {
|
||||
@@ -60,12 +83,43 @@ export function initCodexTokenRotation(): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (accounts.length > 1) loadCodexState();
|
||||
logger.info(
|
||||
{ count: accounts.length, dir: ACCOUNTS_DIR },
|
||||
{ count: accounts.length, dir: ACCOUNTS_DIR, activeIndex: currentIndex },
|
||||
`Codex token rotation: ${accounts.length} account(s) found`,
|
||||
);
|
||||
}
|
||||
|
||||
function saveCodexState(): void {
|
||||
try {
|
||||
const state = {
|
||||
currentIndex,
|
||||
rateLimits: accounts.map((a) => a.rateLimitedUntil),
|
||||
};
|
||||
fs.writeFileSync(STATE_FILE, JSON.stringify(state));
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
function loadCodexState(): void {
|
||||
try {
|
||||
if (!fs.existsSync(STATE_FILE)) return;
|
||||
const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
|
||||
const now = Date.now();
|
||||
if (typeof state.currentIndex === 'number' && state.currentIndex < accounts.length) {
|
||||
currentIndex = state.currentIndex;
|
||||
}
|
||||
if (Array.isArray(state.rateLimits)) {
|
||||
for (let i = 0; i < Math.min(state.rateLimits.length, accounts.length); i++) {
|
||||
const until = state.rateLimits[i];
|
||||
if (typeof until === 'number' && until > now) {
|
||||
accounts[i].rateLimitedUntil = until;
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info({ currentIndex, accountCount: accounts.length }, 'Codex rotation state restored');
|
||||
} catch { /* start fresh */ }
|
||||
}
|
||||
|
||||
/** Get the auth.json path for the current active account. */
|
||||
export function getActiveCodexAuthPath(): string | null {
|
||||
if (accounts.length === 0) return null;
|
||||
@@ -89,9 +143,14 @@ export function rotateCodexToken(): boolean {
|
||||
acct.rateLimitedUntil = null;
|
||||
currentIndex = idx;
|
||||
logger.info(
|
||||
{ accountIndex: currentIndex, totalAccounts: accounts.length, accountId: acct.accountId },
|
||||
{
|
||||
accountIndex: currentIndex,
|
||||
totalAccounts: accounts.length,
|
||||
accountId: acct.accountId,
|
||||
},
|
||||
`Codex rotated to account #${currentIndex + 1}/${accounts.length}`,
|
||||
);
|
||||
saveCodexState();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -105,6 +164,7 @@ export function markCodexTokenHealthy(): void {
|
||||
const acct = accounts[currentIndex];
|
||||
if (acct?.rateLimitedUntil) {
|
||||
acct.rateLimitedUntil = null;
|
||||
saveCodexState();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +175,7 @@ export function getCodexAccountCount(): number {
|
||||
export function getAllCodexAccounts(): {
|
||||
index: number;
|
||||
accountId: string;
|
||||
planType: string;
|
||||
isActive: boolean;
|
||||
isRateLimited: boolean;
|
||||
}[] {
|
||||
@@ -122,6 +183,7 @@ export function getAllCodexAccounts(): {
|
||||
return accounts.map((a, i) => ({
|
||||
index: i,
|
||||
accountId: a.accountId,
|
||||
planType: a.planType,
|
||||
isActive: i === currentIndex,
|
||||
isRateLimited: Boolean(a.rateLimitedUntil && a.rateLimitedUntil > now),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user