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),
|
||||
}));
|
||||
|
||||
@@ -10,9 +10,15 @@
|
||||
* All exhausted: fall through to provider fallback (Kimi etc.)
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from './config.js';
|
||||
import { readEnvFile } from './env.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
const STATE_FILE = path.join(DATA_DIR, 'token-rotation-state.json');
|
||||
|
||||
interface TokenState {
|
||||
token: string;
|
||||
rateLimitedUntil: number | null;
|
||||
@@ -31,11 +37,9 @@ export function initTokenRotation(): void {
|
||||
'CLAUDE_CODE_OAUTH_TOKEN',
|
||||
]);
|
||||
const multi =
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKENS ||
|
||||
envFile.CLAUDE_CODE_OAUTH_TOKENS;
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKENS || envFile.CLAUDE_CODE_OAUTH_TOKENS;
|
||||
const single =
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN ||
|
||||
envFile.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
process.env.CLAUDE_CODE_OAUTH_TOKEN || envFile.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
|
||||
const raw = multi
|
||||
? multi
|
||||
@@ -51,13 +55,44 @@ export function initTokenRotation(): void {
|
||||
}
|
||||
|
||||
if (tokens.length > 1) {
|
||||
loadState();
|
||||
logger.info(
|
||||
{ count: tokens.length },
|
||||
{ count: tokens.length, activeIndex: currentIndex },
|
||||
`Token rotation initialized with ${tokens.length} tokens`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function saveState(): void {
|
||||
try {
|
||||
const state = {
|
||||
currentIndex,
|
||||
rateLimits: tokens.map((t) => t.rateLimitedUntil),
|
||||
};
|
||||
fs.writeFileSync(STATE_FILE, JSON.stringify(state));
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
function loadState(): 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 < tokens.length) {
|
||||
currentIndex = state.currentIndex;
|
||||
}
|
||||
if (Array.isArray(state.rateLimits)) {
|
||||
for (let i = 0; i < Math.min(state.rateLimits.length, tokens.length); i++) {
|
||||
const until = state.rateLimits[i];
|
||||
if (typeof until === 'number' && until > now) {
|
||||
tokens[i].rateLimitedUntil = until;
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info({ currentIndex, tokenCount: tokens.length }, 'Token rotation state restored');
|
||||
} catch { /* start fresh */ }
|
||||
}
|
||||
|
||||
/** Get the current active token. */
|
||||
export function getCurrentToken(): string | undefined {
|
||||
if (tokens.length === 0) return process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
@@ -86,6 +121,7 @@ export function rotateToken(): boolean {
|
||||
{ tokenIndex: currentIndex, totalTokens: tokens.length },
|
||||
`Rotated to token #${currentIndex + 1}/${tokens.length}`,
|
||||
);
|
||||
saveState();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -103,6 +139,7 @@ export function markTokenHealthy(): void {
|
||||
const state = tokens[currentIndex];
|
||||
if (state?.rateLimitedUntil) {
|
||||
state.rateLimitedUntil = null;
|
||||
saveState();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ClaudeUsageData,
|
||||
type ClaudeAccountUsage,
|
||||
} from './claude-usage.js';
|
||||
import { getAllCodexAccounts } from './codex-token-rotation.js';
|
||||
import {
|
||||
composeDashboardContent,
|
||||
formatElapsed,
|
||||
@@ -572,7 +573,7 @@ async function buildUsageContent(): Promise<string> {
|
||||
const d7 = u.seven_day;
|
||||
const label =
|
||||
claudeAccounts.length > 1
|
||||
? `Claude${account.index + 1}${account.isActive ? '⚡' : ''}${account.isRateLimited ? '🚫' : ''}`
|
||||
? `Claude${account.index + 1}${account.isActive ? '*' : ''}${account.isRateLimited ? '!' : ''}`
|
||||
: claudeUsageIsCached
|
||||
? 'Claude*'
|
||||
: 'Claude';
|
||||
@@ -593,7 +594,39 @@ async function buildUsageContent(): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
if (codexUsage && Array.isArray(codexUsage)) {
|
||||
const codexAccounts = getAllCodexAccounts();
|
||||
if (codexAccounts.length > 1) {
|
||||
// Multi-account: show each account with plan + status
|
||||
for (const acct of codexAccounts) {
|
||||
const icon = acct.isActive ? '*' : acct.isRateLimited ? '!' : ' ';
|
||||
const label = `Codex${acct.index + 1}${icon}`;
|
||||
if (acct.isActive && codexUsage && Array.isArray(codexUsage)) {
|
||||
const relevant = codexUsage.filter(
|
||||
(limit) =>
|
||||
limit.primary.usedPercent > 0 || limit.secondary.usedPercent > 0,
|
||||
);
|
||||
const display = relevant.length > 0 ? relevant : codexUsage.slice(0, 1);
|
||||
for (const limit of display) {
|
||||
rows.push({
|
||||
name: `${label} ${acct.planType}`,
|
||||
h5pct: Math.round(limit.primary.usedPercent),
|
||||
h5reset: formatResetKST(limit.primary.resetsAt),
|
||||
d7pct: Math.round(limit.secondary.usedPercent),
|
||||
d7reset: formatResetKST(limit.secondary.resetsAt),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
rows.push({
|
||||
name: `${label} ${acct.planType}`,
|
||||
h5pct: -1,
|
||||
h5reset: '',
|
||||
d7pct: -1,
|
||||
d7reset: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (codexUsage && Array.isArray(codexUsage)) {
|
||||
// Single account: existing behavior
|
||||
const relevant = codexUsage.filter(
|
||||
(limit) =>
|
||||
limit.primary.usedPercent > 0 || limit.secondary.usedPercent > 0,
|
||||
@@ -611,8 +644,13 @@ async function buildUsageContent(): Promise<string> {
|
||||
}
|
||||
|
||||
if (rows.length > 0) {
|
||||
// Emoji characters take 2 columns in monospace — count visual width
|
||||
const visualWidth = (s: string) =>
|
||||
[...s].reduce((w, c) => w + (c.codePointAt(0)! > 0x7f ? 2 : 1), 0);
|
||||
const maxNameWidth = Math.max(8, ...rows.map((r) => visualWidth(r.name))) + 1;
|
||||
const padName = (s: string) => s + ' '.repeat(maxNameWidth - visualWidth(s));
|
||||
lines.push('```');
|
||||
lines.push(' 5-Hour 7-Day');
|
||||
lines.push(`${' '.repeat(maxNameWidth)}5-Hour 7-Day`);
|
||||
for (const row of rows) {
|
||||
const h5 =
|
||||
row.h5pct >= 0
|
||||
@@ -622,7 +660,7 @@ async function buildUsageContent(): Promise<string> {
|
||||
row.d7pct >= 0
|
||||
? `${bar(row.d7pct)} ${String(row.d7pct).padStart(3)}%`
|
||||
: ' — ';
|
||||
lines.push(`${row.name.padEnd(8)}${h5} ${d7}`);
|
||||
lines.push(`${padName(row.name)}${h5} ${d7}`);
|
||||
}
|
||||
lines.push('```');
|
||||
} else {
|
||||
@@ -732,7 +770,10 @@ export async function startUnifiedDashboard(
|
||||
const content = buildUnifiedDashboardContent();
|
||||
if (!content) {
|
||||
logger.warn(
|
||||
{ cachedUsageLength: cachedUsageContent.length, statusShowRooms: STATUS_SHOW_ROOMS },
|
||||
{
|
||||
cachedUsageLength: cachedUsageContent.length,
|
||||
statusShowRooms: STATUS_SHOW_ROOMS,
|
||||
},
|
||||
'Dashboard content empty, skipping render',
|
||||
);
|
||||
statusMessageId = null;
|
||||
|
||||
Reference in New Issue
Block a user