diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 238d23b336..dafb51dc7a 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -427,6 +427,13 @@ manager. Its routes are: | `POST /api/codex-auth/login/cancel` | Cancel a Codex login flow | — | | `GET /api/codex-auth/login-status` | Poll a flow or account login state. A completed new-account flow includes `catalogRefreshPending: true` only when recovery is needed. | Unknown flows report `expired`; no active flow reports `idle` | +For reset-credit consumption, a different `operationId` supplied while the same physical +account has an unfinished operation joins that operation as an alias. Its retry uses the +original upstream request ID and records the outcome under that same identity, so later +requests with the original ID or a known alias replay the stored result without another +consume request. A previously unseen ID supplied after settlement starts a new explicit +redemption; clients retrying an existing action should keep its ID. + 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 6768c4fa09..7ced31b3df 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -2341,7 +2341,7 @@ export async function handleCodexAuthAPI( const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => { // The ledger keys manual operations by the *physical* ChatGPT account, which is // only known after the auth wrapper resolves credentials. Open here, not earlier. - const identity = requestedOperationId === undefined + let identity = requestedOperationId === undefined ? undefined : { accountId, @@ -2377,6 +2377,7 @@ export async function handleCodexAuthAPI( return response; } // Canonical id, which an alias join may map to an earlier caller id. + identity = { ...identity, operationId: opened.operationId }; idempotencyKey = opened.operationId; } else { idempotencyKey = crypto.randomUUID(); diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 04e19d49fa..0fea9ee659 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -3280,6 +3280,13 @@ describe("codex-auth API", () => { config, ); expect(retried!.status).toBe(200); + const replayed = await handleCodexAuthAPI( + consumeRequest({ accountId: "pool-alias", operationId: OTHER_OP_ID }), + new URL("http://localhost/api/codex-auth/reset-credits/consume"), + config, + ); + expect(replayed!.status).toBe(200); + expect(await replayed!.json()).toEqual({ code: "reset", replayed: true }); expect(upstream.redeemRequestIds).toEqual([OP_ID, OP_ID]); } finally { globalThis.fetch = previousFetch;