Repository navigation
Codex Auth quota refresh reads fresh WHAM plan_type but keeps a stale pool account plan #724
Description
Client or integration
OpenCodex dashboard
Area
Authentication and account pool
Summary
A stored Codex pool account keeps the subscription plan captured when it was added. The quota-refresh path successfully reads the current plan_type from WHAM, but the Dashboard DTO and persisted codexAccounts[].plan continue using the old value.
Sanitized live reproduction on OpenCodex 2.7.43:
stored pool account:
management API plan: plus
direct read-only WHAM plan_type: prolite
WHAM HTTP status: 200
WHAM rate_limit present: true
A second sanitized pool row demonstrated functional impact:
management API plan: free
management API quota fields: resetCredits only
direct read-only WHAM plan_type: pro
WHAM primary_window present: true
The stale free plan causes the account DTO to remove valid weekly usage fields. The same stored plan also participates in pool quota scoring, so this can affect both Dashboard display and automatic account selection rather than only the plan badge.
Expected: after a successful WHAM response with a non-empty plan_type, the refreshed DTO and persisted pool account should use the current plan, subject to the existing credential-generation safety check.
Reproduction
- Add a Codex pool account while its WHAM
plan_typeisplus. - Change the subscription so WHAM now returns
prolite. - Open Dashboard → Codex Auth.
- Use Refresh quota, or request
GET /api/codex-auth/accounts?refresh=1. - Observe that quota values refresh but the returned account still has
plan: plus. - Reauthenticate the pool account and observe that the login path can finally persist the current plan.
The code path reproduces the cause directly:
fetchPoolAccountQuota()returnsfreshPlanfrom WHAM.listCodexAuthAccounts()discardsfreshPlanand passes the stored account topoolAccountDto().- DTO quota filtering and routing continue to use
account.plan.
Suggested regression coverage:
- Stored
plus, WHAMprolite:refresh=1returns and persistsprolite. - Stored
free, WHAMpro: the refreshed DTO retains current weekly quota fields rather than filtering them as Free-plan data. - A stale refresh result must not update the plan after credential generation changes or account deletion/recreation.
- A missing upstream
plan_typemust leave the stored plan unchanged.
To avoid parallel persistence races, the refresh mapper can collect authoritative plan changes and save them once after confirming the corresponding account and credential generation are still current.
Version
2.7.43 (d1f544bbc22d25b9b2bd3c8e776fb8e3242b4ed5). The same path remains in current dev (bfacc3bcf71077a77afc25d9b613644d3409750c).
Operating system
Windows 11 Pro 25H2, build 26200.8894
Provider and model
OpenAI Codex account pool; model-independent
Logs or error output
/api/codex-auth/accounts?refresh=1
stored plan: plus
fresh WHAM plan_type: prolite
/api/codex-auth/accounts?refresh=1
stored plan: free
fresh WHAM plan_type: pro
valid weekly quota omitted from the DTO
All emails, account IDs, access tokens, request credentials, and quota values were removed.
Screenshots and supporting files
Relevant source paths:
- Fresh plan is read and returned:
opencodex/src/codex/auth-api.ts
Lines 443 to 470 in d1f544b
async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, configuredPlan?: string): Promise<PoolQuotaResult> { const existing = getAccountQuota(accountId); if (!forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) { return { quota: existing, needsReauth: false }; } try { const { accessToken, chatgptAccountId, generation } = await getValidCodexToken(accountId); const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId }, signal: AbortSignal.timeout(8000), }); if (!resp.ok) return { quota: existing ?? null, needsReauth: resp.status === 401 }; const data = (await resp.json()) as WhamUsageResponse; const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined; const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan }); const freshResetCredits = quota?.resetCredits; if (!quota) return { quota: existing ?? null, needsReauth: false }; if (!isCodexAccountGenerationLive(accountId, generation)) { return { quota: getAccountQuota(accountId), needsReauth: false }; } setAccountQuotaFromParsed(accountId, quota); return { quota: getAccountQuota(accountId), needsReauth: false, freshQuota: quota, freshCredentialGeneration: generation, ...(freshPlan !== undefined ? { freshPlan } : {}), ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), - Refresh path discards it:
opencodex/src/codex/auth-api.ts
Lines 541 to 550 in d1f544b
export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = false): Promise<CodexAuthAccountDto[]> { const runtimeConfig = getRuntimeConfig(config); const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount); const mainInfo = await fetchMainAccountInfo(forceRefresh); const withQuota = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async a => { const cred = getCodexAccountCredential(a.id); const quotaResult = cred ? await fetchPoolAccountQuota(a.id, forceRefresh, a.plan) : { quota: null, needsReauth: true }; return poolAccountDto(a, quotaResult, !!cred, isCodexAccountPaused(runtimeConfig, a.id)); - DTO uses the stored plan:
opencodex/src/codex/auth-api.ts
Lines 134 to 155 in d1f544b
function poolAccountDto( account: CodexAccount, quotaResult: PoolQuotaResult, hasCredential: boolean, paused: boolean, ): CodexAuthAccountDto { const quota = quotaForPlan(quotaResult.quota, account.plan); const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id); const health = projectCodexAccountHealth({ accountId: account.id, needsReauth }); return { id: account.id, email: maskEmail(account.email) ?? account.email, ...(account.alias !== undefined ? { alias: account.alias } : {}), ...(account.plan !== undefined ? { plan: account.plan } : {}), ...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}), isMain: false, paused, quota: quota ? { ...quota } : null, needsReauth, hasCredential, ...oauthAccountHealthFields("codex", account.id, health), }; - Plan-dependent quota filtering:
Lines 253 to 303 in d1f544b
export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuota, "updatedAt"> | null { const resetCredits = typeof data.rate_limit_reset_credits?.available_count === "number" ? data.rate_limit_reset_credits.available_count : undefined; if (!data.rate_limit) { return resetCredits !== undefined ? { resetCredits } : null; } const quota: Omit<StoredAccountQuota, "updatedAt"> = {}; const thirtyDayOnly = data.plan_type?.trim().toLowerCase() === "go" || data.plan_type?.trim().toLowerCase() === "free"; const primaryWindow = data.rate_limit.primary_window; const secondaryWindow = data.rate_limit.secondary_window; const tertiaryWindow = data.rate_limit.tertiary_window; const primaryPercent = normalizeUsagePercent(primaryWindow?.used_percent); const secondaryPercent = normalizeUsagePercent(secondaryWindow?.used_percent); const tertiaryPercent = normalizeUsagePercent(tertiaryWindow?.used_percent); const primaryResetAt = normalizeResetAt(primaryWindow?.reset_at); const secondaryResetAt = normalizeResetAt(secondaryWindow?.reset_at); const tertiaryResetAt = normalizeResetAt(tertiaryWindow?.reset_at); const primaryIsMonthly = isExplicitMonthlyWindow(primaryWindow); // [Decision Log] // - 목적과 의도: distinguish weekly and roughly monthly WHAM primary windows without plan-name guesses. // - 기존 구현 및 제약 조건: primary meant weekly, and older responses omit limit_window_seconds. // - 검토한 주요 대안: exact-duration matching, plan-specific mapping, and a duration lower bound. // - 선택한 방식: only an explicit primary duration of at least 28 days changes it to monthly. // - 다른 대안 대신 이 방식을 선택한 이유: it accepts calendar-month variance and preserves legacy payloads. // - 장점, 단점 및 영향: Team monthly quotas classify correctly; unknown durations remain weekly by design. const weeklyPercent = primaryIsMonthly ? secondaryPercent : primaryPercent ?? secondaryPercent; const weeklyResetAt = primaryIsMonthly ? secondaryResetAt : primaryPercent !== undefined ? primaryResetAt : secondaryResetAt; const monthlyPercent = primaryIsMonthly ? primaryPercent ?? tertiaryPercent : tertiaryPercent; const monthlyResetAt = primaryIsMonthly && primaryPercent !== undefined ? primaryResetAt : tertiaryResetAt; if (thirtyDayOnly) { if (monthlyPercent !== undefined) { quota.monthlyPercent = monthlyPercent; if (monthlyResetAt !== undefined) quota.monthlyResetAt = monthlyResetAt; } } else if (weeklyPercent !== undefined) { quota.weeklyPercent = weeklyPercent; if (weeklyResetAt !== undefined) quota.weeklyResetAt = weeklyResetAt; } if (!thirtyDayOnly && monthlyPercent !== undefined) { quota.monthlyPercent = monthlyPercent; if (monthlyResetAt !== undefined) quota.monthlyResetAt = monthlyResetAt; } if (resetCredits !== undefined) quota.resetCredits = resetCredits; return hasKnownQuotaValue(quota) || resetCredits !== undefined ? quota : null; - Pool routing uses the stored plan:
opencodex/src/codex/routing.ts
Lines 246 to 260 in d1f544b
export function computeCodexUsageScore(quota: { weeklyPercent?: number; monthlyPercent?: number; } | null, plan?: string | null): number { if (!quota) return CODEX_UNKNOWN_USAGE_SCORE; const normalizedPlan = plan?.trim().toLowerCase(); if (normalizedPlan === "go" || normalizedPlan === "free") { return typeof quota.monthlyPercent === "number" && Number.isFinite(quota.monthlyPercent) ? quota.monthlyPercent : CODEX_UNKNOWN_USAGE_SCORE; } const values = [quota.weeklyPercent, quota.monthlyPercent] .filter((value): value is number => typeof value === "number" && Number.isFinite(value)); return values.length > 0 ? Math.max(...values) : CODEX_UNKNOWN_USAGE_SCORE; } - Existing refresh test covers quota but not plan changes:
opencodex/tests/codex-auth-api.test.ts
Lines 649 to 685 in d1f544b
test("GET /api/codex-auth/accounts refresh=1 bypasses cached pool quota", async () => { const config = makeConfig(); seedPoolAccount(config, { id: "pool-refresh", email: "pool-refresh@example.com", accessToken: "tok", refreshToken: "ref", chatgptAccountId: "acc-pool-refresh", }); updateAccountQuota("pool-refresh", 72); const originalFetch = globalThis.fetch; let calls = 0; globalThis.fetch = (async (_input, init) => { calls++; const headers = new Headers(init?.headers); expect(headers.get("Authorization")).toBe("Bearer tok"); expect(headers.get("ChatGPT-Account-Id")).toBe("acc-pool-refresh"); return new Response(JSON.stringify({ rate_limit: { secondary_window: { used_percent: 6, reset_at: 1782628379 }, }, }), { status: 200 }); }) as typeof fetch; try { const req = new Request("http://localhost/api/codex-auth/accounts?refresh=1", { method: "GET" }); const resp = await handleCodexAuthAPI(req, new URL(req.url), config); expect(resp!.status).toBe(200); const data = await resp!.json() as { accounts: { id: string; quota: unknown }[] }; const pool = data.accounts.find(a => a.id === "pool-refresh"); expect(pool?.quota).toMatchObject({ weeklyPercent: 6, weeklyResetAt: 1782628379 }); expect(calls).toBe(1); } finally { globalThis.fetch = originalFetch; } });
Merged PR #667 uses freshPlan for bulk-exhaustion classification but does not update the Dashboard DTO or stored pool plan.
Redacted configuration
{
"providers": {
"openai": {
"authMode": "forward",
"codexAccountMode": "pool"
}
},
"codexAccounts": [
{
"isMain": false,
"plan": "plus"
}
]
}Checks
- I searched existing issues and documentation.
- I removed secrets, tokens, account details, request credentials, and personal data.
Activity
github-actions commentedon Jul 30, 2026
Automated translation bookkeeping — detected language: unknown.
Wibias commentedon Jul 31, 2026
[shipping-github] Merge ready (linked PR)
Issue:
#724Covering PR:
#750(7b22a8b1) — ready to merge ondevWhat fixed: quota refresh now returns and persists WHAM
plan_typeinto the Dashboard DTO /codexAccounts[].plan, with generation-safe concurrent persistence.Waiting on maintainer merge of
#750.Wibias commentedon Jul 31, 2026
Thanks @luvs01 — fixed by PR #750 (
8c883213): refreshed WHAMplan_typenow persists into the pool account plan under the shared config/credential lock, so the Dashboard no longer keeps a stale plan after a successful quota refresh.