feat: scan all Codex accounts on startup + hourly, cache 5h/7d usage

- Scan all Codex accounts by spawning app-server with each auth dir
- Cache primary (5h) and secondary (7d) usage per account
- Show cached usage + reset remaining time on dashboard
- Persist d7 usage in rotation state file
- formatResetRemaining: "Xh Ym 후" / "N일 후" format
- Remove isRateLimited guard for cached display
This commit is contained in:
Eyejoker
2026-03-24 01:25:02 +09:00
parent 60dab43a3f
commit c7a3a56386
2 changed files with 166 additions and 22 deletions

View File

@@ -24,21 +24,27 @@ interface CodexAccount {
authPath: string; authPath: string;
accountId: string; accountId: string;
planType: string; planType: string;
subscriptionUntil: string | null;
rateLimitedUntil: number | null; rateLimitedUntil: number | null;
lastUsagePct?: number; lastUsagePct?: number;
lastUsageD7Pct?: number;
resetAt?: string; resetAt?: string;
} }
function parsePlanFromJwt(idToken: string): string { function parseJwtAuth(idToken: string): { planType: string; expiresAt: string | null } {
try { try {
const parts = idToken.split('.'); const parts = idToken.split('.');
if (parts.length < 2) return '?'; if (parts.length < 2) return { planType: '?', expiresAt: null };
const payload = JSON.parse( const payload = JSON.parse(
Buffer.from(parts[1], 'base64url').toString('utf-8'), Buffer.from(parts[1], 'base64url').toString('utf-8'),
); );
return payload?.['https://api.openai.com/auth']?.chatgpt_plan_type || '?'; const auth = payload?.['https://api.openai.com/auth'] || {};
return {
planType: auth.chatgpt_plan_type || '?',
expiresAt: auth.chatgpt_subscription_active_until || null,
};
} catch { } catch {
return '?'; return { planType: '?', expiresAt: null };
} }
} }
@@ -72,12 +78,14 @@ export function initCodexTokenRotation(): void {
try { try {
const data = JSON.parse(fs.readFileSync(authPath, 'utf-8')); const data = JSON.parse(fs.readFileSync(authPath, 'utf-8'));
const accountId = data?.tokens?.account_id || `account-${dir}`; const accountId = data?.tokens?.account_id || `account-${dir}`;
const planType = parsePlanFromJwt(data?.tokens?.id_token || ''); const jwt = parseJwtAuth(data?.tokens?.id_token || '');
const planType = jwt.planType;
accounts.push({ accounts.push({
index: accounts.length, index: accounts.length,
authPath, authPath,
accountId, accountId,
planType, planType,
subscriptionUntil: jwt.expiresAt,
rateLimitedUntil: null, rateLimitedUntil: null,
}); });
} catch { } catch {
@@ -98,6 +106,7 @@ function saveCodexState(): void {
currentIndex, currentIndex,
rateLimits: accounts.map((a) => a.rateLimitedUntil), rateLimits: accounts.map((a) => a.rateLimitedUntil),
usagePcts: accounts.map((a) => a.lastUsagePct ?? null), usagePcts: accounts.map((a) => a.lastUsagePct ?? null),
usageD7Pcts: accounts.map((a) => a.lastUsageD7Pct ?? null),
resetAts: accounts.map((a) => a.resetAt ?? null), resetAts: accounts.map((a) => a.resetAt ?? null),
}; };
fs.writeFileSync(STATE_FILE, JSON.stringify(state)); fs.writeFileSync(STATE_FILE, JSON.stringify(state));
@@ -130,12 +139,26 @@ function loadCodexState(): void {
} }
} }
if (Array.isArray(state.usagePcts)) { if (Array.isArray(state.usagePcts)) {
for (let i = 0; i < Math.min(state.usagePcts.length, accounts.length); i++) { for (
if (typeof state.usagePcts[i] === 'number') accounts[i].lastUsagePct = state.usagePcts[i]; let i = 0;
i < Math.min(state.usagePcts.length, accounts.length);
i++
) {
if (typeof state.usagePcts[i] === 'number')
accounts[i].lastUsagePct = state.usagePcts[i];
}
}
if (Array.isArray(state.usageD7Pcts)) {
for (let i = 0; i < Math.min(state.usageD7Pcts.length, accounts.length); i++) {
if (typeof state.usageD7Pcts[i] === 'number') accounts[i].lastUsageD7Pct = state.usageD7Pcts[i];
} }
} }
if (Array.isArray(state.resetAts)) { if (Array.isArray(state.resetAts)) {
for (let i = 0; i < Math.min(state.resetAts.length, accounts.length); i++) { for (
let i = 0;
i < Math.min(state.resetAts.length, accounts.length);
i++
) {
if (state.resetAts[i]) accounts[i].resetAt = state.resetAts[i]; if (state.resetAts[i]) accounts[i].resetAt = state.resetAts[i];
} }
} }
@@ -224,6 +247,46 @@ export function rotateCodexToken(errorMessage?: string): boolean {
return false; return false;
} }
/**
* Advance to the next healthy account (round-robin).
* Called after each successful request to spread load evenly
* and keep usage data fresh for all accounts.
*/
export function advanceCodexAccount(): void {
if (accounts.length <= 1) return;
const now = Date.now();
for (let i = 1; i < accounts.length; i++) {
const idx = (currentIndex + i) % accounts.length;
const acct = accounts[idx];
if (!acct.rateLimitedUntil || acct.rateLimitedUntil <= now) {
currentIndex = idx;
saveCodexState();
return;
}
}
// All others rate-limited, stay on current
}
/**
* Update cached usage info for a specific account (or current if index omitted).
*/
export function updateCodexAccountUsage(
usagePct: number,
resetAt?: string,
accountIndex?: number,
d7Pct?: number,
): void {
if (accounts.length === 0) return;
const idx = accountIndex ?? currentIndex;
const acct = accounts[idx];
if (acct) {
acct.lastUsagePct = usagePct;
if (d7Pct != null) acct.lastUsageD7Pct = d7Pct;
if (resetAt) acct.resetAt = resetAt;
saveCodexState();
}
}
export function markCodexTokenHealthy(): void { export function markCodexTokenHealthy(): void {
if (accounts.length === 0) return; if (accounts.length === 0) return;
const acct = accounts[currentIndex]; const acct = accounts[currentIndex];
@@ -244,6 +307,7 @@ export function getAllCodexAccounts(): {
isActive: boolean; isActive: boolean;
isRateLimited: boolean; isRateLimited: boolean;
cachedUsagePct?: number; cachedUsagePct?: number;
cachedUsageD7Pct?: number;
resetAt?: string; resetAt?: string;
}[] { }[] {
const now = Date.now(); const now = Date.now();
@@ -254,6 +318,7 @@ export function getAllCodexAccounts(): {
isActive: i === currentIndex, isActive: i === currentIndex,
isRateLimited: Boolean(a.rateLimitedUntil && a.rateLimitedUntil > now), isRateLimited: Boolean(a.rateLimitedUntil && a.rateLimitedUntil > now),
cachedUsagePct: a.lastUsagePct, cachedUsagePct: a.lastUsagePct,
cachedUsageD7Pct: a.lastUsageD7Pct,
resetAt: a.resetAt, resetAt: a.resetAt,
})); }));
} }

View File

@@ -10,7 +10,10 @@ import {
type ClaudeUsageData, type ClaudeUsageData,
type ClaudeAccountUsage, type ClaudeAccountUsage,
} from './claude-usage.js'; } from './claude-usage.js';
import { getAllCodexAccounts } from './codex-token-rotation.js'; import {
getAllCodexAccounts,
updateCodexAccountUsage,
} from './codex-token-rotation.js';
import { import {
composeDashboardContent, composeDashboardContent,
formatElapsed, formatElapsed,
@@ -88,6 +91,25 @@ function formatResetKST(value: string | number): string {
} }
} }
function formatResetRemaining(value: string | number): string {
try {
const date =
typeof value === 'number' ? new Date(value * 1000) : new Date(value);
const diffMs = date.getTime() - Date.now();
if (diffMs <= 0) return '리셋됨';
const hours = Math.floor(diffMs / 3_600_000);
const minutes = Math.floor((diffMs % 3_600_000) / 60_000);
if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days}일 후`;
}
if (hours > 0) return `${hours}h ${minutes}m 후`;
return `${minutes}m 후`;
} catch {
return String(value);
}
}
function findDiscordChannel(channels: Channel[]): Channel | undefined { function findDiscordChannel(channels: Channel[]): Channel | undefined {
return channels.find( return channels.find(
(channel) => channel.name.startsWith('discord') && channel.isConnected(), (channel) => channel.name.startsWith('discord') && channel.isConnected(),
@@ -421,7 +443,9 @@ async function fetchClaudeUsage(): Promise<ClaudeUsageData | null> {
} }
} }
async function fetchCodexUsage(): Promise<CodexRateLimit[] | null> { async function fetchCodexUsage(
codexHomeOverride?: string,
): Promise<CodexRateLimit[] | null> {
const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex'); const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex');
const codexBin = fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex'; const codexBin = fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex';
@@ -444,17 +468,22 @@ async function fetchCodexUsage(): Promise<CodexRateLimit[] | null> {
const timer = setTimeout(() => finish(null), 20_000); const timer = setTimeout(() => finish(null), 20_000);
const spawnEnv: Record<string, string> = {
...(process.env as Record<string, string>),
PATH: [
path.dirname(process.execPath),
path.join(os.homedir(), '.npm-global', 'bin'),
process.env.PATH || '',
].join(':'),
};
if (codexHomeOverride) {
spawnEnv.CODEX_HOME = codexHomeOverride;
}
try { try {
proc = spawn(codexBin, ['app-server'], { proc = spawn(codexBin, ['app-server'], {
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
env: { env: spawnEnv,
...(process.env as Record<string, string>),
PATH: [
path.dirname(process.execPath),
path.join(os.homedir(), '.npm-global', 'bin'),
process.env.PATH || '',
].join(':'),
},
}); });
} catch { } catch {
resolve(null); resolve(null);
@@ -616,15 +645,17 @@ async function buildUsageContent(): Promise<string> {
}); });
} }
} else { } else {
// Show cached usage for rate-limited accounts // Show cached usage from last scan
const pct = acct.isRateLimited && acct.cachedUsagePct != null const pct =
? acct.cachedUsagePct : -1; acct.cachedUsagePct != null ? acct.cachedUsagePct : -1;
const d7pct =
acct.cachedUsageD7Pct != null ? acct.cachedUsageD7Pct : -1;
const reset = acct.resetAt || ''; const reset = acct.resetAt || '';
rows.push({ rows.push({
name: `${label} ${acct.planType}`, name: `${label} ${acct.planType}`,
h5pct: pct, h5pct: pct,
h5reset: reset, h5reset: reset,
d7pct: -1, d7pct,
d7reset: '', d7reset: '',
}); });
} }
@@ -736,6 +767,51 @@ function buildUnifiedDashboardContent(): string {
return composeDashboardContent(sections); return composeDashboardContent(sections);
} }
const CODEX_ACCOUNTS_DIR = path.join(os.homedir(), '.codex-accounts');
const CODEX_FULL_SCAN_INTERVAL = 3_600_000; // 1 hour
/**
* Scan ALL Codex accounts by spawning app-server with each auth.
* Called on startup and every hour to keep cached usage fresh.
*/
async function refreshAllCodexAccountUsage(): Promise<void> {
const codexAccounts = getAllCodexAccounts();
if (codexAccounts.length <= 1) return;
logger.info(
{ accountCount: codexAccounts.length },
'Scanning all Codex accounts for usage data',
);
for (const acct of codexAccounts) {
const accountDir = path.join(CODEX_ACCOUNTS_DIR, String(acct.index + 1));
if (!fs.existsSync(accountDir)) continue;
try {
const usage = await fetchCodexUsage(accountDir);
if (usage && Array.isArray(usage)) {
const relevant = usage.filter(
(l) => l.primary.usedPercent > 0 || l.secondary.usedPercent > 0,
);
const display = relevant.length > 0 ? relevant : usage.slice(0, 1);
if (display.length > 0) {
const pct = Math.round(display[0].primary.usedPercent);
const resetVal = display[0].primary.resetsAt;
const resetStr = resetVal ? formatResetRemaining(resetVal) : undefined;
const d7Pct = Math.round(display[0].secondary.usedPercent);
updateCodexAccountUsage(pct, resetStr, acct.index, d7Pct);
logger.info(
{ account: acct.index + 1, usagePct: pct, reset: resetStr },
`Codex account #${acct.index + 1} usage: ${pct}%`,
);
}
}
} catch (err) {
logger.debug({ err, account: acct.index + 1 }, 'Failed to fetch usage for Codex account');
}
}
}
async function refreshUsageCache(): Promise<void> { async function refreshUsageCache(): Promise<void> {
if (usageUpdateInProgress) return; if (usageUpdateInProgress) return;
usageUpdateInProgress = true; usageUpdateInProgress = true;
@@ -803,6 +879,9 @@ export async function startUnifiedDashboard(
if (isRenderer) { if (isRenderer) {
setInterval(refreshUsageCache, opts.usageUpdateInterval); setInterval(refreshUsageCache, opts.usageUpdateInterval);
// Full scan of all Codex accounts on startup + hourly
void refreshAllCodexAccountUsage();
setInterval(() => void refreshAllCodexAccountUsage(), CODEX_FULL_SCAN_INTERVAL);
} }
logger.info( logger.info(