Codex refresh: pull live plan_type from /wham/usage (#50)
* Settings: clarify Codex 만료=결제, drop Claude expiry badge - Codex expiry badge now says "결제 만료/까지" so it's unambiguous that this is the ChatGPT Pro/Team subscription end-date, not an OAuth access-token TTL. - Claude rows no longer show an expiry badge: Claude credentials only expose an OAuth access_token TTL (auto-refreshed every few minutes by the existing claude token refresh loop), which is meaningless to the user. Replace it with a static "토큰 자동갱신" tag so the column alignment stays consistent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Codex refresh: pull live plan_type from /wham/usage (sidecar override) OAuth refresh_token grant returns a fresh JWT but auth0 caches the chatgpt_plan_type and chatgpt_subscription_active_until claims at issue time. So users who upgrade/downgrade their plan kept seeing the old plan in the dashboard even after a forced refresh. After each refreshCodexAccount() call, additionally GET https://chatgpt.com/backend-api/wham/usage with the freshly-issued access_token. That endpoint returns the current plan_type and email from OpenAI's account service. Persist it to a sidecar plan-status.json next to auth.json (not into auth.json itself, since codex CLI may rewrite that file). readCodexAccount prefers the live plan_type and uses the sidecar's checked_at as subscriptionLastChecked, so the dashboard immediately reflects post-billing-event plan changes. Confirmed end-to-end: account that was billed-down from Pro to Plus now shows plus instead of stale pro after one refresh. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<CodexLiveStatus>(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<CodexAuthFile>(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');
|
||||
|
||||
Reference in New Issue
Block a user