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:
Eyejoker
2026-04-27 22:33:22 +09:00
committed by GitHub
parent dafb84fa4d
commit 4ed12332d4

View File

@@ -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');