feat: show live Codex usage status in dashboard
Store Codex wham/usage live plan and rate-limit state during account refresh, surface safe dashboard badges, and keep JWT subscription expiry as stale fallback only. Redacts balance and spend limit amounts from API/UI exposure.
This commit is contained in:
245
src/codex-live-status.ts
Normal file
245
src/codex-live-status.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
export interface CodexRateLimitWindowSummary {
|
||||
limitWindowSeconds: number | null;
|
||||
resetAfterSeconds: number | null;
|
||||
resetAt: string | null;
|
||||
usedPercent: number | null;
|
||||
}
|
||||
|
||||
export interface CodexRateLimitSummary {
|
||||
allowed: boolean | null;
|
||||
limitReached: boolean | null;
|
||||
primaryWindow: CodexRateLimitWindowSummary | null;
|
||||
secondaryWindow: CodexRateLimitWindowSummary | null;
|
||||
}
|
||||
|
||||
export interface CodexAdditionalRateLimitSummary {
|
||||
limitName: string | null;
|
||||
meteredFeature: string | null;
|
||||
rateLimit: CodexRateLimitSummary | null;
|
||||
}
|
||||
|
||||
export interface CodexCreditsSummary {
|
||||
hasCredits: boolean | null;
|
||||
overageLimitReached: boolean | null;
|
||||
unlimited: boolean | null;
|
||||
}
|
||||
|
||||
export interface CodexSpendControlSummary {
|
||||
reached: boolean | null;
|
||||
}
|
||||
|
||||
export interface CodexLiveStatusSummary {
|
||||
checkedAt: string;
|
||||
source: 'wham/usage';
|
||||
planType: string | null;
|
||||
email: string | null;
|
||||
rateLimit: CodexRateLimitSummary | null;
|
||||
rateLimitReachedType: string | null;
|
||||
additionalRateLimits: CodexAdditionalRateLimitSummary[];
|
||||
credits: CodexCreditsSummary | null;
|
||||
spendControl: CodexSpendControlSummary | null;
|
||||
}
|
||||
|
||||
interface CodexLiveRateLimitWindow {
|
||||
limit_window_seconds?: number | null;
|
||||
reset_after_seconds?: number | null;
|
||||
reset_at?: number | null;
|
||||
used_percent?: number | null;
|
||||
}
|
||||
|
||||
interface CodexLiveRateLimit {
|
||||
allowed?: boolean | null;
|
||||
limit_reached?: boolean | null;
|
||||
primary_window?: CodexLiveRateLimitWindow | null;
|
||||
secondary_window?: CodexLiveRateLimitWindow | null;
|
||||
}
|
||||
|
||||
interface CodexAdditionalLiveRateLimit {
|
||||
limit_name?: string | null;
|
||||
metered_feature?: string | null;
|
||||
rate_limit?: CodexLiveRateLimit | null;
|
||||
}
|
||||
|
||||
interface CodexLiveCredits {
|
||||
has_credits?: boolean | null;
|
||||
overage_limit_reached?: boolean | null;
|
||||
unlimited?: boolean | null;
|
||||
}
|
||||
|
||||
interface CodexLiveSpendControl {
|
||||
reached?: boolean | null;
|
||||
}
|
||||
|
||||
export interface CodexLiveStatus {
|
||||
checked_at: string;
|
||||
plan_type?: string | null;
|
||||
email?: string | null;
|
||||
rate_limit?: CodexLiveRateLimit | null;
|
||||
rate_limit_reached_type?: string | null;
|
||||
additional_rate_limits?: CodexAdditionalLiveRateLimit[];
|
||||
credits?: CodexLiveCredits | null;
|
||||
spend_control?: CodexLiveSpendControl | null;
|
||||
}
|
||||
|
||||
function recordOrNull(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function numberOrNull(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function booleanOrNull(value: unknown): boolean | null {
|
||||
return typeof value === 'boolean' ? value : null;
|
||||
}
|
||||
|
||||
function epochSecondsToIso(value: number | null | undefined): string | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
||||
return new Date(value * 1000).toISOString();
|
||||
}
|
||||
|
||||
function normalizeRateLimitWindow(
|
||||
value: unknown,
|
||||
): CodexLiveRateLimitWindow | null {
|
||||
const record = recordOrNull(value);
|
||||
if (!record) return null;
|
||||
return {
|
||||
limit_window_seconds: numberOrNull(record.limit_window_seconds),
|
||||
reset_after_seconds: numberOrNull(record.reset_after_seconds),
|
||||
reset_at: numberOrNull(record.reset_at),
|
||||
used_percent: numberOrNull(record.used_percent),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRateLimit(value: unknown): CodexLiveRateLimit | null {
|
||||
const record = recordOrNull(value);
|
||||
if (!record) return null;
|
||||
return {
|
||||
allowed: booleanOrNull(record.allowed),
|
||||
limit_reached: booleanOrNull(record.limit_reached),
|
||||
primary_window: normalizeRateLimitWindow(record.primary_window),
|
||||
secondary_window: normalizeRateLimitWindow(record.secondary_window),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAdditionalRateLimits(
|
||||
value: unknown,
|
||||
): CodexAdditionalLiveRateLimit[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item): CodexAdditionalLiveRateLimit | null => {
|
||||
const record = recordOrNull(item);
|
||||
if (!record) return null;
|
||||
return {
|
||||
limit_name: stringOrNull(record.limit_name),
|
||||
metered_feature: stringOrNull(record.metered_feature),
|
||||
rate_limit: normalizeRateLimit(record.rate_limit),
|
||||
};
|
||||
})
|
||||
.filter((item): item is CodexAdditionalLiveRateLimit => item !== null);
|
||||
}
|
||||
|
||||
function normalizeCredits(value: unknown): CodexLiveCredits | null {
|
||||
const record = recordOrNull(value);
|
||||
if (!record) return null;
|
||||
return {
|
||||
has_credits: booleanOrNull(record.has_credits),
|
||||
overage_limit_reached: booleanOrNull(record.overage_limit_reached),
|
||||
unlimited: booleanOrNull(record.unlimited),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSpendControl(value: unknown): CodexLiveSpendControl | null {
|
||||
const record = recordOrNull(value);
|
||||
if (!record) return null;
|
||||
return {
|
||||
reached: booleanOrNull(record.reached),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCodexLiveStatus(
|
||||
value: unknown,
|
||||
checkedAt: string,
|
||||
): CodexLiveStatus | null {
|
||||
const record = recordOrNull(value);
|
||||
if (!record) return null;
|
||||
return {
|
||||
checked_at: checkedAt,
|
||||
plan_type: stringOrNull(record.plan_type),
|
||||
email: stringOrNull(record.email),
|
||||
rate_limit: normalizeRateLimit(record.rate_limit),
|
||||
rate_limit_reached_type: stringOrNull(record.rate_limit_reached_type),
|
||||
additional_rate_limits: normalizeAdditionalRateLimits(
|
||||
record.additional_rate_limits,
|
||||
),
|
||||
credits: normalizeCredits(record.credits),
|
||||
spend_control: normalizeSpendControl(record.spend_control),
|
||||
};
|
||||
}
|
||||
|
||||
function toRateLimitWindowSummary(
|
||||
window: CodexLiveRateLimitWindow | null | undefined,
|
||||
): CodexRateLimitWindowSummary | null {
|
||||
if (!window) return null;
|
||||
return {
|
||||
limitWindowSeconds: window.limit_window_seconds ?? null,
|
||||
resetAfterSeconds: window.reset_after_seconds ?? null,
|
||||
resetAt: epochSecondsToIso(window.reset_at),
|
||||
usedPercent: window.used_percent ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function toRateLimitSummary(
|
||||
rateLimit: CodexLiveRateLimit | null | undefined,
|
||||
): CodexRateLimitSummary | null {
|
||||
if (!rateLimit) return null;
|
||||
return {
|
||||
allowed: rateLimit.allowed ?? null,
|
||||
limitReached: rateLimit.limit_reached ?? null,
|
||||
primaryWindow: toRateLimitWindowSummary(rateLimit.primary_window),
|
||||
secondaryWindow: toRateLimitWindowSummary(rateLimit.secondary_window),
|
||||
};
|
||||
}
|
||||
|
||||
export function toCodexLiveStatusSummary(
|
||||
status: CodexLiveStatus,
|
||||
): CodexLiveStatusSummary {
|
||||
return {
|
||||
checkedAt: status.checked_at,
|
||||
source: 'wham/usage',
|
||||
planType: status.plan_type ?? null,
|
||||
email: status.email ?? null,
|
||||
rateLimit: toRateLimitSummary(status.rate_limit),
|
||||
rateLimitReachedType: status.rate_limit_reached_type ?? null,
|
||||
additionalRateLimits: (status.additional_rate_limits ?? []).map(
|
||||
(limit) => ({
|
||||
limitName: limit.limit_name ?? null,
|
||||
meteredFeature: limit.metered_feature ?? null,
|
||||
rateLimit: toRateLimitSummary(limit.rate_limit),
|
||||
}),
|
||||
),
|
||||
credits: status.credits
|
||||
? {
|
||||
hasCredits: status.credits.has_credits ?? null,
|
||||
overageLimitReached: status.credits.overage_limit_reached ?? null,
|
||||
unlimited: status.credits.unlimited ?? null,
|
||||
}
|
||||
: null,
|
||||
spendControl: status.spend_control
|
||||
? {
|
||||
reached: status.spend_control.reached ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -2,33 +2,63 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getCodexFeatures, updateCodexFeatures } from './settings-store.js';
|
||||
import {
|
||||
getCodexFeatures,
|
||||
listCodexAccounts,
|
||||
refreshCodexAccount,
|
||||
updateCodexFeatures,
|
||||
} from './settings-store.js';
|
||||
|
||||
describe('settings-store Codex features', () => {
|
||||
let tempDir: string;
|
||||
let previousCwd: string;
|
||||
let previousCodexGoals: string | undefined;
|
||||
let previousHome: string | undefined;
|
||||
let previousSettingsHome: string | undefined;
|
||||
let previousFetch: typeof fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-settings-'));
|
||||
previousCwd = process.cwd();
|
||||
previousCodexGoals = process.env.CODEX_GOALS;
|
||||
previousHome = process.env.HOME;
|
||||
previousSettingsHome = process.env.EJCLAW_SETTINGS_HOME;
|
||||
previousFetch = globalThis.fetch;
|
||||
delete process.env.CODEX_GOALS;
|
||||
process.env.HOME = tempDir;
|
||||
process.env.EJCLAW_SETTINGS_HOME = tempDir;
|
||||
process.chdir(tempDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
globalThis.fetch = previousFetch;
|
||||
if (previousCodexGoals === undefined) {
|
||||
delete process.env.CODEX_GOALS;
|
||||
} else {
|
||||
process.env.CODEX_GOALS = previousCodexGoals;
|
||||
}
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
if (previousSettingsHome === undefined) {
|
||||
delete process.env.EJCLAW_SETTINGS_HOME;
|
||||
} else {
|
||||
process.env.EJCLAW_SETTINGS_HOME = previousSettingsHome;
|
||||
}
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function fakeJwt(payload: Record<string, unknown>): string {
|
||||
const encode = (value: Record<string, unknown>) =>
|
||||
Buffer.from(JSON.stringify(value)).toString('base64');
|
||||
return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.`;
|
||||
}
|
||||
|
||||
it('stores the Codex goals opt-in in the EJClaw .env file', () => {
|
||||
expect(getCodexFeatures()).toEqual({ goals: false });
|
||||
|
||||
@@ -42,4 +72,123 @@ describe('settings-store Codex features', () => {
|
||||
'CODEX_GOALS=false',
|
||||
);
|
||||
});
|
||||
|
||||
it('stores wham usage status and does not expose stale JWT expiry as live billing', async () => {
|
||||
const accountDir = path.join(tempDir, '.codex-accounts', '1');
|
||||
fs.mkdirSync(accountDir, { recursive: true });
|
||||
const staleJwt = fakeJwt({
|
||||
email: 'cached@example.com',
|
||||
sub: 'acct_cached',
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_plan_type: 'free',
|
||||
chatgpt_subscription_active_until: '2026-01-01T00:00:00.000Z',
|
||||
chatgpt_subscription_last_checked: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(accountDir, 'auth.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
tokens: {
|
||||
access_token: 'old-access',
|
||||
id_token: staleJwt,
|
||||
refresh_token: 'refresh-token',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
const resetAt = 1_779_300_000;
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
if (url === 'https://auth.openai.com/oauth/token') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'live-access',
|
||||
id_token: staleJwt,
|
||||
refresh_token: 'next-refresh-token',
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url === 'https://chatgpt.com/backend-api/wham/usage') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
email: 'live@example.com',
|
||||
plan_type: 'pro',
|
||||
rate_limit: {
|
||||
allowed: true,
|
||||
limit_reached: false,
|
||||
primary_window: {
|
||||
limit_window_seconds: 18000,
|
||||
reset_after_seconds: 600,
|
||||
reset_at: resetAt,
|
||||
used_percent: 21,
|
||||
},
|
||||
secondary_window: {
|
||||
limit_window_seconds: 604800,
|
||||
reset_after_seconds: 3600,
|
||||
reset_at: resetAt + 600,
|
||||
used_percent: 7,
|
||||
},
|
||||
},
|
||||
rate_limit_reached_type: null,
|
||||
credits: {
|
||||
has_credits: false,
|
||||
overage_limit_reached: false,
|
||||
unlimited: false,
|
||||
},
|
||||
spend_control: { reached: false },
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
return new Response('not found', { status: 404 });
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const refreshed = await refreshCodexAccount(1);
|
||||
|
||||
expect(refreshed.planType).toBe('pro');
|
||||
expect(refreshed.email).toBe('live@example.com');
|
||||
expect(refreshed.subscriptionUntil).toBeNull();
|
||||
expect(refreshed.subscriptionSource).toBeNull();
|
||||
expect(refreshed.liveStatus).toMatchObject({
|
||||
source: 'wham/usage',
|
||||
planType: 'pro',
|
||||
rateLimit: {
|
||||
allowed: true,
|
||||
limitReached: false,
|
||||
primaryWindow: {
|
||||
limitWindowSeconds: 18000,
|
||||
resetAfterSeconds: 600,
|
||||
resetAt: new Date(resetAt * 1000).toISOString(),
|
||||
usedPercent: 21,
|
||||
},
|
||||
},
|
||||
credits: {
|
||||
hasCredits: false,
|
||||
overageLimitReached: false,
|
||||
unlimited: false,
|
||||
},
|
||||
spendControl: { reached: false },
|
||||
});
|
||||
|
||||
const sidecar = JSON.parse(
|
||||
fs.readFileSync(path.join(accountDir, 'plan-status.json'), 'utf-8'),
|
||||
) as Record<string, unknown>;
|
||||
expect(sidecar).toMatchObject({
|
||||
plan_type: 'pro',
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 21 },
|
||||
},
|
||||
});
|
||||
expect(listCodexAccounts()[0]).toMatchObject({
|
||||
planType: 'pro',
|
||||
subscriptionUntil: null,
|
||||
liveStatus: { source: 'wham/usage' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,21 @@ import {
|
||||
getActiveCodexAuthPath,
|
||||
setCurrentCodexAccountIndex as setRotationIndex,
|
||||
} from './codex-token-rotation.js';
|
||||
import {
|
||||
normalizeCodexLiveStatus,
|
||||
toCodexLiveStatusSummary,
|
||||
type CodexLiveStatus,
|
||||
type CodexLiveStatusSummary,
|
||||
} from './codex-live-status.js';
|
||||
|
||||
export type {
|
||||
CodexAdditionalRateLimitSummary,
|
||||
CodexCreditsSummary,
|
||||
CodexLiveStatusSummary,
|
||||
CodexRateLimitSummary,
|
||||
CodexRateLimitWindowSummary,
|
||||
CodexSpendControlSummary,
|
||||
} from './codex-live-status.js';
|
||||
|
||||
export interface ClaudeAccountSummary {
|
||||
index: number;
|
||||
@@ -37,6 +52,8 @@ export interface CodexAccountSummary {
|
||||
planType: string | null;
|
||||
subscriptionUntil: string | null;
|
||||
subscriptionLastChecked: string | null;
|
||||
subscriptionSource: 'jwt-cache' | null;
|
||||
liveStatus: CodexLiveStatusSummary | null;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
@@ -75,12 +92,16 @@ function readJson<T>(file: string): T | null {
|
||||
}
|
||||
}
|
||||
|
||||
function settingsHomeDir(): string {
|
||||
return process.env.EJCLAW_SETTINGS_HOME || os.homedir();
|
||||
}
|
||||
|
||||
function claudeCredsPath(index: number): string {
|
||||
if (index === 0) {
|
||||
return path.join(os.homedir(), '.claude', '.credentials.json');
|
||||
return path.join(settingsHomeDir(), '.claude', '.credentials.json');
|
||||
}
|
||||
return path.join(
|
||||
os.homedir(),
|
||||
settingsHomeDir(),
|
||||
'.claude-accounts',
|
||||
String(index),
|
||||
'.credentials.json',
|
||||
@@ -89,9 +110,14 @@ function claudeCredsPath(index: number): string {
|
||||
|
||||
function codexAuthPath(index: number): string {
|
||||
if (index === 0) {
|
||||
return path.join(os.homedir(), '.codex', 'auth.json');
|
||||
return path.join(settingsHomeDir(), '.codex', 'auth.json');
|
||||
}
|
||||
return path.join(os.homedir(), '.codex-accounts', String(index), 'auth.json');
|
||||
return path.join(
|
||||
settingsHomeDir(),
|
||||
'.codex-accounts',
|
||||
String(index),
|
||||
'auth.json',
|
||||
);
|
||||
}
|
||||
|
||||
function readClaudeAccount(index: number): ClaudeAccountSummary | null {
|
||||
@@ -126,6 +152,8 @@ function readCodexAccount(index: number): CodexAccountSummary | null {
|
||||
let planType: string | null = null;
|
||||
let subscriptionUntil: string | null = null;
|
||||
let subscriptionLastChecked: string | null = null;
|
||||
let subscriptionSource: 'jwt-cache' | null = null;
|
||||
let liveStatus: CodexLiveStatusSummary | null = null;
|
||||
const idToken = data?.tokens?.id_token;
|
||||
if (idToken && typeof idToken === 'string') {
|
||||
const parts = idToken.split('.');
|
||||
@@ -145,6 +173,7 @@ function readCodexAccount(index: number): CodexAccountSummary | null {
|
||||
}
|
||||
if (typeof auth.chatgpt_subscription_active_until === 'string') {
|
||||
subscriptionUntil = auth.chatgpt_subscription_active_until;
|
||||
subscriptionSource = 'jwt-cache';
|
||||
}
|
||||
if (typeof auth.chatgpt_subscription_last_checked === 'string') {
|
||||
subscriptionLastChecked = auth.chatgpt_subscription_last_checked;
|
||||
@@ -156,10 +185,15 @@ function readCodexAccount(index: number): CodexAccountSummary | null {
|
||||
}
|
||||
}
|
||||
// Live wham/usage data, written by refreshCodexAccount, takes precedence.
|
||||
// When live status exists, do not surface the stale JWT active_until claim as
|
||||
// a real billing expiry. OpenAI/Auth0 can cache that claim for days.
|
||||
if (live) {
|
||||
liveStatus = toCodexLiveStatusSummary(live);
|
||||
if (typeof live.plan_type === 'string') planType = live.plan_type;
|
||||
if (typeof live.email === 'string') email = live.email;
|
||||
subscriptionLastChecked = live.checked_at;
|
||||
subscriptionUntil = null;
|
||||
subscriptionSource = null;
|
||||
}
|
||||
return {
|
||||
index,
|
||||
@@ -168,6 +202,8 @@ function readCodexAccount(index: number): CodexAccountSummary | null {
|
||||
planType,
|
||||
subscriptionUntil,
|
||||
subscriptionLastChecked,
|
||||
subscriptionSource,
|
||||
liveStatus,
|
||||
exists: true,
|
||||
};
|
||||
}
|
||||
@@ -176,7 +212,7 @@ export function listClaudeAccounts(): ClaudeAccountSummary[] {
|
||||
const out: ClaudeAccountSummary[] = [];
|
||||
const def = readClaudeAccount(0);
|
||||
if (def) out.push(def);
|
||||
const dir = path.join(os.homedir(), '.claude-accounts');
|
||||
const dir = path.join(settingsHomeDir(), '.claude-accounts');
|
||||
if (fs.existsSync(dir)) {
|
||||
const entries = fs.readdirSync(dir);
|
||||
const indices = entries
|
||||
@@ -196,7 +232,7 @@ export function listCodexAccounts(): CodexAccountSummary[] {
|
||||
const out: CodexAccountSummary[] = [];
|
||||
const def = readCodexAccount(0);
|
||||
if (def) out.push(def);
|
||||
const dir = path.join(os.homedir(), '.codex-accounts');
|
||||
const dir = path.join(settingsHomeDir(), '.codex-accounts');
|
||||
if (fs.existsSync(dir)) {
|
||||
const entries = fs.readdirSync(dir);
|
||||
const indices = entries
|
||||
@@ -309,18 +345,12 @@ interface CodexAuthFile {
|
||||
last_refresh?: string;
|
||||
}
|
||||
|
||||
interface CodexLiveStatus {
|
||||
checked_at: string;
|
||||
plan_type?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
function planStatusPath(index: number): string {
|
||||
if (index === 0) {
|
||||
return path.join(os.homedir(), '.codex', 'plan-status.json');
|
||||
return path.join(settingsHomeDir(), '.codex', 'plan-status.json');
|
||||
}
|
||||
return path.join(
|
||||
os.homedir(),
|
||||
settingsHomeDir(),
|
||||
'.codex-accounts',
|
||||
String(index),
|
||||
'plan-status.json',
|
||||
@@ -341,24 +371,17 @@ function writeCodexLiveStatus(index: number, status: CodexLiveStatus): void {
|
||||
fs.renameSync(tempPath, file);
|
||||
}
|
||||
|
||||
async function fetchCodexLivePlanType(
|
||||
async function fetchCodexLiveUsage(
|
||||
accessToken: string,
|
||||
): Promise<{ plan_type?: string; email?: string } | null> {
|
||||
): Promise<CodexLiveStatus | null> {
|
||||
try {
|
||||
const res = await fetch(CODEX_USAGE_URL, {
|
||||
method: 'GET',
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const json = (await res.json()) as {
|
||||
plan_type?: unknown;
|
||||
email?: unknown;
|
||||
};
|
||||
return {
|
||||
plan_type:
|
||||
typeof json.plan_type === 'string' ? json.plan_type : undefined,
|
||||
email: typeof json.email === 'string' ? json.email : undefined,
|
||||
};
|
||||
const json = (await res.json()) as unknown;
|
||||
return normalizeCodexLiveStatus(json, new Date().toISOString());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -426,21 +449,17 @@ export async function refreshCodexAccount(
|
||||
};
|
||||
writeCodexAuthFile(file, updated);
|
||||
|
||||
// Hit chatgpt.com/backend-api/wham/usage to read the live plan_type
|
||||
// (the JWT id_token's chatgpt_plan_type / *_active_until / *_last_checked
|
||||
// claims are cached on OpenAI's auth0 side and don't refresh on every
|
||||
// OAuth refresh_token grant). Persist the live values to a sidecar
|
||||
// plan-status.json so readCodexAccount can prefer them.
|
||||
// Hit chatgpt.com/backend-api/wham/usage to read the live plan, rate limit,
|
||||
// and spend-control state. The JWT id_token's chatgpt_plan_type /
|
||||
// *_active_until / *_last_checked claims are cached on OpenAI's Auth0 side and
|
||||
// don't refresh on every OAuth refresh_token grant, so the dashboard must not
|
||||
// present that cached expiry as live billing truth.
|
||||
const accessToken =
|
||||
payload.access_token ?? data?.tokens?.access_token ?? null;
|
||||
if (accessToken) {
|
||||
const live = await fetchCodexLivePlanType(accessToken);
|
||||
const live = await fetchCodexLiveUsage(accessToken);
|
||||
if (live) {
|
||||
writeCodexLiveStatus(index, {
|
||||
checked_at: new Date().toISOString(),
|
||||
plan_type: live.plan_type ?? null,
|
||||
email: live.email ?? null,
|
||||
});
|
||||
writeCodexLiveStatus(index, live);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,7 +484,7 @@ export function getActiveCodexSettingsIndex(): number | null {
|
||||
if (codexAuthPath(0) === activePath && fs.existsSync(activePath)) {
|
||||
return 0;
|
||||
}
|
||||
const dir = path.join(os.homedir(), '.codex-accounts');
|
||||
const dir = path.join(settingsHomeDir(), '.codex-accounts');
|
||||
if (fs.existsSync(dir)) {
|
||||
const indices = fs
|
||||
.readdirSync(dir)
|
||||
@@ -546,11 +565,11 @@ export function stopCodexAccountRefreshLoop(): void {
|
||||
}
|
||||
|
||||
function codexConfigPath(): string {
|
||||
return path.join(os.homedir(), '.codex', 'config.toml');
|
||||
return path.join(settingsHomeDir(), '.codex', 'config.toml');
|
||||
}
|
||||
|
||||
function claudeSettingsPath(): string {
|
||||
return path.join(os.homedir(), '.claude', 'settings.json');
|
||||
return path.join(settingsHomeDir(), '.claude', 'settings.json');
|
||||
}
|
||||
|
||||
function readCodexFastMode(): boolean {
|
||||
@@ -677,8 +696,8 @@ export function removeAccountDirectory(
|
||||
}
|
||||
const baseDir =
|
||||
provider === 'claude'
|
||||
? path.join(os.homedir(), '.claude-accounts', String(index))
|
||||
: path.join(os.homedir(), '.codex-accounts', String(index));
|
||||
? path.join(settingsHomeDir(), '.claude-accounts', String(index))
|
||||
: path.join(settingsHomeDir(), '.codex-accounts', String(index));
|
||||
if (!fs.existsSync(baseDir)) {
|
||||
throw new Error(`account directory not found: ${baseDir}`);
|
||||
}
|
||||
@@ -707,7 +726,7 @@ export function addClaudeAccountFromToken(token: string): {
|
||||
const exp = typeof payload.exp === 'number' ? payload.exp * 1000 : 0;
|
||||
const accountId = typeof payload.sub === 'string' ? payload.sub : null;
|
||||
|
||||
const baseDir = path.join(os.homedir(), '.claude-accounts');
|
||||
const baseDir = path.join(settingsHomeDir(), '.claude-accounts');
|
||||
if (!fs.existsSync(baseDir)) {
|
||||
fs.mkdirSync(baseDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
@@ -208,6 +208,28 @@ function makeCodexAccount(): CodexAccountSummary {
|
||||
planType: 'pro',
|
||||
subscriptionUntil: null,
|
||||
subscriptionLastChecked: '2026-04-28T08:00:00.000Z',
|
||||
subscriptionSource: null,
|
||||
liveStatus: {
|
||||
checkedAt: '2026-04-28T08:00:00.000Z',
|
||||
source: 'wham/usage',
|
||||
planType: 'pro',
|
||||
email: 'codex@example.com',
|
||||
rateLimit: {
|
||||
allowed: true,
|
||||
limitReached: false,
|
||||
primaryWindow: {
|
||||
limitWindowSeconds: 18000,
|
||||
resetAfterSeconds: 1200,
|
||||
resetAt: '2026-04-28T08:20:00.000Z',
|
||||
usedPercent: 12,
|
||||
},
|
||||
secondaryWindow: null,
|
||||
},
|
||||
rateLimitReachedType: null,
|
||||
additionalRateLimits: [],
|
||||
credits: null,
|
||||
spendControl: null,
|
||||
},
|
||||
exists: true,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user