diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index a9a54c69c3..3da85e231b 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -253,6 +253,14 @@ OpenAI도 같은 규칙을 따르며, 스위치를 켠다고 별도의 922k 모 | `POST /api/codex-auth/login/cancel` | Codex 로그인 흐름을 취소합니다 | — | | `GET /api/codex-auth/login-status` | 흐름 또는 account 로그인 상태를 조회합니다. 새 계정 완료 시 복구가 필요할 때만 `catalogRefreshPending: true`를 포함합니다. | 알 수 없는 흐름은 `expired`로 보고되며, 활성 흐름이 없으면 `idle`로 보고됩니다 | +수동 리셋에서 `code: "reset"`을 받은 뒤, 같은 계정의 새롭고 완전한 사용량 조회로 복구가 확인되면 +그 계정에 남아 있던 일반 `reset-derived` 대기만 해제합니다. main과 추가 계정 모두 적용되며, +리셋 전에 시작된 조회는 복구 근거로 사용하지 않습니다. 새 오류, 명시적 `Retry-After`, +Spark/Reserve 제한, 고정 선택과 일시정지는 유지합니다. `already_redeemed`나 저장된 결과 재생은 +대기를 해제하지 않습니다. 리셋 성공 후 조회가 바쁘거나 실패하거나 계정이 바뀌면 확인된 성공 +코드를 반환하고 대기는 유지합니다. 같은 계정의 신선한 크레딧 수를 확인하지 못하면 `remaining`은 +생략합니다. 사용량 조회를 재시도하기 위해 크레딧을 다시 소비하지 마십시오. + 새 account의 config row는 저장되었지만 credential setup을 완료하지 못하면 OAuth `login-status`는 `status: "error"`를 보고하며 `code: "codex_credential_persistence_failed"`, `accountId`, `needsReauth: true`, 필요한 경우 diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 82c30a2c30..3d83858430 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -440,6 +440,12 @@ security find-generic-password -w openrouter | ocx account add-key openrouter -- Inspect Codex reset credits for an account. Consuming a credit is destructive and requires both `--consume` and `--yes`. +After a confirmed new reset, a fresh complete usage reading for the same account can clear its +prior ordinary quota cooldown. Other limits and newer failures remain intact. If that reading +cannot complete, the confirmed reset still succeeds and may omit the remaining credit count; +retry the account usage refresh without consuming another credit. See the +[management API reset contract](/reference/management-api/#codex-authentication-delegation). + ### `ocx account main ` Manage named native Codex main-login profiles without changing OpenCodex account-pool routing: diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index dafb51dc7a..3eeaebecf5 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -434,6 +434,15 @@ requests with the original ID or a known alias replay the stored result without consume request. A previously unseen ID supplied after settlement starts a new explicit redemption; clients retrying an existing action should keep its ID. +A manual reset returning `code: "reset"` reconciles that account's prior ordinary +`reset-derived` cooldown only after a new, complete usage reading confirms recovery for the same +identity. This applies to main and added accounts. A usage request already running before the reset +cannot supply that evidence. Newer failures, explicit `Retry-After`, Spark/Reserve limits, pins and +pauses are preserved. `already_redeemed` and durable replay do not clear cooldowns. +If the reset is confirmed but usage reconciliation is busy, fails or changes identity, the API keeps +the confirmed success code and leaves the cooldown intact; it omits `remaining` when no fresh +same-identity credit count is available. Do not consume another credit just to retry that read. + If a new account config row is saved but credential setup cannot finish, OAuth `login-status` reports `status: "error"` with `code: "codex_credential_persistence_failed"`, `accountId`, `needsReauth: true`, and optional diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 7ced31b3df..c1e084e334 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -39,6 +39,7 @@ import { setCodexAccountPriority, } from "./account-priority"; import { + captureCodexResetCreditCooldown, claimDueCodexQuotaRecoveryProbes, clearCodexAccountCooldown, clearThreadAccountMapForAccount, @@ -1417,6 +1418,7 @@ async function fetchPoolAccountQuota( forceRefresh = false, configuredPlan?: string, getValidToken: typeof getValidCodexToken = getValidCodexToken, + joinExisting = true, ): Promise { const existing = getAccountQuota(accountId); if (!forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) { @@ -1431,6 +1433,13 @@ async function fetchPoolAccountQuota( // replacement credential with the same pool id start its own request. const record = readCodexAccountRecord(accountId); const flights = poolQuotaRefreshInFlight.get(accountId); + if (!joinExisting && flights?.size) { + // Credential transitions can leave several old flights, including one that + // later adopts the current generation. Drain the entire pre-reset set so + // none can be joined as fresh evidence or overwrite the new quota cache. + await Promise.allSettled([...flights].map(flight => flight.promise)); + return fetchPoolAccountQuota(accountId, true, configuredPlan, getValidToken); + } const current = flights && [...flights].find(flight => { const generation = flight.state.resolvedCredentialGeneration ?? flight.state.startCredentialGeneration; @@ -2382,6 +2391,11 @@ export async function handleCodexAuthAPI( } else { idempotencyKey = crypto.randomUUID(); } + // Establish a first main identity while the native claim is held, before capturing its generation. + if (auth.isMain) reconcileMainCodexAccountRuntimeState(); + const recoverCooldown = captureCodexResetCreditCooldown(accountId); + const resetMainGeneration = auth.isMain ? captureMainAccountIdentityGeneration() : undefined; + const resetPoolRecord = auth.isMain ? undefined : readCodexAccountRecord(accountId); let resp: Response; try { resp = await fetch( @@ -2428,16 +2442,39 @@ export async function handleCodexAuthAPI( // Do not fall back to a preserved cached resetCredits (failed/omitted refresh). if (result.code === "reset" || result.code === "already_redeemed") { let freshResetCredits: number | undefined; - if (auth.isMain) { - ({ freshResetCredits } = await fetchMainAccountInfoAttempt( - true, - 1, - auth.nativeMainLease, - auth.nativeMainSharedClaimHeld === true, - )); - } else { - const account = configuredPoolAccount(getRuntimeConfig(config), accountId); - ({ freshResetCredits } = await fetchPoolAccountQuota(accountId, true, account?.plan)); + try { + if (auth.isMain) { + // Main force refresh starts its own WHAM request while the native claim is held. + const fresh = await fetchMainAccountInfoAttempt( + true, 1, auth.nativeMainLease, auth.nativeMainSharedClaimHeld === true, + ); + const sameIdentity = resetMainGeneration !== undefined + && fresh.identityGeneration === resetMainGeneration + && isMainAccountIdentityGenerationLive(resetMainGeneration) + && getMainChatgptAccountId() === auth.chatgptAccountId; + freshResetCredits = sameIdentity ? fresh.freshResetCredits : undefined; + recoverCooldown(result.code === "reset" && sameIdentity + && isCompleteCodexQuotaRecoverySnapshot(fresh.freshQuota ?? null, fresh.info.plan)); + } else { + const account = configuredPoolAccount(getRuntimeConfig(config), accountId); + // A flight already running when the reset completes cannot prove post-reset recovery. + const fresh = await fetchPoolAccountQuota(accountId, true, account?.plan, + getValidCodexToken, result.code !== "reset"); + const currentRecord = readCodexAccountRecord(accountId); + const generation = fresh.freshCredentialGeneration; + const sameCredential = resetPoolRecord != null && generation !== undefined + && resetPoolRecord.credential?.chatgptAccountId === auth.chatgptAccountId + && currentRecord?.credential?.chatgptAccountId === auth.chatgptAccountId + && currentRecord.replacedAt === resetPoolRecord.replacedAt + && (generation === resetPoolRecord.generation || generation === resetPoolRecord.generation + 1) + && isCodexAccountGenerationLive(accountId, generation); + freshResetCredits = sameCredential ? fresh.freshResetCredits : undefined; + recoverCooldown(result.code === "reset" && sameCredential + && isCompleteCodexQuotaRecoverySnapshot(fresh.freshQuota ?? null, fresh.freshPlan ?? account?.plan)); + } + } catch { + // The credit is already spent. Failed reconciliation must not invite another spend. + recoverCooldown(false); } return jsonResponse({ code: result.code, diff --git a/src/codex/routing.ts b/src/codex/routing.ts index dbf9cab086..f7d1768dbe 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -852,6 +852,39 @@ export function getCodexQuotaHealthSnapshot( }; } +/** + * Capture the ordinary reset-derived cooldowns before an authenticated manual reset. + * The caller must prove a new reset and fresh recovery for the same credential identity. + * Object identity fences newer failures and delete/recreate ABA without claiming a probe + * lease or changing selection state while the reset is in flight. + */ +export function captureCodexResetCreditCooldown(accountId: string): (recovered: boolean) => boolean { + const shared = scopedHealthFor(accountId, "shared"); + const account = upstreamHealth.get(accountId); + let settled = false; + return recovered => { + if (settled) return false; + settled = true; + if (!recovered) return false; + let cleared = false; + if (shared?.cooldownSource === "reset-derived" && scopedHealthFor(accountId, "shared") === shared) { + deleteScopedHealth(accountId, "shared"); + cleared = true; + } + if (account?.cooldownSource === "reset-derived" && upstreamHealth.get(accountId) === account) { + const { + cooldownUntil: _until, cooldownSince: _since, cooldownSource: _source, + probeLeaseId: _lease, probeLeaseGeneration: _leaseGeneration, ...rest + } = account; + upstreamHealth.set(accountId, { + ...rest, cooldownGeneration: (account.cooldownGeneration ?? 0) + 1, lastProbeAt: Date.now(), + }); + cleared = true; + } + return cleared; + }; +} + export function isCodexAccountInCooldown(accountId: string, now = Date.now()): boolean { return getCodexAccountCooldownUntil(accountId, now) !== null; } diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 610ce7d9e4..9ef154256f 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -33,6 +33,7 @@ import { openManualResetCreditOperation } from "../../src/codex/reset-credit-ope import { clearCodexUpstreamHealth, clearThreadAccountMap, + getCodexQuotaHealthSnapshot, getCodexUpstreamHealth, recordCodexUpstreamOutcome, resetCodexRoutingForManualSelection, @@ -972,13 +973,23 @@ describe("codex-auth API", () => { } }); - test("busy pool-quota probe maps reset-credit refresh to 503 server_busy with Retry-After 1", async () => { + test("busy pool-quota reconciliation preserves a confirmed reset and the prior cooldown", async () => { const config = makeConfig(); seedPoolAccount(config, { id: "quota-reset-busy", email: "busy@example.test" }); + recordCodexUpstreamOutcome(config, "quota-reset-busy", 429, { + modelId: "gpt-6-astra", resetAt: Date.now() + 600_000, fixedAccount: true, + }); + const cooldown = getCodexQuotaHealthSnapshot("quota-reset-busy", "shared"); + expect(cooldown).not.toBeNull(); const cleanup = seedCodexAuthAdmissionForTests({ quotaFlights: 16 }); - globalThis.fetch = (async (input: RequestInfo | URL) => String(input).includes("/consume") - ? Response.json({ code: "reset" }) - : previousFetch(input)) as typeof fetch; + let consumeCalls = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (String(input).includes("/consume")) { + consumeCalls += 1; + return Response.json({ code: "reset" }); + } + throw new Error("Busy quota reconciliation must not dispatch a usage request"); + }) as typeof fetch; try { const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { method: "POST", @@ -986,9 +997,11 @@ describe("codex-auth API", () => { body: JSON.stringify({ accountId: "quota-reset-busy" }), }); const response = await handleCodexAuthAPI(req, new URL(req.url), config); - expect(response?.status).toBe(503); - expect(response?.headers.get("Retry-After")).toBe("1"); - expect(await response?.json()).toMatchObject({ code: "server_busy" }); + expect(response?.status).toBe(200); + expect(response?.headers.get("Retry-After")).toBeNull(); + expect(await response?.json()).toEqual({ code: "reset" }); + expect(consumeCalls).toBe(1); + expect(getCodexQuotaHealthSnapshot("quota-reset-busy", "shared")).toEqual(cooldown); } finally { cleanup(); } @@ -2717,6 +2730,338 @@ describe("codex-auth API", () => { }); }); + for (const accountKind of ["main", "pool"] as const) { + test(`reset-credit success reconciles only the prior shared cooldown for ${accountKind}`, async () => { + const accountId = accountKind === "main" ? MAIN_CODEX_ACCOUNT_ID : "reset-recovery-pool"; + const config = makeConfig({ activeCodexAccountPinned: accountId, pausedCodexAccountIds: [accountId] }); + if (accountKind === "main") { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { + access_token: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), + account_id: "acct-reset-recovery-main", + }, + })); + reconcileMainCodexAccountRuntimeState(); + } else { + seedPoolAccount(config, { id: accountId, email: "reset-recovery@example.test", plan: "pro" }); + } + const now = Date.now(); + for (const modelId of ["gpt-6-astra", "gpt-5.3-codex-spark", "gpt-reserve"]) { + recordCodexUpstreamOutcome(config, accountId, 429, { + now, modelId, resetAt: now + 10 * 60_000, fixedAccount: true, + }); + } + expect(getCodexQuotaHealthSnapshot(accountId, "shared")).toMatchObject({ cooldownSource: "reset-derived" }); + const sparkBefore = getCodexQuotaHealthSnapshot(accountId, "spark"); + expect(sparkBefore).not.toBeNull(); + const reserveBefore = getCodexQuotaHealthSnapshot(accountId, "reserve"); + expect(reserveBefore).not.toBeNull(); + let consumeCalls = 0; + let usageCalls = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/backend-api/wham/rate-limit-reset-credits/consume")) { + consumeCalls += 1; + return Response.json({ code: "reset" }); + } + if (url.endsWith("/backend-api/wham/usage")) { + usageCalls += 1; + return Response.json({ + email: "reset-recovery@example.test", + plan_type: "pro", + rate_limit: { + primary_window: { used_percent: 0, limit_window_seconds: 18000, reset_at: Math.floor(now / 1000) + 18000 }, + secondary_window: { used_percent: 0, limit_window_seconds: 604800, reset_at: Math.floor(now / 1000) + 604800 }, + }, + rate_limit_reset_credits: { available_count: 1 }, + }); + } + throw new Error(`Unexpected request in reset recovery fixture: ${url}`); + }) as typeof fetch; + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(resp!.status).toBe(200); + expect(await resp!.json()).toEqual({ code: "reset", remaining: 1 }); + expect({ consumeCalls, usageCalls }).toEqual({ consumeCalls: 1, usageCalls: 1 }); + expect(getAccountQuota(accountId)?.weeklyPercent).toBe(0); + expect(getCodexQuotaHealthSnapshot(accountId, "shared")).toBeNull(); + expect(getCodexQuotaHealthSnapshot(accountId, "spark")).toEqual(sparkBefore); + expect(getCodexQuotaHealthSnapshot(accountId, "reserve")).toEqual(reserveBefore); + expect(config.activeCodexAccountPinned).toBe(accountId); + expect(config.pausedCodexAccountIds).toEqual([accountId]); + }); + } + + test("reset-credit recovery cannot clear the replacement main identity cooldown", async () => { + const accountId = MAIN_CODEX_ACCOUNT_ID; + const config = makeConfig({ activeCodexAccountPinned: accountId }); + const now = Date.now(); + const writeIdentity = (id: string) => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: jwtWithExp(Math.floor(now / 1000) + 3600), account_id: id }, + })); + reconcileMainCodexAccountRuntimeState(); + }; + const recordCooldown = () => recordCodexUpstreamOutcome(config, accountId, 429, { + now, modelId: "gpt-6-astra", resetAt: now + 600_000, fixedAccount: true, + }); + writeIdentity("acct-reset-main-before"); + recordCooldown(); + let replacement: ReturnType = null; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/backend-api/wham/rate-limit-reset-credits/consume")) { + return Response.json({ code: "reset" }); + } + if (url.endsWith("/backend-api/wham/usage")) { + writeIdentity("acct-reset-main-after"); + recordCooldown(); + replacement = getCodexQuotaHealthSnapshot(accountId, "shared"); + return Response.json({ + plan_type: "pro", rate_limit_reset_credits: { available_count: 1 }, + rate_limit: { secondary_window: { used_percent: 0, limit_window_seconds: 604800, reset_at: Math.floor(now / 1000) + 604800 } }, + }); + } + throw new Error(`Unexpected request in main reset identity fixture: ${url}`); + }) as typeof fetch; + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ accountId }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(resp!.status).toBe(200); + expect(await resp!.json()).toEqual({ code: "reset" }); + expect(replacement).not.toBeNull(); + expect(getCodexQuotaHealthSnapshot(accountId, "shared")).toEqual(replacement); + }); + + for (const scenario of [ + "already-redeemed", "incomplete", "quota-error", "new-failure", "recreated", + "credential-replaced", "retry-after", "consume-failed", + ] as const) { + test(`reset-credit recovery preserves a cooldown on ${scenario}`, async () => { + const accountId = "reset-recovery-preserve"; + const config = makeConfig({ activeCodexAccountPinned: accountId }); + seedPoolAccount(config, { id: accountId, email: "preserve@example.test", plan: "pro" }); + const now = Date.now(); + const recordCooldown = () => recordCodexUpstreamOutcome(config, accountId, 429, { + now: Date.now(), modelId: "gpt-6-astra", fixedAccount: true, + ...(scenario === "retry-after" ? { retryAfter: "600" } : { resetAt: now + 10 * 60_000 }), + }); + recordCooldown(); + let expected = getCodexQuotaHealthSnapshot(accountId, "shared"); + expect(expected).not.toBeNull(); + let usageCalls = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/backend-api/wham/rate-limit-reset-credits/consume")) { + if (scenario === "credential-replaced") { + saveCodexAccountCredential(accountId, { + accessToken: "access-replacement", refreshToken: "refresh-replacement", + expiresAt: now + 3600_000, chatgptAccountId: "acct-replacement", + }); + } + return scenario === "consume-failed" ? new Response("unavailable", { status: 500 }) + : Response.json({ code: scenario === "already-redeemed" ? "already_redeemed" : "reset" }); + } + if (url.endsWith("/backend-api/wham/usage")) { + usageCalls += 1; + if (scenario === "new-failure" || scenario === "recreated") { + if (scenario === "recreated") clearCodexUpstreamHealth(); + recordCooldown(); + expected = getCodexQuotaHealthSnapshot(accountId, "shared"); + } + if (scenario === "quota-error") return new Response("unavailable", { status: 500 }); + return Response.json({ + plan_type: "pro", rate_limit_reset_credits: { available_count: 1 }, + ...(scenario === "incomplete" ? {} : { + rate_limit: { secondary_window: { used_percent: 0, limit_window_seconds: 604800, reset_at: Math.floor(now / 1000) + 604800 } }, + }), + }); + } + throw new Error(`Unexpected request in reset recovery fixture: ${url}`); + }) as typeof fetch; + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ accountId }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(resp!.status).toBe(scenario === "consume-failed" ? 500 : 200); + if (scenario !== "consume-failed") { + expect(await resp!.json()).toMatchObject({ code: scenario === "already-redeemed" ? "already_redeemed" : "reset" }); + } + expect(usageCalls).toBe(scenario === "consume-failed" ? 0 : 1); + expect(getCodexQuotaHealthSnapshot(accountId, "shared")).toEqual(expected); + }); + } + + test("reset-credit recovery drains a pre-reset observation before reading recovered quota", async () => { + const accountId = "reset-recovery-flight"; + const config = makeConfig({ activeCodexAccountPinned: accountId }); + seedPoolAccount(config, { id: accountId, email: "flight@example.test", plan: "pro" }); + const now = Date.now(); + recordCodexUpstreamOutcome(config, accountId, 429, { + now, modelId: "gpt-6-astra", resetAt: now + 600_000, fixedAccount: true, + }); + let releaseOld!: (value: Response) => void; + let markStarted!: () => void; + let markReset!: () => void; + const started = new Promise(resolve => { markStarted = resolve; }); + const reset = new Promise(resolve => { markReset = resolve; }); + const old = new Promise(resolve => { releaseOld = resolve; }); + const events: string[] = []; + let usageCalls = 0; + const usage = (percent: number) => Response.json({ + plan_type: "pro", rate_limit_reset_credits: { available_count: 1 }, + rate_limit: { secondary_window: { used_percent: percent, limit_window_seconds: 604800, reset_at: Math.floor(now / 1000) + 604800 } }, + }); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/backend-api/wham/rate-limit-reset-credits/consume")) { + events.push("reset"); markReset(); return Response.json({ code: "reset" }); + } + if (url.endsWith("/backend-api/wham/usage")) { + usageCalls += 1; + events.push(`usage-${usageCalls}`); + if (usageCalls === 1) { markStarted(); return old; } + return usage(0); + } + throw new Error(`Unexpected request in reset recovery fixture: ${url}`); + }) as typeof fetch; + const oldRefresh = listCodexAuthAccounts(config, true); + let consumed: ReturnType | undefined; + try { + await started; + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ accountId }), + }); + consumed = handleCodexAuthAPI(req, new URL(req.url), config); + await reset; + expect(getCodexQuotaHealthSnapshot(accountId, "shared")).not.toBeNull(); + events.push("release-old"); + releaseOld(usage(100)); + await oldRefresh; + const resp = await consumed; + expect(await resp!.json()).toEqual({ code: "reset", remaining: 1 }); + expect(events).toEqual(["usage-1", "reset", "release-old", "usage-2"]); + expect(getAccountQuota(accountId)?.weeklyPercent).toBe(0); + expect(getCodexQuotaHealthSnapshot(accountId, "shared")).toBeNull(); + } finally { + releaseOld(usage(100)); + await Promise.allSettled([oldRefresh, ...(consumed ? [consumed] : [])]); + } + }); + + test("reset-credit recovery drains all old credential-generation observations", async () => { + const accountId = "reset-recovery-multiple-flights"; + const config = makeConfig({ activeCodexAccountPinned: accountId }); + seedPoolAccount(config, { id: accountId, email: "multiple@example.test", plan: "pro" }); + const now = Date.now(); + recordCodexUpstreamOutcome(config, accountId, 429, { + now, modelId: "gpt-6-astra", resetAt: now + 600_000, fixedAccount: true, + }); + function pending() { + let resolve!: (value: T) => void; + const promise = new Promise(r => { resolve = r; }); + return { promise, resolve }; + } + const first = pending(); + const second = pending(); + const replay = pending(); + const starts = [pending(), pending(), pending()]; + const resetStarted = pending(); + let usageCalls = 0; + let consumeCalls = 0; + const usage = (percent: number) => Response.json({ + plan_type: "pro", rate_limit_reset_credits: { available_count: 1 }, + rate_limit: { secondary_window: { used_percent: percent, limit_window_seconds: 604800, reset_at: Math.floor(now / 1000) + 604800 } }, + }); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/backend-api/wham/rate-limit-reset-credits/consume")) { + consumeCalls += 1; resetStarted.resolve(); return Response.json({ code: "reset" }); + } + if (url.endsWith("/backend-api/wham/usage")) { + const index = usageCalls++; + starts[index]?.resolve(); + return [first.promise, second.promise, replay.promise][index] ?? usage(0); + } + throw new Error(`Unexpected request in reset recovery fixture: ${url}`); + }) as typeof fetch; + const oldFirst = listCodexAuthAccounts(config, true); + let oldSecond: ReturnType | undefined; + let consumed: ReturnType | undefined; + try { + await starts[0]!.promise; + saveCodexAccountCredential(accountId, { + accessToken: "access-current-flight", refreshToken: "refresh-current-flight", + expiresAt: now + 3600_000, chatgptAccountId: `acct-${accountId}`, + }); + oldSecond = listCodexAuthAccounts(config, true); + await starts[1]!.promise; + // The first flight adopts the replacement generation and now overlaps the second. + first.resolve(new Response("expired", { status: 401 })); + await starts[2]!.promise; + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ accountId }), + }); + let settled = false; + consumed = handleCodexAuthAPI(req, new URL(req.url), config).then(value => { settled = true; return value; }); + await resetStarted.promise; + replay.resolve(usage(0)); + await oldFirst; + expect(settled).toBe(false); + expect(usageCalls).toBe(3); + second.resolve(usage(0)); + await oldSecond; + const resp = await consumed; + expect(await resp!.json()).toEqual({ code: "reset", remaining: 1 }); + expect({ consumeCalls, usageCalls }).toEqual({ consumeCalls: 1, usageCalls: 4 }); + expect(getAccountQuota(accountId)?.weeklyPercent).toBe(0); + expect(getCodexQuotaHealthSnapshot(accountId, "shared")).toBeNull(); + } finally { + first.resolve(usage(0)); second.resolve(usage(0)); replay.resolve(usage(0)); + await Promise.allSettled([oldFirst, ...(oldSecond ? [oldSecond] : []), ...(consumed ? [consumed] : [])]); + } + }); + + test("reset-credit durable replay preserves a cooldown recorded after the reset", async () => { + const accountId = "reset-recovery-replayed"; + const config = makeConfig(); + seedPoolAccount(config, { id: accountId, email: "replayed@example.test", plan: "pro" }); + const now = Date.now(); + let consumeCalls = 0; + let usageCalls = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/backend-api/wham/rate-limit-reset-credits/consume")) { + consumeCalls += 1; return Response.json({ code: "reset" }); + } + if (url.endsWith("/backend-api/wham/usage")) { + usageCalls += 1; return Response.json({ + plan_type: "pro", rate_limit_reset_credits: { available_count: 1 }, + rate_limit: { secondary_window: { used_percent: 0, limit_window_seconds: 604800, reset_at: Math.floor(now / 1000) + 604800 } }, + }); + } + throw new Error(`Unexpected request in reset recovery fixture: ${url}`); + }) as typeof fetch; + const request = () => new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId, operationId: "d102d6c4-e2c1-4c42-9e40-63e6ef559703" }), + }); + const first = request(); + expect(await (await handleCodexAuthAPI(first, new URL(first.url), config))!.json()).toMatchObject({ code: "reset" }); + recordCodexUpstreamOutcome(config, accountId, 429, { + now, modelId: "gpt-6-astra", resetAt: now + 600_000, fixedAccount: true, + }); + const expected = getCodexQuotaHealthSnapshot(accountId, "shared"); + const second = request(); + expect(await (await handleCodexAuthAPI(second, new URL(second.url), config))!.json()).toEqual({ code: "reset", replayed: true }); + expect({ consumeCalls, usageCalls }).toEqual({ consumeCalls: 1, usageCalls: 1 }); + expect(getCodexQuotaHealthSnapshot(accountId, "shared")).toEqual(expected); + }); + test("reset-credit consume rejects invalid account ids before credential lookup", async () => { const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { method: "POST", @@ -3048,6 +3393,46 @@ describe("codex-auth API", () => { } }); + test.each(["reset", "already_redeemed"] as const)("first main reset-credit %s returns fresh remaining without a prior account lookup", async code => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { + access_token: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), + account_id: `acct-first-main-${code}`, + }, + })); + let consumeCalls = 0; + let usageCalls = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/backend-api/wham/rate-limit-reset-credits/consume")) { + consumeCalls += 1; + return Response.json({ code, remaining: 99 }); + } + if (url.endsWith("/backend-api/wham/usage")) { + usageCalls += 1; + return Response.json({ + email: "first-main@example.test", plan_type: "pro", + rate_limit: { + primary_window: { used_percent: 0, limit_window_seconds: 18000 }, + secondary_window: { used_percent: 0, limit_window_seconds: 604800 }, + }, + rate_limit_reset_credits: { available_count: 1 }, + }); + } + throw new Error("Unexpected request in first main reset fixture"); + }) as typeof fetch; + + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId: MAIN_CODEX_ACCOUNT_ID }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + expect(resp!.status).toBe(200); + expect(await resp!.json()).toEqual({ code, remaining: 1 }); + expect(consumeCalls).toBe(1); + expect(usageCalls).toBe(1); + }); + test("reset-credit consume returns remaining from fresh main WHAM credits", async () => { writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ tokens: { access_token: "main-reset-ok", account_id: "acct-main-reset-ok" },