diff --git a/src/settings-store.ts b/src/settings-store.ts index 3519439..57aa11e 100644 --- a/src/settings-store.ts +++ b/src/settings-store.ts @@ -111,6 +111,7 @@ function readCodexAccount(index: number): CodexAccountSummary | null { OPENAI_API_KEY?: string; tokens?: { id_token?: string; access_token?: string }; }>(file); + const live = readCodexLiveStatus(index); let accountId: string | null = null; let email: string | null = null; let planType: string | null = null; @@ -145,6 +146,12 @@ function readCodexAccount(index: number): CodexAccountSummary | null { } } } + // Live wham/usage data, written by refreshCodexAccount, takes precedence. + if (live) { + if (typeof live.plan_type === 'string') planType = live.plan_type; + if (typeof live.email === 'string') email = live.email; + subscriptionLastChecked = live.checked_at; + } return { index, accountId, @@ -279,6 +286,7 @@ export function updateModelConfig( const CODEX_OAUTH_TOKEN_URL = 'https://auth.openai.com/oauth/token'; const CODEX_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; +const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage'; interface CodexAuthFile { auth_mode?: string; @@ -292,6 +300,61 @@ 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( + os.homedir(), + '.codex-accounts', + String(index), + 'plan-status.json', + ); +} + +function readCodexLiveStatus(index: number): CodexLiveStatus | null { + return readJson(planStatusPath(index)); +} + +function writeCodexLiveStatus(index: number, status: CodexLiveStatus): void { + const file = planStatusPath(index); + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tempPath = `${file}.tmp`; + fs.writeFileSync(tempPath, `${JSON.stringify(status, null, 2)}\n`, { + mode: 0o600, + }); + fs.renameSync(tempPath, file); +} + +async function fetchCodexLivePlanType( + accessToken: string, +): Promise<{ plan_type?: string; email?: string } | 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, + }; + } catch { + return null; + } +} + function readCodexAuthFile(file: string): CodexAuthFile | null { return readJson(file); } @@ -354,6 +417,24 @@ 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. + const accessToken = + payload.access_token ?? data?.tokens?.access_token ?? null; + if (accessToken) { + const live = await fetchCodexLivePlanType(accessToken); + if (live) { + writeCodexLiveStatus(index, { + checked_at: new Date().toISOString(), + plan_type: live.plan_type ?? null, + email: live.email ?? null, + }); + } + } + const summary = readCodexAccount(index); if (!summary) throw new Error('failed to re-read codex account after refresh');