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_type is plus.
- 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() returns freshPlan from WHAM.
listCodexAuthAccounts() discards freshPlan and passes the stored account to poolAccountDto().
- DTO quota filtering and routing continue to use
account.plan.
Suggested regression coverage:
- Stored
plus, WHAM prolite: refresh=1 returns and persists prolite.
- Stored
free, WHAM pro: 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_type must 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:
|
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:
|
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:
|
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:
|
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:
|
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:
|
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
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_typefrom WHAM, but the Dashboard DTO and persistedcodexAccounts[].plancontinue using the old value.Sanitized live reproduction on OpenCodex 2.7.43:
A second sanitized pool row demonstrated functional impact:
The stale
freeplan 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
plan_typeisplus.prolite.GET /api/codex-auth/accounts?refresh=1.plan: plus.The code path reproduces the cause directly:
fetchPoolAccountQuota()returnsfreshPlanfrom WHAM.listCodexAuthAccounts()discardsfreshPlanand passes the stored account topoolAccountDto().account.plan.Suggested regression coverage:
plus, WHAMprolite:refresh=1returns and persistsprolite.free, WHAMpro: the refreshed DTO retains current weekly quota fields rather than filtering them as Free-plan data.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 currentdev(bfacc3bcf71077a77afc25d9b613644d3409750c).Operating system
Windows 11 Pro 25H2, build 26200.8894
Provider and model
OpenAI Codex account pool; model-independent
Logs or error output
All emails, account IDs, access tokens, request credentials, and quota values were removed.
Screenshots and supporting files
Relevant source paths:
opencodex/src/codex/auth-api.ts
Lines 443 to 470 in d1f544b
opencodex/src/codex/auth-api.ts
Lines 541 to 550 in d1f544b
opencodex/src/codex/auth-api.ts
Lines 134 to 155 in d1f544b
opencodex/src/codex/quota.ts
Lines 253 to 303 in d1f544b
opencodex/src/codex/routing.ts
Lines 246 to 260 in d1f544b
opencodex/tests/codex-auth-api.test.ts
Lines 649 to 685 in d1f544b
Merged PR #667 uses
freshPlanfor 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