Skip to content

Codex Auth quota refresh reads fresh WHAM plan_type but keeps a stale pool account plan #724

Description

@luvs01

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

  1. Add a Codex pool account while its WHAM plan_type is plus.
  2. Change the subscription so WHAM now returns prolite.
  3. Open Dashboard → Codex Auth.
  4. Use Refresh quota, or request GET /api/codex-auth/accounts?refresh=1.
  5. Observe that quota values refresh but the returned account still has plan: plus.
  6. 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

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions