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:
@@ -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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
try {
|
const spawnEnv: Record<string, string> = {
|
||||||
proc = spawn(codexBin, ['app-server'], {
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
env: {
|
|
||||||
...(process.env as Record<string, string>),
|
...(process.env as Record<string, string>),
|
||||||
PATH: [
|
PATH: [
|
||||||
path.dirname(process.execPath),
|
path.dirname(process.execPath),
|
||||||
path.join(os.homedir(), '.npm-global', 'bin'),
|
path.join(os.homedir(), '.npm-global', 'bin'),
|
||||||
process.env.PATH || '',
|
process.env.PATH || '',
|
||||||
].join(':'),
|
].join(':'),
|
||||||
},
|
};
|
||||||
|
if (codexHomeOverride) {
|
||||||
|
spawnEnv.CODEX_HOME = codexHomeOverride;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
proc = spawn(codexBin, ['app-server'], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: spawnEnv,
|
||||||
});
|
});
|
||||||
} 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(
|
||||||
|
|||||||
Reference in New Issue
Block a user